fix(seg): prevent segmentations from spreading to all viewports before hydration confirmation in 3D four-up (#5967)
@ -2096,6 +2096,15 @@ function commandsModule({
|
|||||||
displaySetInstanceUIDs,
|
displaySetInstanceUIDs,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!updatedViewports?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedViewports.forEach(({ viewportId: csViewportId }) => {
|
||||||
|
const csViewport = cornerstoneViewportService.getCornerstoneViewport(csViewportId);
|
||||||
|
csViewport?.setNeedsRender?.();
|
||||||
|
});
|
||||||
|
|
||||||
actions.setDisplaySetsForViewports({
|
actions.setDisplaySetsForViewports({
|
||||||
viewportsToUpdate: updatedViewports.map(viewport => ({
|
viewportsToUpdate: updatedViewports.map(viewport => ({
|
||||||
viewportId: viewport.viewportId,
|
viewportId: viewport.viewportId,
|
||||||
|
|||||||
@ -49,6 +49,47 @@ const EVENTS = {
|
|||||||
const MIN_STACK_VIEWPORTS_TO_ENQUEUE_RESIZE = 12;
|
const MIN_STACK_VIEWPORTS_TO_ENQUEUE_RESIZE = 12;
|
||||||
const MIN_VOLUME_VIEWPORTS_TO_ENQUEUE_RESIZE = 6;
|
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_NAVIGATION = { withNavigation: true, withOrientation: false };
|
||||||
export const WITH_ORIENTATION = { withNavigation: true, withOrientation: true };
|
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);
|
await this._addOverlayRepresentations(overlayProcessingResults);
|
||||||
viewport.render();
|
viewport.render();
|
||||||
|
|
||||||
@ -1124,8 +1202,11 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.setPresentations(viewport.id, presentations);
|
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);
|
const imageIndex = this._getInitialImageIndexForViewport(viewportInfo);
|
||||||
|
|
||||||
if (imageIndex !== undefined) {
|
if (imageIndex !== undefined) {
|
||||||
|
|||||||
@ -135,16 +135,27 @@ const _getSegmentationPresentationId = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { displaySetService } = servicesManager.services;
|
||||||
const { displaySetInstanceUIDs, viewportOptions } = viewport;
|
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;
|
let orientation = viewportOptions.orientation;
|
||||||
|
|
||||||
if (!orientation) {
|
if (!orientation) {
|
||||||
// Calculate orientation from the viewport sample image
|
// Calculate orientation from the viewport sample image
|
||||||
const displaySet = servicesManager.services.displaySetService.getDisplaySetByUID(
|
const displaySet = displaySetService.getDisplaySetByUID(nonOverlayUIDs[0]);
|
||||||
displaySetInstanceUIDs[0]
|
const sampleImage = displaySet?.images?.[0];
|
||||||
);
|
|
||||||
const sampleImage = displaySet.images?.[0];
|
|
||||||
const imageOrientationPatient = sampleImage?.ImageOrientationPatient;
|
const imageOrientationPatient = sampleImage?.ImageOrientationPatient;
|
||||||
|
|
||||||
orientation = getViewportOrientationFromImageOrientationPatient(imageOrientationPatient);
|
orientation = getViewportOrientationFromImageOrientationPatient(imageOrientationPatient);
|
||||||
@ -152,7 +163,7 @@ const _getSegmentationPresentationId = ({
|
|||||||
|
|
||||||
const segmentationPresentationArr = [];
|
const segmentationPresentationArr = [];
|
||||||
|
|
||||||
segmentationPresentationArr.push(...displaySetInstanceUIDs);
|
segmentationPresentationArr.push(...nonOverlayUIDs);
|
||||||
|
|
||||||
// Uncomment if unique indexing is needed
|
// Uncomment if unique indexing is needed
|
||||||
// addUniqueIndex(
|
// addUniqueIndex(
|
||||||
|
|||||||
@ -262,6 +262,57 @@ describe('getUpdatedViewportsForSegmentation', () => {
|
|||||||
expect(result).toEqual(mockUpdatedViewports);
|
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', () => {
|
it('should handle complex viewport structure', () => {
|
||||||
const complexViewport = {
|
const complexViewport = {
|
||||||
viewportOptions: {
|
viewportOptions: {
|
||||||
|
|||||||
@ -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({
|
function getUpdatedViewportsForSegmentation({
|
||||||
viewportId,
|
viewportId,
|
||||||
servicesManager,
|
servicesManager,
|
||||||
@ -5,7 +65,7 @@ function getUpdatedViewportsForSegmentation({
|
|||||||
}: withAppTypes) {
|
}: withAppTypes) {
|
||||||
const { hangingProtocolService, viewportGridService } = servicesManager.services;
|
const { hangingProtocolService, viewportGridService } = servicesManager.services;
|
||||||
|
|
||||||
const { isHangingProtocolLayout } = viewportGridService.getState();
|
const { isHangingProtocolLayout, viewports } = viewportGridService.getState();
|
||||||
|
|
||||||
const viewport = getTargetViewport({ viewportId, viewportGridService });
|
const viewport = getTargetViewport({ viewportId, viewportGridService });
|
||||||
const targetViewportId = viewport.viewportOptions.viewportId;
|
const targetViewportId = viewport.viewportOptions.viewportId;
|
||||||
@ -16,7 +76,11 @@ function getUpdatedViewportsForSegmentation({
|
|||||||
isHangingProtocolLayout
|
isHangingProtocolLayout
|
||||||
);
|
);
|
||||||
|
|
||||||
return updatedViewports;
|
if (updatedViewports == null) {
|
||||||
|
return updatedViewports;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergeVolumeSharingViewports(updatedViewports, displaySetInstanceUIDs[0], viewports);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTargetViewport = ({ viewportId, viewportGridService }) => {
|
const getTargetViewport = ({ viewportId, viewportGridService }) => {
|
||||||
|
|||||||
@ -67,8 +67,14 @@ export function configureViewportForLayerAddition(params: {
|
|||||||
|
|
||||||
// If a viewport type was already set do not reset it.
|
// If a viewport type was already set do not reset it.
|
||||||
if (!viewport.viewportOptions.viewportType) {
|
if (!viewport.viewportOptions.viewportType) {
|
||||||
// Special handling for overlay display sets
|
// If the live Cornerstone viewport is already orthographic (volume), keep it as
|
||||||
if (requestedLayerDisplaySet.isOverlayDisplaySet) {
|
// '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
|
// 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 isSameDisplaySet = currentDisplaySetUIDs.every(uid => {
|
||||||
const currentDisplaySet = displaySetService.getDisplaySetByUID(uid);
|
const currentDisplaySet = displaySetService.getDisplaySetByUID(uid);
|
||||||
@ -131,7 +137,9 @@ export function configureViewportForLayerRemoval(params: {
|
|||||||
viewport.viewportOptions = {};
|
viewport.viewportOptions = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
viewport.viewportOptions.viewportType = 'volume';
|
const csViewportOrthographic =
|
||||||
|
cornerstoneViewportService.getCornerstoneViewport(viewportId)?.type === 'orthographic';
|
||||||
|
viewport.viewportOptions.viewportType = csViewportOrthographic ? 'volume' : 'stack';
|
||||||
|
|
||||||
// orientation
|
// orientation
|
||||||
if (!viewport.viewportOptions.orientation) {
|
if (!viewport.viewportOptions.orientation) {
|
||||||
|
|||||||
@ -661,6 +661,12 @@ export default class HangingProtocolService extends PubSubService {
|
|||||||
if (displaySet?.unsupported) {
|
if (displaySet?.unsupported) {
|
||||||
throw new Error('Unsupported displaySet');
|
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 protocol = this.protocol;
|
||||||
const protocolStage = protocol.stages[this.stageIndex];
|
const protocolStage = protocol.stages[this.stageIndex];
|
||||||
const protocolViewports = protocolStage.viewports;
|
const protocolViewports = protocolStage.viewports;
|
||||||
|
|||||||
@ -24,6 +24,13 @@ export type DisplaySetAndViewportOptions = {
|
|||||||
displaySetOptions: DisplaySetOptions;
|
displaySetOptions: DisplaySetOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface ViewportUpdate {
|
||||||
|
viewportId?: string;
|
||||||
|
displaySetInstanceUIDs: string[];
|
||||||
|
viewportOptions?: ViewportOptions;
|
||||||
|
displaySetOptions?: DisplaySetOptions[];
|
||||||
|
}
|
||||||
|
|
||||||
export type DisplayArea = {
|
export type DisplayArea = {
|
||||||
type?: 'SCALE' | 'FIT';
|
type?: 'SCALE' | 'FIT';
|
||||||
scale?: number;
|
scale?: number;
|
||||||
|
|||||||
@ -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 { press } from './utils/keyboardUtils';
|
||||||
import { assertNumberOfModalityLoadBadges } from './utils/assertions';
|
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');
|
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();
|
await dataOverlayPageObject.toggle();
|
||||||
|
|
||||||
|
// Start watching for viewport to render
|
||||||
|
const viewportRenderCycle = waitForViewportRenderCycle(page);
|
||||||
|
|
||||||
await dataOverlayPageObject.addSegmentation('T2 Weighted Axial Segmentations');
|
await dataOverlayPageObject.addSegmentation('T2 Weighted Axial Segmentations');
|
||||||
|
|
||||||
// Adding an overlay should not show the LOAD button.
|
// 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.
|
// Hide the overlay menu.
|
||||||
await dataOverlayPageObject.toggle();
|
await dataOverlayPageObject.toggle();
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
|
await viewportRenderCycle;
|
||||||
|
|
||||||
await checkForScreenshot(
|
await checkForScreenshot(
|
||||||
page,
|
page,
|
||||||
page,
|
viewportPageObject.grid,
|
||||||
screenShotPaths.segDataOverlayForUnreferencedDisplaySetNoHydration.overlayFirstImage
|
screenShotPaths.segDataOverlayForUnreferencedDisplaySetNoHydration.overlayFirstImage
|
||||||
);
|
);
|
||||||
|
|
||||||
// Navigate to the middle image of the default viewport.
|
// Navigate to the middle image of the default viewport.
|
||||||
await press({ page, key: 'ArrowDown', nTimes: 12 });
|
await press({ page, key: 'ArrowDown', nTimes: 12 });
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
|
||||||
|
|
||||||
await checkForScreenshot(
|
await checkForScreenshot(
|
||||||
page,
|
page,
|
||||||
page,
|
viewportPageObject.grid,
|
||||||
screenShotPaths.segDataOverlayForUnreferencedDisplaySetNoHydration.overlayMiddleImage
|
screenShotPaths.segDataOverlayForUnreferencedDisplaySetNoHydration.overlayMiddleImage
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import {
|
|||||||
screenShotPaths,
|
screenShotPaths,
|
||||||
test,
|
test,
|
||||||
visitStudy,
|
visitStudy,
|
||||||
|
waitForViewportsRendered,
|
||||||
|
waitForViewportRenderCycle,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@ -19,35 +21,45 @@ test.describe('3D four up SEG hydration', async () => {
|
|||||||
DOMOverlayPageObject,
|
DOMOverlayPageObject,
|
||||||
leftPanelPageObject,
|
leftPanelPageObject,
|
||||||
mainToolbarPageObject,
|
mainToolbarPageObject,
|
||||||
|
viewportPageObject,
|
||||||
}) => {
|
}) => {
|
||||||
await mainToolbarPageObject.layoutSelection.threeDFourUp.click();
|
await mainToolbarPageObject.layoutSelection.threeDFourUp.click();
|
||||||
|
|
||||||
await attemptAction(() => reduce3DViewportSize(page), 10, 100);
|
await attemptAction(() => reduce3DViewportSize(page), 10, 100);
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await waitForViewportsRendered(page);
|
||||||
|
|
||||||
await checkForScreenshot(
|
await checkForScreenshot(
|
||||||
page,
|
page,
|
||||||
page,
|
viewportPageObject.grid,
|
||||||
screenShotPaths.segHydrationFrom3DFourUp.threeDFourUpBeforeSEG
|
screenShotPaths.segHydrationFrom3DFourUp.threeDFourUpBeforeSEG
|
||||||
);
|
);
|
||||||
|
|
||||||
await leftPanelPageObject.loadSeriesByDescription('SEG');
|
await leftPanelPageObject.loadSeriesByDescription('SEG');
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await waitForViewportsRendered(page);
|
||||||
|
|
||||||
await checkForScreenshot(
|
await checkForScreenshot(
|
||||||
page,
|
page,
|
||||||
page,
|
viewportPageObject.grid,
|
||||||
screenShotPaths.segHydrationFrom3DFourUp.threeDFourUpAfterSEG
|
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 DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
// Wait until all viewports have finished rendering
|
||||||
await checkForScreenshot(
|
await viewportRenderCycle;
|
||||||
|
|
||||||
|
await checkForScreenshot({
|
||||||
page,
|
page,
|
||||||
page,
|
locator: viewportPageObject.grid,
|
||||||
screenShotPaths.segHydrationFrom3DFourUp.threeDFourUpAfterSegHydrated
|
screenshotPath: screenShotPaths.segHydrationFrom3DFourUp.threeDFourUpAfterSegHydrated,
|
||||||
);
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,11 @@
|
|||||||
import { checkForScreenshot, screenShotPaths, test, visitStudy } from './utils';
|
import {
|
||||||
|
checkForScreenshot,
|
||||||
|
screenShotPaths,
|
||||||
|
test,
|
||||||
|
visitStudy,
|
||||||
|
waitForViewportRenderCycle,
|
||||||
|
waitForViewportsRendered,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
const studyInstanceUID = '1.3.12.2.1107.5.2.32.35162.30000015050317233592200000046';
|
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,
|
leftPanelPageObject,
|
||||||
mainToolbarPageObject,
|
mainToolbarPageObject,
|
||||||
rightPanelPageObject,
|
rightPanelPageObject,
|
||||||
|
viewportPageObject,
|
||||||
}) => {
|
}) => {
|
||||||
await rightPanelPageObject.toggle();
|
await rightPanelPageObject.toggle();
|
||||||
|
|
||||||
await mainToolbarPageObject.layoutSelection.MPR.click();
|
await mainToolbarPageObject.layoutSelection.MPR.click();
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await waitForViewportsRendered(page);
|
||||||
await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprBeforeSEG);
|
|
||||||
|
await checkForScreenshot(
|
||||||
|
page,
|
||||||
|
viewportPageObject.grid,
|
||||||
|
screenShotPaths.segHydrationFromMPR.mprBeforeSEG
|
||||||
|
);
|
||||||
|
|
||||||
await leftPanelPageObject.loadSeriesByDescription('SEG');
|
await leftPanelPageObject.loadSeriesByDescription('SEG');
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await waitForViewportsRendered(page);
|
||||||
await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprAfterSEG);
|
|
||||||
|
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 DOMOverlayPageObject.viewport.segmentationHydration.yes.click();
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await viewportRenderCycle;
|
||||||
await checkForScreenshot(page, page, screenShotPaths.segHydrationFromMPR.mprAfterSegHydrated);
|
|
||||||
|
await checkForScreenshot(
|
||||||
|
page,
|
||||||
|
viewportPageObject.grid,
|
||||||
|
screenShotPaths.segHydrationFromMPR.mprAfterSegHydrated
|
||||||
|
);
|
||||||
|
|
||||||
|
const viewportRenderAfterLayoutChange = waitForViewportRenderCycle(page);
|
||||||
|
|
||||||
await mainToolbarPageObject.layoutSelection.axialPrimary.click();
|
await mainToolbarPageObject.layoutSelection.axialPrimary.click();
|
||||||
|
|
||||||
await page.waitForTimeout(5000);
|
await viewportRenderAfterLayoutChange;
|
||||||
|
|
||||||
await checkForScreenshot(
|
await checkForScreenshot(
|
||||||
page,
|
page,
|
||||||
page,
|
viewportPageObject.grid,
|
||||||
screenShotPaths.segHydrationFromMPR.mprAfterSegHydratedAfterLayoutChange
|
screenShotPaths.segHydrationFromMPR.mprAfterSegHydratedAfterLayoutChange
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 367 KiB After Width: | Height: | Size: 255 KiB |
|
Before Width: | Height: | Size: 382 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 621 KiB After Width: | Height: | Size: 548 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 282 KiB |
|
Before Width: | Height: | Size: 283 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 329 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 188 KiB |
@ -1,5 +1,7 @@
|
|||||||
import { expect } from 'playwright-test-coverage';
|
import { expect, test } from 'playwright-test-coverage';
|
||||||
import { Locator, Page } from 'playwright';
|
import { Locator, Page } from 'playwright';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
type CheckForScreenshotProps = {
|
type CheckForScreenshotProps = {
|
||||||
page: Page;
|
page: Page;
|
||||||
@ -18,12 +20,69 @@ type CheckForScreenshotProps = {
|
|||||||
fullPage?: boolean;
|
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 _checkForScreenshot = async (props: CheckForScreenshotProps) => {
|
||||||
const {
|
const {
|
||||||
page,
|
page,
|
||||||
screenshotPath,
|
screenshotPath,
|
||||||
attempts = 10,
|
attempts = 10,
|
||||||
delay = 500,
|
delay = 1250,
|
||||||
maxDiffPixelRatio = 0.02,
|
maxDiffPixelRatio = 0.02,
|
||||||
threshold = 0.05,
|
threshold = 0.05,
|
||||||
normalizedClip,
|
normalizedClip,
|
||||||
@ -31,6 +90,7 @@ const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
let { locator = page } = props;
|
let { locator = page } = props;
|
||||||
|
const testOutputDir = test.info().outputDir;
|
||||||
|
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
@ -70,6 +130,7 @@ const _checkForScreenshot = async (props: CheckForScreenshotProps) => {
|
|||||||
console.debug('Screenshot comparison failed after all attempts');
|
console.debug('Screenshot comparison failed after all attempts');
|
||||||
throw error; // Throw the original error with details instead of a generic message
|
throw error; // Throw the original error with details instead of a generic message
|
||||||
}
|
}
|
||||||
|
await _cleanupIntermediateScreenshotArtifacts(testOutputDir, screenshotPath);
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,6 +24,11 @@ import { getSvgPath } from './getSvgPath';
|
|||||||
import { navigateWithViewportArrow } from './navigateWithViewportArrow';
|
import { navigateWithViewportArrow } from './navigateWithViewportArrow';
|
||||||
import { test, expect } from './fixture';
|
import { test, expect } from './fixture';
|
||||||
import { subscribeToMeasurementAdded } from './subscribeToMeasurement';
|
import { subscribeToMeasurementAdded } from './subscribeToMeasurement';
|
||||||
|
import {
|
||||||
|
waitForAnyViewportNeedsRender,
|
||||||
|
waitForViewportsRendered,
|
||||||
|
waitForViewportRenderCycle,
|
||||||
|
} from './waitForViewportsRendered';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
visitStudy,
|
visitStudy,
|
||||||
@ -48,6 +53,9 @@ export {
|
|||||||
subscribeToMeasurementAdded,
|
subscribeToMeasurementAdded,
|
||||||
getSvgPath,
|
getSvgPath,
|
||||||
navigateWithViewportArrow,
|
navigateWithViewportArrow,
|
||||||
|
waitForAnyViewportNeedsRender,
|
||||||
|
waitForViewportsRendered,
|
||||||
|
waitForViewportRenderCycle,
|
||||||
test,
|
test,
|
||||||
expect,
|
expect,
|
||||||
};
|
};
|
||||||
|
|||||||
163
tests/utils/waitForViewportsRendered.ts
Normal 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 };
|
||||||