fix(seg): prevent segmentations from spreading to all viewports before hydration confirmation in 3D four-up (#5967)

This commit is contained in:
Ghadeer Albattarni 2026-04-27 12:55:12 -04:00 committed by GitHub
parent c9c4f11b82
commit f8ccf9ff2e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 558 additions and 39 deletions

View File

@ -2096,6 +2096,15 @@ function commandsModule({
displaySetInstanceUIDs,
});
if (!updatedViewports?.length) {
return;
}
updatedViewports.forEach(({ viewportId: csViewportId }) => {
const csViewport = cornerstoneViewportService.getCornerstoneViewport(csViewportId);
csViewport?.setNeedsRender?.();
});
actions.setDisplaySetsForViewports({
viewportsToUpdate: updatedViewports.map(viewport => ({
viewportId: viewport.viewportId,

View File

@ -49,6 +49,47 @@ const EVENTS = {
const MIN_STACK_VIEWPORTS_TO_ENQUEUE_RESIZE = 12;
const MIN_VOLUME_VIEWPORTS_TO_ENQUEUE_RESIZE = 6;
function getVolumeActorReferencedIds(viewport: Types.IVolumeViewport): string[] {
const actors = viewport.getActors?.() ?? [];
return actors
.filter(ac => ac.actor?.getClassName?.() === 'vtkVolume')
.map(ac => ac.referencedId)
.filter(Boolean) as string[];
}
function volumeIdPrefixesMatch(
existingIds: string[],
prefixLen: number,
targetIds: string[]
): boolean {
if (prefixLen > targetIds.length) {
return false;
}
for (let i = 0; i < prefixLen; i++) {
if (existingIds[i] !== targetIds[i]) {
return false;
}
}
return true;
}
/**
* Returns true when the viewport type matches a volume-based presentation (ORTHOGRAPHIC or VOLUME_3D).
*/
function viewportMatchesDesiredVolumePresentation(
viewport: Types.IViewport,
desiredViewportInfo: ViewportInfo
): boolean {
const desiredType = desiredViewportInfo.getViewportType();
if (viewport.type !== desiredType) {
return false;
}
return (
desiredType === csEnums.ViewportType.ORTHOGRAPHIC ||
desiredType === csEnums.ViewportType.VOLUME_3D
);
}
export const WITH_NAVIGATION = { withNavigation: true, withOrientation: false };
export const WITH_ORIENTATION = { withNavigation: true, withOrientation: true };
@ -1112,7 +1153,44 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
});
}
await viewport.setVolumes(volumeInputArray);
const baseVolumeInputs = filteredVolumeInputArray.map(({ volumeInput }) => volumeInput);
const nextBaseVolumeIds = baseVolumeInputs.map(v => v.volumeId);
const existingVolumeIds = getVolumeActorReferencedIds(viewport);
let skippedIdenticalBaseVolumes = false;
if (baseVolumeInputs.length) {
const singleBaseViewport = nextBaseVolumeIds.length === 1;
// Only skip setVolumes() when the viewport type already matches the desired OHIF type
// (ORTHOGRAPHIC / VOLUME_3D); otherwise a stack → MPR switch with the same volumeId
// would incorrectly skip rebuilding the viewport.
if (
singleBaseViewport &&
existingVolumeIds.length >= nextBaseVolumeIds.length &&
volumeIdPrefixesMatch(existingVolumeIds, nextBaseVolumeIds.length, nextBaseVolumeIds) &&
viewportMatchesDesiredVolumePresentation(viewport, viewportInfo)
) {
// Same primary volume already loaded (e.g. labelmap / extra actors after it) — avoid
// setVolumes(), which tears down all actors and blanks MPR during SEG hydrate.
skippedIdenticalBaseVolumes = true;
} else if (
existingVolumeIds.length &&
nextBaseVolumeIds.length > existingVolumeIds.length &&
volumeIdPrefixesMatch(existingVolumeIds, existingVolumeIds.length, nextBaseVolumeIds) &&
typeof viewport.addVolumes === 'function'
) {
const toAdd = baseVolumeInputs.slice(existingVolumeIds.length);
if (toAdd.length) {
await viewport.addVolumes(toAdd);
}
} else {
await viewport.setVolumes(baseVolumeInputs);
}
} else if (volumeInputArray.length) {
await viewport.setVolumes(volumeInputArray);
}
await this._addOverlayRepresentations(overlayProcessingResults);
viewport.render();
@ -1124,8 +1202,11 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
});
this.setPresentations(viewport.id, presentations);
// Presentations apply segmentation (hydrated labelmap etc.) after the render above — redraw so
// every orthographic/3D tile shows the updated scene (fixes MPR siblings blank after SEG hydrate).
viewport.render();
if (!presentations.positionPresentation) {
if (!presentations.positionPresentation && !skippedIdenticalBaseVolumes) {
const imageIndex = this._getInitialImageIndexForViewport(viewportInfo);
if (imageIndex !== undefined) {

View File

@ -135,16 +135,27 @@ const _getSegmentationPresentationId = ({
return;
}
const { displaySetService } = servicesManager.services;
const { displaySetInstanceUIDs, viewportOptions } = viewport;
// Match keys used by updateStoredSegmentationPresentation (referenced volume only).
// Including overlay UIDs (e.g. SEG) produced ids like "MR&SEG" while the store
// entry is under "MR", so hydrated segmentations never applied and viewports could mis-render.
const nonOverlayUIDs = displaySetInstanceUIDs.filter(uid => {
const ds = displaySetService.getDisplaySetByUID(uid);
return ds && !ds.isOverlayDisplaySet;
});
if (!nonOverlayUIDs.length) {
return;
}
let orientation = viewportOptions.orientation;
if (!orientation) {
// Calculate orientation from the viewport sample image
const displaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
displaySetInstanceUIDs[0]
);
const sampleImage = displaySet.images?.[0];
const displaySet = displaySetService.getDisplaySetByUID(nonOverlayUIDs[0]);
const sampleImage = displaySet?.images?.[0];
const imageOrientationPatient = sampleImage?.ImageOrientationPatient;
orientation = getViewportOrientationFromImageOrientationPatient(imageOrientationPatient);
@ -152,7 +163,7 @@ const _getSegmentationPresentationId = ({
const segmentationPresentationArr = [];
segmentationPresentationArr.push(...displaySetInstanceUIDs);
segmentationPresentationArr.push(...nonOverlayUIDs);
// Uncomment if unique indexing is needed
// addUniqueIndex(

View File

@ -262,6 +262,57 @@ describe('getUpdatedViewportsForSegmentation', () => {
expect(result).toEqual(mockUpdatedViewports);
});
it('should merge all grid viewports that reference the volume after hydration (e.g. 3D four-up)', () => {
const volumeUid = 'volume-1';
const viewports = new Map([
[
'vp-axial',
{
viewportId: 'vp-axial',
viewportOptions: { viewportId: 'vp-axial' },
displaySetInstanceUIDs: [volumeUid],
},
],
[
'vp-sagittal',
{
viewportId: 'vp-sagittal',
viewportOptions: { viewportId: 'vp-sagittal' },
displaySetInstanceUIDs: [volumeUid],
},
],
[
'vp-other-study',
{
viewportId: 'vp-other-study',
viewportOptions: { viewportId: 'vp-other-study' },
displaySetInstanceUIDs: ['other-volume'],
},
],
]);
mockViewportGridService.getState.mockReturnValue({
isHangingProtocolLayout: true,
viewports,
activeViewportId: 'vp-axial',
});
mockHangingProtocolService.getViewportsRequireUpdate.mockReturnValue([
{ viewportId: 'vp-axial', displaySetInstanceUIDs: [volumeUid] },
]);
const result = getUpdatedViewportsForSegmentation({
viewportId: 'vp-axial',
servicesManager: mockServicesManager as unknown as AppTypes.ServicesManager,
displaySetInstanceUIDs: [volumeUid],
});
expect(result).toEqual([
{ viewportId: 'vp-axial', displaySetInstanceUIDs: [volumeUid] },
{ viewportId: 'vp-sagittal', displaySetInstanceUIDs: [volumeUid] },
]);
});
it('should handle complex viewport structure', () => {
const complexViewport = {
viewportOptions: {

View File

@ -1,3 +1,63 @@
import type { Types } from '@ohif/core';
/**
* After SEG hydration we must refresh every viewport that shows the referenced volume so
* presentations (including segmentation) apply to all MPR/3D tiles. Hanging-protocol matching
* can return only the active viewport when protocol definitions omit viewportId (e.g. 3D four-up)
* or when layout state diverges from the protocol; this merges in all grid panes that already
* list that volume in `displaySetInstanceUIDs`.
*
* Only exact displaySetInstanceUID matches are used (no Frame-of-Reference inference): sibling
* MPR planes must already share the same referenced volume UID in grid state, or HP matching
* must list them; otherwise forcing a different UID onto a volume viewport can leave it blank.
*/
function mergeVolumeSharingViewports(
hangingProtocolUpdates: Types.HangingProtocol.ViewportUpdate[] | null | undefined,
volumeUid: string | undefined,
viewports: AppTypes.ViewportGrid.GridViewports
): Types.HangingProtocol.ViewportUpdate[] {
if (!volumeUid) {
return (hangingProtocolUpdates ?? []) as Types.HangingProtocol.ViewportUpdate[];
}
const byId = new Map<string, Types.HangingProtocol.ViewportUpdate>();
const add = (viewportId: string, uids: string[]) => {
if (!viewportId) {
return;
}
byId.set(viewportId, { viewportId, displaySetInstanceUIDs: uids });
};
if (Array.isArray(hangingProtocolUpdates)) {
for (const entry of hangingProtocolUpdates) {
const vid = entry.viewportId ?? entry.viewportOptions?.viewportId;
if (vid) {
add(vid, entry.displaySetInstanceUIDs?.length ? entry.displaySetInstanceUIDs : [volumeUid]);
}
}
}
viewports.forEach(vp => {
const uids = vp.displaySetInstanceUIDs || [];
if (uids.includes(volumeUid)) {
add(vp.viewportId, [volumeUid]);
}
});
const merged = Array.from(byId.values());
if (
merged.length === 0 &&
Array.isArray(hangingProtocolUpdates) &&
hangingProtocolUpdates.length > 0
) {
return hangingProtocolUpdates;
}
return merged;
}
function getUpdatedViewportsForSegmentation({
viewportId,
servicesManager,
@ -5,7 +65,7 @@ function getUpdatedViewportsForSegmentation({
}: withAppTypes) {
const { hangingProtocolService, viewportGridService } = servicesManager.services;
const { isHangingProtocolLayout } = viewportGridService.getState();
const { isHangingProtocolLayout, viewports } = viewportGridService.getState();
const viewport = getTargetViewport({ viewportId, viewportGridService });
const targetViewportId = viewport.viewportOptions.viewportId;
@ -16,7 +76,11 @@ function getUpdatedViewportsForSegmentation({
isHangingProtocolLayout
);
return updatedViewports;
if (updatedViewports == null) {
return updatedViewports;
}
return mergeVolumeSharingViewports(updatedViewports, displaySetInstanceUIDs[0], viewports);
}
const getTargetViewport = ({ viewportId, viewportGridService }) => {

View File

@ -67,8 +67,14 @@ export function configureViewportForLayerAddition(params: {
// If a viewport type was already set do not reset it.
if (!viewport.viewportOptions.viewportType) {
// Special handling for overlay display sets
if (requestedLayerDisplaySet.isOverlayDisplaySet) {
// If the live Cornerstone viewport is already orthographic (volume), keep it as
// 'volume'. Prevents accidentally downgrading a volume viewport to 'stack' when
// viewportOptions arrives without a viewportType (e.g. from the HP overlay path).
const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (csViewport?.type === 'orthographic') {
viewport.viewportOptions.viewportType = 'volume';
// Special handling for overlay display sets
} else if (requestedLayerDisplaySet.isOverlayDisplaySet) {
// Do not force volume for SEG and RTSTRUCT if it and all the current display sets are for the same display set
const isSameDisplaySet = currentDisplaySetUIDs.every(uid => {
const currentDisplaySet = displaySetService.getDisplaySetByUID(uid);
@ -131,7 +137,9 @@ export function configureViewportForLayerRemoval(params: {
viewport.viewportOptions = {};
}
viewport.viewportOptions.viewportType = 'volume';
const csViewportOrthographic =
cornerstoneViewportService.getCornerstoneViewport(viewportId)?.type === 'orthographic';
viewport.viewportOptions.viewportType = csViewportOrthographic ? 'volume' : 'stack';
// orientation
if (!viewport.viewportOptions.orientation) {

View File

@ -661,6 +661,12 @@ export default class HangingProtocolService extends PubSubService {
if (displaySet?.unsupported) {
throw new Error('Unsupported displaySet');
}
// Overlays (SEG, RTSTRUCT) share selectors with referenced volumes but must not
// sync to every matched viewport before hydration (would replace e.g. 3D primary).
if (displaySet?.isOverlayDisplaySet) {
return defaultReturn;
}
const protocol = this.protocol;
const protocolStage = protocol.stages[this.stageIndex];
const protocolViewports = protocolStage.viewports;

View File

@ -24,6 +24,13 @@ export type DisplaySetAndViewportOptions = {
displaySetOptions: DisplaySetOptions;
};
export interface ViewportUpdate {
viewportId?: string;
displaySetInstanceUIDs: string[];
viewportOptions?: ViewportOptions;
displaySetOptions?: DisplaySetOptions[];
}
export type DisplayArea = {
type?: 'SCALE' | 'FIT';
scale?: number;

View File

@ -1,4 +1,10 @@
import { checkForScreenshot, screenShotPaths, test, visitStudy } from './utils';
import {
checkForScreenshot,
screenShotPaths,
test,
visitStudy,
waitForViewportRenderCycle,
} from './utils';
import { press } from './utils/keyboardUtils';
import { assertNumberOfModalityLoadBadges } from './utils/assertions';
@ -15,9 +21,13 @@ test('should overlay an unhydrated SEG over a display set that the SEG does NOT
}) => {
await leftPanelPageObject.loadSeriesByDescription('Apparent Diffusion Coefficient');
const dataOverlayPageObject = (await viewportPageObject.getById('default')).overlayMenu.dataOverlay;
const dataOverlayPageObject = (await viewportPageObject.getById('default')).overlayMenu
.dataOverlay;
await dataOverlayPageObject.toggle();
// Start watching for viewport to render
const viewportRenderCycle = waitForViewportRenderCycle(page);
await dataOverlayPageObject.addSegmentation('T2 Weighted Axial Segmentations');
// Adding an overlay should not show the LOAD button.
@ -25,22 +35,21 @@ test('should overlay an unhydrated SEG over a display set that the SEG does NOT
// Hide the overlay menu.
await dataOverlayPageObject.toggle();
await page.waitForTimeout(5000);
await viewportRenderCycle;
await checkForScreenshot(
page,
page,
viewportPageObject.grid,
screenShotPaths.segDataOverlayForUnreferencedDisplaySetNoHydration.overlayFirstImage
);
// Navigate to the middle image of the default viewport.
await press({ page, key: 'ArrowDown', nTimes: 12 });
await page.waitForTimeout(5000);
await checkForScreenshot(
page,
page,
viewportPageObject.grid,
screenShotPaths.segDataOverlayForUnreferencedDisplaySetNoHydration.overlayMiddleImage
);
});

View File

@ -5,6 +5,8 @@ import {
screenShotPaths,
test,
visitStudy,
waitForViewportsRendered,
waitForViewportRenderCycle,
} from './utils';
test.beforeEach(async ({ page }) => {
@ -19,35 +21,45 @@ test.describe('3D four up SEG hydration', async () => {
DOMOverlayPageObject,
leftPanelPageObject,
mainToolbarPageObject,
viewportPageObject,
}) => {
await mainToolbarPageObject.layoutSelection.threeDFourUp.click();
await attemptAction(() => reduce3DViewportSize(page), 10, 100);
await page.waitForTimeout(5000);
await waitForViewportsRendered(page);
await checkForScreenshot(
page,
page,
viewportPageObject.grid,
screenShotPaths.segHydrationFrom3DFourUp.threeDFourUpBeforeSEG
);
await leftPanelPageObject.loadSeriesByDescription('SEG');
await page.waitForTimeout(5000);
await waitForViewportsRendered(page);
await checkForScreenshot(
page,
page,
viewportPageObject.grid,
screenShotPaths.segHydrationFrom3DFourUp.threeDFourUpAfterSEG
);
// start watching for viewports to render
// High rendered timeout needed: layout has 4 viewports (3D volume + MPR planes + SEG overlays),
// which can take significantly longer time to fully render
const viewportRenderCycle = waitForViewportRenderCycle(page, { renderedTimeout: 180000 });
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
await page.waitForTimeout(5000);
await checkForScreenshot(
// Wait until all viewports have finished rendering
await viewportRenderCycle;
await checkForScreenshot({
page,
page,
screenShotPaths.segHydrationFrom3DFourUp.threeDFourUpAfterSegHydrated
);
locator: viewportPageObject.grid,
screenshotPath: screenShotPaths.segHydrationFrom3DFourUp.threeDFourUpAfterSegHydrated,
});
});
});

View File

@ -1,4 +1,11 @@
import { checkForScreenshot, screenShotPaths, test, visitStudy } from './utils';
import {
checkForScreenshot,
screenShotPaths,
test,
visitStudy,
waitForViewportRenderCycle,
waitForViewportsRendered,
} from './utils';
test.beforeEach(async ({ page }) => {
const studyInstanceUID = '1.3.12.2.1107.5.2.32.35162.30000015050317233592200000046';
@ -12,30 +19,52 @@ test('should properly display MPR for MR', async ({
leftPanelPageObject,
mainToolbarPageObject,
rightPanelPageObject,
viewportPageObject,
}) => {
await rightPanelPageObject.toggle();
await mainToolbarPageObject.layoutSelection.MPR.click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprBeforeSEG);
await waitForViewportsRendered(page);
await checkForScreenshot(
page,
viewportPageObject.grid,
screenShotPaths.segHydrationFromMPR.mprBeforeSEG
);
await leftPanelPageObject.loadSeriesByDescription('SEG');
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprAfterSEG);
await waitForViewportsRendered(page);
await checkForScreenshot(
page,
viewportPageObject.grid,
screenShotPaths.segHydrationFromMPR.mprAfterSEG
);
// start watching for viewports to render
const viewportRenderCycle = waitForViewportRenderCycle(page);
await DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
await page.waitForTimeout(5000);
await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprAfterSegHydrated);
await viewportRenderCycle;
await checkForScreenshot(
page,
viewportPageObject.grid,
screenShotPaths.segHydrationFromMPR.mprAfterSegHydrated
);
const viewportRenderAfterLayoutChange = waitForViewportRenderCycle(page);
await mainToolbarPageObject.layoutSelection.axialPrimary.click();
await page.waitForTimeout(5000);
await viewportRenderAfterLayoutChange;
await checkForScreenshot(
page,
page,
viewportPageObject.grid,
screenShotPaths.segHydrationFromMPR.mprAfterSegHydratedAfterLayoutChange
);
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 621 KiB

After

Width:  |  Height:  |  Size: 548 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 350 KiB

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 283 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 188 KiB

View File

@ -1,5 +1,7 @@
import { expect } from 'playwright-test-coverage';
import { expect, test } from 'playwright-test-coverage';
import { Locator, Page } from 'playwright';
import { promises as fs } from 'fs';
import path from 'path';
type CheckForScreenshotProps = {
page: Page;
@ -18,12 +20,69 @@ type CheckForScreenshotProps = {
fullPage?: boolean;
};
const _isIntermediateScreenshotArtifact = (filename: string, screenshotPath: string) => {
const { name } = path.parse(screenshotPath);
const lowerFilename = filename.toLowerCase();
if (!lowerFilename.endsWith('.png')) {
return false;
}
return (
lowerFilename.startsWith(`${name.toLowerCase()}-`) &&
(lowerFilename.endsWith('-actual.png') ||
lowerFilename.endsWith('-diff.png') ||
lowerFilename.endsWith('-expected.png'))
);
};
const _cleanupIntermediateScreenshotArtifacts = async (
outputDir: string,
screenshotPath: string
) => {
const stack = [outputDir];
while (stack.length) {
const currentDir = stack.pop();
if (!currentDir) {
continue;
}
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
stack.push(fullPath);
continue;
}
if (!_isIntermediateScreenshotArtifact(entry.name, screenshotPath)) {
continue;
}
try {
await fs.unlink(fullPath);
} catch {
// Best-effort cleanup only.
}
}
}
};
const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
const {
page,
screenshotPath,
attempts = 10,
delay = 500,
delay = 1250,
maxDiffPixelRatio = 0.02,
threshold = 0.05,
normalizedClip,
@ -31,6 +90,7 @@ const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
} = props;
let { locator = page } = props;
const testOutputDir = test.info().outputDir;
await page.waitForLoadState('networkidle');
@ -70,6 +130,7 @@ const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
console.debug('Screenshot comparison failed after all attempts');
throw error; // Throw the original error with details instead of a generic message
}
await _cleanupIntermediateScreenshotArtifacts(testOutputDir, screenshotPath);
await new Promise(resolve => setTimeout(resolve, delay));
}
}

View File

@ -24,6 +24,11 @@ import { getSvgPath } from './getSvgPath';
import { navigateWithViewportArrow } from './navigateWithViewportArrow';
import { test, expect } from './fixture';
import { subscribeToMeasurementAdded } from './subscribeToMeasurement';
import {
waitForAnyViewportNeedsRender,
waitForViewportsRendered,
waitForViewportRenderCycle,
} from './waitForViewportsRendered';
export {
visitStudy,
@ -48,6 +53,9 @@ export {
subscribeToMeasurementAdded,
getSvgPath,
navigateWithViewportArrow,
waitForAnyViewportNeedsRender,
waitForViewportsRendered,
waitForViewportRenderCycle,
test,
expect,
};

View File

@ -0,0 +1,163 @@
import type { Page } from '@playwright/test';
type WaitForAnyViewportNeedsRenderOptions = {
timeout?: number;
};
type WaitForViewportsRenderedOptions = {
timeout?: number;
/**
* If true (default), also waits for any volume actors referenced by the
* viewports to report loaded.
*/
waitVolumeLoad?: boolean;
};
type WaitForRenderCycleToCompleteOptions = {
/**
* Timeout for waiting until at least one viewport reaches `needsRender`.
*/
needsRenderTimeout?: number;
/**
* Timeout for waiting until all viewports are `rendered`
* (and optionally volume-loaded).
*/
renderedTimeout?: number;
/**
* If true (default), also waits for any volume actors referenced by the
* viewports to report loaded during the rendered phase.
*/
waitVolumeLoad?: boolean;
};
/**
* Waits for a full render cycle:
* 1) any viewport requests render (`needsRender`)
* 2) all viewports finish rendering (`rendered`)
*/
const waitForViewportRenderCycle = async (
page: Page,
options: WaitForRenderCycleToCompleteOptions = {}
) => {
const { needsRenderTimeout = 5000, renderedTimeout = 15000, waitVolumeLoad = true } = options;
await waitForAnyViewportNeedsRender(page, { timeout: needsRenderTimeout });
await waitForViewportsRendered(page, { timeout: renderedTimeout, waitVolumeLoad });
};
/**
* Waits until at least one viewport enters the 'needsRender' state, indicating
* that a render has been requested but not yet started.
*/
const waitForAnyViewportNeedsRender = async (
page: Page,
options: WaitForAnyViewportNeedsRenderOptions = {}
) => {
const { timeout = 5000 } = options;
await page.waitForFunction(
() => {
const cornerstone = (window as any).cornerstone;
if (!cornerstone?.getRenderingEngines) {
return false;
}
const renderingEngines = cornerstone.getRenderingEngines();
const viewports = renderingEngines.flatMap(engine =>
engine.getViewports ? engine.getViewports() : []
);
if (!viewports.length) {
return false;
}
const needsRender = viewports.some(viewport => viewport?.viewportStatus === 'needsRender');
return needsRender;
},
{},
{ timeout }
);
};
/**
* Stabilize tests by waiting for a short tick, network idle, then viewport render completion.
* To use this method safely, you may need to make changes to OHIF and/or CS3D
* methods handling clicks (SHOULD be commands modules only). These should set the
* state to needs render synchronously so that this method can safely wait for the render to complete.
* Examples such as changing the hanging protocol currently don't set such a state
* and thus can't be rendered without a delay.
*
* If options.waitVolumeLoad is not false, then this method will wait for all volumes
* associated with viewports to be loaded.
*/
const waitForViewportsRendered = async (
page: Page,
options: WaitForViewportsRenderedOptions = {}
) => {
const { timeout = 15000, waitVolumeLoad = true } = options;
await page.waitForFunction(
({ waitVolumeLoad }) => {
const cornerstone = (window as any).cornerstone;
if (!cornerstone?.getRenderingEngines) {
return false;
}
const renderingEngines = cornerstone.getRenderingEngines();
const viewports = renderingEngines.flatMap(engine =>
engine.getViewports ? engine.getViewports() : []
);
if (!viewports.length) {
return false;
}
const allRendered = viewports.every(viewport => viewport?.viewportStatus === 'rendered');
if (!allRendered) {
return false;
}
if (!waitVolumeLoad) {
return true;
}
const cache = cornerstone.cache;
if (!cache?.getVolume) {
return true;
}
const actorEntries = viewports.flatMap(viewport =>
viewport?.getActors ? viewport.getActors() : []
);
for (const actorEntry of actorEntries) {
const id = actorEntry?.referencedId || actorEntry?.uid;
if (!id) {
continue;
}
let volume: any;
try {
volume = cache.getVolume(id);
} catch {
continue;
}
const loaded =
volume?.loadStatus && typeof volume.loadStatus.loaded === 'boolean'
? volume.loadStatus.loaded
: true;
if (!loaded) {
return false;
}
}
return true;
},
{ waitVolumeLoad },
{ timeout }
);
};
export { waitForViewportsRendered, waitForAnyViewportNeedsRender, waitForViewportRenderCycle };