Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/components/tools/SelectTool.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ onVTKEvent(
// position or, mid capture, nothing at all.
const { x, y } = event.position;
const selectedData = await view.widgetManager.getSelectedDataForXY(x, y);
// the pick spans a capture, which the view teardown can outrun
if (view.widgetManager.isDeleted()) return;
if ('widget' in selectedData) {
const widget =
selectedData.widget as Partial<vtkAnnotationToolWidget> | null;
Expand Down
11 changes: 10 additions & 1 deletion src/components/vtk/VtkRenderWindowParent.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { VtkRenderWindowParentContext } from '@/src/components/vtk/context';
import { releaseOpenGLRenderWindow } from '@/src/core/vtk/releaseRenderWindow';
import vtkRenderWindow from '@kitware/vtk.js/Rendering/Core/RenderWindow';
import vtkOpenGLRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/RenderWindow';
import { effectScope, onUnmounted, provide } from 'vue';
import { effectScope, onScopeDispose, onUnmounted, provide } from 'vue';

const scope = effectScope(true);

Expand All @@ -12,6 +13,14 @@ const api = scope.run(() => {
renderWindow.addView(rwView);
rwView.initialize();

// Child views unmount before this hook runs, so their nodes are already off
// the scene graph by the time the WebGL context they draw through goes away.
onScopeDispose(() => {
renderWindow.removeView(rwView);
releaseOpenGLRenderWindow(rwView as vtkOpenGLRenderWindow);
renderWindow.delete();
});

return {
renderWindow,
renderWindowView: rwView as vtkOpenGLRenderWindow,
Expand Down
139 changes: 139 additions & 0 deletions src/composables/__tests__/useVolumeThumbnailing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { describe, expect, it, beforeEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { defineComponent, h, ref, type Ref } from 'vue';
import { flushPromises, mount } from '@vue/test-utils';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import { useVolumeThumbnailing } from '@/src/composables/useVolumeThumbnailing';
import { CurrentImageInjectionKey } from '@/src/composables/useCurrentImage';
import { useImageCacheStore } from '@/src/store/image-cache';
import { NOOP } from '@/src/constants';
import type { createVolumeThumbnailer } from '@/src/core/thumbnailers/volume-thumbnailer';

type Thumbnailer = ReturnType<typeof createVolumeThumbnailer>;

const IMAGE_ID = 'img-1';
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<string>) {
let deleted = false;
let captureCount = 0;
const noopProxy = {
setDataRange: NOOP,
setMode: NOOP,
setPoints: NOOP,
setGaussians: NOOP,
setPresetName: NOOP,
};
const renderWindow = {
render: NOOP,
captureImages: () => {
captureCount += 1;
return [capture ?? Promise.resolve('')];
},
};
const thumbnailer = {
scene: { getRenderWindow: () => renderWindow },
opacityFuncProxy: noopProxy,
colorTransferFuncProxy: noopProxy,
setInputImage: NOOP,
resetCameraWithOrientation: NOOP,
delete() {
deleted = true;
},
};
return {
thumbnailer: thumbnailer as unknown as Thumbnailer,
isDeleted: () => deleted,
getCaptureCount: () => captureCount,
};
}

function addImageToCache(id = IMAGE_ID) {
const image = vtkImageData.newInstance();
image.setDimensions([2, 2, 2]);
image
.getPointData()
.setScalars(vtkDataArray.newInstance({ values: new Uint8Array(8) }));
useImageCacheStore().addVTKImageData(image, 'CT', { id });
}

function mountThumbnailing(
thumbnailer: Thumbnailer,
imageID: Ref<string | null>
) {
const component = defineComponent({
setup() {
useVolumeThumbnailing(64, () => thumbnailer);
return () => h('div');
},
});
return mount(component, {
global: {
provide: {
[CurrentImageInjectionKey as symbol]: { imageID },
},
},
});
}

describe('useVolumeThumbnailing', () => {
beforeEach(() => {
setActivePinia(createPinia());
});

it('disposes the thumbnailer when the component unmounts', async () => {
const stub = createStubThumbnailer();
const wrapper = mountThumbnailing(stub.thumbnailer, ref(null));
expect(stub.isDeleted()).toBe(false);

wrapper.unmount();
await flushPromises();

expect(stub.isDeleted()).toBe(true);
});

it('holds off deletion until an in-flight capture settles', async () => {
let resolveCapture!: (uri: string) => void;
const capture = new Promise<string>((resolve) => {
resolveCapture = resolve;
});
const stub = createStubThumbnailer(capture);
addImageToCache();

const wrapper = mountThumbnailing(stub.thumbnailer, ref(IMAGE_ID));
await flushPromises();
expect(stub.getCaptureCount()).toBe(1);

// captureImages() finishes its render on a timer, so deleting the render
// window while the capture is pending would crash that callback.
wrapper.unmount();
await flushPromises();
expect(stub.isDeleted()).toBe(false);

resolveCapture('data:image/png;base64,');
await flushPromises();
expect(stub.isDeleted()).toBe(true);

// the unmount sentinel keeps the remaining presets from capturing
expect(stub.getCaptureCount()).toBe(1);
});

it('starts a new cycle even while an earlier capture never settles', async () => {
const stub = createStubThumbnailer(new Promise<string>(() => {}));
addImageToCache();
addImageToCache(OTHER_IMAGE_ID);
const imageID = ref<string | null>(IMAGE_ID);

mountThumbnailing(stub.thumbnailer, imageID);
await flushPromises();
expect(stub.getCaptureCount()).toBe(1);

imageID.value = OTHER_IMAGE_ID;
await flushPromises();

expect(stub.getCaptureCount()).toBeGreaterThan(1);
});
});
32 changes: 27 additions & 5 deletions src/composables/useVolumeThumbnailing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getOpacityRangeFromPreset,
} from '../utils/vtk-helpers';
import { PresetNameList } from '../vtk/ColorMaps';
import { logError } from '../utils/loggers';

function resetOpacityFunction(
pwfProxy: vtkPiecewiseFunctionProxy,
Expand Down Expand Up @@ -42,9 +43,12 @@ function resetOpacityFunction(
}
}

export function useVolumeThumbnailing(thumbnailSize: number) {
export function useVolumeThumbnailing(
thumbnailSize: number,
createThumbnailer = createVolumeThumbnailer
) {
const thumbnails = reactive<Record<string, Record<string, string>>>({});
const thumbnailer = createVolumeThumbnailer(thumbnailSize);
const thumbnailer = createThumbnailer(thumbnailSize);
const currentThumbnails = ref<Record<string, string>>({});

const { currentImageMetadata, currentImageID, currentImageData } =
Expand All @@ -59,9 +63,15 @@ export function useVolumeThumbnailing(thumbnailSize: number) {

// used to interrupt a thumbnailing cycle if
// doThumbnailing is called again
const UNMOUNTED = Symbol('unmount');
let interruptSentinel = Symbol('interrupt');

// captures finish on a timer, so deletion waits for the in-flight ones
const inFlightCaptures = new Set<Promise<string>>();

async function doThumbnailing(imageID: string, image: vtkImageData) {
if (interruptSentinel === UNMOUNTED) return;

const localSentinel = Symbol('interrupt');
interruptSentinel = localSentinel;

Expand Down Expand Up @@ -108,7 +118,16 @@ export function useVolumeThumbnailing(thumbnailSize: number) {

const renWin = thumbnailer.scene.getRenderWindow();
renWin.render();
const imageURL = await renWin.captureImages()[0];
const capture = renWin.captureImages()[0];
inFlightCaptures.add(capture);
const imageURL = await capture.finally(() => {
inFlightCaptures.delete(capture);
});

// the capture spans a render, so the cycle may have been superseded
if (interruptSentinel !== localSentinel) return;
if (imageID !== currentImageID.value) return;

if (imageURL) {
thumbnails[imageID][presetName] = imageURL;
}
Expand All @@ -117,7 +136,7 @@ export function useVolumeThumbnailing(thumbnailSize: number) {
PresetNameList.reduce(
(promise, presetName) => promise.then(() => helper(presetName)),
Promise.resolve()
);
).catch(logError);
}

// workaround for computed not properly working on deeply reactive objects
Expand All @@ -134,7 +153,10 @@ export function useVolumeThumbnailing(thumbnailSize: number) {

// force thumbnailing to stop
onBeforeUnmount(() => {
interruptSentinel = Symbol('unmount');
interruptSentinel = UNMOUNTED;
Promise.allSettled([...inFlightCaptures])
.then(() => thumbnailer.delete())
.catch(logError);
});

// trigger thumbnailing
Expand Down
30 changes: 30 additions & 0 deletions src/core/thumbnailers/volume-thumbnailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import vtkLookupTableProxy from '@kitware/vtk.js/Proxy/Core/LookupTableProxy';
import { vec3 } from 'gl-matrix';
import type { Vector3 } from '@kitware/vtk.js/types';
import vtkVolumeProperty from '@kitware/vtk.js/Rendering/Core/VolumeProperty';
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';

export function createRenderingPipeline() {
const actor = vtkVolume.newInstance();
Expand Down Expand Up @@ -127,5 +130,32 @@ export function createVolumeThumbnailer(size: number) {
renderer.updateLightsGeometryToFollowCamera();
}
},
delete() {
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();
try {
// nothing that walks the render window's view list should reach the
// deleted view
renderWindow.removeView(apiRenderWindow);
scene.delete();
deleteInteractor(interactor);
} finally {
loseContext();
}
renderWindow.delete();
renderer.delete();
actor.delete();
mapper.delete();
opacityFuncProxy.delete();
colorTransferFuncProxy.delete();
pipeline.cfun.delete();
pipeline.ofun.delete();
},
};
}
Loading
Loading