diff --git a/src/components/vtk/VtkSliceView.vue b/src/components/vtk/VtkSliceView.vue index 2ad7b548f..aa8a08e7c 100644 --- a/src/components/vtk/VtkSliceView.vue +++ b/src/components/vtk/VtkSliceView.vue @@ -48,7 +48,10 @@ const { metadata: imageMetadata } = useImage(imageID); // the renderer before the renderer is deleted. const scope = effectScope(true); const view = scope.run(() => - useVtkView(vtkContainerRef, { separateImageLayer: true }) + useVtkView(vtkContainerRef, { + separateImageLayer: true, + clippingBounds: computed(() => imageMetadata.value.worldBounds), + }) )!; onUnmounted(() => { diff --git a/src/core/vtk/useVtkView.ts b/src/core/vtk/useVtkView.ts index de45fde9e..4a2e61f49 100644 --- a/src/core/vtk/useVtkView.ts +++ b/src/core/vtk/useVtkView.ts @@ -11,13 +11,14 @@ import vtkRenderWindowInteractor from '@kitware/vtk.js/Rendering/Core/RenderWind import vtkRenderer from '@kitware/vtk.js/Rendering/Core/Renderer'; import vtkOpenGLRenderWindow from '@kitware/vtk.js/Rendering/OpenGL/RenderWindow'; import vtkWidgetManager from '@kitware/vtk.js/Widgets/Core/WidgetManager'; +import type { Bounds } from '@kitware/vtk.js/types'; import { useElementSize } from '@vueuse/core'; import { inject, MaybeRef, onScopeDispose, unref, - watchEffect, + watch, watchPostEffect, } from 'vue'; @@ -86,9 +87,14 @@ export function useWidgetManager(renderer: vtkRenderer) { return manager; } +type VtkViewOptions = { + separateImageLayer?: boolean; + clippingBounds?: MaybeRef; +}; + export function useVtkView( container: MaybeRef>, - { separateImageLayer = false } = {} + { separateImageLayer = false, clippingBounds }: VtkViewOptions = {} ): View { const parent = inject(VtkRenderWindowParentContext, null); const renderer = vtkRenderer.newInstance(); @@ -185,7 +191,7 @@ export function useVtkView( const scaledWidth = Math.max(1, width * globalThis.devicePixelRatio); const scaledHeight = Math.max(1, height * globalThis.devicePixelRatio); renderWindowView.setSize(scaledWidth, scaledHeight); - imageRenderer.resetCameraClippingRange(); + imageRenderer.resetCameraClippingRange(unref(clippingBounds)); parent?.renderWindowView.resizeFromChildRenderWindows(); parent?.renderWindow.render(); @@ -193,9 +199,7 @@ export function useVtkView( }; const { width, height } = useElementSize(container); - watchEffect(() => { - setSize(width.value, height.value); - }); + watch([width, height], ([w, h]) => setSize(w, h), { immediate: true }); // cleanup onScopeDispose(() => { diff --git a/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png b/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png index 50ae7a145..509943192 100644 Binary files a/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png and b/tests/baseline/different_direction_labelmap_paint_coronal-chrome-1.png differ diff --git a/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png b/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png index 7ab9b5494..ed1f1794c 100644 Binary files a/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png and b/tests/baseline/paint_tool_axial_view_after_stroke-chrome-1.png differ diff --git a/tests/specs/different-direction-labelmap.e2e.ts b/tests/specs/different-direction-labelmap.e2e.ts index 6eae69bbe..6f571075e 100644 --- a/tests/specs/different-direction-labelmap.e2e.ts +++ b/tests/specs/different-direction-labelmap.e2e.ts @@ -1,5 +1,6 @@ import { PROSTATE_610_LABELMAP_MANIFEST } from './configTestUtils'; import { writeManifestToFile } from './utils'; +import { moveTo } from './annotationTestUtils'; import { volViewPage } from '../pageobjects/volview.page'; import { openAnnotationSegments, @@ -8,6 +9,8 @@ import { waitForSegmentContent, } from './segmentationTestUtils'; +const MAX_MISMATCH_PERCENT = 0.01; + /** * Regression test for labelmap with different direction matrix than parent image. * @@ -73,18 +76,25 @@ describe('Labelmap with different direction matrix', () => { .up() .perform(); + await moveTo(10, 10); + const annotations = await coronalView.$('.view-annotations'); await browser.waitUntil( async () => { const result = await browser.checkElement( coronalView, - 'different_direction_labelmap_paint_coronal' + 'different_direction_labelmap_paint_coronal', + { + biDiOrigin: 'viewport', + removeElements: [annotations], + rawMisMatchPercentage: true, + saveAboveTolerance: MAX_MISMATCH_PERCENT, + } ); - return (result as number) < 5; + return (result as number) <= MAX_MISMATCH_PERCENT; }, { timeout: 10000, - timeoutMsg: - 'Paint stroke on coronal view with misaligned labelmap should match baseline', + timeoutMsg: `Painted coronal view with misaligned labelmap should match its baseline within ${MAX_MISMATCH_PERCENT}%`, interval: 1000, } ); diff --git a/tests/specs/layer-display-controls.e2e.ts b/tests/specs/layer-display-controls.e2e.ts index d3f72491a..79c57ef74 100644 --- a/tests/specs/layer-display-controls.e2e.ts +++ b/tests/specs/layer-display-controls.e2e.ts @@ -120,6 +120,7 @@ async function openDisplayFixture({ mappingRange = [-2.75, 100.25], opacity = 0.5, transparentBackground = false, + singleSlice = false, } = {}) { const display = { preset, mappingRange, opacity, transparentBackground }; const base = writeMetaImage('display-base.mha'); @@ -128,6 +129,25 @@ async function openDisplayFixture({ await writeManifestToFile( { ...layeredImageManifest(base, overlay), + ...(singleSlice + ? { + layout: { + direction: 'row', + items: [{ type: 'slot', slotIndex: 0 }], + }, + layoutSlots: ['axial'], + activeView: 'axial', + viewByID: { + axial: { + id: 'axial', + type: '2D', + name: 'Axial', + dataID: 'base', + options: { orientation: 'Axial' }, + }, + }, + } + : {}), layerDisplay: { 'base::overlay': display, }, @@ -149,18 +169,20 @@ async function openDisplayFixture({ async () => { const pixels = await captureViewPixels(); return ( - pixels.length === 3 && pixels.every(({ colored }) => colored > 1000) + pixels.length === (singleSlice ? 1 : 3) && + pixels.every(({ colored }) => colored > 1000) ); }, - { timeoutMsg: 'Expected the overlay to render in all three 2D views' } + { timeoutMsg: 'Expected the overlay to render in every 2D view' } ); } -async function openEditedDisplayFixture() { +async function openEditedDisplayFixture(singleSlice = false) { await openDisplayFixture({ preset: 'DICOM Hot Iron', mappingRange: [0, 255], opacity: 0.6, + singleSlice, }); const select = $('[data-testid="layer-colormap"]'); await select.scrollIntoView({ block: 'center', behavior: 'instant' }); @@ -185,12 +207,12 @@ async function openEditedDisplayFixture() { async () => { const pixels = await captureViewPixels(); return ( - pixels.length === 3 && + pixels.length === (singleSlice ? 1 : 3) && pixels.every(({ colored, blue }) => colored > 1000 && blue > 0) ); }, { - timeoutMsg: 'Expected the edited Cool to Warm palette in all three views', + timeoutMsg: 'Expected the edited Cool to Warm palette in every 2D view', } ); const baseWindowing = await windowingLabels(); @@ -199,6 +221,20 @@ async function openEditedDisplayFixture() { return { baseWindowing, appliedPixels }; } +async function convertSlicesToVolumes() { + const views = await volViewPage.getViews2D(); + const count = await views.length; + expect(count).toBeGreaterThan(0); + for (let remaining = count - 1; remaining >= 0; remaining--) { + const view = (await volViewPage.getViews2D())[0]; + await view.$('.view-type-select').click(); + const volume = $('[role="option"]*=Volume'); + await volume.waitForClickable(); + await volume.click(); + await volViewPage.waitForViewCounts(remaining, true); + } +} + describe('Overlay display controls', () => { for (const { title, preset } of [ { title: 'missing', preset: undefined }, @@ -536,24 +572,26 @@ describe('Overlay display controls', () => { await expectRenderedPixels(appliedPixels); }); - it('restores global appearance after converting every 2D view and saving a 3D-only session', async () => { + it('restores global appearance after converting every 2D view', async () => { const { baseWindowing, appliedPixels } = await openEditedDisplayFixture(); - for (let remaining = 2; remaining >= 0; remaining--) { - const view = (await volViewPage.getViews2D())[0]; - await view.$('.view-type-select').click(); - const volume = $('[role="option"]*=Volume'); - await volume.waitForClickable(); - await volume.click(); - await volViewPage.waitForViewCounts(remaining, true); - } - await selectLayout('3D Only', 0, true); + await convertSlicesToVolumes(); + await selectLayout('Four Up', 3, true); + await expectDisplaySettings('-2.75', '100.25', '100.3'); + expect(await windowingLabels()).toEqual(baseWindowing); + await expectRenderedPixels(appliedPixels); + }); + + it('restores global appearance from a session saved after removing the last 2D view', async () => { + const { baseWindowing, appliedPixels } = + await openEditedDisplayFixture(true); + await convertSlicesToVolumes(); const saved = await volViewPage.saveSession(); await waitForDownload(path.join(TEMP_DIR, saved), SESSION_SAVE_TIMEOUT); await openVolViewPage(saved); await volViewPage.waitForViewCounts(0, true); await openLayerDisplayControls(); await expect($('[data-testid="layer-range-min"] input')).toBeEnabled(); - await selectLayout('Four Up', 3, true); + await selectLayout('Axial Only', 1, false); await expectDisplaySettings('-2.75', '100.25', '100.3'); expect(await windowingLabels()).toEqual(baseWindowing); await expectRenderedPixels(appliedPixels); diff --git a/tests/specs/paint-tool-rendering.e2e.ts b/tests/specs/paint-tool-rendering.e2e.ts index 13d8f384c..35a8a0f05 100644 --- a/tests/specs/paint-tool-rendering.e2e.ts +++ b/tests/specs/paint-tool-rendering.e2e.ts @@ -3,6 +3,8 @@ import { PROSTATEX_DATASET } from '../datasets'; import { openUrls } from './utils'; import { moveTo } from './annotationTestUtils'; +const MAX_MISMATCH_PERCENT = 0.01; + describe('Paint tool rendering', () => { it('should not black out axial view after painting', async () => { await openUrls([PROSTATEX_DATASET]); @@ -34,22 +36,6 @@ describe('Paint tool rendering', () => { .perform(); } - await browser.waitUntil( - async () => { - const result = await browser.checkElement( - axialView, - 'paint_tool_axial_view_after_stroke' - ); - return (result as number) < 5; - }, - { - timeout: 10000, - timeoutMsg: - 'Axial view should not go black after painting with paint tool', - interval: 1000, - } - ); - const canvasImage = () => browser.execute( (element) => (element as HTMLCanvasElement).toDataURL(), @@ -61,6 +47,28 @@ describe('Paint tool rendering', () => { timeoutMsg: 'Brush preview should disappear when leaving the view', }); const withoutPreview = await canvasImage(); + const annotations = await axialView.$('.view-annotations'); + + await browser.waitUntil( + async () => { + const result = await browser.checkElement( + axialView, + 'paint_tool_axial_view_after_stroke', + { + biDiOrigin: 'viewport', + removeElements: [annotations], + rawMisMatchPercentage: true, + saveAboveTolerance: MAX_MISMATCH_PERCENT, + } + ); + return (result as number) <= MAX_MISMATCH_PERCENT; + }, + { + timeout: 10000, + timeoutMsg: `Painted axial view should match its baseline within ${MAX_MISMATCH_PERCENT}%`, + interval: 1000, + } + ); await moveTo(centerX - 60, centerY); await browser.waitUntil( diff --git a/tests/specs/slice-display-lifecycle.e2e.ts b/tests/specs/slice-display-lifecycle.e2e.ts index 80aba56ba..c8ed3a8f8 100644 --- a/tests/specs/slice-display-lifecycle.e2e.ts +++ b/tests/specs/slice-display-lifecycle.e2e.ts @@ -24,8 +24,8 @@ const captureSlicePixels = () => captureViewPixels('[data-testid~="vtk-two-view"]', 40, 'slice-lifecycle.png'); const obliqueSlices = '.oblique-grid > .d-flex:not(:nth-child(2)) [data-testid="vtk-view"]'; -const captureOblique = () => - captureViewPixels(obliqueSlices, 40, 'slice-lifecycle-oblique.png'); +const captureOblique = (fileName = 'slice-lifecycle-oblique.png') => + captureViewPixels(obliqueSlices, 40, fileName); const coloredPixelCount = (data: Buffer) => data.filter( @@ -305,10 +305,14 @@ describe('Slice display lifecycle', () => { pixels.every(({ data }) => coloredPixelCount(data) > 1000) ); }); - const baseImages = await captureOblique(); + const baseImages = await captureOblique( + `slice-lifecycle-oblique-before-${cycle}.png` + ); await toggleVisibility('base-image'); await browser.waitUntil(async () => { - const pixels = await captureOblique(); + const pixels = await captureOblique( + `slice-lifecycle-oblique-hidden-${cycle}.png` + ); return ( pixels.length === 3 && pixels.every( @@ -319,15 +323,23 @@ describe('Slice display lifecycle', () => { ); }); await toggleVisibility('base-image'); - await browser.waitUntil(async () => { - const pixels = await captureOblique(); - return ( - pixels.length === 3 && - pixels.every(({ data }, index) => - data.equals(baseImages[index].data) - ) - ); - }); + await browser.waitUntil( + async () => { + const pixels = await captureOblique( + `slice-lifecycle-oblique-restored-${cycle}.png` + ); + return ( + pixels.length === 3 && + pixels.every(({ data }, index) => + data.equals(baseImages[index].data) + ) + ); + }, + { + timeoutMsg: + 'Expected restored oblique slices to match their visible appearance', + } + ); await selectLayout('Four Up', 3, true); await expectSlicesMatch(fourUp); }