diff --git a/.agents/skills/ohif-test-agent/SKILL.md b/.agents/skills/ohif-test-agent/SKILL.md index dea38c445..650e82764 100644 --- a/.agents/skills/ohif-test-agent/SKILL.md +++ b/.agents/skills/ohif-test-agent/SKILL.md @@ -17,7 +17,7 @@ This package follows the agentskills.io SKILL.md convention. `SKILL.md` is the e 2. **Read the seed spec.** Consult [references/patterns-by-feature.md](references/patterns-by-feature.md) to find the canonical existing spec for that area. Read it end-to-end before writing. This is the single most important step — OHIF specs follow consistent idioms that are easier to mimic than to reconstruct from first principles. (This mirrors Playwright's own agent guidance: use seed tests as the example for generated tests.) 3. **Scaffold from the template.** Start from [assets/spec-template.ts](assets/spec-template.ts) — or copy the seed spec and adapt. 4. **Look up specifics in the source, not from memory.** The reference files [page-objects.md](references/page-objects.md) and [utilities.md](references/utilities.md) capture the **stable rules** — fixture keys, import conventions, access idioms, the reasons certain things trip people up. They deliberately do not enumerate methods. For the current method surface or a utility's exact signature, open the relevant file under `tests/pages/` or `tests/utils/` — the source evolves, and the source is always right. The seed spec you picked in step 2 is usually the fastest second source, because it co-evolves with the API. -5. **Run the test when execution is available.** `yarn test:e2e:ci` runs the whole suite, but for iteration use `yarn playwright test tests/YourNew.spec.ts` (or via the Playwright VS Code extension). +5. **Run the test when execution is available.** `pnpm run test:e2e:ci` runs the whole suite, but for iteration use `TEST_ENV=true pnpm exec playwright test tests/YourNew.spec.ts` (or the Playwright VS Code extension). Invoke Playwright directly for targeted flags; `pnpm run test:e2e -- ...` inserts a `--` separator that can prevent Playwright from parsing options such as `--update-snapshots` and `--reporter`. 6. **If runtime execution is unavailable, do static validation.** Validate import source, fixture keys, normalized viewport usage, UID/mode pairing, and hydration/tracking prompt handling. Then report clearly that execution was not performed. 7. **If it fails, triage before debugging.** Use [references/failure-triage.md](references/failure-triage.md) — most OHIF test failures are timing / hydration, not real regressions. diff --git a/.agents/skills/ohif-test-agent/references/failure-triage.md b/.agents/skills/ohif-test-agent/references/failure-triage.md index b11b90dc5..419f18d7c 100644 --- a/.agents/skills/ohif-test-agent/references/failure-triage.md +++ b/.agents/skills/ohif-test-agent/references/failure-triage.md @@ -6,9 +6,9 @@ Before debugging, classify. Most OHIF test failures are timing or hydration — |----------|---------|-----| | Timing | Element not visible, action timeout | Add / increase the `delay` param of `visitStudy`; for actions that re-render viewports, use `waitForViewportRenderCycle(page)` (started before the action) instead of `waitForTimeout`; wrap the assertion in `expect.toPass({ timeout })` | | Selector | Element not found | Verify `data-cy` on the target; confirm the panel is open (`toggle()` / `select()` before interacting); check for capital `D` in `DOMOverlayPageObject` when destructuring | -| Hydration | Segmentation/RT not interactive | Ensure the `segmentationHydration.yes.click()` fired; add `waitForTimeout(3000)` after `loadSeriesByModality('SEG'\|'RTSTRUCT'\|'SR')` | +| Hydration | Segmentation/RT/SR not interactive | Ensure the `segmentationHydration.yes.click()` fired; wait for an observable hydrated state such as measurement/segment rows or the target series overlay, then wait for the resulting viewport render | | Data | Study not found, empty viewport | Confirm the UID is in the canonical list (see [patterns-by-feature.md](patterns-by-feature.md)); confirm the mode supports the feature (segmentation tools aren't in `viewer` mode) | -| Visual drift | Screenshot mismatch but feature works | Have a human review the diff, then regenerate the baseline with `yarn playwright test --update-snapshots`. Do not adjust `maxDiffPixelRatio` or `threshold` to make a failing screenshot pass. | +| Visual drift | Screenshot mismatch but feature works | Have a human review the diff, then regenerate the baseline with `TEST_ENV=true pnpm exec playwright test --update-snapshots`. Do not adjust `maxDiffPixelRatio` or `threshold` to make a failing screenshot pass. | | Real regression | Feature is actually broken | Report as a bug — this is the test doing its job | ## Prefer render-cycle waits over sleeps @@ -33,6 +33,8 @@ Symptom: the test fails inside `waitForAnyViewportNeedsRender` after 5s, with th Don't react by raising the cycle's timeout — the transition isn't coming. Replace the cycle wrapper with an auto-retrying DOM/SVG assertion, or `expect.toPass({ timeout })` around the assertion block. +An immediate `waitForViewportsRendered(page)` can also return too early when a click dispatches work through an asynchronous state machine: the old viewport is already `rendered` before the new series or annotations are applied. In that case, first wait for the target state (for example, hydrated measurement rows or the expected series overlay), then call `waitForViewportsRendered(page)` to settle that state's render. + ## The `toPass` pattern When an assertion needs to wait for async render / propagation: @@ -64,7 +66,7 @@ await press({ page, key: 'ArrowDown', nTimes: 50 }); // object param, not (page, Screenshots live under `tests/screenshots/chromium//`. To accept new output as the baseline: ```sh -yarn playwright test tests/YourSpec.spec.ts --update-snapshots +TEST_ENV=true pnpm exec playwright test tests/YourSpec.spec.ts --update-snapshots ``` Review the resulting PNGs carefully — an agent-accepted baseline that's subtly wrong is worse than a failing test. diff --git a/extensions/cornerstone-dicom-pmap/package.json b/extensions/cornerstone-dicom-pmap/package.json index 8a8ba8671..559eef5bb 100644 --- a/extensions/cornerstone-dicom-pmap/package.json +++ b/extensions/cornerstone-dicom-pmap/package.json @@ -41,8 +41,8 @@ }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/adapters": "5.4.13", - "@cornerstonejs/core": "5.4.13", + "@cornerstonejs/adapters": "5.4.17", + "@cornerstonejs/core": "5.4.17", "@kitware/vtk.js": "35.5.3" }, "devDependencies": { diff --git a/extensions/cornerstone-dicom-seg/package.json b/extensions/cornerstone-dicom-seg/package.json index daa8add22..beb970127 100644 --- a/extensions/cornerstone-dicom-seg/package.json +++ b/extensions/cornerstone-dicom-seg/package.json @@ -41,8 +41,8 @@ }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/adapters": "5.4.13", - "@cornerstonejs/core": "5.4.13", + "@cornerstonejs/adapters": "5.4.17", + "@cornerstonejs/core": "5.4.17", "@kitware/vtk.js": "35.5.3" }, "devDependencies": { diff --git a/extensions/cornerstone-dicom-seg/src/commandsModule.ts b/extensions/cornerstone-dicom-seg/src/commandsModule.ts index 43ed2dff8..b70f60071 100644 --- a/extensions/cornerstone-dicom-seg/src/commandsModule.ts +++ b/extensions/cornerstone-dicom-seg/src/commandsModule.ts @@ -6,7 +6,11 @@ import { adaptersRT, adaptersSEG } from '@cornerstonejs/adapters'; import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default'; import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES'; -import { getSegmentationSaveOptions } from './utils/segmentationConfig'; +import { + getSegmentationSaveOptions, + LABELMAP_SEG_SOP_CLASS_UID, + BITMAP_SEG_SOP_CLASS_UID, +} from './utils/segmentationConfig'; const getTargetViewport = ({ viewportId, viewportGridService }) => { const { viewports, activeViewportId } = viewportGridService.getState(); @@ -29,7 +33,6 @@ const { }, } = adaptersRT; - const commandsModule = ({ servicesManager, extensionManager, @@ -105,57 +108,69 @@ const commandsModule = ({ : extensionManager.getActiveDataSourceDefinition(); const dataSourceStoreOverride = dataSourceDefinition?.configuration?.segmentation?.store; - const { imageIds } = segmentation.representationData.Labelmap; + const labelmapData = segmentation.representationData.Labelmap; - const segImages = imageIds.map(imageId => cache.getImage(imageId)); - const referencedImages = segImages.map((segImage, sliceIndex) => { - const referencedImage = cache.getImage(segImage.referencedImageId); + // Build a labelmap3D (one labelmaps2D entry per source slice) from a list of + // derived labelmap image ids. When `referencedImageIds` is supplied (the + // multi-layer/overlap path) each frame is indexed by its source slice so the + // layers align to the same frames; otherwise frames are sequential (the legacy + // single-layer behavior, kept byte-identical). + const buildLabelmap3D = (segImageIds: string[], metadata, referencedImageIds?: string[]) => { + const segImages = segImageIds.map(imageId => cache.getImage(imageId)); + const labelmaps2D = []; - if (!referencedImage) { - throw new Error( - `Referenced source image not in cache for segmentation slice ${sliceIndex} ` + - `(referencedImageId: ${segImage.referencedImageId}). Ensure the referenced series is fully loaded before storing.` - ); - } + // Map each source imageId to its frame index once (O(n)) so the per-slice lookup + // below is O(1) — avoids the O(slices^2) indexOf scan on the multi-layer path. + const referencedFrameIndexById = referencedImageIds + ? new Map(referencedImageIds.map((imageId, index) => [imageId, index])) + : undefined; - return referencedImage; - }); + let z = 0; - const labelmaps2D = []; + for (const segImage of segImages) { + const segmentsOnLabelmap = new Set(); + const pixelData = segImage.getPixelData(); + const { rows, columns } = segImage; - let z = 0; - - for (const segImage of segImages) { - const segmentsOnLabelmap = new Set(); - const pixelData = segImage.getPixelData(); - const { rows, columns } = segImage; - - // Use a single pass through the pixel data - for (let i = 0; i < pixelData.length; i++) { - const segment = pixelData[i]; - if (segment !== 0) { - segmentsOnLabelmap.add(segment); + // Use a single pass through the pixel data + for (let i = 0; i < pixelData.length; i++) { + const segment = pixelData[i]; + if (segment !== 0) { + segmentsOnLabelmap.add(segment); + } } + + const frameIndex = referencedFrameIndexById + ? referencedFrameIndexById.get(segImage.referencedImageId) ?? -1 + : z++; + + if (frameIndex < 0) { + continue; + } + + labelmaps2D[frameIndex] = { + segmentsOnLabelmap: Array.from(segmentsOnLabelmap), + pixelData, + rows, + columns, + }; } - labelmaps2D[z++] = { - segmentsOnLabelmap: Array.from(segmentsOnLabelmap), - pixelData, - rows, - columns, + const allSegmentsOnLabelmap = labelmaps2D + .filter(Boolean) + .map(labelmap => labelmap.segmentsOnLabelmap); + + return { + segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())), + metadata, + labelmaps2D, }; - } - - const allSegmentsOnLabelmap = labelmaps2D.map(labelmap => labelmap.segmentsOnLabelmap); - - const labelmap3D = { - segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())), - metadata: [], - labelmaps2D, }; + // Segment metadata (shared across all layers). const segmentationInOHIF = segmentationService.getSegmentation(segmentationId); const representations = segmentationService.getRepresentationsForSegmentation(segmentationId); + const metadata = []; Object.entries(segmentationInOHIF.segments).forEach(([segmentIndex, segment]) => { // segmentation service already has a color for each segment @@ -176,7 +191,7 @@ const commandsModule = ({ color.slice(0, 3).map(value => value / 255) ).map(value => Math.round(value)); - const segmentMetadata = { + metadata[segmentIndex] = { SegmentNumber: segmentIndex.toString(), SegmentLabel: label, SegmentAlgorithmType: segment?.algorithmType || 'MANUAL', @@ -193,14 +208,76 @@ const commandsModule = ({ CodeMeaning: 'Tissue', }, }; - labelmap3D.metadata[segmentIndex] = segmentMetadata; }); - const generatedSegmentation = generateSegmentation(referencedImages, labelmap3D, metaData, { + // Multi-layer (overlapping) SEGs register one labelmap layer per conflict-free + // group. Export each layer as its own labelmap3D against the UNIQUE referenced + // source series, so cornerstone writes overlapping segments as separate frames + // that reference the same source slice (the DICOM SEG overlap encoding). The + // cs3D adapter's fillSegmentation accepts an array of labelmap3D for exactly + // this. Single-layer SEGs keep the original single-labelmap3D path unchanged. + const layers = labelmapData.labelmaps ? Object.values(labelmapData.labelmaps) : undefined; + + // The referenced source images must be fully loaded (in cache) before we can + // build the SEG dataset against them; fail loudly rather than passing undefined + // frames to the adapter. + const resolveReferencedImage = (referencedImageId: string, sliceIndex: number) => { + const referencedImage = cache.getImage(referencedImageId); + if (!referencedImage) { + throw new Error( + `Referenced source image not in cache for segmentation slice ${sliceIndex} ` + + `(referencedImageId: ${referencedImageId}). Ensure the referenced series is fully loaded before storing.` + ); + } + return referencedImage; + }; + + let referencedImages; + let labelmaps3D; + + if (layers && layers.length > 1) { + const referencedImageIds = + layers[0].referencedImageIds ?? labelmapData.referencedImageIds ?? []; + referencedImages = referencedImageIds.map(resolveReferencedImage); + labelmaps3D = layers.map(layer => + buildLabelmap3D(layer.imageIds ?? [], metadata, referencedImageIds) + ); + } else { + const { imageIds } = labelmapData; + const segImages = imageIds.map(imageId => cache.getImage(imageId)); + referencedImages = segImages.map((image, sliceIndex) => + resolveReferencedImage(image.referencedImageId, sliceIndex) + ); + labelmaps3D = buildLabelmap3D(imageIds, metadata); + } + + const saveOptions = { predecessorImageId, ...getSegmentationSaveOptions(customizationService, dataSourceStoreOverride), ...generateOptions, - }); + }; + + // A LABELMAP SEG frame stores a single label per voxel, so the labelmap + // encoder cannot represent overlapping segments — it keeps only the last + // layer written to each voxel. Overlapping segmentations arrive here as + // multiple layers, so switch those to the binary SEG encoding, which + // writes overlapping segments as separate frames referencing the same + // source slice. + const hasOverlappingLayers = Boolean(layers && layers.length > 1); + if (hasOverlappingLayers && saveOptions.sopClassUID === LABELMAP_SEG_SOP_CLASS_UID) { + console.warn( + 'generateSegmentation: overlapping segments cannot be stored as a LABELMAP SEG; ' + + 'switching to the binary SEG encoding for this store.' + ); + saveOptions.sopClassUID = BITMAP_SEG_SOP_CLASS_UID; + } + + const generatedSegmentation = generateSegmentation( + referencedImages, + labelmaps3D, + metaData, + saveOptions + ); return generatedSegmentation; }, @@ -266,9 +343,7 @@ const commandsModule = ({ } const defaultFileName = - modality === 'RTSTRUCT' - ? `rtss-${segmentationId}.dcm` - : `${label || 'segmentation'}.dcm`; + modality === 'RTSTRUCT' ? `rtss-${segmentationId}.dcm` : `${label || 'segmentation'}.dcm`; const storeFn = commandsManager.runCommand('createStoreFunction', { dataSource: dataSourceName, diff --git a/extensions/cornerstone-dicom-sr/package.json b/extensions/cornerstone-dicom-sr/package.json index e408d9ae3..d10b8f7ee 100644 --- a/extensions/cornerstone-dicom-sr/package.json +++ b/extensions/cornerstone-dicom-sr/package.json @@ -40,9 +40,9 @@ }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/adapters": "5.4.13", - "@cornerstonejs/core": "5.4.13", - "@cornerstonejs/tools": "5.4.13", + "@cornerstonejs/adapters": "5.4.17", + "@cornerstonejs/core": "5.4.17", + "@cornerstonejs/tools": "5.4.17", "classnames": "2.5.1" }, "devDependencies": { diff --git a/extensions/cornerstone-dicom-sr/src/utils/hydrateStructuredReport.ts b/extensions/cornerstone-dicom-sr/src/utils/hydrateStructuredReport.ts index b934f6562..4d6a8a1a8 100644 --- a/extensions/cornerstone-dicom-sr/src/utils/hydrateStructuredReport.ts +++ b/extensions/cornerstone-dicom-sr/src/utils/hydrateStructuredReport.ts @@ -142,15 +142,22 @@ export default function hydrateStructuredReport( hydratableMeasurementsInSR, sopInstanceUIDToImageId ); + const displaySetsByFrameOfReferenceUID = new Map(); for (const FrameOfReferenceUID of frameOfReferenceUIDs) { const displaySetsFOR = displaySetService.getDisplaySetsBy( ds => ds.FrameOfReferenceUID === FrameOfReferenceUID && !ds.isDerivedDisplaySet ); - const ds = chooseDisplaySet(displaySetsFOR, FrameOfReferenceUID); + const ds = getReferencedDisplaySet( + displaySet, + displaySetsFOR, + FrameOfReferenceUID, + displaySetService + ); if (!ds) { continue; } + displaySetsByFrameOfReferenceUID.set(FrameOfReferenceUID, ds); if (!SeriesInstanceUIDs.includes(ds.SeriesInstanceUID)) { SeriesInstanceUIDs.push(ds.SeriesInstanceUID); } @@ -174,7 +181,7 @@ export default function hydrateStructuredReport( const imageId = sopInstanceUIDToImageId[`${toolData.sopInstanceUid}:${frameNumber}`]; if (!imageId) { - return getReferenceData3D(toolData, servicesManager); + return getReferenceData3D(toolData, servicesManager, displaySetsByFrameOfReferenceUID); } const instance = metaData.get('instance', imageId); @@ -316,21 +323,60 @@ function chooseDisplaySet(displaySets, reference) { console.warn('No display set found for', reference); return; } - if (displaySets.length === 1) { - return displaySets[0]; + const sortedDisplaySets = OHIF.utils.sortDisplaySetsCopy(displaySets); + if (sortedDisplaySets.length === 1) { + return sortedDisplaySets[0]; } - const volumeDs = displaySets.find(ds => ds.isReconstructable); + const volumeDs = sortedDisplaySets.find(ds => ds.isReconstructable); if (volumeDs) { return volumeDs; } - return displaySets[0]; + return sortedDisplaySets[0]; +} + +/** + * SCOORD3D only identifies a frame of reference, so many series can be valid + * candidates. The SR loader has already selected and recorded a stable display + * set for each measurement. Reuse that selection during hydration so the + * viewport series and annotation volume cannot depend on display-set load order. + */ +function getReferencedDisplaySet( + srDisplaySet, + displaySets, + FrameOfReferenceUID, + displaySetService +) { + const referencedDisplaySetInstanceUID = srDisplaySet.measurements?.find(measurement => + measurement.coords?.some( + coord => + coord.ValueType === 'SCOORD3D' && + coord.ReferencedFrameOfReferenceSequence === FrameOfReferenceUID + ) + )?.displaySetInstanceUID; + + const referencedDisplaySet = referencedDisplaySetInstanceUID + ? displaySetService.getDisplaySetByUID(referencedDisplaySetInstanceUID) + : undefined; + + if ( + referencedDisplaySet?.FrameOfReferenceUID === FrameOfReferenceUID && + !referencedDisplaySet.isDerivedDisplaySet + ) { + return referencedDisplaySet; + } + + return chooseDisplaySet(displaySets, FrameOfReferenceUID); } /** * Gets the additional reference data appropriate for a 3d reference. * This will choose a volume id, frame of reference and a plane restriction. */ -function getReferenceData3D(toolData, servicesManager: Types.ServicesManager) { +function getReferenceData3D( + toolData, + servicesManager: Types.ServicesManager, + displaySetsByFrameOfReferenceUID = new Map() +) { const { FrameOfReferenceUID } = toolData.annotation.metadata; const { points } = toolData.annotation.data.handles; const { displaySetService } = servicesManager.services; @@ -342,7 +388,9 @@ function getReferenceData3D(toolData, servicesManager: Types.ServicesManager) { FrameOfReferenceUID, }; } - const ds = chooseDisplaySet(displaySetsFOR, toolData.annotation); + const ds = + displaySetsByFrameOfReferenceUID.get(FrameOfReferenceUID) || + chooseDisplaySet(displaySetsFOR, toolData.annotation); const cameraView = chooseCameraView(ds, points); const viewReference = { diff --git a/extensions/cornerstone-dynamic-volume/package.json b/extensions/cornerstone-dynamic-volume/package.json index bee6c9568..953135e06 100644 --- a/extensions/cornerstone-dynamic-volume/package.json +++ b/extensions/cornerstone-dynamic-volume/package.json @@ -45,8 +45,8 @@ }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/core": "5.4.13", - "@cornerstonejs/tools": "5.4.13", + "@cornerstonejs/core": "5.4.17", + "@cornerstonejs/tools": "5.4.17", "classnames": "2.5.1" }, "devDependencies": { diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index e6a5bfa56..3b74f7f42 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -38,7 +38,7 @@ "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2", "@cornerstonejs/codec-openjpeg": "1.3.0", "@cornerstonejs/codec-openjph": "2.4.7", - "@cornerstonejs/dicom-image-loader": "5.4.13", + "@cornerstonejs/dicom-image-loader": "5.4.17", "@ohif/core": "workspace:*", "@ohif/extension-default": "workspace:*", "@ohif/ui": "workspace:*", @@ -52,13 +52,13 @@ }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/adapters": "5.4.13", - "@cornerstonejs/ai": "5.4.13", - "@cornerstonejs/core": "5.4.13", - "@cornerstonejs/labelmap-interpolation": "5.4.13", - "@cornerstonejs/metadata": "5.4.13", - "@cornerstonejs/polymorphic-segmentation": "5.4.13", - "@cornerstonejs/tools": "5.4.13", + "@cornerstonejs/adapters": "5.4.17", + "@cornerstonejs/ai": "5.4.17", + "@cornerstonejs/core": "5.4.17", + "@cornerstonejs/labelmap-interpolation": "5.4.17", + "@cornerstonejs/metadata": "5.4.17", + "@cornerstonejs/polymorphic-segmentation": "5.4.17", + "@cornerstonejs/tools": "5.4.17", "@icr/polyseg-wasm": "0.4.0", "@itk-wasm/morphological-contour-interpolation": "1.1.0", "@kitware/vtk.js": "35.5.3", diff --git a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx index e9a91a920..557ba77ee 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/CustomizableViewportOverlay.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { vec3 } from 'gl-matrix'; import PropTypes from 'prop-types'; -import { metaData, Enums, utilities, eventTarget } from '@cornerstonejs/core'; +import { metaData, Enums, eventTarget } from '@cornerstonejs/core'; import { Enums as csToolsEnums, UltrasoundPleuraBLineTool } from '@cornerstonejs/tools'; import type { ImageSliceData } from '@cornerstonejs/core/types'; import { ViewportOverlay, formatDICOMDate } from '@ohif/ui-next'; @@ -9,6 +9,8 @@ import type { InstanceMetadata } from '@ohif/core/src/types'; import { formatDICOMTime, formatNumberPrecision } from './utils'; import { utils } from '@ohif/core'; import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService'; +import { getViewportAdapter } from '../../services/ViewportService/adapter'; +import { getViewportDataShapeType } from '../../utils/viewportDataShape'; import './CustomizableViewportOverlay.css'; import { useViewportRendering } from '../../hooks'; @@ -269,7 +271,7 @@ function getDisplaySets(viewportData, displaySetService) { const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => { let instanceNumber; - switch (viewportData.viewportType) { + switch (getViewportDataShapeType(viewportData)) { case Enums.ViewportType.STACK: instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex); break; @@ -336,8 +338,11 @@ function _getInstanceNumberFromVolume( return; } - const camera = cornerstoneViewport.getCamera(); - const { viewPlaneNormal } = camera; + const viewPlaneNormal = getViewportAdapter(cornerstoneViewport).getViewPlaneNormal(); + + if (!viewPlaneNormal) { + return; + } // checking if camera is looking at the acquisition plane (defined by the direction on the volume) const scanAxisNormal = direction.slice(6, 9); diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx index b7d3f004a..11b4d4009 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportImageScrollbar.tsx @@ -1,8 +1,9 @@ import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { Enums, utilities as csUtils } from '@cornerstonejs/core'; +import { utilities as csUtils } from '@cornerstonejs/core'; import { ImageScrollbar } from '@ohif/ui-next'; import { isVolume3DViewportType } from '../../utils/getLegacyViewportType'; +import { getSliceEventName, getViewportSliceCount } from '../../utils/viewportDataShape'; function CornerstoneImageScrollbar({ viewportData, @@ -47,10 +48,10 @@ function CornerstoneImageScrollbar({ try { const imageIndex = viewport.getCurrentImageIdIndex(); - const numberOfSlices = viewport.getNumberOfSlices(); + const numberOfSlices = getViewportSliceCount(viewportData, viewport); setImageSliceData({ - imageIndex: imageIndex, + imageIndex, numberOfSlices, }); } catch (error) { @@ -62,11 +63,7 @@ function CornerstoneImageScrollbar({ if (!viewportData) { return; } - const { viewportType } = viewportData; - const eventId = - (viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) || - (viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) || - Enums.Events.IMAGE_RENDERED; + const eventId = getSliceEventName(viewportData); const updateIndex = event => { const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx index c390308b8..19eb04ac1 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportOrientationMarkers.tsx @@ -6,6 +6,7 @@ import { vec3 } from 'gl-matrix'; import './ViewportOrientationMarkers.css'; import { useViewportRendering } from '../../hooks'; +import { getViewportDataShapeType } from '../../utils/viewportDataShape'; const { getOrientationStringLPS, invertOrientationStringLPS } = utilities.orientation; function ViewportOrientationMarkers({ @@ -46,7 +47,9 @@ function ViewportOrientationMarkers({ return ''; } - if (viewportData.viewportType === 'stack') { + // Use the persisted data shape, not viewportType: a native stack reports + // PLANAR_NEXT, which would skip this synthetic-IOP default-cosine guard. + if (getViewportDataShapeType(viewportData) === Enums.ViewportType.STACK) { const imageIndex = imageSliceData.imageIndex; const imageId = viewportData.data[0].imageIds?.[imageIndex]; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/helpers.ts b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/helpers.ts index 0022ba277..783668ed8 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/helpers.ts +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/helpers.ts @@ -1,6 +1,6 @@ -import { Enums } from '@cornerstonejs/core'; import { ViewportData } from './types'; import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType'; +import { getViewportAdapter } from '../../../services/ViewportService/adapter'; export function getImageIndexFromEvent(event): number | undefined { const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail; @@ -24,12 +24,17 @@ export function isProgressFullMode(viewportData: ViewportData, viewport): boolea return false; } - if (viewportData.viewportType === Enums.ViewportType.STACK) { + // A stack renders the full progress UI; an acquisition-plane volume is the + // volume-mode equivalent. The adapter classifies both lanes (legacy by + // viewport type / isInAcquisitionPlane; native by content mode + view-state + // orientation, since PLANAR_NEXT collapses the runtime type). + const adapter = getViewportAdapter(viewport); + const shape = adapter.getShape(); + if (shape === 'stack') { return true; } - - if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) { - return !!viewport.isInAcquisitionPlane?.(); + if (shape === 'volume') { + return adapter.isInAcquisitionPlane(); } return false; diff --git a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts index a0ca18448..6943d96da 100644 --- a/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts +++ b/extensions/cornerstone/src/Viewport/Overlays/ViewportSliceProgressScrollbar/hooks.ts @@ -1,12 +1,8 @@ import { useEffect, useRef, useState } from 'react'; -import { - cache as cornerstoneCache, - Enums, - eventTarget, - utilities, -} from '@cornerstonejs/core'; +import { cache as cornerstoneCache, Enums, eventTarget, utilities } from '@cornerstonejs/core'; import { useByteArray } from '@ohif/ui-next'; import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType'; +import { getSliceEventName, getViewportSliceCount } from '../../../utils/viewportDataShape'; import { getImageIdFromCacheEvent, getImageIndexFromEvent, isProgressFullMode } from './helpers'; import { ImageSliceData, ViewportData } from './types'; @@ -97,26 +93,48 @@ export function useViewportSliceSync({ return; } - const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - if (viewport && !isVolume3DViewportType(viewport)) { + // Last values we pushed, so re-seeding on camera changes does not churn React + // state on pure pan/zoom (which keep the slice geometry unchanged). + const lastSlice = { imageIndex: -1, numberOfSlices: -1 }; + + const pushSliceData = (imageIndex: number, numberOfSlices: number) => { + if (imageIndex === lastSlice.imageIndex && numberOfSlices === lastSlice.numberOfSlices) { + return; + } + lastSlice.imageIndex = imageIndex; + lastSlice.numberOfSlices = numberOfSlices; + setImageSliceData({ imageIndex, numberOfSlices }); + }; + + // Seeds the shared slice state from the live viewport. Re-run on the initial + // effect and on camera/orientation changes (below). + const syncFromViewport = () => { + const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + if (!viewport || isVolume3DViewportType(viewport)) { + return; + } try { const currentImageIndex = viewport.getCurrentImageIdIndex(); - const currentNumberOfSlices = viewport.getNumberOfSlices(); + const currentNumberOfSlices = getViewportSliceCount(viewportData, viewport); - setImageSliceData({ - imageIndex: currentImageIndex, - numberOfSlices: currentNumberOfSlices, - }); + pushSliceData(currentImageIndex, currentNumberOfSlices); } catch (error) { console.warn(error); } - } + }; - const { viewportType } = viewportData; - const eventId = - (viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) || - (viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) || - Enums.Events.IMAGE_RENDERED; + syncFromViewport(); + + // A post-mount camera carry (e.g. the layout-selector MPR protocol restoring + // the prior stack slice onto the freshly-mounted volume viewport) moves the + // camera and fires its slice events synchronously during the mount — before + // these listeners attach and around the initial seed above — so the scrollbar + // can latch the mount-time index instead of the carried slice. Re-seed once on + // the next frame, after the mount+carry settles; pushSliceData makes it a + // no-op when nothing changed (no churn/flicker). + const reseedRaf = requestAnimationFrame(syncFromViewport); + + const eventId = getSliceEventName(viewportData); const updateIndex = event => { const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); @@ -130,16 +148,22 @@ export function useViewportSliceSync({ } const nextNumberOfSlices = viewport.getNumberOfSlices(); - setImageSliceData({ - imageIndex: nextImageIndex, - numberOfSlices: nextNumberOfSlices, - }); + pushSliceData(nextImageIndex, nextNumberOfSlices); }; element.addEventListener(eventId, updateIndex); + // Native ("next") viewports keep the same viewportData across a stack->volume + // transition or an orientation change, so this effect does not re-run and the + // slice-navigation event above may not fire until the first scroll, leaving the + // scrollbar unseeded (or stale, with a now-wrong slice count). CAMERA_MODIFIED + // fires on those orientation/geometry changes, so re-seed from the viewport + // then; the pushSliceData guard makes pan/zoom (same geometry) a no-op. + element.addEventListener(Enums.Events.CAMERA_MODIFIED, syncFromViewport); return () => { + cancelAnimationFrame(reseedRaf); element.removeEventListener(eventId, updateIndex); + element.removeEventListener(Enums.Events.CAMERA_MODIFIED, syncFromViewport); }; }, [viewportData, element, viewportId, cornerstoneViewportService, setImageSliceData]); } diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index ad77a0aba..cd71c3386 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -29,8 +29,10 @@ import { colorPickerDialog, callInputDialog, } from '@ohif/extension-default'; -import { vec3, mat4 } from 'gl-matrix'; import toggleImageSliceSync from './utils/imageSliceSync/toggleImageSliceSync'; +// Sanctioned flag read: RTSTRUCT contour hydration pins the referenced image to +// stack mode on the native ("next") path, a decision made before a target viewport exists. +import { getHydrationViewportTypeForModality } from './utils/nextViewportPolicies'; import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection'; import { getViewportEnabledElement } from './utils/getViewportEnabledElement'; import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement'; @@ -41,6 +43,8 @@ import { isVolume3DViewportType, isVolumeViewportType, } from './utils/getLegacyViewportType'; +import { viewportOperations as ops } from './services/ViewportService/backends/viewportOperations'; +import { getViewportAdapter } from './services/ViewportService/adapter'; import { usePositionPresentationStore, useSegmentationPresentationStore, @@ -52,8 +56,6 @@ import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats import { generateSegmentationCSVReport } from './utils/generateSegmentationCSVReport'; import { getUpdatedViewportsForSegmentation } from './utils/hydrationUtils'; import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; -import { isMeasurementWithinViewport } from './utils/isMeasurementWithinViewport'; -import { getCenterExtent } from './utils/getCenterExtent'; import { EasingFunctionEnum } from './utils/transitions'; import { createSegmentationForViewport } from './utils/createSegmentationForViewport'; import { utilities as segmentationUtilities } from '@cornerstonejs/tools/segmentation'; @@ -146,6 +148,15 @@ function commandsModule({ return getViewportEnabledElement(viewportId); } + // Resolves the cornerstone viewport for a command: the given viewport id, else the + // active one. Returns undefined when nothing is enabled. + function _resolveViewport(viewportId?: string) { + const enabledElement = viewportId + ? _getViewportEnabledElement(viewportId) + : _getActiveViewportEnabledElement(); + return enabledElement?.viewport; + } + function _getActiveViewportToolGroupId() { const viewport = _getActiveViewportEnabledElement(); const toolGroup = viewport && toolGroupService.getToolGroupForViewport(viewport.id); @@ -238,23 +249,11 @@ function commandsModule({ viewport.setViewReference(metadata); viewport.render(); - /** - * If the measurement is not visible inside the current viewport, - * we need to move the camera to the measurement. - */ - if (!isMeasurementWithinViewport(viewport, measurement)) { - const camera = viewport.getCamera(); - const { focalPoint: cameraFocalPoint, position: cameraPosition } = camera; - const { center, extent } = getCenterExtent(measurement); - const position = vec3.sub(vec3.create(), cameraPosition, cameraFocalPoint); - vec3.add(position, position, center); - viewport.setCamera({ focalPoint: center, position: position as any }); - /** Zoom out if the measurement is too large */ - const measurementSize = vec3.dist(extent.min, extent.max); - if (measurementSize > camera.parallelScale) { - const scaleFactor = measurementSize / camera.parallelScale; - viewport.setZoom(viewport.getZoom() / scaleFactor); - } + // If the measurement is not visible inside the current viewport, move the + // camera to it. The operations backend handles the lane: legacy re-centers + // in-plane (getCamera/setCamera), native skips it (no in-plane pan yet, CS-14) + // since setViewReference above already navigated to the measurement's slice. + if (ops.centerOnMeasurement(viewport, measurement)) { viewport.render(); } @@ -372,6 +371,9 @@ function commandsModule({ const results = commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', { viewportId, displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID], + // RTSTRUCT-on-next pins the referenced image to stack mode on hydrate; + // see the policy's rationale in utils/nextViewportPolicies. + viewportType: getHydrationViewportTypeForModality(displaySet.Modality), }); const disableEditing = customizationService.getCustomization( @@ -921,34 +923,28 @@ function commandsModule({ const windowWidthNum = Number(windowWidth); const windowCenterNum = Number(windowCenter); - // get actor from the viewport const renderingEngine = cornerstoneViewportService.getRenderingEngine(); const viewport = renderingEngine.getViewport(viewportId); - const { lower, upper } = csUtils.windowLevel.toLowHighRange(windowWidthNum, windowCenterNum); - - if (isVolumeViewportType(viewport)) { - const volumeId = actions.getVolumeIdForDisplaySet({ - viewportId, - displaySetInstanceUID, - }); - viewport.setProperties( - { - voiRange: { - upper, - lower, - }, - }, - volumeId - ); - } else { - viewport.setProperties({ - voiRange: { - upper, - lower, - }, - }); + // Stale/invalid viewport ids resolve to undefined; bail out before the VOI + // apply + render below would throw. + if (!viewport) { + return; } + + // Legacy volume viewports target a specific volume; the command owns that + // resolution (it needs the service). The operations backend applies the VOI + // (legacy setProperties vs native setDisplaySetPresentation on the active binding). + const volumeId = isVolumeViewportType(viewport) + ? actions.getVolumeIdForDisplaySet({ viewportId, displaySetInstanceUID }) + : undefined; + + ops.setWindowLevel(viewport, { + windowWidth: windowWidthNum, + windowCenter: windowCenterNum, + volumeId, + displaySetInstanceUID, + }); viewport.render(); }, toggleViewportColorbar: ({ viewportId, displaySetInstanceUIDs, options = {} }) => { @@ -1187,25 +1183,11 @@ function commandsModule({ viewportId?: string; newValue?: 'toggle' | boolean; }) => { - const enabledElement = viewportId - ? _getViewportEnabledElement(viewportId) - : _getActiveViewportEnabledElement(); - - if (!enabledElement) { + const viewport = _resolveViewport(viewportId); + if (!viewport) { return; } - - const { viewport } = enabledElement; - - let flipHorizontal: boolean; - if (newValue === 'toggle') { - const { flipHorizontal: currentHorizontalFlip } = viewport.getCamera(); - flipHorizontal = !currentHorizontalFlip; - } else { - flipHorizontal = newValue; - } - - viewport.setCamera({ flipHorizontal }); + ops.flipHorizontal(viewport, newValue); viewport.render(); }, flipViewportVertical: ({ @@ -1215,78 +1197,36 @@ function commandsModule({ viewportId?: string; newValue?: 'toggle' | boolean; }) => { - const enabledElement = viewportId - ? _getViewportEnabledElement(viewportId) - : _getActiveViewportEnabledElement(); - - if (!enabledElement) { + const viewport = _resolveViewport(viewportId); + if (!viewport) { return; } - - const { viewport } = enabledElement; - - let flipVertical: boolean; - if (newValue === 'toggle') { - const { flipVertical: currentVerticalFlip } = viewport.getCamera(); - flipVertical = !currentVerticalFlip; - } else { - flipVertical = newValue; - } - viewport.setCamera({ flipVertical }); + ops.flipVertical(viewport, newValue); viewport.render(); }, invertViewport: ({ element }) => { - let enabledElement; - - if (element === undefined) { - enabledElement = _getActiveViewportEnabledElement(); - } else { - enabledElement = element; - } - - if (!enabledElement) { + const viewport = element === undefined ? _resolveViewport() : element.viewport; + if (!viewport) { return; } - - const { viewport } = enabledElement; - - const { invert } = viewport.getProperties(); - viewport.setProperties({ invert: !invert }); + ops.invert(viewport); viewport.render(); }, resetViewport: () => { - const enabledElement = _getActiveViewportEnabledElement(); - - if (!enabledElement) { + const viewport = _resolveViewport(); + if (!viewport) { return; } - - const { viewport } = enabledElement; - - viewport.resetProperties?.(); - viewport.resetCamera(); - + ops.reset(viewport); viewport.render(); }, scaleViewport: ({ direction }) => { - const enabledElement = _getActiveViewportEnabledElement(); - const scaleFactor = direction > 0 ? 0.9 : 1.1; - - if (!enabledElement) { + const viewport = _resolveViewport(); + if (!viewport) { return; } - const { viewport } = enabledElement; - - if (isStackViewportType(viewport)) { - if (direction) { - const { parallelScale } = viewport.getCamera(); - viewport.setCamera({ parallelScale: parallelScale * scaleFactor }); - viewport.render(); - } else { - viewport.resetCamera(); - viewport.render(); - } - } + ops.scaleBy(viewport, direction); + viewport.render(); }, /** Jumps the active viewport or the specified one to the given slice index */ @@ -1365,24 +1305,15 @@ function commandsModule({ // HP takes priority over the default opacity colormap = { ...colormap, opacity: hpOpacity || opacity }; - if (isStackViewportType(viewport)) { - viewport.setProperties({ colormap }); + // The legacy orthographic branch resolves the volumeId from the display set; + // fall back to the viewport's first display set (needs viewportGridService, so + // it is resolved here in the command rather than in the operations backend). + if (isOrthographicViewportType(viewport) && !displaySetInstanceUID) { + const { viewports } = viewportGridService.getState(); + displaySetInstanceUID = viewports.get(viewportId)?.displaySetInstanceUIDs[0]; } - if (isOrthographicViewportType(viewport)) { - if (!displaySetInstanceUID) { - const { viewports } = viewportGridService.getState(); - displaySetInstanceUID = viewports.get(viewportId)?.displaySetInstanceUIDs[0]; - } - - // ToDo: Find a better way of obtaining the volumeId that corresponds to the displaySetInstanceUID - const volumeId = - viewport - .getAllVolumeIds() - .find((_volumeId: string) => _volumeId.includes(displaySetInstanceUID)) ?? - viewport.getVolumeId(); - viewport.setProperties({ colormap }, volumeId); - } + ops.setColormap(viewport, { colormap, displaySetInstanceUID }); if (immediate) { viewport.render(); @@ -1488,9 +1419,7 @@ function commandsModule({ if (!viewport) { return; } - viewport.setProperties({ - preset, - }); + ops.setPreset(viewport, preset); viewport.render(); }, @@ -1502,20 +1431,10 @@ function commandsModule({ setVolumeRenderingQulaity: ({ viewportId, volumeQuality }) => { const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - const { actor } = viewport.getActors()[0]; - const mapper = actor.getMapper(); - const image = mapper.getInputData(); - const dims = image.getDimensions(); - const spacing = image.getSpacing(); - const spatialDiagonal = vec3.length( - vec3.fromValues(dims[0] * spacing[0], dims[1] * spacing[1], dims[2] * spacing[2]) - ); - - let sampleDistance = spacing.reduce((a, b) => a + b) / 3.0; - sampleDistance /= volumeQuality > 1 ? 0.5 * volumeQuality ** 2 : 1.0; - const samplesPerRay = spatialDiagonal / sampleDistance + 1; - mapper.setMaximumSamplesPerRay(samplesPerRay); - mapper.setSampleDistance(sampleDistance); + if (!viewport) { + return; + } + ops.setVolumeRenderingQuality(viewport, volumeQuality); viewport.render(); }, @@ -1526,27 +1445,10 @@ function commandsModule({ */ shiftVolumeOpacityPoints: ({ viewportId, shift }) => { const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - const { actor } = viewport.getActors()[0]; - const ofun = actor.getProperty().getScalarOpacity(0); - - const opacityPointValues = []; // Array to hold values - // Gather Existing Values - const size = ofun.getSize(); - for (let pointIdx = 0; pointIdx < size; pointIdx++) { - const opacityPointValue = [0, 0, 0, 0]; - ofun.getNodeValue(pointIdx, opacityPointValue); - // opacityPointValue now holds [xLocation, opacity, midpoint, sharpness] - opacityPointValues.push(opacityPointValue); + if (!viewport) { + return; } - // Add offset - opacityPointValues.forEach(opacityPointValue => { - opacityPointValue[0] += shift; // Change the location value - }); - // Set new values - ofun.removeAllPoints(); - opacityPointValues.forEach(opacityPointValue => { - ofun.addPoint(...opacityPointValue); - }); + ops.shiftVolumeOpacityPoints(viewport, shift); viewport.render(); }, @@ -1562,25 +1464,10 @@ function commandsModule({ setVolumeLighting: ({ viewportId, options }) => { const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - const { actor } = viewport.getActors()[0]; - const property = actor.getProperty(); - - if (options.shade !== undefined) { - property.setShade(options.shade); + if (!viewport) { + return; } - - if (options.ambient !== undefined) { - property.setAmbient(options.ambient); - } - - if (options.diffuse !== undefined) { - property.setDiffuse(options.diffuse); - } - - if (options.specular !== undefined) { - property.setSpecular(options.specular); - } - + ops.setVolumeLighting(viewport, options); viewport.render(); }, resetCrosshairs: ({ viewportId }) => { @@ -1588,7 +1475,13 @@ function commandsModule({ const getCrosshairInstances = toolGroupId => { const toolGroup = toolGroupService.getToolGroup(toolGroupId); - crosshairInstances.push(toolGroup.getToolInstance('Crosshairs')); + // Only fetch the instance when Crosshairs is registered in this tool + // group. getToolInstance logs a warning for an unregistered tool, and a + // viewport's default tool group does not always include Crosshairs (e.g. + // next viewports), which made Reset Viewport log a spurious warning. + if (toolGroup?.hasTool('Crosshairs')) { + crosshairInstances.push(toolGroup.getToolInstance('Crosshairs')); + } }; if (!viewportId) { @@ -1596,7 +1489,9 @@ function commandsModule({ toolGroupIds.forEach(getCrosshairInstances); } else { const toolGroup = toolGroupService.getToolGroupForViewport(viewportId); - getCrosshairInstances(toolGroup.id); + if (toolGroup) { + getCrosshairInstances(toolGroup.id); + } } crosshairInstances.forEach(ins => { @@ -2165,7 +2060,11 @@ function commandsModule({ } segmentationService.addSegment(activeSegmentation.segmentationId); }, - loadSegmentationDisplaySetsForViewport: ({ viewportId, displaySetInstanceUIDs }) => { + loadSegmentationDisplaySetsForViewport: ({ + viewportId, + displaySetInstanceUIDs, + viewportType, + }) => { const updatedViewports = getUpdatedViewportsForSegmentation({ viewportId, servicesManager, @@ -2185,13 +2084,21 @@ function commandsModule({ viewportsToUpdate: updatedViewports.map(viewport => ({ viewportId: viewport.viewportId, displaySetInstanceUIDs: viewport.displaySetInstanceUIDs, + // When the caller pins a viewportType (RTSTRUCT contour hydration on a + // native "next" viewport requests 'stack'), force it so the referenced + // image stays in that render mode instead of resolving to a volume slice. + ...(viewportType + ? { viewportOptions: { ...viewport.viewportOptions, viewportType } } + : {}), })), }); }, setViewportOrientation: ({ viewportId, orientation }) => { const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - if (!viewport || !isOrthographicViewportType(viewport)) { + // Accept any viewport already rendering volume content (legacy ORTHOGRAPHIC + // or a native viewport in volume mode) — both expose setOrientation(). + if (!viewport || !getViewportAdapter(viewport).canReorientInPlace()) { console.warn('Orientation can only be set on volume viewports'); return; } @@ -2263,50 +2170,12 @@ function commandsModule({ viewportId?: string; rotationMode?: 'apply' | 'set'; }) => { - const enabledElement = viewportId - ? _getViewportEnabledElement(viewportId) - : _getActiveViewportEnabledElement(); - - if (!enabledElement) { + const viewport = _resolveViewport(viewportId); + if (!viewport) { return; } - - const { viewport } = enabledElement; - - if (isVolumeViewportType(viewport)) { - const camera = viewport.getCamera(); - const rotAngle = (rotation * Math.PI) / 180; - const rotMat = mat4.identity(new Float32Array(16)); - mat4.rotate(rotMat, rotMat, rotAngle, camera.viewPlaneNormal); - const rotatedViewUp = vec3.transformMat4(vec3.create(), camera.viewUp, rotMat); - viewport.setCamera({ viewUp: rotatedViewUp as CoreTypes.Point3 }); - viewport.render(); - return; - } - - if (viewport.getRotation !== undefined) { - const { rotation: currentRotation } = viewport.getViewPresentation(); - const newRotation = - rotationMode === 'apply' - ? (currentRotation + rotation + 360) % 360 - : (() => { - // In 'set' mode, account for the effect horizontal/vertical flips - // have on the perceived rotation direction. A single flip mirrors - // the image and inverses rotation direction, while two flips - // restore the original parity. We therefore invert the rotation - // angle when an odd number of flips are applied so that the - // requested absolute rotation matches the user expectation. - const { flipHorizontal = false, flipVertical = false } = - viewport.getViewPresentation(); - - const flipsParity = (flipHorizontal ? 1 : 0) + (flipVertical ? 1 : 0); - const effectiveRotation = flipsParity % 2 === 1 ? -rotation : rotation; - - return (effectiveRotation + 360) % 360; - })(); - viewport.setViewPresentation({ rotation: newRotation }); - viewport.render(); - } + ops.rotate(viewport, rotation, rotationMode); + viewport.render(); }, startRecordingForAnnotationGroup: () => { cornerstoneTools.AnnotationTool.startGroupRecording(); diff --git a/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx b/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx index b4f0fc08b..3864f8885 100644 --- a/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx +++ b/extensions/cornerstone/src/components/ViewportColorbar/ViewportColorbarsContainer.tsx @@ -83,10 +83,16 @@ const ViewportColorbarsContainer = memo(function ViewportColorbarsContainer({ const { displaySetInstanceUID: dsUID } = displaySetService.getDisplaySetByUID(displaySetInstanceUID) ?? {}; + // Default the fused (horizontal) colorbar to the foreground (e.g. the PT + // in a PET/CT fusion), which is the meaningful layer. Only fall back to + // the background (CT) colorbar when the foreground has been explicitly + // faded to zero opacity. Previously a null/undefined opacity (e.g. before + // the hook resolved it) also fell through to the background, so the + // colorbar would flicker between CT and PT depending on timing. const targetUID = - opacity === 0 || opacity == null + opacity === 0 ? backgroundDisplaySet?.displaySetInstanceUID - : foregroundDisplaySets[0].displaySetInstanceUID; + : foregroundDisplaySets[0]?.displaySetInstanceUID; return dsUID === targetUID; }); diff --git a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx index 8452b7841..508d5a974 100644 --- a/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx +++ b/extensions/cornerstone/src/components/ViewportOrientationMenu/ViewportOrientationMenu.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { cn, Icons, useIconPresentation } from '@ohif/ui-next'; import { useSystem } from '@ohif/core'; import { Enums } from '@cornerstonejs/core'; +import { getViewportAdapter } from '../../services/ViewportService/adapter'; import { Popover, PopoverTrigger, PopoverContent, Button, useViewportGrid } from '@ohif/ui-next'; function ViewportOrientationMenu({ @@ -36,8 +37,6 @@ function ViewportOrientationMenu({ const handleOrientationChange = (orientation: string) => { setCurrentOrientation(orientation); - const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportIdToUse); - const currentViewportType = viewportInfo?.getViewportType(); if (!displaySets.length) { return; @@ -72,8 +71,17 @@ function ViewportOrientationMenu({ const displaySetUIDs = displaySets.map(ds => ds.displaySetInstanceUID); - // If viewport is not already a volume type, we need to convert it - if (currentViewportType !== Enums.ViewportType.ORTHOGRAPHIC) { + // A viewport already rendering in volume mode (legacy ORTHOGRAPHIC OR a next + // viewport that reports planarNext but renders volume actors, e.g. a CT+PET + // fusion) can be reoriented in place. Recreating it via setDisplaySetsForViewports + // would pass empty displaySetOptions and drop per-display-set presentation such + // as the PET overlay colormap/opacity. Only the genuine stack -> volume (MPR) + // case needs recreation. + const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportIdToUse); + const isVolumeMode = !!csViewport && getViewportAdapter(csViewport).canReorientInPlace(); + + // If viewport is not already in volume mode, we need to convert it + if (!isVolumeMode) { // Configure the viewport to be a volume viewport with current display sets const updatedViewport = { viewportId: viewportIdToUse, diff --git a/extensions/cornerstone/src/components/ViewportWindowLevel/utils.ts b/extensions/cornerstone/src/components/ViewportWindowLevel/utils.ts index a68728aab..ea7ff017e 100644 --- a/extensions/cornerstone/src/components/ViewportWindowLevel/utils.ts +++ b/extensions/cornerstone/src/components/ViewportWindowLevel/utils.ts @@ -2,6 +2,7 @@ import { cache as cs3DCache, Types } from '@cornerstonejs/core'; import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps'; import { utilities as csUtils } from '@cornerstonejs/core'; import { getViewportVolumeHistogram } from './getViewportVolumeHistogram'; +import { getViewportAdapter } from '../../services/ViewportService/adapter'; /** * Gets node opacity from volume actor @@ -101,8 +102,18 @@ export const getWindowLevelsData = async ( return []; } - const volumeIds = (viewport as Types.IBaseVolumeViewport).getAllVolumeIds(); - const viewportProperties = viewport.getProperties(); + // The per-volume histogram WL panel is a legacy volume-viewport feature; the + // adapter reports no volumeIds for native viewports (and legacy stacks), so + // those degrade gracefully to no histogram rows rather than erroring; the + // native WL path is driven by setViewportWindowLevel. + // TODO(next): port per-volume histograms to the native volume API. + const adapter = getViewportAdapter(viewport); + const volumeIds = adapter.getVolumeIds(); + if (!volumeIds.length) { + return []; + } + + const viewportProperties = adapter.getPresentation(); const { voiRange } = viewportProperties || {}; const viewportVoi = voiRange ? { diff --git a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx index 623cdaedc..7f0b264e3 100644 --- a/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx +++ b/extensions/cornerstone/src/components/WindowLevelActionMenu/WindowLevel.tsx @@ -1,15 +1,54 @@ import React, { ReactElement, useEffect, useRef, useState } from 'react'; import { AllInOneMenu, ScrollArea, Switch, Tabs, TabsList, TabsTrigger } from '@ohif/ui-next'; import { useViewportRendering } from '../../hooks/useViewportRendering'; +import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets'; import { WindowLevelPreset } from '../../types/WindowLevel'; import { useTranslation } from 'react-i18next'; export function WindowLevel({ viewportId }: { viewportId?: string } = {}): ReactElement { const { t } = useTranslation('WindowLevelActionMenu'); - const { viewportDisplaySets } = useViewportRendering(viewportId); + const { viewportDisplaySets, foregroundDisplaySets } = useViewportDisplaySets(viewportId); + // Default the active tab to the foreground layer (e.g. the PT in a PET/CT + // fusion), matching the other window-level controls, instead of the grayscale + // background (CT) at index 0. The CT/PT tabs still let the user switch. + const defaultDisplaySetUID = + foregroundDisplaySets?.length > 0 + ? foregroundDisplaySets[foregroundDisplaySets.length - 1].displaySetInstanceUID + : viewportDisplaySets?.[0]?.displaySetInstanceUID; const [activeDisplaySetUID, setActiveDisplaySetUID] = useState( - viewportDisplaySets?.[0]?.displaySetInstanceUID + defaultDisplaySetUID ); + // Tracks whether the user has explicitly picked a tab, so the foreground-default + // sync below stops overriding their choice. + const userSelectedRef = useRef(false); + + // Adopt the foreground default if the display sets resolve after first render + // (and the user has not picked a tab yet). This must re-sync even when the + // initial render already seeded `activeDisplaySetUID` with the CT fallback + // (because `foregroundDisplaySets` was still empty at mount) — otherwise the + // tab stays pinned to CT once the foreground (PT) layer resolves. + useEffect(() => { + // If the active tab's display set is no longer in the viewport (e.g. the + // viewport switched to a different study/series), drop the now-stale + // selection — including a user pick — so it re-defaults instead of leaving an + // invalid UID that later fails validateActiveDisplaySet. + const activeStillPresent = viewportDisplaySets?.some( + ds => ds.displaySetInstanceUID === activeDisplaySetUID + ); + if (activeDisplaySetUID && viewportDisplaySets?.length && !activeStillPresent) { + userSelectedRef.current = false; + setActiveDisplaySetUID(defaultDisplaySetUID); + return; + } + + if ( + !userSelectedRef.current && + defaultDisplaySetUID && + activeDisplaySetUID !== defaultDisplaySetUID + ) { + setActiveDisplaySetUID(defaultDisplaySetUID); + } + }, [activeDisplaySetUID, defaultDisplaySetUID, viewportDisplaySets]); // Use the hook with the active display set const { windowLevelPresets, setWindowLevel } = useViewportRendering(viewportId, { @@ -49,6 +88,7 @@ export function WindowLevel({ viewportId }: { viewportId?: string } = {}): React { + userSelectedRef.current = true; setActiveDisplaySetUID(displaySetUID); }} > diff --git a/extensions/cornerstone/src/getSopClassHandlerModule.js b/extensions/cornerstone/src/getSopClassHandlerModule.js index 157b7aef2..071083d5a 100644 --- a/extensions/cornerstone/src/getSopClassHandlerModule.js +++ b/extensions/cornerstone/src/getSopClassHandlerModule.js @@ -8,7 +8,11 @@ import { buildEcgModule } from './utils/ecgMetadata'; const { MetadataModules } = csEnums; const { utils } = OHIF; const { denaturalizeDataset } = dcmjs.data.DicomMetaDictionary; -const { transferDenaturalizedDataset, fixMultiValueKeys } = dicomWebUtils; +// NOTE: access transferDenaturalizedDataset / fixMultiValueKeys lazily (at call +// time) rather than destructuring here. This module and @ohif/extension-default +// form a circular import, so dicomWebUtils can be undefined at module-eval time +// depending on bundler eval order; a top-level destructure then throws and +// crashes app boot. const SOP_CLASS_UIDS = { VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.6', @@ -139,8 +143,8 @@ function getDICOMwebMetadata(instanceMap, imageId) { console.warn('Metadata not already found for', imageId, 'in', instanceMap); return this.super.getDICOMwebMetadata(imageId); } - return transferDenaturalizedDataset( - denaturalizeDataset(fixMultiValueKeys(instanceMap.get(imageId))) + return dicomWebUtils.transferDenaturalizedDataset( + denaturalizeDataset(dicomWebUtils.fixMultiValueKeys(instanceMap.get(imageId))) ); } diff --git a/extensions/cornerstone/src/getToolbarModule.tsx b/extensions/cornerstone/src/getToolbarModule.tsx index 5f96730a1..80fa83c4f 100644 --- a/extensions/cornerstone/src/getToolbarModule.tsx +++ b/extensions/cornerstone/src/getToolbarModule.tsx @@ -1,5 +1,6 @@ import { Enums } from '@cornerstonejs/tools'; import i18n from '@ohif/i18n'; +import { getViewportAdapter, isVolumeRenderingViewport } from './services/ViewportService/adapter'; import { utils } from '@ohif/ui-next'; import { ViewportDataOverlayMenuWrapper } from './components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper'; import { ViewportOrientationMenuWrapper } from './components/ViewportOrientationMenu/ViewportOrientationMenuWrapper'; @@ -309,7 +310,11 @@ export default function getToolbarModule({ servicesManager, extensionManager }: }; } - if (viewport.type !== 'orthographic') { + // Recognize native "next" volume viewports too. A next MPR/volume viewport + // runs as PLANAR_NEXT (requestedType PLANAR_NEXT, not ORTHOGRAPHIC), so this + // checks volume content via getCurrentMode(). Without it the PT threshold + // control stayed disabled on the next backend (e.g. TMTV fusion/PT). + if (!isVolumeRenderingViewport(viewport)) { return { disabled: true, }; @@ -332,7 +337,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }: evaluate: ({ viewportId }) => { const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - if (!viewport || viewport.type !== 'orthographic') { + if (!viewport || !isVolumeRenderingViewport(viewport)) { return { disabled: true, }; @@ -449,8 +454,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }: mode === Enums.ToolModes.Enabled; const toolBindings = toolGroupService.getToolBindings(toolGroup.id, toolName); - const hasModifierKey = - toolBindings?.some(binding => binding.modifierKey != null) ?? false; + const hasModifierKey = toolBindings?.some(binding => binding.modifierKey != null) ?? false; return { disabled: false, @@ -459,7 +463,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }: icon: isToggled && hasModifierKey && toggledOnIcon ? toggledOnIcon - : defaultIcon ?? button.props.icon, + : (defaultIcon ?? button.props.icon), }; }, }, @@ -543,8 +547,9 @@ export default function getToolbarModule({ servicesManager, extensionManager }: const propId = button.id; - const properties = viewport.getProperties(); - const camera = viewport.getCamera(); + const adapter = getViewportAdapter(viewport); + const properties = adapter.getPresentation(); + const camera = adapter.getViewState(); const prop = camera?.[propId] || properties?.[propId]; diff --git a/extensions/cornerstone/src/hooks/useViewportRendering.tsx b/extensions/cornerstone/src/hooks/useViewportRendering.tsx index c134657f9..19d43dbbe 100644 --- a/extensions/cornerstone/src/hooks/useViewportRendering.tsx +++ b/extensions/cornerstone/src/hooks/useViewportRendering.tsx @@ -1,13 +1,9 @@ import React, { useCallback, useState, useEffect, useMemo } from 'react'; import { useSystem } from '@ohif/core'; import { useViewportDisplaySets } from './useViewportDisplaySets'; -import { Types, utilities, Enums, cache } from '@cornerstonejs/core'; -import { getDataIdForViewport } from '../utils/getDataIdForViewport'; -import { - isStackViewportType, - isVolumeViewportType, - isVolume3DViewportType, -} from '../utils/getLegacyViewportType'; +import { Types, utilities, Enums } from '@cornerstonejs/core'; +import { isVolume3DViewportType } from '../utils/getLegacyViewportType'; +import { getViewportAdapter, LEGACY_OPACITY_GAMMA } from '../services/ViewportService/adapter'; import { WindowLevelPreset } from '../types/WindowLevel'; import { ColorbarPositionType, ColorbarOptions, ColorbarProperties } from '../types/Colorbar'; import { VolumeRenderingConfig } from '../types/VolumeRenderingConfig'; @@ -92,14 +88,26 @@ const getPosition = (location: number): ColorbarPositionType => { } }; -const GAMMA = 1 / 5; +/** + * Normalizes a colormap opacity value to a single 0..1 scalar for the opacity + * slider. `colormap.opacity` may be a plain number or an array of + * `{ value, opacity }` points (e.g. the HP fusion opacity ramp); for the array + * case we represent it by its maximum opacity. (A prior reduce ran over the point + * objects directly, producing NaN and a mispositioned slider.) + */ +const resolveOpacityScalar = (opacityVal: unknown): number | undefined => { + if (opacityVal === undefined || opacityVal === null) { + return undefined; + } -const linearToOpacity = (linearValue: number): number => { - return Math.pow(linearValue, GAMMA); -}; + if (Array.isArray(opacityVal)) { + return opacityVal.reduce((max: number, point) => { + const value = typeof point === 'number' ? point : (point?.opacity ?? 0); + return Math.max(max, value); + }, 0); + } -const opacityToLinear = (opacityValue: number): number => { - return Math.pow(opacityValue, 1.0 / GAMMA); + return opacityVal as number; }; /** @@ -128,12 +136,28 @@ export function useViewportRendering( viewportId ? (cornerstoneViewportService.getCornerstoneViewport(viewportId) ?? null) : null ); const [is3DVolume, setIs3DVolume] = useState(isVolume3DViewportType(viewport)); + + // The opacity slider gamma follows the rendering path (linear on native, + // the historical 1/5 curve on legacy), so the slider feel and its initial + // position match what is rendered. + const opacityGamma = viewport + ? getViewportAdapter(viewport).getOpacityGamma() + : LEGACY_OPACITY_GAMMA; + const linearToOpacity = useCallback( + (linearValue: number): number => Math.pow(linearValue, opacityGamma), + [opacityGamma] + ); + const opacityToLinear = useCallback( + (opacityValue: number): number => Math.pow(opacityValue, 1.0 / opacityGamma), + [opacityGamma] + ); + const [opacity, setOpacityState] = useState(); const [opacityLinear, setOpacityLinearState] = useState(); const [threshold, setThresholdState] = useState(); const [pixelValueRange, setPixelValueRange] = useState({ min: 0, max: 255 }); - const { viewportDisplaySets } = useViewportDisplaySets(viewportId); + const { viewportDisplaySets, foregroundDisplaySets } = useViewportDisplaySets(viewportId); const { displaySetService } = servicesManager.services; // Determine the active display set instance UID (internal only, not exposed) @@ -142,12 +166,21 @@ export function useViewportRendering( return options.displaySetInstanceUID; } + // Window-level / colormap / threshold controls operate on the foreground + // layer (e.g. the PT in a PET/CT fusion), not the grayscale background (CT). + // Use the topmost foreground display set when present; otherwise fall back to + // the (single) primary display set. SEG/derived overlays are already excluded + // from foregroundDisplaySets. + if (foregroundDisplaySets && foregroundDisplaySets.length > 0) { + return foregroundDisplaySets[foregroundDisplaySets.length - 1].displaySetInstanceUID; + } + if (viewportDisplaySets && viewportDisplaySets.length > 0) { return viewportDisplaySets[0].displaySetInstanceUID; } return undefined; - }, [options?.displaySetInstanceUID, viewportDisplaySets]); + }, [options?.displaySetInstanceUID, viewportDisplaySets, foregroundDisplaySets]); const viewportInfo = viewportId ? cornerstoneViewportService.getViewportInfo(viewportId) : null; @@ -216,28 +249,14 @@ export function useViewportRendering( return; } - if (!isVolumeViewportType(viewport)) { + const voxelManager = getViewportAdapter(viewport).getVoxelManagerForDisplaySet( + activeDisplaySetInstanceUID + ); + + if (!voxelManager?.getRange) { return; } - const volumeIds = viewport.getAllVolumeIds(); - const volumeId = volumeIds.find(id => id.includes(activeDisplaySetInstanceUID)); - - if (!volumeId) { - return; - } - - // only handle volume viewports for now - const imageData = viewport.getImageData(volumeId); - - if (!imageData) { - return; - } - - const imageDataVtk = imageData.imageData; - - const { voxelManager } = imageDataVtk.get('voxelManager'); - const range = voxelManager.getRange(); setPixelValueRange({ min: range[0], max: range[1] }); @@ -267,12 +286,9 @@ export function useViewportRendering( return; } try { - const dataId = getDataIdForViewport(viewport as unknown, activeDisplaySetInstanceUID); - - const properties = - dataId != null - ? (viewport as Types.IBaseVolumeViewport).getProperties(dataId) - : viewport.getProperties(); + const adapter = getViewportAdapter(viewport); + const dataId = adapter.getDataIdForDisplaySet(activeDisplaySetInstanceUID); + const properties = adapter.getPresentation(dataId ?? activeDisplaySetInstanceUID); if (!properties) { return; @@ -281,18 +297,26 @@ export function useViewportRendering( if (properties.voiRange) { setVoiRange(properties.voiRange); voiRangeRef.current = properties.voiRange; + } else { + // Native ("next") viewports store only explicit VOI overrides in the + // per-display-set presentation; a freshly shown series has none, so fall + // back to its computed default VOI (undefined on legacy, whose + // getProperties always returns the applied VOI). Without this, changing + // the series left the overlay showing the previous series' window level. + const defaultVOIRange = adapter.getDefaultVOIRange(dataId ?? activeDisplaySetInstanceUID); + + if (defaultVOIRange) { + setVoiRange(defaultVOIRange); + voiRangeRef.current = defaultVOIRange; + } } if (properties.colormap?.opacity !== undefined) { - const opacityVal = properties.colormap.opacity; - const opacity = Array.isArray(opacityVal) - ? (opacityVal as unknown as number[]).reduce( - (max, current) => Math.max(max, current), - 0 - ) - : opacityVal; - setOpacityState(opacity); - setOpacityLinearState(opacityToLinear(opacity)); + const opacity = resolveOpacityScalar(properties.colormap.opacity); + if (opacity !== undefined) { + setOpacityState(opacity); + setOpacityLinearState(opacityToLinear(opacity)); + } } if (properties.colormap?.threshold !== undefined) { @@ -362,8 +386,11 @@ export function useViewportRendering( } if (colormap.opacity !== undefined) { - setOpacityState(colormap.opacity); - setOpacityLinearState(opacityToLinear(colormap.opacity)); + const opacity = resolveOpacityScalar(colormap.opacity); + if (opacity !== undefined) { + setOpacityState(opacity); + setOpacityLinearState(opacityToLinear(opacity)); + } } }; @@ -570,7 +597,7 @@ export function useViewportRendering( const setOpacity = useCallback( (opacityValue: number) => { - if (!viewport || !isVolumeViewportType(viewport)) { + if (!viewport) { return; } @@ -580,32 +607,10 @@ export function useViewportRendering( setOpacityLinearState(opacityToLinear(opacityValue)); const displaySetInstanceUID = validateActiveDisplaySet(); - const volumeIds = viewport.getAllVolumeIds(); - const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID)); - if (!volumeId) { - return; + if (getViewportAdapter(viewport).setLayerOpacity(displaySetInstanceUID, opacityValue)) { + viewport.render(); } - - // Get current properties including colormap - const properties = viewport.getProperties(volumeId); - const currentColormap = properties.colormap || {}; - - // Update colormap with new opacity - const updatedColormap = { - ...currentColormap, - opacity: opacityValue, - }; - - // Apply updated colormap - viewport.setProperties( - { - colormap: updatedColormap, - }, - volumeId - ); - - viewport.render(); }, [validateActiveDisplaySet, opacityToLinear, viewport] ); @@ -621,32 +626,16 @@ export function useViewportRendering( const setThreshold = useCallback( (thresholdValue: number) => { - if (!viewport || !isVolumeViewportType(viewport)) { + if (!viewport) { return; } - setThresholdState(thresholdValue); - const displaySetInstanceUID = validateActiveDisplaySet(); - const volumeIds = viewport.getAllVolumeIds(); - const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID)); + setThresholdState(thresholdValue); - if (!volumeId) { - return; + if (getViewportAdapter(viewport).setLayerThreshold(displaySetInstanceUID, thresholdValue)) { + viewport.render(); } - - console.debug('🚀 ~ thresholdValue:', thresholdValue); - - viewport.setProperties( - { - colormap: { - threshold: thresholdValue, - }, - }, - volumeId - ); - - viewport.render(); }, [validateActiveDisplaySet, viewport] ); @@ -662,41 +651,13 @@ export function useViewportRendering( return null; } - if (isStackViewportType(viewport)) { - const { colormap } = viewport.getProperties(); - if (!colormap) { - return ( - colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || - colorbarProperties?.colormaps?.[0] - ); - } - return colormap; - } + const colormap = getViewportAdapter(viewport).getColormap(activeDisplaySetInstanceUID); - const actorEntries = viewport.getActors(); - const actorEntry = actorEntries?.find(entry => - entry.referencedId?.includes(activeDisplaySetInstanceUID) + return ( + colormap || + colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || + colorbarProperties?.colormaps?.[0] ); - - if (!actorEntry) { - return ( - colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || - colorbarProperties?.colormaps?.[0] - ); - } - - const { colormap } = (viewport as Types.IVolumeViewport).getProperties( - actorEntry.referencedId - ); - - if (!colormap) { - return ( - colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || - colorbarProperties?.colormaps?.[0] - ); - } - - return colormap; } catch (error) { console.error('Error getting viewport colormap:', error); return ( diff --git a/extensions/cornerstone/src/index.tsx b/extensions/cornerstone/src/index.tsx index 730ee4aad..5e5fe954d 100644 --- a/extensions/cornerstone/src/index.tsx +++ b/extensions/cornerstone/src/index.tsx @@ -37,6 +37,18 @@ import RectangleROI from './utils/measurementServiceMappings/RectangleROI'; import type { PublicViewportOptions } from './services/ViewportService/Viewport'; import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool'; import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes'; +import { + getViewportAdapter, + getViewportFocalPoint, + isNextViewport, + isVolumeRenderingViewport, +} from './services/ViewportService/adapter'; +import { isNextViewportsEnabled } from './utils/nextViewports'; +import { + NEXT_FUSION_PT_OPACITY, + NEXT_OVERLAY_OPACITY, + getHydrationViewportTypeForModality, +} from './utils/nextViewportPolicies'; import { findNearbyToolData } from './utils/findNearbyToolData'; import { createFrameViewSynchronizer } from './synchronizers/frameViewSynchronizer'; import { getSopClassHandlerModule } from './getSopClassHandlerModule'; @@ -103,11 +115,7 @@ const cornerstoneExtension: Types.Extensions.Extension = { */ id, - onModeEnter: ({ - servicesManager, - commandsManager, - extensionManager, - }: withAppTypes): void => { + onModeEnter: ({ servicesManager, commandsManager, extensionManager }: withAppTypes): void => { const { cornerstoneViewportService, toolbarService, segmentationService } = servicesManager.services; @@ -153,7 +161,10 @@ const cornerstoneExtension: Types.Extensions.Extension = { */ const sourceConfig = extensionManager?.getActiveDataSource?.()?.[0]?.getConfig?.() ?? {}; const config = sourceConfig.stackRetrieveOptions ?? {}; - const stackOptions = update(DEFAULT_STACK_RETRIEVE_OPTIONS, toUpdateSpec(config)) as typeof DEFAULT_STACK_RETRIEVE_OPTIONS; + const stackOptions = update( + DEFAULT_STACK_RETRIEVE_OPTIONS, + toUpdateSpec(config) + ) as typeof DEFAULT_STACK_RETRIEVE_OPTIONS; imageRetrieveMetadataProvider.add('stack', stackOptions); }, getPanelModule, @@ -286,6 +297,14 @@ export { getEnabledElement, ImageOverlayViewerTool, getSOPInstanceAttributes, + getViewportAdapter, + getViewportFocalPoint, + isNextViewport, + isVolumeRenderingViewport, + isNextViewportsEnabled, + NEXT_FUSION_PT_OPACITY, + NEXT_OVERLAY_OPACITY, + getHydrationViewportTypeForModality, dicomLoaderService, // Export all stores useLutPresentationStore, diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index cc870af9a..3f0f9e02b 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -27,6 +27,12 @@ import initCornerstoneTools from './initCornerstoneTools'; import { connectToolsToMeasurementService } from './initMeasurementService'; import initCineService from './initCineService'; import initStudyPrefetcherService from './initStudyPrefetcherService'; +import { + setNextViewportsEnabled, + resolveNextViewportsEnabled, + resolveViewportRendering, + setViewportRenderingOverrides, +} from './utils/nextViewports'; import interleaveCenterLoader from './utils/interleaveCenterLoader'; import nthLoader from './utils/nthLoader'; import interleaveTopToBottom from './utils/interleaveTopToBottom'; @@ -62,13 +68,52 @@ export default async function init({ // Note: this should run first before initializing the cornerstone // DO NOT CHANGE THE ORDER + // Enable cornerstone's stats/debug overlay when `?debug=true` is in the URL. + // Mirrors the cornerstone demo trigger so the same overlay is available inside + // OHIF: FPS / MS / MB panels plus the per-viewport actor & mapper bindings. + const statsOverlay = + new URLSearchParams(window.location.search).get('debug') === 'true'; + await cs3DInit({ peerImport: appConfig.peerImport, + debug: { statsOverlay }, }); - // For debugging e2e tests that are failing on CI cornerstone.setUseCPURendering(Boolean(appConfig.useCPURendering)); + // All native ("next") Generic Viewport settings live under one config object: + // appConfig.genericViewports = { enabled, viewportRendering }. + const genericViewportsConfig = appConfig.genericViewports ?? {}; + + // viewportRendering selects the render backend per-session: + // `?viewportRendering=cpu|webgl|webgpu|auto` for all viewports, plus + // `?.viewportRendering=` (e.g. + // `?orthographic.viewportRendering=cpu`) to override a single viewport type + // via the per-mount renderBackend option. The global value maps to + // cornerstone's setRenderBackend; 'cpu'/'gpu' additionally drive the legacy + // useCPURendering flag so pre-generic viewports follow the same selection + // (letting a session force GPU when the deployed config defaults to CPU). + const { renderBackend, renderBackendByViewportType } = resolveViewportRendering( + genericViewportsConfig.viewportRendering + ); + if (renderBackend) { + if (renderBackend === 'cpu') { + cornerstone.setUseCPURendering(true); + } else if (renderBackend === 'gpu') { + cornerstone.setUseCPURendering(false); + } + try { + cornerstone.setRenderBackend(renderBackend as cornerstone.RenderBackendValue); + } catch (error) { + console.warn( + `viewportRendering: "${renderBackend}" is not a registered render backend; ` + + `keeping "${cornerstone.getRenderBackend()}".`, + error + ); + } + } + setViewportRenderingOverrides(renderBackendByViewportType); + cornerstone.setConfiguration({ ...cornerstone.getConfiguration(), rendering: { @@ -81,6 +126,14 @@ export default async function init({ }, }); + // Opt-in: drive viewports through the DIRECT native GenericViewport ("next") + // API (PLANAR_NEXT, setDisplaySets, ...). Read by getCornerstoneViewportType + // and the CornerstoneViewportService backend split. Distinct from + // useGenericViewport above (which only enables cornerstone's compat remap). + // resolveNextViewportsEnabled lets a `?useNextViewports=true` URL param opt in + // per-session; when the param is absent, appConfig.genericViewports.enabled wins. + setNextViewportsEnabled(resolveNextViewportsEnabled(genericViewportsConfig.enabled)); + // For debugging large datasets, otherwise prefer the defaults const { maxCacheSize } = appConfig; if (maxCacheSize) { diff --git a/extensions/cornerstone/src/services/ColorbarService/ColorbarService.ts b/extensions/cornerstone/src/services/ColorbarService/ColorbarService.ts index 7e1705631..9601ed9c7 100644 --- a/extensions/cornerstone/src/services/ColorbarService/ColorbarService.ts +++ b/extensions/cornerstone/src/services/ColorbarService/ColorbarService.ts @@ -1,7 +1,7 @@ import { PubSubService, Types as OhifTypes } from '@ohif/core'; import { RENDERING_ENGINE_ID } from '../ViewportService/constants'; import { getRenderingEngine } from '@cornerstonejs/core'; -import { getDataIdForViewport } from '../../utils/getDataIdForViewport'; +import { getViewportAdapter } from '../ViewportService/adapter'; import { ColorbarOptions, ChangeTypes } from '../../types/Colorbar'; export default class ColorbarService extends PubSubService { @@ -60,8 +60,8 @@ export default class ColorbarService extends PubSubService { return; } - const actorEntries = viewport.getActors(); - if (!actorEntries || actorEntries.length === 0) { + const adapter = getViewportAdapter(viewport); + if (!adapter.hasContent()) { return; } @@ -74,8 +74,8 @@ export default class ColorbarService extends PubSubService { return; } - const dataId = getDataIdForViewport(viewport, displaySetInstanceUID); - const properties = dataId ? viewport.getProperties(dataId) : viewport.getProperties(); + const dataId = adapter.getDataIdForDisplaySet(displaySetInstanceUID); + const properties = adapter.getPresentation(dataId); const colormap = properties?.colormap; if (activeColormapName && !colormap) { @@ -222,16 +222,18 @@ export default class ColorbarService extends PubSubService { private setViewportColormap(viewportId, displaySetInstanceUID, colormap, immediate = false) { const renderingEngine = getRenderingEngine(RENDERING_ENGINE_ID); const viewport = renderingEngine.getViewport(viewportId); - const actorEntries = viewport?.getActors(); - if (!viewport || !actorEntries || actorEntries.length === 0) { + if (!viewport) { + return; + } + const adapter = getViewportAdapter(viewport); + if (!adapter.hasContent()) { return; } - // Get the appropriate dataId for this viewport/displaySet combination - const dataId = getDataIdForViewport(viewport, displaySetInstanceUID); - - // Set properties with or without dataId based on what the viewport supports - viewport.setProperties({ colormap }, dataId); + // Address the display set's binding (volumeId on legacy multi-volume, bare + // UID on native, active binding otherwise) + const dataId = adapter.getDataIdForDisplaySet(displaySetInstanceUID); + adapter.setPresentation({ colormap }, dataId); if (immediate) { viewport.render(); diff --git a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts index 8940a17cd..73096bab6 100644 --- a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts @@ -41,12 +41,39 @@ class CornerstoneCacheService { const cs3DViewportType = getCornerstoneViewportType(viewportType, displaySets); let viewportData: StackViewportData | VolumeViewportData; + // Native Generic ("next") viewport types (e.g. PLANAR_NEXT) intentionally + // collapse the stack/volume distinction into a single type, so they cannot + // drive the stack-vs-volume data-builder decision below. Resolve the data + // shape from the legacy mapping (which preserves that distinction) and keep + // the resolved native type as the produced viewportData's viewportType. + let dataShapeType = getCornerstoneViewportType(viewportType, displaySets, false); + + // A data overlay (fusion) of two or more reconstructable image display sets + // must render as a volume viewport so the source and overlay share one + // representation (volume slice). Without this, a next (PLANAR_NEXT) viewport + // keeps the source in vtkImage (stack) mode while the added overlay is a + // vtkVolumeSlice, producing the broken/unstable fusion. SEG/RT overlays are + // non-reconstructable, so they are not affected. + // + // Scoped to the native (PLANAR_NEXT) path via cs3DViewportType — NOT the flag, and + // NOT the legacy lane: a legacy stack-shaped reconstructable overlay must keep its + // existing stack build so the flag-off path stays byte-identical. + const isReconstructableFusion = + displaySets.length > 1 && displaySets.every(ds => ds.isReconstructable); if ( - cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC || - cs3DViewportType === Enums.ViewportType.VOLUME_3D + isReconstructableFusion && + dataShapeType === Enums.ViewportType.STACK && + cs3DViewportType === Enums.ViewportType.PLANAR_NEXT + ) { + dataShapeType = Enums.ViewportType.ORTHOGRAPHIC; + } + + if ( + dataShapeType === Enums.ViewportType.ORTHOGRAPHIC || + dataShapeType === Enums.ViewportType.VOLUME_3D ) { viewportData = await this._getVolumeViewportData(dataSource, displaySets, cs3DViewportType); - } else if (cs3DViewportType === Enums.ViewportType.STACK) { + } else if (dataShapeType === Enums.ViewportType.STACK) { // Everything else looks like a stack viewportData = await this._getStackViewportData( dataSource, @@ -64,6 +91,9 @@ class CornerstoneCacheService { } viewportData.viewportType = cs3DViewportType; + // Persist the legacy stack/volume shape so consumers can distinguish stack from + // volume content even when viewportType is a native Generic type (PLANAR_NEXT). + viewportData.dataShapeType = dataShapeType; return viewportData; } @@ -74,7 +104,13 @@ class CornerstoneCacheService { dataSource, displaySetService ): Promise { - if (viewportData.viewportType === Enums.ViewportType.STACK) { + // Decide stack-vs-volume rebuild from the persisted data shape, NOT viewportType: + // native viewports collapse both onto PLANAR_NEXT, so a native stack would + // otherwise fall through to the volume rebuild and re-mount as volume data. + // Falls back to viewportType for legacy/older viewportData (byte-identical off-path). + const dataShapeType = viewportData.dataShapeType ?? viewportData.viewportType; + + if (dataShapeType === Enums.ViewportType.STACK) { const displaySet = displaySetService.getDisplaySetByUID(invalidatedDisplaySetInstanceUID); const imageIds = this._getCornerstoneStackImageIds(displaySet, dataSource); @@ -86,7 +122,10 @@ class CornerstoneCacheService { }); return { - viewportType: Enums.ViewportType.STACK, + // Preserve the original viewportType (legacy STACK or native PLANAR_NEXT); + // the rebuilt data shape, not this field, drives the native re-mount dispatch. + viewportType: viewportData.viewportType, + dataShapeType: Enums.ViewportType.STACK, data: { StudyInstanceUID: displaySet.StudyInstanceUID, displaySetInstanceUID: invalidatedDisplaySetInstanceUID, @@ -128,6 +167,7 @@ class CornerstoneCacheService { displaySets, viewportData.viewportType ); + newViewportData.dataShapeType = dataShapeType; return newViewportData; } diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts index 8ad9bd959..754c4aaf0 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.test.ts @@ -19,6 +19,7 @@ import { import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions'; import * as MapROIContoursToRTStructData from './RTSTRUCT/mapROIContoursToRTStructData'; import SegmentationServiceClass, { SegmentationRepresentation } from './SegmentationService'; +import { setNextViewportsEnabled } from '../../utils/nextViewports'; jest.mock('@cornerstonejs/core', () => ({ ...jest.requireActual('@cornerstonejs/core'), @@ -914,6 +915,101 @@ describe('SegmentationService', () => { }); }); + describe('next (generic) viewport', () => { + // A native GenericViewport (raw PlanarViewport) exposes setDisplaySets / + // setDisplaySetPresentation / setViewState, so csUtils.isGenericViewport is + // true and addSegmentationRepresentation routes to NextSegmentationBackend. + // It deliberately has NO getViewPresentation: the native path must never reach + // convertStackToVolumeViewport (the source of the observed + // "getViewPresentation is not a function" / silent ORTHOGRAPHIC flip). + const makeNextViewport = () => ({ + element: { addEventListener: jest.fn(), removeEventListener: jest.fn() }, + id: viewportId, + type: ViewportType.PLANAR_NEXT, + setDisplaySets: jest.fn(), + setDisplaySetPresentation: jest.fn(), + setViewState: jest.fn(), + }); + + it('renders the labelmap in place and never promotes the viewport (resolver maps in place)', async () => { + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport') + .mockReturnValue(makeNextViewport() as unknown as csTypes.IStackViewport); + jest + .spyOn(cstSegmentation.state, 'updateLabelmapSegmentationImageReferences') + .mockReturnValue('labelmapImageId'); + jest + .spyOn(cstSegmentation, 'addSegmentationRepresentations') + .mockReturnValueOnce(undefined); + const convertSpy = jest.spyOn(service, 'convertStackToVolumeViewport'); + + const callback = jest.fn(); + service.subscribe(service.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, callback); + + await service.addSegmentationRepresentation(viewportId, { + segmentationId: mockCornerstoneSegmentation.segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }); + + // the native in-place resolver is consulted... + expect( + cstSegmentation.state.updateLabelmapSegmentationImageReferences + ).toHaveBeenCalledWith(viewportId, mockCornerstoneSegmentation.segmentationId); + + // ...but the viewport is NEVER promoted to an ORTHOGRAPHIC volume viewport + expect(convertSpy).not.toHaveBeenCalled(); + expect( + serviceManagerMock.services.viewportGridService.setDisplaySetsForViewport + ).not.toHaveBeenCalled(); + + // the representation is added in place, synchronously (isConverted === false) + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1); + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledWith(viewportId, [ + { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + segmentationId: mockCornerstoneSegmentation.segmentationId, + config: { colorLUTOrIndex: undefined }, + }, + ]); + + expect(callback).toHaveBeenCalledWith({ + segmentationId: mockCornerstoneSegmentation.segmentationId, + }); + }); + + it('never promotes on native even when the in-place resolver cannot map (returns isConverted:false unconditionally)', async () => { + jest + .spyOn(cstSegmentation.state, 'getSegmentation') + .mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation); + jest + .spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport') + .mockReturnValue(makeNextViewport() as unknown as csTypes.IStackViewport); + // resolver fails (FrameOfReference mismatch / mount-timing race): on legacy this + // would fall through to convertStackToVolumeViewport; on native it must not. + jest + .spyOn(cstSegmentation.state, 'updateLabelmapSegmentationImageReferences') + .mockReturnValue(undefined); + jest + .spyOn(cstSegmentation, 'addSegmentationRepresentations') + .mockReturnValueOnce(undefined); + const convertSpy = jest.spyOn(service, 'convertStackToVolumeViewport'); + + await service.addSegmentationRepresentation(viewportId, { + segmentationId: mockCornerstoneSegmentation.segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }); + + expect(convertSpy).not.toHaveBeenCalled(); + expect( + serviceManagerMock.services.viewportGridService.setDisplaySetsForViewport + ).not.toHaveBeenCalled(); + expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1); + }); + }); + describe('volume viewport', () => { it('should add a segmentation representation to volume viewport without need for handling', async () => { jest @@ -1471,6 +1567,136 @@ describe('SegmentationService', () => { expect(retrievedSegmentationId).toEqual(segmentationId); }); + + describe('overlap / useSliceRendering (next backend)', () => { + const segmentationId = 'segmentationId'; + + // The session flag selects the seg-backend lane at SEG-load; reset it so it + // never leaks into the legacy-path tests that follow. + afterEach(() => { + setNextViewportsEnabled(false); + }); + + const segMetadata = { + data: [ + {}, + { SegmentNumber: '1', SegmentLabel: 'Segment 1', rgba: [255, 0, 0, 255] }, + { SegmentNumber: '2', SegmentLabel: 'Segment 2', rgba: [0, 255, 0, 255] }, + ], + }; + const centroids = new Map([ + [1, { image: { x: 0, y: 0, z: 0 }, world: { x: 0, y: 0, z: 0 } }], + [2, { image: { x: 1, y: 1, z: 1 }, world: { x: 1, y: 1, z: 1 } }], + ]); + + // labelMapImages is the adapter's array-of-GROUPS (one conflict-free group per + // overlap layer). Two groups whose voxels carry values {1} and {2} respectively. + const makeOverlapGroups = () => { + const vm0 = { getScalarData: jest.fn().mockReturnValue([1, 0]), setScalarData: jest.fn() }; + const vm1 = { getScalarData: jest.fn().mockReturnValue([0, 2]), setScalarData: jest.fn() }; + return [ + [ + { imageId: 'g0i1', referencedImageId: 'r1', voxelManager: vm0 }, + { imageId: 'g0i2', referencedImageId: 'r2', voxelManager: vm0 }, + ], + [ + { imageId: 'g1i1', referencedImageId: 'r1', voxelManager: vm1 }, + { imageId: 'g1i2', referencedImageId: 'r2', voxelManager: vm1 }, + ], + ]; + }; + + const makeSegDisplaySet = (labelMapImages, overlappingSegments) => ({ + centroids, + displaySetInstanceUID: 'display-set-uid', + referencedDisplaySetInstanceUID: 'existent-display-set-uid', + labelMapImages, + overlappingSegments, + segMetadata, + SeriesDate: '2025-01-01', + SeriesDescription: 'Series Description', + Modality: 'SEG', + SeriesNumber: 1, + }); + + const primeMocks = () => { + jest + .spyOn(serviceManagerMock.services.displaySetService, 'getDisplaySetByUID') + .mockReturnValue({ instances: [{ imageId: 'r1' }, { imageId: 'r2' }] }); + jest.spyOn(metaData, 'get').mockReturnValue({}); + jest.spyOn(service, 'addOrUpdateSegmentation').mockReturnValue(undefined); + }; + + it('flag ON + overlapping SEG builds one labelmap layer per group + segmentBindings', async () => { + setNextViewportsEnabled(true); + primeMocks(); + + await service.createSegmentationForSEGDisplaySet( + makeSegDisplaySet(makeOverlapGroups(), true), + { type: csToolsEnums.SegmentationRepresentations.Labelmap, segmentationId } + ); + + const seg = jest.mocked(service.addOrUpdateSegmentation).mock.calls[0][0]; + const data = seg.representation.data as Record; + + // one labelmap layer per conflict-free group, under ONE segmentationId + expect(Object.keys(data.labelmaps)).toEqual([ + 'segmentationId-storage-0', + 'segmentationId-storage-1', + ]); + expect(data.labelmaps['segmentationId-storage-0'].imageIds).toEqual(['g0i1', 'g0i2']); + expect(data.labelmaps['segmentationId-storage-1'].imageIds).toEqual(['g1i1', 'g1i2']); + expect(data.labelmaps['segmentationId-storage-0'].storageKind).toBe('stack'); + + // segment->layer bindings recovered from the distinct non-zero voxel values + expect(data.segmentBindings).toEqual({ + 1: { labelmapId: 'segmentationId-storage-0', labelValue: 1 }, + 2: { labelmapId: 'segmentationId-storage-1', labelValue: 2 }, + }); + expect(data.primaryLabelmapId).toBe('segmentationId-storage-0'); + // flattened list retained for legacy singular readers + expect(data.imageIds).toEqual(['g0i1', 'g0i2', 'g1i1', 'g1i2']); + }); + + it('flag ON + non-overlapping SEG stays a single layer (no multi-layer map)', async () => { + setNextViewportsEnabled(true); + primeMocks(); + + const vm = { getScalarData: jest.fn().mockReturnValue([1, 0]), setScalarData: jest.fn() }; + const singleGroup = [ + [ + { imageId: 'i1', referencedImageId: 'r1', voxelManager: vm }, + { imageId: 'i2', referencedImageId: 'r2', voxelManager: vm }, + ], + ]; + + await service.createSegmentationForSEGDisplaySet(makeSegDisplaySet(singleGroup, false), { + type: csToolsEnums.SegmentationRepresentations.Labelmap, + segmentationId, + }); + + const seg = jest.mocked(service.addOrUpdateSegmentation).mock.calls[0][0]; + const data = seg.representation.data as Record; + expect(data.labelmaps).toBeUndefined(); + expect(data.segmentBindings).toBeUndefined(); + expect(data.imageIds).toEqual(['i1', 'i2']); + }); + + it('flag OFF + overlapping SEG collapses to a single flattened layer (legacy byte-identical)', async () => { + // flag stays off (default) + primeMocks(); + + await service.createSegmentationForSEGDisplaySet( + makeSegDisplaySet(makeOverlapGroups(), true), + { type: csToolsEnums.SegmentationRepresentations.Labelmap, segmentationId } + ); + + const seg = jest.mocked(service.addOrUpdateSegmentation).mock.calls[0][0]; + const data = seg.representation.data as Record; + expect(data.labelmaps).toBeUndefined(); + expect(data.imageIds).toEqual(['g0i1', 'g0i2', 'g1i1', 'g1i2']); + }); + }); }); describe('createSegmentationForRTDisplaySet', () => { @@ -2698,6 +2924,47 @@ describe('SegmentationService', () => { ); }); + it('navigates a native viewport via setViewReference (no jumpToWorld) and still highlights', () => { + const segmentationId = 'segmentationId'; + const segmentIndex = 1; + const viewportId = 'viewportId'; + // A native PlanarViewport: csUtils.isGenericViewport is true (setDisplaySets + + // setDisplaySetPresentation + setViewState) and it has setViewReference but NO + // jumpToWorld, so jumpToSegmentCenter routes to the next twin. + const viewport = { + setDisplaySets: jest.fn(), + setDisplaySetPresentation: jest.fn(), + setViewState: jest.fn(), + setViewReference: jest.fn(), + render: jest.fn(), + }; + + const segmentationWithCenter = { + ...mockCornerstoneSegmentation, + segments: { + ...mockCornerstoneSegmentation.segments, + [segmentIndex]: { + ...mockCornerstoneSegmentation.segments[segmentIndex], + cachedStats: { center: { image: [1, 1, 1], world: [10, 10, 10] } }, + }, + }, + }; + + jest.spyOn(cstSegmentation.state, 'getSegmentation').mockReturnValue(segmentationWithCenter); + // @ts-expect-error - mock only needed properties + getEnabledElementByViewportId.mockReturnValue({ viewport }); + jest.spyOn(service, 'highlightSegment').mockReturnValue(undefined); + + service.jumpToSegmentCenter(segmentationId, segmentIndex, viewportId); + + // native jump: a view reference centered on the segment world point, then render + expect(viewport.setViewReference).toHaveBeenCalledTimes(1); + expect(viewport.setViewReference).toHaveBeenCalledWith({ cameraFocalPoint: [10, 10, 10] }); + expect(viewport.render).toHaveBeenCalledTimes(1); + // the recenter happened, so the highlight still runs + expect(service.highlightSegment).toHaveBeenCalledTimes(1); + }); + it('should correctly handle custom animation parameters', () => { const segmentationId = 'segmentationId'; const segmentIndex = 1; diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index 7c75978a4..a1dd32264 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -10,10 +10,7 @@ import { metaData, } from '@cornerstonejs/core'; import { ViewportType } from '@cornerstonejs/core/enums'; -import { - isVolume3DViewportType, - isVolumeViewportType, -} from '../../utils/getLegacyViewportType'; +import { isVolume3DViewportType } from '../../utils/getLegacyViewportType'; import { Enums as csToolsEnums, @@ -30,6 +27,17 @@ import { mapROIContoursToRTStructData } from './RTSTRUCT/mapROIContoursToRTStruc import { SegmentationPresentation, SegmentationPresentationItem } from '../../types/Presentation'; import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions'; import { ViewReference } from '@cornerstonejs/core/types'; +import { + LegacySegmentationBackend, + NextSegmentationBackend, + type ISegmentationBackend, + type ISegmentationServiceInternals, +} from './backends'; +// Sanctioned flag read: the SEG data shape (single- vs multi-layer) is fixed at +// load time, before any target viewport exists, so this one seg-backend dispatch +// cannot use a per-viewport capability check and reads the session flag instead. +import { isNextViewportsEnabled } from '../../utils/nextViewports'; +import { isNextViewport } from '../ViewportService/adapter'; const { DefaultHistoryMemo } = csUtils.HistoryMemo; @@ -42,7 +50,7 @@ const { const { getLabelmapImageIds, helpers: { convertStackToVolumeLabelmap }, - state: { addColorLUT, updateLabelmapSegmentationImageReferences }, + state: { addColorLUT }, triggerSegmentationEvents: { triggerSegmentationRepresentationModified }, } = cstSegmentation; @@ -98,7 +106,7 @@ const EVENTS = { const VALUE_TYPES = {}; -class SegmentationService extends PubSubService { +class SegmentationService extends PubSubService implements ISegmentationServiceInternals { static REGISTRATION = { name: 'segmentationService', altName: 'SegmentationService', @@ -109,6 +117,8 @@ class SegmentationService extends PubSubService { private _segmentationIdToColorLUTIndexMap: Map; private _segmentationGroupStatsMap: Map; + private readonly _legacySegBackend: ISegmentationBackend; + private readonly _nextSegBackend: ISegmentationBackend; readonly servicesManager: AppTypes.ServicesManager; highlightIntervalId = null; readonly EVENTS = EVENTS; @@ -121,6 +131,24 @@ class SegmentationService extends PubSubService { this.servicesManager = servicesManager; this._segmentationGroupStatsMap = new Map(); + + // Segmentation backend twins (mirror the viewport backend family). Routed PER + // VIEWPORT via _segBackend() using the adapter's isNextViewport predicate, + // because a flag-on session can mix native and legacy viewports. Both are + // constructed eagerly: + // per-viewport dispatch has no per-session flag to defer on, and the twins read + // state at call time (post-init), not at construction. + this._legacySegBackend = new LegacySegmentationBackend(this); + this._nextSegBackend = new NextSegmentationBackend(); + } + + /** + * Picks the segmentation backend lane for a specific viewport: the native + * ("next") twin for a raw GenericViewport (PlanarViewport), the legacy twin + * otherwise. Mirrors viewportOperations' per-viewport dispatch. + */ + private _segBackend(viewport: csTypes.IViewport): ISegmentationBackend { + return isNextViewport(viewport) ? this._nextSegBackend : this._legacySegBackend; } public onModeEnter(): void { @@ -297,6 +325,7 @@ class SegmentationService extends PubSubService { type?: csToolsEnums.SegmentationRepresentations; config?: { blendMode?: csEnums.BlendModes; + useSliceRendering?: boolean; }; suppressEvents?: boolean; } @@ -319,6 +348,15 @@ class SegmentationService extends PubSubService { return; } + // A stale/invalid segmentationId yields no segmentation; fail fast with a clear + // message instead of dereferencing representationData deep inside the backend + // classification below. + if (!segmentation) { + throw new Error( + `SegmentationService: cannot add representation - segmentation "${segmentationId}" not found.` + ); + } + const colorLUTIndex = this._segmentationIdToColorLUTIndexMap.get(segmentationId); let isConverted = false; @@ -328,20 +366,35 @@ class SegmentationService extends PubSubService { let representationTypeToUse = type || defaultRepresentationType; if (representationTypeToUse === LABELMAP) { - const { isVolumeViewport, isVolumeSegmentation } = this.determineViewportAndSegmentationType( - csViewport, - segmentation - ) || { isVolumeViewport: false, isVolumeSegmentation: false }; - - ({ representationTypeToUse, isConverted } = await this.handleViewportConversion( - isVolumeViewport, - isVolumeSegmentation, + ({ representationTypeToUse, isConverted } = await this._segBackend( + csViewport + ).classifyAndPrepareLabelmapAdd( csViewport, segmentation, viewportId, segmentationId, representationTypeToUse )); + + // Overlap precondition: an overlapping SEG is registered as multiple labelmap + // layers, but cornerstone only stacks them (slice rendering) when the viewport + // renders as a volume slice (VTK_VOLUME_SLICE) — i.e. an MPR/volume viewport. On + // a stack/acquisition viewport the render plan falls back to a single layer, so + // only the primary group is visible. Warn rather than fail silently. + const labelmapLayers = segmentation?.representationData?.[LABELMAP]?.labelmaps; + const isOverlapping = labelmapLayers && Object.keys(labelmapLayers).length > 1; + if ( + isOverlapping && + isNextViewport(csViewport) && + !csUtils.viewportIsInVolumeMode(csViewport) + ) { + console.warn( + `Overlapping segmentation ${segmentationId} has multiple labelmap layers, but ` + + `viewport ${viewportId} does not render as a volume slice (VTK_VOLUME_SLICE); ` + + `only the primary layer will be visible. Display the segmentation in an ` + + `MPR/volume layout to see all overlapping segments.` + ); + } } await this._addSegmentationRepresentation( @@ -592,22 +645,21 @@ class SegmentationService extends PubSubService { const colorLUTIndex = addColorLUT(colorLUT); this._segmentationIdToColorLUTIndexMap.set(segmentationId, colorLUTIndex); - const seg: cstTypes.SegmentationPublicInput = { + // Build the segmentation input via the backend twin. At SEG-load there is no + // target viewport yet, so the lane is chosen by the session flag (the one + // viewport-less seg-backend dispatch): the next twin registers overlapping SEGs + // as multiple labelmap layers (slice rendering); the legacy twin keeps the single + // flattened layer (byte-identical). + const segBackend = isNextViewportsEnabled() ? this._nextSegBackend : this._legacySegBackend; + const seg = segBackend.assembleSegmentationDataForSEG({ segmentationId, - representation: { - type: LABELMAP, - data: { - imageIds: derivedImageIds, - // referencedVolumeId: this._getVolumeIdForDisplaySet(referencedDisplaySet), - referencedImageIds: imageIds as string[], - }, - }, - config: { - label: segDisplaySet.SeriesDescription, - fallbackLabel: `S:${segDisplaySet.SeriesNumber} ${segDisplaySet.Modality}`, - segments, - }, - }; + segDisplaySet, + derivedImageIds, + referencedImageIds: imageIds as string[], + label: segDisplaySet.SeriesDescription, + fallbackLabel: `S:${segDisplaySet.SeriesNumber} ${segDisplaySet.Modality}`, + segments, + }); segDisplaySet.isLoaded = true; @@ -1515,13 +1567,17 @@ class SegmentationService extends PubSubService { viewportIds.forEach(viewportId => { const { viewport } = getEnabledElementByViewportId(viewportId); - if (!viewport?.jumpToWorld) { + if (!viewport) { return; } - viewport.jumpToWorld(world); + // Recenter via the backend twin: legacy jumpToWorld, or native setViewReference + // (a native PlanarViewport has no jumpToWorld). Skip the highlight when the + // recenter did not happen, matching the previous guarded behavior. + const didJump = this._segBackend(viewport).jumpToSegmentCenter(viewport, world); - highlightSegment && + didJump && + highlightSegment && this.highlightSegment( segmentationId, segmentIndex, @@ -1626,87 +1682,11 @@ class SegmentationService extends PubSubService { ); } - private determineViewportAndSegmentationType(csViewport, segmentation) { - const isVolumeViewport = isVolumeViewportType(csViewport); - const labelmapData = segmentation?.representationData?.[LABELMAP]; - - if (!labelmapData) { - return { isVolumeViewport, isVolumeSegmentation: false }; - } - - const isVolumeSegmentation = 'volumeId' in labelmapData; - return { isVolumeViewport, isVolumeSegmentation }; - } - - private async handleViewportConversion( - isVolumeViewport: boolean, - isVolumeSegmentation: boolean, - csViewport: csTypes.IViewport, - segmentation: cstTypes.Segmentation, - viewportId: string, - segmentationId: string, - representationType: csToolsEnums.SegmentationRepresentations - ) { - let representationTypeToUse = representationType; - let isConverted = false; - - const handler = isVolumeViewport ? this.handleVolumeViewportCase : this.handleStackViewportCase; - - ({ representationTypeToUse, isConverted } = await handler.apply(this, [ - csViewport, - segmentation, - isVolumeSegmentation, - viewportId, - segmentationId, - ])); - - return { representationTypeToUse, isConverted }; - } - - private async handleVolumeViewportCase(csViewport, segmentation, isVolumeSegmentation) { - if (isVolume3DViewportType(csViewport)) { - return { - representationTypeToUse: SURFACE, - isConverted: false, - }; - } else { - await this.handleVolumeViewport( - csViewport as csTypes.IVolumeViewport, - segmentation, - isVolumeSegmentation - ); - return { representationTypeToUse: LABELMAP, isConverted: false }; - } - } - - private async handleStackViewportCase( - csViewport: csTypes.IViewport, - segmentation: cstTypes.Segmentation, - isVolumeSegmentation: boolean, - viewportId: string, - segmentationId: string - ): Promise<{ - representationTypeToUse: csToolsEnums.SegmentationRepresentations; - isConverted: boolean; - }> { - if (isVolumeSegmentation) { - const isConverted = await this.convertStackToVolumeViewport(csViewport); - return { representationTypeToUse: LABELMAP, isConverted }; - } - - if (updateLabelmapSegmentationImageReferences(viewportId, segmentationId)) { - return { representationTypeToUse: LABELMAP, isConverted: false }; - } - - const isConverted = await this.attemptStackToVolumeConversion( - csViewport as csTypes.IStackViewport, - segmentation, - viewportId, - segmentationId - ); - - return { representationTypeToUse: LABELMAP, isConverted }; - } + // Labelmap-add classification (determineViewportAndSegmentationType + + // handleViewportConversion + the stack/volume case handlers) now lives in the + // segmentation backend twins (backends/{Legacy,Next}SegmentationBackend), routed + // per viewport via _segBackend(). The legacy twin reaches the viewport-recreation + // and data-volume-conversion helpers below through ISegmentationServiceInternals. private async _addSegmentationRepresentation( viewportId: string, @@ -1716,6 +1696,7 @@ class SegmentationService extends PubSubService { isConverted: boolean, config?: { blendMode?: csEnums.BlendModes; + useSliceRendering?: boolean; } ): Promise { const representation = { @@ -1743,7 +1724,7 @@ class SegmentationService extends PubSubService { addRepresentation(); } } - private async handleVolumeViewport( + public async handleVolumeViewport( viewport: csTypes.IVolumeViewport, segmentation: SegmentationData, isVolumeSegmentation: boolean @@ -1761,7 +1742,7 @@ class SegmentationService extends PubSubService { } } - private async convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise { + public async convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise { const { viewportGridService, cornerstoneViewportService } = this.servicesManager.services; const state = viewportGridService.getState(); const gridViewport = state.viewports.get(viewport.id); @@ -1800,7 +1781,7 @@ class SegmentationService extends PubSubService { return true; } - private async attemptStackToVolumeConversion( + public async attemptStackToVolumeConversion( viewport: csTypes.IStackViewport, segmentation: SegmentationData, viewportId: string, @@ -1820,6 +1801,10 @@ class SegmentationService extends PubSubService { return isConverted; } + + // Frame-of-reference mismatch (or missing): no conversion happened. Return an + // explicit boolean so the Promise contract holds for callers. + return false; } private addSegmentationToSource(segmentationPublicInput: cstTypes.SegmentationPublicInput) { diff --git a/extensions/cornerstone/src/services/SegmentationService/backends/ISegmentationBackend.ts b/extensions/cornerstone/src/services/SegmentationService/backends/ISegmentationBackend.ts new file mode 100644 index 000000000..71d98fbda --- /dev/null +++ b/extensions/cornerstone/src/services/SegmentationService/backends/ISegmentationBackend.ts @@ -0,0 +1,102 @@ +import type { Types as csTypes } from '@cornerstonejs/core'; +import type { Enums as csToolsEnums, Types as cstTypes } from '@cornerstonejs/tools'; + +/** A derived labelmap image produced by the SEG adapter (one per referenced image, + * per overlap group). `voxelManager.getScalarData()` yields the slice's label values. */ +export interface SegLabelmapImage { + imageId: string; + voxelManager?: { getScalarData: () => ArrayLike }; +} + +/** Inputs for assembling the cornerstone SegmentationPublicInput from a loaded SEG + * display set. `segDisplaySet.labelMapImages` is the adapter's array-of-groups (one + * conflict-free group per overlap layer); `overlappingSegments` flags whether the + * SEG actually has overlap. `derivedImageIds` is the flattened image-id list. */ +export interface AssembleSegmentationForSEGParams { + segmentationId: string; + segDisplaySet: { + labelMapImages?: SegLabelmapImage[][]; + overlappingSegments?: boolean; + [key: string]: unknown; + }; + derivedImageIds: string[]; + referencedImageIds: string[]; + label: string; + fallbackLabel: string; + segments: { [segmentIndex: string]: cstTypes.Segment }; +} + +/** + * Result of classifying a labelmap add: the representation type to actually use + * (LABELMAP, or SURFACE for a 3D viewport) and whether the viewport was promoted + * stack -> volume (ORTHOGRAPHIC). When `isConverted` is true the caller defers the + * representation add until the grid re-mounts the recreated viewport. + */ +export interface LabelmapAddClassification { + representationTypeToUse: csToolsEnums.SegmentationRepresentations; + isConverted: boolean; +} + +/** + * Segmentation backend twin (mirrors the viewport backend family at + * `ViewportService/backends/`). One implementation per lane: + * - LegacySegmentationBackend: today's behavior (may promote a stack viewport to + * an ORTHOGRAPHIC volume viewport via the host's convertStackToVolumeViewport). + * - NextSegmentationBackend: the native GenericViewport ("next") path — renders + * the labelmap IN PLACE on the raw PlanarViewport and never promotes. + * + * DISPATCH (deliberately diverges from IViewportBackend): unlike the viewport + * lifecycle backend, which is selected ONCE by the appConfig flag, the segmentation + * twin is routed PER VIEWPORT via `isNextViewport(viewport)` (the same + * runtime predicate used by `viewportOperations`). A flag-on session can hold both + * legacy and native viewports, and every viewport-bearing method already has an + * already-resolved, self-describing viewport in hand, so per-viewport routing is the + * runtime truth. `isNextViewport` is true for the native raw PlanarViewport and + * false for legacy StackViewport/VolumeViewport. + * + * BOUNDARY: viewport (re)creation is NOT a segmentation concern. The Next twin never + * calls `convertStackToVolumeViewport` (that recreates the viewport as ORTHOGRAPHIC + * and is owned by CornerstoneViewportService); the Legacy twin reaches it through + * `ISegmentationServiceInternals`, so the off path stays byte-identical. + */ +export interface ISegmentationBackend { + /** + * Decide how a LABELMAP representation is added for `csViewport`, performing any + * stack->volume promotion the lane requires. Called only inside the LABELMAP gate + * of `addSegmentationRepresentation` (CONTOUR/SURFACE never reach here). + */ + classifyAndPrepareLabelmapAdd( + csViewport: csTypes.IViewport, + segmentation: cstTypes.Segmentation, + viewportId: string, + segmentationId: string, + representationType: csToolsEnums.SegmentationRepresentations + ): Promise; + + /** + * Build the cornerstone SegmentationPublicInput for a loaded DICOM SEG display set. + * + * Dispatched by the session flag at SEG-load (NOT per viewport): no target viewport + * exists yet, and the data shape (single- vs multi-layer) is fixed at creation. + * + * Legacy: a single flattened labelmap layer (today's behavior, byte-identical). + * Next: when the SEG has overlapping segments, register each conflict-free group as + * its own labelmap layer under one segmentationId (+ segmentBindings) so cornerstone + * renders all overlapping segments via the slice path; otherwise identical to Legacy. + */ + assembleSegmentationDataForSEG( + params: AssembleSegmentationForSEGParams + ): cstTypes.SegmentationPublicInput; + + /** + * Recenter a viewport on a segment's world-space center point. Returns whether it + * actually recentered, so the caller can skip the segment highlight when it did not + * (preserving today's "no jump -> no highlight" behavior). + * + * Legacy: `viewport.jumpToWorld(world)` (guarded; absent -> false no-op, as today). + * Next: a native PlanarViewport has no jumpToWorld -> navigate via a view reference + * centered on `world` (setViewReference), turning today's silent native no-op into a + * working jump-to-slice. (In-plane pan-to-center is a separate, deferred refinement.) + */ + jumpToSegmentCenter(viewport: csTypes.IViewport, world: csTypes.Point3): boolean; +} diff --git a/extensions/cornerstone/src/services/SegmentationService/backends/ISegmentationServiceInternals.ts b/extensions/cornerstone/src/services/SegmentationService/backends/ISegmentationServiceInternals.ts new file mode 100644 index 000000000..13be732d1 --- /dev/null +++ b/extensions/cornerstone/src/services/SegmentationService/backends/ISegmentationServiceInternals.ts @@ -0,0 +1,44 @@ +import type { Types as csTypes } from '@cornerstonejs/core'; +import type { Types as cstTypes } from '@cornerstonejs/tools'; + +/** + * The narrow slice of SegmentationService that a segmentation backend is allowed to + * reach (mirrors `IViewportServiceInternals`). The service `implements` this and + * passes `this` to each backend, so the legacy twin can delegate the viewport + * (re)creation / data-volume conversion work back to the shared service methods + * (which touch servicesManager-owned services) without the backends reaching into + * unrelated internals. Keeping this surface narrow is what stops the legacy path + * from drifting as the next backend grows. + * + * Only the LEGACY twin uses these; the NEXT twin renders in place and needs none of + * them (it never converts). + */ +export interface ISegmentationServiceInternals { + /** + * Recreate the stack viewport as an ORTHOGRAPHIC volume viewport (legacy + * promotion). Owned by the service because it drives viewportGridService / + * cornerstoneViewportService. Returns true (converted). + */ + convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise; + + /** + * Promote a stack viewport to volume only when the segmentation's + * FrameOfReference matches the viewport's. Returns whether it converted. + */ + attemptStackToVolumeConversion( + viewport: csTypes.IStackViewport, + segmentation: cstTypes.Segmentation, + viewportId: string, + segmentationId: string + ): Promise; + + /** + * Convert the segmentation DATA to a volume labelmap when its FrameOfReference + * matches a volume viewport (pure data; no viewport recreation). + */ + handleVolumeViewport( + viewport: csTypes.IVolumeViewport, + segmentation: cstTypes.Segmentation, + isVolumeSegmentation: boolean + ): Promise; +} diff --git a/extensions/cornerstone/src/services/SegmentationService/backends/LegacySegmentationBackend.ts b/extensions/cornerstone/src/services/SegmentationService/backends/LegacySegmentationBackend.ts new file mode 100644 index 000000000..e382e92d3 --- /dev/null +++ b/extensions/cornerstone/src/services/SegmentationService/backends/LegacySegmentationBackend.ts @@ -0,0 +1,139 @@ +import type { Types as csTypes } from '@cornerstonejs/core'; +import { + Enums as csToolsEnums, + segmentation as cstSegmentation, + type Types as cstTypes, +} from '@cornerstonejs/tools'; +import { isVolume3DViewportType, isVolumeViewportType } from '../../../utils/getLegacyViewportType'; +import type { + AssembleSegmentationForSEGParams, + ISegmentationBackend, + LabelmapAddClassification, +} from './ISegmentationBackend'; +import type { ISegmentationServiceInternals } from './ISegmentationServiceInternals'; + +const { Labelmap: LABELMAP, Surface: SURFACE } = csToolsEnums.SegmentationRepresentations; +const { + state: { updateLabelmapSegmentationImageReferences }, +} = cstSegmentation; + +/** + * Legacy (default) segmentation backend. Selected when the target viewport is NOT a + * native GenericViewport (the off path / legacy StackViewport / VolumeViewport). + * Holds the labelmap-add decision tree verbatim (determine + handleViewportConversion + * + the stack/volume case handlers) and delegates the servicesManager-coupled work + * (convertStackToVolumeViewport / attemptStackToVolumeConversion / handleVolumeViewport) + * back to the service via ISegmentationServiceInternals, so behavior is byte-identical + * to before the backend split. + */ +export class LegacySegmentationBackend implements ISegmentationBackend { + constructor(private readonly service: ISegmentationServiceInternals) {} + + async classifyAndPrepareLabelmapAdd( + csViewport: csTypes.IViewport, + segmentation: cstTypes.Segmentation, + viewportId: string, + segmentationId: string, + // The case handlers return LABELMAP/SURFACE directly (byte-identical to the + // pre-split handleViewportConversion), so the incoming type is unused here. + _representationType: csToolsEnums.SegmentationRepresentations + ): Promise { + const isVolumeViewport = isVolumeViewportType(csViewport); + // A missing labelmap representation (stale/partial segmentation state) must not + // throw on the `'volumeId' in ...` probe; treat it as a non-volume segmentation. + const labelmapData = segmentation?.representationData?.[LABELMAP]; + const isVolumeSegmentation = !!labelmapData && 'volumeId' in labelmapData; + + return isVolumeViewport + ? this.handleVolumeViewportCase(csViewport, segmentation, isVolumeSegmentation) + : this.handleStackViewportCase( + csViewport, + segmentation, + isVolumeSegmentation, + viewportId, + segmentationId + ); + } + + private async handleVolumeViewportCase( + csViewport: csTypes.IViewport, + segmentation: cstTypes.Segmentation, + isVolumeSegmentation: boolean + ): Promise { + if (isVolume3DViewportType(csViewport)) { + return { representationTypeToUse: SURFACE, isConverted: false }; + } + + await this.service.handleVolumeViewport( + csViewport as csTypes.IVolumeViewport, + segmentation, + isVolumeSegmentation + ); + return { representationTypeToUse: LABELMAP, isConverted: false }; + } + + private async handleStackViewportCase( + csViewport: csTypes.IViewport, + segmentation: cstTypes.Segmentation, + isVolumeSegmentation: boolean, + viewportId: string, + segmentationId: string + ): Promise { + if (isVolumeSegmentation) { + const isConverted = await this.service.convertStackToVolumeViewport(csViewport); + return { representationTypeToUse: LABELMAP, isConverted }; + } + + if (updateLabelmapSegmentationImageReferences(viewportId, segmentationId)) { + return { representationTypeToUse: LABELMAP, isConverted: false }; + } + + const isConverted = await this.service.attemptStackToVolumeConversion( + csViewport as csTypes.IStackViewport, + segmentation, + viewportId, + segmentationId + ); + + return { representationTypeToUse: LABELMAP, isConverted }; + } + + assembleSegmentationDataForSEG( + params: AssembleSegmentationForSEGParams + ): cstTypes.SegmentationPublicInput { + const { segmentationId, derivedImageIds, referencedImageIds, label, fallbackLabel, segments } = + params; + + // Single flattened labelmap layer — byte-identical to the pre-split builder in + // createSegmentationForSEGDisplaySet. Overlap is collapsed (one voxel = one id). + return { + segmentationId, + representation: { + type: LABELMAP, + data: { + imageIds: derivedImageIds, + referencedImageIds, + }, + }, + config: { + label, + fallbackLabel, + segments, + }, + }; + } + + jumpToSegmentCenter(viewport: csTypes.IViewport, world: csTypes.Point3): boolean { + // Byte-identical to the pre-split guarded recenter: legacy stack/volume viewports + // have jumpToWorld; if absent (e.g. a native viewport reaching the legacy twin), + // no-op and report it so the caller skips the highlight, exactly as before. + const legacyViewport = viewport as csTypes.IViewport & { + jumpToWorld?: (world: csTypes.Point3) => void; + }; + if (!legacyViewport?.jumpToWorld) { + return false; + } + legacyViewport.jumpToWorld(world); + return true; + } +} diff --git a/extensions/cornerstone/src/services/SegmentationService/backends/NextSegmentationBackend.ts b/extensions/cornerstone/src/services/SegmentationService/backends/NextSegmentationBackend.ts new file mode 100644 index 000000000..49c3036ca --- /dev/null +++ b/extensions/cornerstone/src/services/SegmentationService/backends/NextSegmentationBackend.ts @@ -0,0 +1,159 @@ +import type { Types as csTypes } from '@cornerstonejs/core'; +import { + Enums as csToolsEnums, + segmentation as cstSegmentation, + type Types as cstTypes, +} from '@cornerstonejs/tools'; +import type { + AssembleSegmentationForSEGParams, + ISegmentationBackend, + LabelmapAddClassification, +} from './ISegmentationBackend'; + +const { Labelmap: LABELMAP } = csToolsEnums.SegmentationRepresentations; +const { + state: { updateLabelmapSegmentationImageReferences }, +} = cstSegmentation; + +/** + * Native GenericViewport ("next") segmentation backend. Selected when the target + * viewport is a native generic viewport (raw PlanarViewport; + * `isNextViewport(viewport)` is true). + * + * The keystone of the native migration: a native PlanarViewport renders a labelmap + * IN PLACE — cornerstone's `resolveLabelmapRenderPlan` picks `legacy-stack-image` + * for a stack labelmap (no volumeId, no VTK_VOLUME_SLICE precondition) and the + * duck-typed image-reference resolver maps it onto the viewport's current image. So + * this twin NEVER promotes the viewport to an ORTHOGRAPHIC volume viewport: the + * legacy `convertStackToVolumeViewport` calls `getViewPresentation` / + * `setViewPresentation`, which the raw PlanarViewport does not implement — it throws + * (the observed `getViewPresentation is not a function`) and would recreate the + * viewport, defeating `useNextViewports`. + * + * Needs nothing from the host service (it never converts / never touches + * servicesManager), so unlike the legacy twin it takes no internals handle. + */ +export class NextSegmentationBackend implements ISegmentationBackend { + async classifyAndPrepareLabelmapAdd( + _csViewport: csTypes.IViewport, + _segmentation: cstTypes.Segmentation, + viewportId: string, + segmentationId: string, + representationType: csToolsEnums.SegmentationRepresentations + ): Promise { + // Try the duck-typed in-place resolver so the labelmap's images map onto the + // viewport's current image when the FrameOfReference matches. Its return value + // is intentionally ignored: we return isConverted:false UNCONDITIONALLY so we + // never promote, even on a mount-timing race where the resolver cannot map yet. + updateLabelmapSegmentationImageReferences(viewportId, segmentationId); + + return { representationTypeToUse: representationType, isConverted: false }; + } + + assembleSegmentationDataForSEG( + params: AssembleSegmentationForSEGParams + ): cstTypes.SegmentationPublicInput { + const { + segmentationId, + segDisplaySet, + derivedImageIds, + referencedImageIds, + label, + fallbackLabel, + segments, + } = params; + + const groups = segDisplaySet.labelMapImages ?? []; + const config = { label, fallbackLabel, segments }; + + // Non-overlapping (or a single group): identical to the legacy single-layer build. + if (!segDisplaySet.overlappingSegments || groups.length <= 1) { + return { + segmentationId, + representation: { + type: LABELMAP, + data: { imageIds: derivedImageIds, referencedImageIds }, + }, + config, + }; + } + + // Overlapping SEG: register each conflict-free group as its OWN labelmap layer + // under one segmentationId. cornerstone's slice path auto-fires for >1 stack layer + // (shouldUseSliceRendering) and stacks one depth-offset vtkImageSlice actor per + // layer, so all overlapping segments stay simultaneously visible. + // ensureLabelmapState preserves a supplied labelmaps/segmentBindings/ + // primaryLabelmapId map via its `||=` guards, so this needs NO cornerstone change. + const labelmaps: Record< + string, + { labelmapId: string; storageKind: 'stack'; imageIds: string[]; referencedImageIds: string[] } + > = {}; + const segmentBindings: Record = {}; + + groups.forEach((group, index) => { + const labelmapId = `${segmentationId}-storage-${index}`; + labelmaps[labelmapId] = { + labelmapId, + storageKind: 'stack', + imageIds: group.map(image => image.imageId), + referencedImageIds, + }; + + // The adapter bin-packs non-overlapping segments into each group and writes each + // segment's index as its label value (the colorLUT is segment-indexed, so + // labelValue === segmentIndex). Group membership is implicit in the pixel data, + // so recover it by collecting the distinct non-zero values present in the group; + // those segment indices bind to this layer (so ensureLabelmapState does not + // default them all onto the primary layer, which would hide layers 1..N-1). + const valuesInGroup = new Set(); + for (const image of group) { + const scalarData = image.voxelManager?.getScalarData(); + if (!scalarData) { + continue; + } + for (let i = 0; i < scalarData.length; i++) { + const value = scalarData[i]; + if (value !== 0) { + valuesInGroup.add(value); + } + } + } + valuesInGroup.forEach(value => { + segmentBindings[value] = { labelmapId, labelValue: value }; + }); + }); + + return { + segmentationId, + representation: { + type: LABELMAP, + data: { + // Keep the flattened list for legacy singular readers (getLabelmapImageIds, + // SEG export); the per-layer truth lives in `labelmaps`. + imageIds: derivedImageIds, + referencedImageIds, + labelmaps, + segmentBindings, + primaryLabelmapId: `${segmentationId}-storage-0`, + }, + }, + config, + } as cstTypes.SegmentationPublicInput; + } + + jumpToSegmentCenter(viewport: csTypes.IViewport, world: csTypes.Point3): boolean { + // A native PlanarViewport has no jumpToWorld; navigate via a view reference + // centered on the segment's world point. This snaps to the slice containing + // `world` along the viewport's current view normal (the core of jump-to-segment). + // In-plane pan-to-center is a separate, deferred refinement. + const nativeViewport = viewport as csTypes.IViewport & { + setViewReference?: (ref: csTypes.ViewReference) => void; + }; + if (typeof nativeViewport.setViewReference !== 'function') { + return false; + } + nativeViewport.setViewReference({ cameraFocalPoint: world } as csTypes.ViewReference); + viewport.render(); + return true; + } +} diff --git a/extensions/cornerstone/src/services/SegmentationService/backends/index.ts b/extensions/cornerstone/src/services/SegmentationService/backends/index.ts new file mode 100644 index 000000000..caff62183 --- /dev/null +++ b/extensions/cornerstone/src/services/SegmentationService/backends/index.ts @@ -0,0 +1,4 @@ +export type { ISegmentationBackend, LabelmapAddClassification } from './ISegmentationBackend'; +export type { ISegmentationServiceInternals } from './ISegmentationServiceInternals'; +export { LegacySegmentationBackend } from './LegacySegmentationBackend'; +export { NextSegmentationBackend } from './NextSegmentationBackend'; diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 7d9734d05..8e35219f7 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -35,13 +35,19 @@ import { usePositionPresentationStore } from '../../stores/usePositionPresentati import { useSynchronizersStore } from '../../stores/useSynchronizersStore'; import { useSegmentationPresentationStore } from '../../stores/useSegmentationPresentationStore'; import getClosestOrientationFromIOP from '../../utils/isReferenceViewable'; +import { getViewportAdapter } from './adapter'; +import { viewportOperations } from './backends/viewportOperations'; import { getLegacyViewportType, - isStackViewportType, isVolume3DViewportType, isVolumeViewportType, } from '../../utils/getLegacyViewportType'; import { BlendModes } from '@cornerstonejs/core/enums'; +import { isNextViewportsEnabled } from '../../utils/nextViewports'; +import type { IViewportBackend } from './backends/IViewportBackend'; +import type { IViewportServiceInternals } from './backends/IViewportServiceInternals'; +import { LegacyViewportBackend } from './backends/LegacyViewportBackend'; +import { NextViewportBackend } from './backends/NextViewportBackend'; const EVENTS = { VIEWPORT_DATA_CHANGED: 'event::cornerstoneViewportService:viewportDataChanged', @@ -108,7 +114,10 @@ export const WITH_ORIENTATION = { withNavigation: true, withOrientation: true }; * Handles cornerstone viewport logic including enabling, disabling, and * updating the viewport. */ -class CornerstoneViewportService extends PubSubService implements IViewportService { +class CornerstoneViewportService + extends PubSubService + implements IViewportService, IViewportServiceInternals +{ static REGISTRATION = { name: 'cornerstoneViewportService', altName: 'CornerstoneViewportService', @@ -133,12 +142,32 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi gridResizeDelay = 50; gridResizeTimeOut = null; + // Resolved once, lazily, on first use. Forked viewport concerns (mount dispatch + + // native dataId lifecycle) route through it. + private _backend: IViewportBackend | null = null; + constructor(servicesManager: AppTypes.ServicesManager) { super(EVENTS); this.renderingEngine = null; this.viewportGridResizeObserver = null; this.servicesManager = servicesManager; } + + /** + * Sanctioned flag read (the exhaustive list lives in backends/README.md): pick + * the viewport backend once, on first use. Resolved lazily (not in the + * constructor) because the service singleton is constructed during extension + * registration, BEFORE init.tsx runs setNextViewportsEnabled — the first mount + * (when this is first read) always happens after init, so the flag is settled. + */ + private get backend(): IViewportBackend { + if (!this._backend) { + this._backend = isNextViewportsEnabled() + ? new NextViewportBackend(this) + : new LegacyViewportBackend(this); + } + return this._backend; + } hangingProtocolService: unknown; viewportsInfo: unknown; sceneVolumeInputs: unknown; @@ -236,6 +265,8 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi public destroy() { this._removeResizeObserver(); this.viewportGridResizeObserver = null; + // Flush any native dataId registrations the backend owns (§4.7); no-op for legacy. + this.backend.destroy(); try { this.renderingEngine?.destroy?.(); } catch (e) { @@ -257,6 +288,10 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi * @param viewportId - The viewportId to disable */ public disableElement(viewportId: string): void { + // Release native dataId registrations BEFORE the viewport bookkeeping is + // deleted (§4.7 ref-counted GC); no-op for the legacy backend. + this.backend.onViewportDisabled(viewportId); + this.renderingEngine?.disableElement(viewportId); // clean up @@ -272,6 +307,12 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi * @param presentations - The presentations to apply to the viewport. * @param viewportInfo - Contains a view reference for immediate application */ + // Public so the viewport backends (IViewportServiceInternals) can record which + // display sets a viewport shows from their mount bodies. + _trackViewportDisplaySets(viewportId: string, displaySetInstanceUIDs: string[]): void { + this.viewportsDisplaySets.set(viewportId, displaySetInstanceUIDs); + } + public setPresentations(viewportId: string, presentations: Presentations): void { const viewport = this.getCornerstoneViewport(viewportId) as | Types.IStackViewport @@ -379,12 +420,9 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi const viewportInfo = this.viewportsById.get(viewportId); - return { - viewportType: viewportInfo.getViewportType(), - viewReference: isVolume3DViewportType(csViewport) ? null : csViewport.getViewReference(), - viewPresentation: csViewport.getViewPresentation({ pan: true, zoom: true }), - viewportId, - }; + // Forked per backend (§4.3 presentation read): legacy reads getViewPresentation + // (pan/zoom); native omits it (a PLANAR_NEXT viewport has no getViewPresentation). + return this.backend.getPositionPresentation(csViewport, viewportInfo, viewportId); } private _getLutPresentation(viewportId: string): LutPresentation { @@ -411,7 +449,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi const properties = isVolumeViewportType(csViewport) ? new Map() - : cleanProperties(csViewport.getProperties()); + : cleanProperties(getViewportAdapter(csViewport).getPresentation()); if (properties instanceof Map) { const volumeIds = (csViewport as Types.IBaseVolumeViewport).getAllVolumeIds(); @@ -661,7 +699,8 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi for (const id of this.viewportsById.keys()) { const viewport = this.getCornerstoneViewport(id); - const { viewPlaneNormal } = viewport.getCamera(); + // Lane-appropriate view-plane normal (legacy getCamera vs native getViewReference). + const viewPlaneNormal = viewportOperations.getViewPlaneNormal(viewport); if (!viewPlaneNormal) { continue; @@ -830,7 +869,8 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi /** * Sets the image data for the given viewport. */ - private async _setEcgViewport( + // Public so the viewport backends (IViewportServiceInternals) can dispatch to it. + async _setEcgViewport( viewport: Types.IECGViewport, viewportData: StackViewportData ): Promise { @@ -840,38 +880,29 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi console.error('[CornerstoneViewportService] ECG display set has no imageId'); return; } - return viewport.setEcg(imageId); + + return this.backend.mountEcg(viewport, displaySet, imageId); } - private async _setOtherViewport( + // Public so the viewport backends (IViewportServiceInternals) can dispatch to it. + async _setOtherViewport( viewport: Types.IStackViewport, viewportData: StackViewportData, viewportInfo: ViewportInfo, _presentations: Presentations = {} ): Promise { const [displaySet] = viewportData.data; - const displaySetId = displaySet.imageIds[0]; - // Register the WSI dataset so the viewport can resolve its imageIds + - // webClient by display-set id, then mount via setDisplaySets. The webClient - // was registered under the WADO_WEB_CLIENT module (keyed by imageIds[0]) by - // the SM SOP class handler. CS3D's "redo viewports" reads this same registry - // (genericViewportDisplaySetMetadataProvider) from its WSI data provider; - // without this entry setDisplaySets throws "No registered WSI dataset" and - // the viewport renders gray. - const webClient = metaData.get(csEnums.MetadataModules.WADO_WEB_CLIENT, displaySetId); - csUtils.genericViewportDisplaySetMetadataProvider.add(displaySetId, { - imageIds: displaySet.imageIds, - kind: 'wsi', - options: { webClient }, - }); - await viewport.setDisplaySets({ displaySetId }); + + await this.backend.mountOther(viewport, displaySet); + const viewReference = viewportInfo.getViewReference(); if (viewReference) { viewport.setViewReference(viewReference); } } - private async _setStackViewport( + // Public so the viewport backends (IViewportServiceInternals) can dispatch to it. + async _setStackViewport( viewport: Types.IStackViewport, viewportData: StackViewportData, viewportInfo: ViewportInfo, @@ -924,7 +955,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi const overlayProcessingResults = this._processExtraDisplaySetsForViewport(viewport); const referencedImageId = presentations?.positionPresentation?.viewReference?.referencedImageId; - if (referencedImageId) { + if (referencedImageId && imageIds) { initialImageIndexToUse = imageIds.indexOf(referencedImageId); } @@ -936,21 +967,20 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi initialImageIndexToUse = this._getInitialImageIndexForViewport(viewportInfo, imageIds) || 0; } - await viewport.setStack(imageIds, initialImageIndexToUse); - viewport.setProperties({ ...properties }); - this.setPresentations(viewport.id, presentations, viewportInfo); - - await this._addOverlayRepresentations(overlayProcessingResults); - - if (displayArea) { - viewport.setDisplayArea(displayArea); - } - if (rotation) { - viewport.setProperties({ rotation }); - } - if (flipHorizontal) { - viewport.setCamera({ flipHorizontal: true }); - } + // The lane-specific mount (legacy setStack/setProperties vs native + // setDisplaySets/setDisplaySetPresentation/setViewState) lives in the backend. + return this.backend.mountStack(viewport, { + displaySetInstanceUIDs, + imageIds, + initialImageIndex: initialImageIndexToUse, + properties, + displayArea, + rotation, + flipHorizontal, + presentations, + viewportInfo, + overlayProcessingResults, + }); } private _getInitialImageIndexForViewport( @@ -1149,6 +1179,24 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi // For SEG and RT viewports const overlayProcessingResults = this._processExtraDisplaySetsForViewport(viewport) || []; + + // Lane-specific volume mount: the native backend mounts registered dataIds via + // setDisplaySets + per-binding presentations and reports the mount handled; + // legacy reports unhandled and runs the shared setVolumes tail below (which a + // native overlay-only mount also traverses — its legacy-surface steps are + // lane-guarded via mountOverlayOnlyVolumes). + const handledByBackend = await this.backend.mountVolumes(viewport, { + filteredVolumeInputArray, + volumesProperties, + viewportInfo, + overlayProcessingResults, + presentations, + }); + if (handledByBackend) { + this._broadcastEvent(this.EVENTS.VIEWPORT_VOLUMES_CHANGED, { viewportInfo }); + return; + } + if (!filteredVolumeInputArray.length && overlayProcessingResults?.length) { overlayProcessingResults.forEach(({ imageIds, addOverlayFn }) => { if (addOverlayFn) { @@ -1213,7 +1261,10 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi await viewport.setVolumes(baseVolumeInputs); } } else if (volumeInputArray.length) { - await viewport.setVolumes(volumeInputArray); + // Every volume input is an overlay display set. Legacy still mounts them via + // setVolumes; the native backend no-ops (its overlays are added via + // _addOverlayRepresentations below). + await this.backend.mountOverlayOnlyVolumes(viewport, volumeInputArray); } await this._addOverlayRepresentations(overlayProcessingResults); @@ -1412,7 +1463,9 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi return applyRepresentation(); } - private async _addOverlayRepresentations( + // Public so the viewport backends (IViewportServiceInternals) can run the + // pending overlay adds from their mount bodies. + async _addOverlayRepresentations( overlayProcessingResults?: Array<{ addOverlayFn?: () => Promise }> ): Promise { if (!overlayProcessingResults?.length) { @@ -1430,21 +1483,20 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi public updateViewport(viewportId: string, viewportData, keepCamera = false) { const viewportInfo = this.getViewportInfo(viewportId); const viewport = this.getCornerstoneViewport(viewportId); - const viewportCamera = viewport.getCamera(); - let displaySetPromise; + // The camera snapshot/restore surface is forked per lane (legacy + // getCamera/setCamera vs native view state), so the backend owns the re-mount. + const displaySetPromise = this.backend.remount( + viewport, + viewportData, + viewportInfo, + keepCamera + ); - if (isVolumeViewportType(viewport)) { - displaySetPromise = this._setVolumeViewport(viewport, viewportData, viewportInfo).then(() => { - if (keepCamera) { - viewport.setCamera(viewportCamera); - viewport.render(); - } - }); - } - - if (isStackViewportType(viewport)) { - displaySetPromise = this._setStackViewport(viewport, viewportData, viewportInfo); + // remount() returns undefined for viewport families with no re-mount path + // (matching legacy behavior); nothing changed, so skip the event broadcast. + if (!displaySetPromise) { + return; } displaySetPromise.then(() => { @@ -1461,37 +1513,11 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi viewportInfo: ViewportInfo, presentations: Presentations = {} ): Promise { - if (isStackViewportType(viewport)) { - return this._setStackViewport( - viewport, - viewportData as StackViewportData, - viewportInfo, - presentations - ); - } - - if (isVolumeViewportType(viewport)) { - return this._setVolumeViewport( - viewport as Types.IVolumeViewport, - viewportData as VolumeViewportData, - viewportInfo, - presentations - ); - } - - if (getLegacyViewportType(viewport) === csEnums.ViewportType.ECG) { - return this._setEcgViewport( - viewport as unknown as Types.IECGViewport, - viewportData as StackViewportData - ); - } - - return this._setOtherViewport( - viewport, - viewportData as StackViewportData, - viewportInfo, - presentations - ); + // The backend (legacy vs native, selected once in the constructor) owns the + // per-family routing: legacy dispatches by the runtime cornerstone viewport + // type; native dispatches by the bound data shape, because native stack and + // volume content both report a single PLANAR_NEXT type (§4.4). + return this.backend.dispatchMount(viewport, viewportData, viewportInfo, presentations); } /** @@ -1626,44 +1652,20 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi viewport: Types.IStackViewport | Types.IVolumeViewport, lutPresentation: LutPresentation ): void { - if (!lutPresentation) { - return; - } - - const { properties } = lutPresentation; - if (isVolumeViewportType(viewport)) { - if (properties instanceof Map) { - properties.forEach((propertiesEntry, volumeId) => { - viewport.setProperties(propertiesEntry, volumeId); - }); - } else { - viewport.setProperties(properties); - } - } else { - viewport.setProperties(properties); - } + // Forked per backend (§4.3 presentation write): legacy applies via setProperties; + // native via setDisplaySetPresentation (a PLANAR_NEXT viewport has no setProperties), + // so setPresentations no longer throws on the native path. + this.backend.setLutPresentation(viewport, lutPresentation); } private _setPositionPresentation( viewport: Types.IStackViewport | Types.IVolumeViewport, positionPresentation: PositionPresentation ): void { - const viewRef = positionPresentation?.viewReference; - if (viewRef) { - // The orientation can be updated here to navigate to the specified - // measurement or previous item, but this will not switch to volume - // or to stack from the other type - if (viewport.isReferenceViewable(viewRef, WITH_ORIENTATION)) { - viewport.setViewReference(viewRef); - } else { - console.warn('Unable to apply reference viewable', viewRef); - } - } - - const viewPresentation = positionPresentation?.viewPresentation; - if (viewPresentation) { - viewport.setViewPresentation(viewPresentation); - } + // Forked per backend (§4.3 presentation write): both apply the view reference; + // legacy then applies getViewPresentation pan/zoom via setViewPresentation, native + // omits it for now (a PLANAR_NEXT viewport has no setViewPresentation). + this.backend.setPositionPresentation(viewport, positionPresentation); } private _setSegmentationPresentation( diff --git a/extensions/cornerstone/src/services/ViewportService/Viewport.ts b/extensions/cornerstone/src/services/ViewportService/Viewport.ts index dadae5878..e45340529 100644 --- a/extensions/cornerstone/src/services/ViewportService/Viewport.ts +++ b/extensions/cornerstone/src/services/ViewportService/Viewport.ts @@ -227,9 +227,16 @@ class ViewportInfo { // via cornerstoneViewportService let viewportData = this.getViewportData(); + // Branch on the persisted data shape, not viewportType: native ("next") volume + // viewports carry viewportType === PLANAR_NEXT while their data is still a volume + // array, so keying off viewportType alone would treat them as a stack object and + // miss the display set — skipping invalidateViewportData() on metadata invalidation. + // Falls back to viewportType for legacy viewportData with no dataShapeType. + const dataShapeType = viewportData.dataShapeType ?? viewportData.viewportType; + if ( - viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC || - viewportData.viewportType === Enums.ViewportType.VOLUME_3D + dataShapeType === Enums.ViewportType.ORTHOGRAPHIC || + dataShapeType === Enums.ViewportType.VOLUME_3D ) { viewportData = viewportData as VolumeViewportData; return viewportData.data.some( diff --git a/extensions/cornerstone/src/services/ViewportService/adapter/IViewportAdapter.contract.test.ts b/extensions/cornerstone/src/services/ViewportService/adapter/IViewportAdapter.contract.test.ts new file mode 100644 index 000000000..b7fe5be43 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/adapter/IViewportAdapter.contract.test.ts @@ -0,0 +1,443 @@ +import { Enums, cache } from '@cornerstonejs/core'; +import { + getViewportAdapter, + isNextViewport, + isVolumeRenderingViewport, +} from './getViewportAdapter'; +import { LegacyViewportAdapter, LEGACY_OPACITY_GAMMA } from './LegacyViewportAdapter'; +import { NextViewportAdapter } from './NextViewportAdapter'; + +/** + * Contract tests for IViewportAdapter: every behavioral guarantee the UI layer + * relies on is asserted against BOTH lane implementations over mock viewports. + * If a legacy/native divergence is intentional (e.g. opacity gamma), the + * divergent expectations are asserted side by side so the difference is + * documented here rather than rediscovered in a viewer session. + */ + +const { ViewportType, OrientationAxis } = Enums; + +/** Minimal native ("next") viewport: satisfies csUtils.isGenericViewport. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeNextViewport(overrides: Record = {}): any { + return { + id: 'next-viewport', + setDisplaySets: jest.fn().mockResolvedValue(undefined), + setDisplaySetPresentation: jest.fn(), + setViewState: jest.fn(), + getViewState: jest.fn().mockReturnValue({ rotation: 90, flipHorizontal: true }), + getCurrentMode: jest.fn().mockReturnValue('stack'), + getSourceDataId: jest.fn().mockReturnValue('source-uid'), + getDisplaySetPresentation: jest.fn().mockReturnValue({}), + getDefaultVOIRange: jest.fn().mockReturnValue(undefined), + getViewReference: jest.fn().mockReturnValue({ + viewPlaneNormal: [0, 0, 1], + cameraFocalPoint: [1, 2, 3], + }), + ...overrides, + }; +} + +/** Minimal legacy stack viewport: no setDisplaySets/setViewState surface. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeLegacyStackViewport(overrides: Record = {}): any { + return { + id: 'legacy-stack', + type: ViewportType.STACK, + getProperties: jest.fn().mockReturnValue({ voiRange: { lower: 0, upper: 100 } }), + setProperties: jest.fn(), + getCamera: jest.fn().mockReturnValue({ + viewPlaneNormal: [0, 0, 1], + focalPoint: [1, 2, 3], + rotation: 90, + }), + setCamera: jest.fn(), + getActors: jest.fn().mockReturnValue([{ referencedId: 'imageId:abc' }]), + ...overrides, + }; +} + +/** Minimal legacy orthographic (volume) viewport. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function makeLegacyVolumeViewport(overrides: Record = {}): any { + const propertiesByVolumeId = { + 'volumeId-ds-1': { colormap: { name: 'hsv', opacity: 0.9 } }, + }; + return { + id: 'legacy-volume', + type: ViewportType.ORTHOGRAPHIC, + getAllVolumeIds: jest.fn().mockReturnValue(['volumeId-ds-1', 'volumeId-ds-2']), + getProperties: jest.fn((volumeId?: string) => + volumeId ? (propertiesByVolumeId[volumeId] ?? {}) : { voiRange: { lower: 5, upper: 50 } } + ), + setProperties: jest.fn(), + getCamera: jest.fn().mockReturnValue({ viewPlaneNormal: [1, 0, 0], focalPoint: [4, 5, 6] }), + setCamera: jest.fn(), + getActors: jest.fn().mockReturnValue([{ referencedId: 'volumeId-ds-1' }]), + isInAcquisitionPlane: jest.fn().mockReturnValue(true), + getImageData: jest.fn(), + ...overrides, + }; +} + +describe('getViewportAdapter dispatch', () => { + it('routes native viewports to NextViewportAdapter and legacy to LegacyViewportAdapter', () => { + expect(getViewportAdapter(makeNextViewport())).toBeInstanceOf(NextViewportAdapter); + expect(getViewportAdapter(makeLegacyStackViewport())).toBeInstanceOf(LegacyViewportAdapter); + expect(getViewportAdapter(makeLegacyVolumeViewport())).toBeInstanceOf(LegacyViewportAdapter); + }); + + it('caches one adapter per viewport instance', () => { + const viewport = makeNextViewport(); + expect(getViewportAdapter(viewport)).toBe(getViewportAdapter(viewport)); + }); + + it('throws on a missing viewport', () => { + expect(() => getViewportAdapter(null)).toThrow(); + expect(() => getViewportAdapter(undefined)).toThrow(); + }); + + it('isNextViewport matches the dispatch decision', () => { + expect(isNextViewport(makeNextViewport())).toBe(true); + expect(isNextViewport(makeLegacyStackViewport())).toBe(false); + }); +}); + +describe('classification', () => { + it('getShape resolves the content shape on both lanes', () => { + expect(getViewportAdapter(makeLegacyStackViewport()).getShape()).toBe('stack'); + expect(getViewportAdapter(makeLegacyVolumeViewport()).getShape()).toBe('volume'); + expect( + getViewportAdapter(makeLegacyVolumeViewport({ type: ViewportType.VOLUME_3D })).getShape() + ).toBe('volume3d'); + + expect( + getViewportAdapter( + makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('stack') }) + ).getShape() + ).toBe('stack'); + expect( + getViewportAdapter( + makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('volume') }) + ).getShape() + ).toBe('volume'); + }); + + it('isVolumeRendering: legacy ORTHOGRAPHIC / native volume mode only', () => { + expect(isVolumeRenderingViewport(makeLegacyVolumeViewport())).toBe(true); + expect(isVolumeRenderingViewport(makeLegacyStackViewport())).toBe(false); + expect( + isVolumeRenderingViewport( + makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('volume') }) + ) + ).toBe(true); + expect( + isVolumeRenderingViewport( + makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('stack') }) + ) + ).toBe(false); + // Native 3D reorients in place but does NOT support planar volume controls. + const next3d = makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('volume3d') }); + expect(isVolumeRenderingViewport(next3d)).toBe(false); + expect(getViewportAdapter(next3d).canReorientInPlace()).toBe(true); + }); + + it('isInAcquisitionPlane: legacy asks the viewport; native reads view-state orientation', () => { + expect(getViewportAdapter(makeLegacyVolumeViewport()).isInAcquisitionPlane()).toBe(true); + expect( + getViewportAdapter( + makeLegacyVolumeViewport({ isInAcquisitionPlane: jest.fn().mockReturnValue(false) }) + ).isInAcquisitionPlane() + ).toBe(false); + + // Native default (unset orientation) counts as acquisition. + expect( + getViewportAdapter( + makeNextViewport({ getViewState: jest.fn().mockReturnValue({}) }) + ).isInAcquisitionPlane() + ).toBe(true); + expect( + getViewportAdapter( + makeNextViewport({ + getViewState: jest.fn().mockReturnValue({ orientation: OrientationAxis.SAGITTAL }), + }) + ).isInAcquisitionPlane() + ).toBe(false); + }); + + it('hasContent: legacy via actors, native via content mode', () => { + expect(getViewportAdapter(makeLegacyStackViewport()).hasContent()).toBe(true); + expect( + getViewportAdapter( + makeLegacyStackViewport({ getActors: jest.fn().mockReturnValue([]) }) + ).hasContent() + ).toBe(false); + + expect(getViewportAdapter(makeNextViewport()).hasContent()).toBe(true); + expect( + getViewportAdapter( + makeNextViewport({ getCurrentMode: jest.fn().mockReturnValue('empty') }) + ).hasContent() + ).toBe(false); + }); +}); + +describe('view geometry', () => { + it('getViewState/setViewState map to getCamera/setCamera on legacy', () => { + const viewport = makeLegacyStackViewport(); + const adapter = getViewportAdapter(viewport); + expect(adapter.getViewState().rotation).toBe(90); + adapter.setViewState({ flipHorizontal: true }); + expect(viewport.setCamera).toHaveBeenCalledWith({ flipHorizontal: true }); + }); + + it('getViewState/setViewState pass through natively', () => { + const viewport = makeNextViewport(); + const adapter = getViewportAdapter(viewport); + expect(adapter.getViewState().rotation).toBe(90); + adapter.setViewState({ rotation: 180 }); + expect(viewport.setViewState).toHaveBeenCalledWith({ rotation: 180 }); + }); + + it('getViewPlaneNormal and getFocalPoint resolve on both lanes', () => { + expect(getViewportAdapter(makeLegacyStackViewport()).getViewPlaneNormal()).toEqual([0, 0, 1]); + expect(getViewportAdapter(makeLegacyStackViewport()).getFocalPoint()).toEqual([1, 2, 3]); + expect(getViewportAdapter(makeNextViewport()).getViewPlaneNormal()).toEqual([0, 0, 1]); + expect(getViewportAdapter(makeNextViewport()).getFocalPoint()).toEqual([1, 2, 3]); + }); +}); + +describe('per-display-set appearance', () => { + it('getPresentation: legacy getProperties with/without dataId', () => { + const viewport = makeLegacyVolumeViewport(); + const adapter = getViewportAdapter(viewport); + expect(adapter.getPresentation()).toEqual({ voiRange: { lower: 5, upper: 50 } }); + expect(adapter.getPresentation('volumeId-ds-1').colormap).toEqual({ + name: 'hsv', + opacity: 0.9, + }); + }); + + it('getPresentation: native per-binding read, defaulting to the source dataId', () => { + const viewport = makeNextViewport({ + getDisplaySetPresentation: jest.fn().mockReturnValue({ invert: true }), + }); + const adapter = getViewportAdapter(viewport); + expect(adapter.getPresentation()).toEqual({ invert: true }); + expect(viewport.getDisplaySetPresentation).toHaveBeenCalledWith('source-uid'); + adapter.getPresentation('ds-2'); + expect(viewport.getDisplaySetPresentation).toHaveBeenCalledWith('ds-2'); + }); + + it('getPresentation: native stamps isComputedVOI when the VOI matches the binding default', () => { + const voiRange = { lower: 0, upper: 80 }; + const stamped = getViewportAdapter( + makeNextViewport({ + getDisplaySetPresentation: jest.fn().mockReturnValue({ voiRange }), + getDefaultVOIRange: jest.fn().mockReturnValue({ lower: 0, upper: 80 }), + }) + ).getPresentation(); + expect(stamped.isComputedVOI).toBe(true); + + const notStamped = getViewportAdapter( + makeNextViewport({ + getDisplaySetPresentation: jest.fn().mockReturnValue({ voiRange }), + getDefaultVOIRange: jest.fn().mockReturnValue({ lower: 10, upper: 90 }), + }) + ).getPresentation(); + expect(notStamped.isComputedVOI).toBeUndefined(); + }); + + it('setPresentation targets setProperties (legacy) / setDisplaySetPresentation (native)', () => { + const legacy = makeLegacyVolumeViewport(); + getViewportAdapter(legacy).setPresentation({ invert: true }, 'volumeId-ds-1'); + expect(legacy.setProperties).toHaveBeenCalledWith({ invert: true }, 'volumeId-ds-1'); + + const next = makeNextViewport(); + getViewportAdapter(next).setPresentation({ invert: true }, 'ds-1'); + expect(next.setDisplaySetPresentation).toHaveBeenCalledWith('ds-1', { invert: true }); + + // No dataId: native falls back to the source binding. + getViewportAdapter(next).setPresentation({ invert: false }); + expect(next.setDisplaySetPresentation).toHaveBeenCalledWith('source-uid', { invert: false }); + }); + + it('getDefaultVOIRange: native binding default; legacy has none', () => { + expect(getViewportAdapter(makeLegacyStackViewport()).getDefaultVOIRange()).toBeUndefined(); + expect( + getViewportAdapter( + makeNextViewport({ getDefaultVOIRange: jest.fn().mockReturnValue({ lower: 1, upper: 2 }) }) + ).getDefaultVOIRange('ds-1') + ).toEqual({ lower: 1, upper: 2 }); + }); + + it('getColormap: legacy stack properties / legacy volume actor lookup / native presentation', () => { + const stack = makeLegacyStackViewport({ + getProperties: jest.fn().mockReturnValue({ colormap: { name: 'gray' } }), + }); + expect(getViewportAdapter(stack).getColormap('anything')).toEqual({ name: 'gray' }); + + const volume = makeLegacyVolumeViewport(); + expect(getViewportAdapter(volume).getColormap('ds-1')).toEqual({ name: 'hsv', opacity: 0.9 }); + expect(getViewportAdapter(volume).getColormap('ds-unknown')).toBeUndefined(); + + const next = makeNextViewport({ + getDisplaySetPresentation: jest.fn().mockReturnValue({ colormap: { name: 'jet' } }), + }); + expect(getViewportAdapter(next).getColormap('ds-1')).toEqual({ name: 'jet' }); + }); + + it('setLayerOpacity merges into the existing colormap on both lanes', () => { + const volume = makeLegacyVolumeViewport(); + expect(getViewportAdapter(volume).setLayerOpacity('ds-1', 0.5)).toBe(true); + expect(volume.setProperties).toHaveBeenCalledWith( + { colormap: { name: 'hsv', opacity: 0.5 } }, + 'volumeId-ds-1' + ); + + const next = makeNextViewport({ + getDisplaySetPresentation: jest.fn().mockReturnValue({ colormap: { name: 'jet' } }), + }); + expect(getViewportAdapter(next).setLayerOpacity('ds-1', 0.5)).toBe(true); + expect(next.setDisplaySetPresentation).toHaveBeenCalledWith('ds-1', { + colormap: { name: 'jet', opacity: 0.5 }, + }); + }); + + it('setLayerOpacity is unsupported on a legacy stack (caller must not render)', () => { + const stack = makeLegacyStackViewport(); + expect(getViewportAdapter(stack).setLayerOpacity('ds-1', 0.5)).toBe(false); + expect(stack.setProperties).not.toHaveBeenCalled(); + }); + + it('setLayerThreshold: legacy historically does NOT merge; native merges', () => { + const volume = makeLegacyVolumeViewport(); + expect(getViewportAdapter(volume).setLayerThreshold('ds-1', 42)).toBe(true); + expect(volume.setProperties).toHaveBeenCalledWith( + { colormap: { threshold: 42 } }, + 'volumeId-ds-1' + ); + + const next = makeNextViewport({ + getDisplaySetPresentation: jest.fn().mockReturnValue({ colormap: { name: 'jet' } }), + }); + expect(getViewportAdapter(next).setLayerThreshold('ds-1', 42)).toBe(true); + expect(next.setDisplaySetPresentation).toHaveBeenCalledWith('ds-1', { + colormap: { name: 'jet', threshold: 42 }, + }); + }); + + it('getOpacityGamma: linear on native, historical 1/5 curve on legacy', () => { + expect(getViewportAdapter(makeNextViewport()).getOpacityGamma()).toBe(1); + expect(getViewportAdapter(makeLegacyVolumeViewport()).getOpacityGamma()).toBe( + LEGACY_OPACITY_GAMMA + ); + }); +}); + +describe('data addressing', () => { + it('getDataIdForDisplaySet: bare UID on native; matching volumeId on legacy volume; undefined on legacy stack', () => { + expect(getViewportAdapter(makeNextViewport()).getDataIdForDisplaySet('ds-1')).toBe('ds-1'); + expect(getViewportAdapter(makeLegacyVolumeViewport()).getDataIdForDisplaySet('ds-1')).toBe( + 'volumeId-ds-1' + ); + expect( + getViewportAdapter(makeLegacyVolumeViewport()).getDataIdForDisplaySet('nope') + ).toBeUndefined(); + expect( + getViewportAdapter(makeLegacyStackViewport()).getDataIdForDisplaySet('ds-1') + ).toBeUndefined(); + }); + + it('getVolumeIds: legacy volume list; empty on native and legacy stack', () => { + expect(getViewportAdapter(makeLegacyVolumeViewport()).getVolumeIds()).toEqual([ + 'volumeId-ds-1', + 'volumeId-ds-2', + ]); + expect(getViewportAdapter(makeLegacyStackViewport()).getVolumeIds()).toEqual([]); + expect(getViewportAdapter(makeNextViewport()).getVolumeIds()).toEqual([]); + }); + + it('getVoxelManagerForDisplaySet: native resolves from the cornerstone cache', () => { + const voxelManager = { getRange: () => [0, 100] as [number, number] }; + const derivedVoxelManager = { getRange: () => [0, 1] as [number, number] }; + const spy = jest.spyOn(cache, 'getVolumes').mockReturnValue([ + // A derived id that merely EMBEDS the UID must not match (anchored lookup); + // real volumeIds are `${volumeLoaderSchema}:${displaySetInstanceUID}`. + { volumeId: 'derived-ds-1-labelmap', voxelManager: derivedVoxelManager }, + { volumeId: 'cornerstoneStreamingImageVolume:ds-1', voxelManager }, + ] as never); + try { + expect(getViewportAdapter(makeNextViewport()).getVoxelManagerForDisplaySet('ds-1')).toBe( + voxelManager + ); + expect( + getViewportAdapter(makeNextViewport()).getVoxelManagerForDisplaySet('missing') + ).toBeUndefined(); + } finally { + spy.mockRestore(); + } + }); + + it('getVoxelManagerForDisplaySet: legacy volume reads getImageData(volumeId)', () => { + const voxelManager = { getRange: () => [0, 50] as [number, number] }; + const viewport = makeLegacyVolumeViewport({ + getImageData: jest.fn().mockReturnValue({ + imageData: { + get: (key: string) => (key === 'voxelManager' ? { voxelManager } : undefined), + }, + }), + }); + expect(getViewportAdapter(viewport).getVoxelManagerForDisplaySet('ds-1')).toBe(voxelManager); + expect(viewport.getImageData).toHaveBeenCalledWith('volumeId-ds-1'); + expect( + getViewportAdapter(makeLegacyStackViewport()).getVoxelManagerForDisplaySet('ds-1') + ).toBeUndefined(); + }); +}); + +describe('capture (copyDisplayedContentTo)', () => { + it('legacy: setStack + properties + view presentation + view reference onto the target', async () => { + const source = makeLegacyStackViewport({ + getCurrentImageId: jest.fn().mockReturnValue('imageId:abc'), + getViewReference: jest.fn().mockReturnValue({ viewPlaneNormal: [0, 0, 1] }), + getViewPresentation: jest.fn().mockReturnValue({ zoom: 2 }), + }); + const target = makeLegacyStackViewport({ + setStack: jest.fn().mockResolvedValue(undefined), + setViewPresentation: jest.fn(), + setViewReference: jest.fn(), + }); + + await getViewportAdapter(source).copyDisplayedContentTo(target as never); + + expect(target.setStack).toHaveBeenCalledWith(['imageId:abc']); + expect(target.setViewPresentation).toHaveBeenCalledWith({ zoom: 2 }); + expect(target.setProperties).toHaveBeenCalledWith({ voiRange: { lower: 0, upper: 100 } }); + expect(target.setViewReference).toHaveBeenCalledWith({ viewPlaneNormal: [0, 0, 1] }); + }); + + it('native: remounts the source dataId and copies presentation + view state', async () => { + const source = makeNextViewport({ + getDisplaySetPresentation: jest.fn().mockReturnValue({ invert: true }), + getViewState: jest.fn().mockReturnValue({ orientation: 'axial', rotation: 45 }), + }); + const target = makeNextViewport({ + getSourceDataId: jest.fn().mockReturnValue('capture-uid'), + setViewReference: jest.fn(), + }); + + await getViewportAdapter(source).copyDisplayedContentTo(target as never); + + expect(target.setDisplaySets).toHaveBeenCalledWith({ + displaySetId: 'source-uid', + options: { orientation: 'axial', role: 'source' }, + }); + expect(target.setDisplaySetPresentation).toHaveBeenCalledWith('capture-uid', { invert: true }); + expect(target.setViewReference).toHaveBeenCalledWith({ + viewPlaneNormal: [0, 0, 1], + cameraFocalPoint: [1, 2, 3], + }); + expect(target.setViewState).toHaveBeenCalledWith({ orientation: 'axial', rotation: 45 }); + }); +}); diff --git a/extensions/cornerstone/src/services/ViewportService/adapter/IViewportAdapter.ts b/extensions/cornerstone/src/services/ViewportService/adapter/IViewportAdapter.ts new file mode 100644 index 000000000..0eaad651f --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/adapter/IViewportAdapter.ts @@ -0,0 +1,179 @@ +import type { Types as CoreTypes } from '@cornerstonejs/core'; + +/** + * Content shape of a viewport, independent of the runtime cornerstone viewport + * type. Native ("next") viewports collapse stack/volume/MPR onto a single + * PLANAR_NEXT runtime type, so `viewport.type` checks cannot classify them; + * this is the lane-agnostic answer to "what is this viewport showing". + */ +export type ViewportShape = 'stack' | 'volume' | 'volume3d' | 'unknown'; + +export type VOIRange = { lower: number; upper: number }; + +export interface ViewportColormap { + name?: string; + opacity?: number | Array<{ value: number; opacity: number }> | number[]; + threshold?: number; + [key: string]: unknown; +} + +/** + * Per-display-set appearance (VOI/colormap/invert). On legacy this is the + * getProperties()/setProperties() surface; on native it is the per-binding + * display-set presentation keyed by dataId. + */ +export interface ViewportPresentation { + voiRange?: VOIRange; + colormap?: ViewportColormap; + invert?: boolean; + isComputedVOI?: boolean; + [key: string]: unknown; +} + +/** + * View-level state (rotation, flip, orientation, pan/zoom). On legacy this is + * the getCamera()/setCamera() surface; on native it is the semantic + * getViewState()/setViewState() surface. Field names follow the native shape + * where the two overlap (rotation, flipHorizontal, flipVertical). + */ +export type ViewportViewState = Record; + +/** + * The single OHIF-facing per-viewport contract over the legacy and native + * ("next") cornerstone viewport APIs. + * + * The contract is NEXT-SHAPED: method names and semantics follow the native + * API (view state, per-display-set presentation keyed by dataId, view + * reference). `NextViewportAdapter` is a thin pass-through; + * `LegacyViewportAdapter` is the side doing the adapting + * (getCamera -> view state, volumeId -> dataId). When the legacy path is + * eventually removed, the migration ends by deleting the legacy adapter — not + * by unwinding call-site ternaries. + * + * Obtain an instance ONLY via `getViewportAdapter(viewport)` — the one place + * allowed to call `csUtils.isGenericViewport`. UI code (hooks, overlays, + * components, toolbar evaluators) must consume this interface instead of + * probing the raw viewport surface. + */ +export interface IViewportAdapter { + // ---- classification ---- + + /** Lane-agnostic content shape (see ViewportShape). */ + getShape(): ViewportShape; + + /** + * True when the viewport renders volume content and supports volume-only + * appearance controls (threshold, per-layer opacity): legacy ORTHOGRAPHIC, + * or a native viewport whose active binding is a volume. + */ + isVolumeRendering(): boolean; + + /** + * True when the viewport can be reoriented in place via setOrientation() + * without being recreated: legacy ORTHOGRAPHIC, or a native viewport already + * rendering volume content (volume slice or 3D). Differs from + * isVolumeRendering() on native 3D viewports, which reorient in place but do + * not support the planar volume appearance controls. + */ + canReorientInPlace(): boolean; + + /** + * True when a volume-mode viewport is looking down the acquisition axis + * (legacy isInAcquisitionPlane(); native view-state orientation ACQUISITION, + * which is also the native default when unset). + */ + isInAcquisitionPlane(): boolean; + + /** + * True when the viewport has renderable content bound. Legacy reports this + * via actors; native (which has no getActors on planar viewports) via its + * content mode. + */ + hasContent(): boolean; + + // ---- view geometry ---- + + /** Read view-level state (rotation/flip/orientation/...). Empty object when unavailable. */ + getViewState(): ViewportViewState; + + /** Apply a partial view-state patch; unspecified fields are preserved. */ + setViewState(patch: ViewportViewState): void; + + /** Current view-plane normal (legacy camera; native view reference). */ + getViewPlaneNormal(): CoreTypes.Point3 | undefined; + + /** World-space focal point / slice center (legacy camera; native view reference). */ + getFocalPoint(): CoreTypes.Point3 | undefined; + + // ---- per-display-set appearance ---- + + /** + * Read appearance (voiRange/colormap/invert/...) for a display set binding. + * Defaults to the active source binding when no dataId is given. On native, + * a VOI matching the binding's computed default is stamped + * `isComputedVOI: true` so LUT-presentation capture strips it (matching + * legacy StackViewport behavior). + */ + getPresentation(dataId?: string): ViewportPresentation; + + /** Write appearance for a display set binding (active source binding when no dataId). */ + setPresentation(props: ViewportPresentation, dataId?: string): void; + + /** The binding's computed default VOI (native only; legacy returns undefined). */ + getDefaultVOIRange(dataId?: string): VOIRange | undefined; + + /** + * The colormap currently applied to a display set's layer, or undefined when + * none (callers supply their own fallback, e.g. Grayscale). + */ + getColormap(displaySetInstanceUID: string): ViewportColormap | undefined; + + /** + * Merge an opacity into the display set layer's colormap. Returns true when + * applied (caller renders); false when the viewport/layer does not support it. + */ + setLayerOpacity(displaySetInstanceUID: string, opacity: number): boolean; + + /** + * Apply a threshold to the display set layer's colormap. Returns true when + * applied (caller renders); false when unsupported. + */ + setLayerThreshold(displaySetInstanceUID: string, threshold: number): boolean; + + /** + * Gamma applied between the fusion opacity slider position and the rendered + * opacity. Native renders a linear blend (gamma 1); legacy rendering expects + * its historical 1/5 curve. + */ + getOpacityGamma(): number; + + // ---- data addressing ---- + + /** + * Resolve the dataId to address a display set's binding on this viewport: + * the bare display set UID on native; the matching volumeId on legacy volume + * viewports; undefined on legacy single-actor viewports (callers fall back + * to the active binding). + */ + getDataIdForDisplaySet(displaySetInstanceUID: string): string | undefined; + + /** + * The legacy volumeIds bound to this viewport ([] on native and on legacy + * stack viewports). For legacy-only features such as per-volume histograms. + */ + getVolumeIds(): string[]; + + /** Voxel data access (getRange etc.) for a display set's volume, when available. */ + getVoxelManagerForDisplaySet( + displaySetInstanceUID: string + ): { getRange?: () => [number, number]; [key: string]: unknown } | undefined; + + // ---- capture ---- + + /** + * Mount this viewport's currently-displayed data onto another (same-lane) + * viewport and copy its appearance + view state. Used by the download/ + * capture form; the caller renders the target afterwards. + */ + copyDisplayedContentTo(target: CoreTypes.IViewport): Promise; +} diff --git a/extensions/cornerstone/src/services/ViewportService/adapter/LegacyViewportAdapter.ts b/extensions/cornerstone/src/services/ViewportService/adapter/LegacyViewportAdapter.ts new file mode 100644 index 000000000..08ca81275 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/adapter/LegacyViewportAdapter.ts @@ -0,0 +1,240 @@ +import { Enums, Types as CoreTypes } from '@cornerstonejs/core'; +import { + getLegacyViewportType, + isOrthographicViewportType, + isStackViewportType, + isVolumeViewportType, +} from '../../../utils/getLegacyViewportType'; +import type { + IViewportAdapter, + ViewportColormap, + ViewportPresentation, + ViewportShape, + ViewportViewState, + VOIRange, +} from './IViewportAdapter'; + +/** + * Structural view of the legacy StackViewport/VolumeViewport surface used by + * the adapter. Optional-chained because different legacy families expose + * different subsets (e.g. only volume viewports have getAllVolumeIds). + * Deliberately NOT an intersection with CoreTypes.IViewport: the adapter + * contract types these members with the next-shaped signatures (e.g. getCamera + * as a plain record), and IViewport's own declarations would win otherwise. + */ +type LegacyViewport = { + getProperties?: (dataId?: string) => ViewportPresentation | undefined; + setProperties?: (props: ViewportPresentation, dataId?: string) => void; + getCamera?: () => Record | undefined; + setCamera?: (patch: Record) => void; + getAllVolumeIds?: () => string[]; + getImageData?: (volumeId?: string) => { + imageData?: { get: (key: string) => { voxelManager?: unknown } | undefined }; + }; + getActors?: () => Array<{ referencedId?: string }>; + isInAcquisitionPlane?: () => boolean; + getViewReference?: () => CoreTypes.ViewReference | undefined; + setViewReference?: (ref: CoreTypes.ViewReference) => void; + getViewPresentation?: () => unknown; + setViewPresentation?: (presentation: unknown) => void; + getCurrentImageId?: () => string; + setStack?: (imageIds: string[]) => Promise; + setVolumes?: (volumes: Array<{ volumeId: string }>) => Promise; +}; + +/** + * Opacity slider gamma the legacy fusion rendering expects: the slider value is + * applied through a 1/5 curve (native renders a linear blend and uses gamma 1). + */ +export const LEGACY_OPACITY_GAMMA = 1 / 5; + +/** + * Legacy lane of IViewportAdapter — adapts the StackViewport/VolumeViewport + * surface (getCamera/getProperties/volumeIds) to the next-shaped contract. + * Deleting the legacy path deletes this file. Instantiated only by + * `getViewportAdapter`. + */ +export class LegacyViewportAdapter implements IViewportAdapter { + constructor(private readonly viewport: LegacyViewport) {} + + // ---- classification ---- + + getShape(): ViewportShape { + switch (getLegacyViewportType(this.viewport)) { + case Enums.ViewportType.STACK: + return 'stack'; + case Enums.ViewportType.ORTHOGRAPHIC: + return 'volume'; + case Enums.ViewportType.VOLUME_3D: + return 'volume3d'; + default: + return 'unknown'; + } + } + + isVolumeRendering(): boolean { + return isOrthographicViewportType(this.viewport); + } + + canReorientInPlace(): boolean { + return isOrthographicViewportType(this.viewport); + } + + isInAcquisitionPlane(): boolean { + return !!this.viewport.isInAcquisitionPlane?.(); + } + + hasContent(): boolean { + const actorEntries = this.viewport.getActors?.(); + return !!actorEntries && actorEntries.length > 0; + } + + // ---- view geometry ---- + + getViewState(): ViewportViewState { + return this.viewport.getCamera?.() ?? {}; + } + + setViewState(patch: ViewportViewState): void { + this.viewport.setCamera?.(patch); + } + + getViewPlaneNormal(): CoreTypes.Point3 | undefined { + return this.viewport.getCamera?.()?.viewPlaneNormal as CoreTypes.Point3 | undefined; + } + + getFocalPoint(): CoreTypes.Point3 | undefined { + return this.viewport.getCamera?.()?.focalPoint as CoreTypes.Point3 | undefined; + } + + // ---- per-display-set appearance ---- + + getPresentation(dataId?: string): ViewportPresentation { + return (dataId ? this.viewport.getProperties?.(dataId) : this.viewport.getProperties?.()) ?? {}; + } + + setPresentation(props: ViewportPresentation, dataId?: string): void { + this.viewport.setProperties?.(props, dataId); + } + + getDefaultVOIRange(): VOIRange | undefined { + // Legacy getProperties always returns the applied VOI; there is no separate + // computed-default accessor. + return undefined; + } + + getColormap(displaySetInstanceUID: string): ViewportColormap | undefined { + if (isStackViewportType(this.viewport)) { + return this.viewport.getProperties?.()?.colormap; + } + + const actorEntries = this.viewport.getActors?.(); + const actorEntry = actorEntries?.find(entry => + entry.referencedId?.includes(displaySetInstanceUID) + ); + if (!actorEntry) { + return undefined; + } + return this.viewport.getProperties?.(actorEntry.referencedId)?.colormap; + } + + setLayerOpacity(displaySetInstanceUID: string, opacity: number): boolean { + if (!isVolumeViewportType(this.viewport)) { + return false; + } + const volumeId = this.getDataIdForDisplaySet(displaySetInstanceUID); + if (!volumeId) { + return false; + } + + // Merge the opacity into the current colormap so its name/threshold persist. + const currentColormap = this.viewport.getProperties?.(volumeId)?.colormap ?? {}; + this.viewport.setProperties?.({ colormap: { ...currentColormap, opacity } }, volumeId); + return true; + } + + setLayerThreshold(displaySetInstanceUID: string, threshold: number): boolean { + if (!isVolumeViewportType(this.viewport)) { + return false; + } + const volumeId = this.getDataIdForDisplaySet(displaySetInstanceUID); + if (!volumeId) { + return false; + } + + this.viewport.setProperties?.({ colormap: { threshold } }, volumeId); + return true; + } + + getOpacityGamma(): number { + return LEGACY_OPACITY_GAMMA; + } + + // ---- data addressing ---- + + getDataIdForDisplaySet(displaySetInstanceUID: string): string | undefined { + // Multi-volume viewports address a layer by the volumeId that embeds the + // display set UID; single-actor viewports (stack) address the active layer + // implicitly (undefined). + if (typeof this.viewport.getAllVolumeIds !== 'function') { + return undefined; + } + const volumeIds = this.viewport.getAllVolumeIds() || []; + return volumeIds.length > 0 + ? (volumeIds.find(id => id.includes(displaySetInstanceUID)) ?? undefined) + : undefined; + } + + getVolumeIds(): string[] { + if (typeof this.viewport.getAllVolumeIds !== 'function') { + return []; + } + return this.viewport.getAllVolumeIds() || []; + } + + getVoxelManagerForDisplaySet( + displaySetInstanceUID: string + ): { getRange?: () => [number, number]; [key: string]: unknown } | undefined { + if (!isVolumeViewportType(this.viewport)) { + return undefined; + } + const volumeId = this.getDataIdForDisplaySet(displaySetInstanceUID); + if (!volumeId) { + return undefined; + } + const imageData = this.viewport.getImageData?.(volumeId); + return imageData?.imageData?.get('voxelManager')?.voxelManager as + | { getRange?: () => [number, number]; [key: string]: unknown } + | undefined; + } + + // ---- capture ---- + + async copyDisplayedContentTo(target: CoreTypes.IViewport): Promise { + const targetViewport = target as unknown as LegacyViewport; + const viewRef = this.viewport.getViewReference?.(); + + // - properties: VOI, colormap, interpolation, etc. + // - viewPresentation: flip/rotate/zoom presentation state (preserves flip/rotate) + const properties = this.viewport.getProperties?.(); + const viewPresentation = this.viewport.getViewPresentation?.(); + + if (isStackViewportType(targetViewport)) { + const imageId = this.viewport.getCurrentImageId?.(); + await targetViewport.setStack?.([imageId]); + } else if (isVolumeViewportType(targetViewport)) { + const volumeIds = this.getVolumeIds(); + await targetViewport.setVolumes?.([{ volumeId: volumeIds[0] }]); + } + + if (viewPresentation && targetViewport.setViewPresentation) { + targetViewport.setViewPresentation(viewPresentation); + } + + targetViewport.setProperties?.(properties); + + if (viewRef && targetViewport.setViewReference) { + targetViewport.setViewReference(viewRef); + } + } +} diff --git a/extensions/cornerstone/src/services/ViewportService/adapter/NextViewportAdapter.ts b/extensions/cornerstone/src/services/ViewportService/adapter/NextViewportAdapter.ts new file mode 100644 index 000000000..4385a7554 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/adapter/NextViewportAdapter.ts @@ -0,0 +1,233 @@ +import { Enums, cache, Types as CoreTypes } from '@cornerstonejs/core'; +import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType'; +import type { + IViewportAdapter, + ViewportColormap, + ViewportPresentation, + ViewportShape, + ViewportViewState, + VOIRange, +} from './IViewportAdapter'; + +/** + * Structural view of the native ("next") viewport surface used by the adapter. + * These accessors live on IGenericViewport (not IViewport), so we cast at this + * boundary rather than import core-internal PlanarViewport types. + */ +type NativeViewport = CoreTypes.IViewport & { + getSourceDataId?: () => string | undefined; + getDisplaySetPresentation?: (dataId: string) => ViewportPresentation | undefined; + getDefaultVOIRange?: (dataId?: string) => VOIRange | undefined; + setDisplaySetPresentation?: { + (props: ViewportPresentation): void; + (dataId: string, props: ViewportPresentation): void; + }; + getViewState?: () => ViewportViewState | undefined; + setViewState?: (patch: ViewportViewState) => void; + getViewReference?: () => CoreTypes.ViewReference | undefined; + setViewReference?: (ref: CoreTypes.ViewReference) => void; + getCurrentMode?: () => string; + setDisplaySets?: (args: { + displaySetId: string; + options?: Record; + }) => Promise; +}; + +const voiRangesClose = (a: VOIRange, b: VOIRange, eps = 0.001): boolean => + Math.abs(a.lower - b.lower) < eps && Math.abs(a.upper - b.upper) < eps; + +/** + * Native ("next") lane of IViewportAdapter — a thin pass-through to the native + * semantic API (view state, per-binding display-set presentation, view + * reference). Instantiated only by `getViewportAdapter`. + */ +export class NextViewportAdapter implements IViewportAdapter { + constructor(private readonly viewport: NativeViewport) {} + + // ---- classification ---- + + getShape(): ViewportShape { + if (isVolume3DViewportType(this.viewport)) { + return 'volume3d'; + } + const mode = this.viewport.getCurrentMode?.(); + if (mode === 'stack') { + return 'stack'; + } + if (mode === 'volume') { + return 'volume'; + } + return 'unknown'; + } + + isVolumeRendering(): boolean { + return this.viewport.getCurrentMode?.() === 'volume'; + } + + canReorientInPlace(): boolean { + const mode = this.viewport.getCurrentMode?.(); + return mode === 'volume' || mode === 'volume3d'; + } + + isInAcquisitionPlane(): boolean { + // orientation defaults to ACQUISITION when unset on the native view state. + const orientation = this.viewport.getViewState?.()?.orientation; + return orientation === Enums.OrientationAxis.ACQUISITION || orientation == null; + } + + hasContent(): boolean { + // Planar native viewports have no getActors (it throws); content presence + // is reported by the content mode (empty/unknown means nothing bound). + const mode = this.viewport.getCurrentMode?.(); + return !!mode && mode !== 'empty' && mode !== 'unknown'; + } + + // ---- view geometry ---- + + getViewState(): ViewportViewState { + return this.viewport.getViewState?.() ?? {}; + } + + setViewState(patch: ViewportViewState): void { + this.viewport.setViewState?.(patch); + } + + getViewPlaneNormal(): CoreTypes.Point3 | undefined { + return this.viewport.getViewReference?.()?.viewPlaneNormal as CoreTypes.Point3 | undefined; + } + + getFocalPoint(): CoreTypes.Point3 | undefined { + // The native view state carries no focalPoint; it comes from the view reference. + return this.viewport.getViewReference?.()?.cameraFocalPoint as CoreTypes.Point3 | undefined; + } + + // ---- per-display-set appearance ---- + + getPresentation(dataId?: string): ViewportPresentation { + const id = dataId ?? this.viewport.getSourceDataId?.(); + const presentation = (id ? this.viewport.getDisplaySetPresentation?.(id) : undefined) ?? {}; + + // A native binding's presentation normally holds only EXPLICIT VOI overrides, + // but a computed default VOI can transiently land here during intermediate + // mounts (e.g. while a SEG hydrates, the base image briefly mounts as a volume + // and its min/max default is stored). Flag such a VOI as computed when it + // matches the binding's default so the LUT-presentation capture (cleanProperties) + // strips it instead of persisting+restoring it over the real default — matching + // legacy StackViewport's isComputedVOI. Stamping is harmless even on a genuine + // user VOI that happens to equal the default (stripping it falls back to the + // same value). + const voiRange = presentation.voiRange; + if (voiRange && presentation.isComputedVOI === undefined) { + const defaultVOIRange = this.viewport.getDefaultVOIRange?.(id); + if (defaultVOIRange && voiRangesClose(voiRange, defaultVOIRange)) { + return { ...presentation, isComputedVOI: true }; + } + } + + return presentation; + } + + setPresentation(props: ViewportPresentation, dataId?: string): void { + const id = dataId ?? this.viewport.getSourceDataId?.(); + if (id) { + this.viewport.setDisplaySetPresentation?.(id, props); + } else { + this.viewport.setDisplaySetPresentation?.(props); + } + } + + getDefaultVOIRange(dataId?: string): VOIRange | undefined { + return this.viewport.getDefaultVOIRange?.(dataId); + } + + getColormap(displaySetInstanceUID: string): ViewportColormap | undefined { + return this.getPresentation(displaySetInstanceUID).colormap; + } + + setLayerOpacity(displaySetInstanceUID: string, opacity: number): boolean { + // Merge the opacity into the existing colormap so its name/threshold persist, + // targeting the binding by its dataId (the bare display set UID). A uniform + // (flat) opacity makes the slider a linear background<->foreground blend, + // matching the flat default presentation set by the fusion hanging protocol. + const currentColormap = this.getPresentation(displaySetInstanceUID).colormap ?? {}; + this.setPresentation({ colormap: { ...currentColormap, opacity } }, displaySetInstanceUID); + return true; + } + + setLayerThreshold(displaySetInstanceUID: string, threshold: number): boolean { + // Merge the threshold into the existing colormap so its name/opacity persist. + // The threshold is an absolute pixel/SUV value, matching the legacy volume path. + const currentColormap = this.getPresentation(displaySetInstanceUID).colormap ?? {}; + this.setPresentation({ colormap: { ...currentColormap, threshold } }, displaySetInstanceUID); + return true; + } + + getOpacityGamma(): number { + // Native volume viewports render the fusion as a volume slice whose blend is + // linear in the opacity scalar, so the slider maps 1:1. + return 1; + } + + // ---- data addressing ---- + + getDataIdForDisplaySet(displaySetInstanceUID: string): string | undefined { + // Native viewports key their per-display-set presentation by the bare display + // set UID (the dataId used by get/setDisplaySetPresentation). Returning it + // directly keeps fusion layer reads on the intended binding instead of falling + // back to the active source layer. + return displaySetInstanceUID; + } + + getVolumeIds(): string[] { + // No legacy getAllVolumeIds surface; volume data is addressed by dataId. + return []; + } + + getVoxelManagerForDisplaySet( + displaySetInstanceUID: string + ): { getRange?: () => [number, number]; [key: string]: unknown } | undefined { + // No getAllVolumeIds/getImageData(volumeId); resolve the display set's volume + // from the cornerstone cache instead. Anchored match: volumeIds are built as + // `${loaderSchema}:${displaySetInstanceUID}`, and an unanchored includes() + // could resolve a different cached volume whose id merely embeds the same + // UID (e.g. a derived labelmap id). + const volume = cache + .getVolumes() + .find( + v => + v.volumeId === displaySetInstanceUID || v.volumeId?.endsWith(`:${displaySetInstanceUID}`) + ); + return volume?.voxelManager as unknown as + | { getRange?: () => [number, number]; [key: string]: unknown } + | undefined; + } + + // ---- capture ---- + + async copyDisplayedContentTo(target: CoreTypes.IViewport): Promise { + const targetViewport = target as NativeViewport; + const viewRef = this.viewport.getViewReference?.(); + + // The source's dataId is already registered in the global GenericViewport + // metadata provider, so re-mount it on the capture viewport by id, then copy + // per-binding presentation + view state. The native classes have no + // setStack/setVolumes/setProperties/setViewPresentation. + const sourceDataId = this.viewport.getSourceDataId?.(); + if (sourceDataId) { + const { orientation } = this.getViewState(); + await targetViewport.setDisplaySets?.({ + displaySetId: sourceDataId, + options: { orientation, role: 'source' }, + }); + + const captureDataId = targetViewport.getSourceDataId?.() ?? sourceDataId; + targetViewport.setDisplaySetPresentation?.(captureDataId, this.getPresentation(sourceDataId)); + } + + // Slice/orientation via the view reference, then pan/zoom/rotate/flip via view state. + if (viewRef && targetViewport.setViewReference) { + targetViewport.setViewReference(viewRef); + } + targetViewport.setViewState?.(this.getViewState()); + } +} diff --git a/extensions/cornerstone/src/services/ViewportService/adapter/getViewportAdapter.ts b/extensions/cornerstone/src/services/ViewportService/adapter/getViewportAdapter.ts new file mode 100644 index 000000000..e1e4c3d2d --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/adapter/getViewportAdapter.ts @@ -0,0 +1,62 @@ +import { utilities as csUtils, Types as CoreTypes } from '@cornerstonejs/core'; +import type { IViewportAdapter } from './IViewportAdapter'; +import { LegacyViewportAdapter } from './LegacyViewportAdapter'; +import { NextViewportAdapter } from './NextViewportAdapter'; + +const adapterCache = new WeakMap(); + +/** + * Resolves the IViewportAdapter for a cornerstone viewport. This is THE ONE + * place (outside the segmentation backend family) allowed to call + * `csUtils.isGenericViewport` — all other code must consume the adapter (or + * the `isNextViewport` predicate below) instead of branching on the raw + * viewport surface. Enforced by scripts/check-next-viewport-boundaries.sh. + * + * Adapters are stateless wrappers over the viewport, cached per viewport + * instance, so calling this in render paths is cheap. + */ +export function getViewportAdapter(viewport: unknown): IViewportAdapter { + if (!viewport || typeof viewport !== 'object') { + throw new Error('getViewportAdapter: a viewport instance is required'); + } + let adapter = adapterCache.get(viewport); + if (!adapter) { + adapter = csUtils.isGenericViewport(viewport) + ? new NextViewportAdapter(viewport as ConstructorParameters[0]) + : new LegacyViewportAdapter( + viewport as ConstructorParameters[0] + ); + adapterCache.set(viewport, adapter); + } + return adapter; +} + +/** + * The per-viewport lane predicate, for the few dispatchers (viewport + * operations, segmentation backend) that hold their own per-lane + * implementations. Everyone else should call adapter methods instead of + * branching on this. + */ +export function isNextViewport(viewport: unknown): boolean { + return csUtils.isGenericViewport(viewport); +} + +/** + * True for any viewport that renders volume content and therefore supports + * volume-only appearance controls (threshold, per-layer opacity): a legacy + * ORTHOGRAPHIC viewport, or a native ("next") viewport whose active binding is + * a volume. A native MPR/volume viewport runs as PLANAR_NEXT (not + * ORTHOGRAPHIC), so legacy type guards alone miss it. + */ +export function isVolumeRenderingViewport(viewport: unknown): boolean { + return !!viewport && getViewportAdapter(viewport).isVolumeRendering(); +} + +/** + * World-space focal point (current slice center) for any viewport. Exposed as + * a free function because it is part of the extension's public API (consumed + * by tmtv). + */ +export function getViewportFocalPoint(viewport: unknown): CoreTypes.Point3 | undefined { + return viewport ? getViewportAdapter(viewport).getFocalPoint() : undefined; +} diff --git a/extensions/cornerstone/src/services/ViewportService/adapter/index.ts b/extensions/cornerstone/src/services/ViewportService/adapter/index.ts new file mode 100644 index 000000000..8f63817d4 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/adapter/index.ts @@ -0,0 +1,16 @@ +export type { + IViewportAdapter, + ViewportColormap, + ViewportPresentation, + ViewportShape, + ViewportViewState, + VOIRange, +} from './IViewportAdapter'; +export { + getViewportAdapter, + getViewportFocalPoint, + isNextViewport, + isVolumeRenderingViewport, +} from './getViewportAdapter'; +export { LegacyViewportAdapter, LEGACY_OPACITY_GAMMA } from './LegacyViewportAdapter'; +export { NextViewportAdapter } from './NextViewportAdapter'; diff --git a/extensions/cornerstone/src/services/ViewportService/backends/IViewportBackend.ts b/extensions/cornerstone/src/services/ViewportService/backends/IViewportBackend.ts new file mode 100644 index 000000000..23e1e8efc --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/backends/IViewportBackend.ts @@ -0,0 +1,195 @@ +import type { Types } from '@cornerstonejs/core'; +import type ViewportInfo from '../Viewport'; +import type { + Presentations, + PositionPresentation, + LutPresentation, +} from '../../../types/Presentation'; +import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService'; +import type { DataIdPayload } from './dataIdRegistry'; + +/** A pending overlay (SEG/RTSTRUCT) add produced by the service's prelude. */ +export type OverlayMountTask = { + imageIds?: string[]; + addOverlayFn?: () => Promise; +}; + +/** + * Everything the service's lane-agnostic stack-mount prelude computes: the + * backend receives it ready-made and performs only the lane-specific mount. + */ +export interface StackMountContext { + /** All display set UIDs bound to the viewport (first = the stack source). */ + displaySetInstanceUIDs: string[]; + imageIds: string[]; + /** Resolved initial slice (position presentation / view reference / HP options). */ + initialImageIndex: number; + /** VOI/invert/colormap seeded from the LUT presentation or display set options. */ + properties: Record; + displayArea?: unknown; + rotation?: number; + flipHorizontal?: boolean; + presentations: Presentations; + viewportInfo: ViewportInfo; + overlayProcessingResults?: OverlayMountTask[]; +} + +/** + * Everything the service's lane-agnostic volume-mount prelude computes before + * the lane fork in setVolumesForViewport. + */ +export interface VolumeMountContext { + /** Volume inputs with their display set options, overlays already filtered out. */ + filteredVolumeInputArray: Array<{ + volumeInput: { + imageIds?: string[]; + volumeId: string; + displaySetInstanceUID: string; + blendMode?: unknown; + slabThickness?: number; + [key: string]: unknown; + }; + displaySetOptions: unknown; + }>; + /** Per-volume VOI/invert/colormap/preset derived from the display set options. */ + volumesProperties: Array<{ properties: Record; volumeId: string }>; + viewportInfo: ViewportInfo; + overlayProcessingResults?: OverlayMountTask[]; + presentations: Presentations; +} + +/** + * Selects how OHIF drives cornerstone viewports (migration plan §4.3). One + * implementation is chosen ONCE, lazily on first use (a `get backend()` getter on + * CornerstoneViewportService — NOT the constructor, because the service singleton is + * built during extension registration before init.tsx sets the flag), from + * `appConfig.useNextViewports`: + * - LegacyViewportBackend: today's behavior, selected when the flag is off (default). + * - NextViewportBackend: the native GenericViewport ("next") path. + * + * The service holds exactly one backend for its lifetime and routes the forked + * concerns through it: mount dispatch, the per-family MOUNT BODIES + * (mountStack/mountVolumes/mountEcg/mountOther/remount), presentation + * capture/restore, and the native dataId lifecycle. The service keeps only the + * lane-agnostic preludes (option/property derivation, bookkeeping, events) and + * the genuinely shared volume tail; it contains no per-lane branches itself. + */ +export interface IViewportBackend { + /** + * Routes a viewport's data to the correct per-family mount. Legacy routes by the + * runtime cornerstone viewport type; next routes by the bound data shape, because + * native stack and volume content both report a single PLANAR_NEXT type (§4.4). + */ + dispatchMount( + viewport: Types.IViewport, + viewportData: StackViewportData | VolumeViewportData, + viewportInfo: ViewportInfo, + presentations?: Presentations + ): Promise; + + /** + * Mounts an image stack. Legacy: setStack/setProperties/setPresentations + + * displayArea/rotation/flip via the camera surface. Next: register the dataId, + * setDisplaySets, seed VOI from metadata, apply presentation + view state. + */ + mountStack(viewport: Types.IStackViewport, context: StackMountContext): Promise; + + /** + * Lane-specific volume mount. Next mounts the volumes natively (registered + * dataIds + one setDisplaySets call + per-binding presentations) and returns + * true — the service then only broadcasts. Legacy returns false, and the + * service runs the shared volume tail (setVolumes/addVolumes optimization, + * property application, presentations, jumpToSlice), which a native + * overlay-only mount also traverses safely. + */ + mountVolumes(viewport: Types.IViewport, context: VolumeMountContext): Promise; + + /** + * The shared volume tail's overlay-only fallback: when every volume input is + * an overlay display set, legacy still mounts them via setVolumes; next + * no-ops (its overlays are added via the segmentation representations). + */ + mountOverlayOnlyVolumes(viewport: Types.IViewport, volumeInputArray: unknown[]): Promise; + + /** + * Mounts an ECG waveform. Legacy: viewport.setEcg(imageId). Next: register + * the display set's dataId and mount through the generic setDisplaySets API. + */ + mountEcg( + viewport: Types.IECGViewport, + displaySet: { displaySetInstanceUID: string; imageIds?: string[] }, + imageId: string + ): Promise; + + /** + * Mounts video / whole-slide content (the caller applies the view reference + * afterwards). Legacy keys the displaySetId off imageIds[0]; next registers + * the family-specific dataId first. + */ + mountOther( + viewport: Types.IViewport, + displaySet: { displaySetInstanceUID: string; imageIds: string[] } + ): Promise; + + /** + * Re-mounts changed viewport data onto an existing viewport (updateViewport), + * optionally restoring the camera afterwards. Legacy snapshots getCamera and + * dispatches by runtime type; next snapshots the semantic view state and + * routes through dispatchMount. May return undefined when the viewport family + * has no re-mount path (matching the historical legacy behavior). + */ + remount( + viewport: Types.IViewport, + viewportData: StackViewportData | VolumeViewportData, + viewportInfo: ViewportInfo, + keepCamera: boolean + ): Promise | undefined; + + /** + * Reads the position presentation (camera/zoom/pan + view reference) to persist + * for restore. Legacy uses getViewPresentation (pan/zoom); native stores the + * semantic view-state displayArea (pan/zoom) since it has no getViewPresentation. + */ + getPositionPresentation( + csViewport: Types.IViewport, + viewportInfo: ViewportInfo, + viewportId: string + ): PositionPresentation; + + /** + * Restores a position presentation. Both apply the view reference (slice/ + * orientation); legacy then applies getViewPresentation via setViewPresentation, + * native applies the stored displayArea via setViewState. + */ + setPositionPresentation( + viewport: Types.IViewport, + positionPresentation: PositionPresentation + ): void; + + /** + * Restores a LUT presentation (VOI/colormap/invert). Legacy uses setProperties; + * native uses setDisplaySetPresentation (a PLANAR_NEXT viewport has no + * setProperties), so calling setPresentations on native no longer throws. + */ + setLutPresentation(viewport: Types.IViewport, lutPresentation: LutPresentation): void; + + /** + * Registers a native dataset id for a viewport against cornerstone's global + * GenericViewport metadata provider (§4.7). Next ref-counts and tracks per + * viewport so it can be released on unmount; legacy is a no-op (never registers). + */ + registerDataId(viewportId: string, dataId: string, payload: DataIdPayload): void; + + /** + * Releases the dataset registrations a viewport owns. Called from the service's + * disableElement BEFORE the viewport bookkeeping is deleted. Next releases (and + * removes from the provider when the last reference is gone); legacy is a no-op. + */ + onViewportDisabled(viewportId: string): void; + + /** + * Flushes all remaining registrations. Called from the service's destroy(). + * Next clears its registry; legacy is a no-op. + */ + destroy(): void; +} diff --git a/extensions/cornerstone/src/services/ViewportService/backends/IViewportOperations.ts b/extensions/cornerstone/src/services/ViewportService/backends/IViewportOperations.ts new file mode 100644 index 000000000..04bc9ff97 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/backends/IViewportOperations.ts @@ -0,0 +1,98 @@ +import type { Types as CoreTypes } from '@cornerstonejs/core'; + +export type FlipValue = 'toggle' | boolean; +export type RotationMode = 'apply' | 'set'; + +export interface VolumeLightingOptions { + shade?: boolean; + ambient?: number; + diffuse?: number; + specular?: number; +} + +export interface WindowLevelParams { + windowWidth: number; + windowCenter: number; + /** Legacy volume target; resolved by the caller. Ignored on native (active binding). */ + volumeId?: string; + /** Native per-binding target (e.g. the PT overlay in a fusion); ignored on legacy. */ + displaySetInstanceUID?: string; +} + +export interface ColormapParams { + colormap: Record; + /** Used by the legacy orthographic branch to resolve the volumeId; ignored on native. */ + displaySetInstanceUID?: string; +} + +/** + * Per-viewport interaction/appearance operations, extracted out of commandsModule so + * command bodies stay thin (migration plan §4.3). This mirrors the IViewportBackend + * family (LegacyViewportBackend / NextViewportBackend): there is one implementation per + * lane and each uses its lane's cornerstone APIs DIRECTLY (legacy getCamera/setProperties/ + * getViewPresentation vs native getViewState/setDisplaySetPresentation/getViewReference). + * + * DISPATCH: unlike IViewportBackend (selected once by the appConfig flag because it owns + * per-session mount lifecycle), operations are routed PER VIEWPORT via the dispatcher in + * viewportOperations.ts (isNextViewport(viewport) ? next : legacy). The viewport is + * already created and self-describing, and a session can hold a mix of legacy and native + * viewports; per-viewport routing is the runtime truth. + * + * RENDER: no method calls viewport.render(). The caller renders, matching today's + * per-command render timing (e.g. setViewportColormap renders only when immediate). + * + * VIEWPORT RESOLUTION: never done here — the command owns "which viewport". + */ +export interface IViewportOperations { + // ---- camera / view-state ---- + + /** Toggle (default) or set horizontal flip. */ + flipHorizontal(viewport: CoreTypes.IViewport, newValue?: FlipValue): void; + + /** Toggle (default) or set vertical flip. */ + flipVertical(viewport: CoreTypes.IViewport, newValue?: FlipValue): void; + + /** Rotate: mode 'apply' = relative, mode 'set' = absolute (with flip-parity correction). */ + rotate(viewport: CoreTypes.IViewport, rotation: number, mode?: RotationMode): void; + + /** Reset properties + camera/view-state to defaults (slice/navigation preserved on native). */ + reset(viewport: CoreTypes.IViewport): void; + + /** Zoom by direction: >0 in, <0 out, 0 = fit-to-window (reset). */ + scaleBy(viewport: CoreTypes.IViewport, direction: number): void; + + /** Read the view-plane normal in a lane-appropriate way. */ + getViewPlaneNormal(viewport: CoreTypes.IViewport): CoreTypes.Point3 | undefined; + + /** + * In-plane re-center (+ zoom-to-fit) of a measurement AFTER the caller's setViewReference + * slice jump. Returns true when it re-centered (caller should render), false when the + * measurement was already visible or in-plane centering is unsupported on the lane. + */ + centerOnMeasurement(viewport: CoreTypes.IViewport, measurement: Record): boolean; + + // ---- appearance / properties ---- + + /** Toggle invert. */ + invert(viewport: CoreTypes.IViewport): void; + + /** Apply a VOI window/level. */ + setWindowLevel(viewport: CoreTypes.IViewport, params: WindowLevelParams): void; + + /** Apply a colormap. */ + setColormap(viewport: CoreTypes.IViewport, params: ColormapParams): void; + + // ---- 3D volume rendering (CS-14: native unsupported yet) ---- + + /** VR preset. */ + setPreset(viewport: CoreTypes.IViewport, preset: string): void; + + /** VR sample distance / samples-per-ray quality. */ + setVolumeRenderingQuality(viewport: CoreTypes.IViewport, volumeQuality: number): void; + + /** Shift scalar-opacity transfer-function points. */ + shiftVolumeOpacityPoints(viewport: CoreTypes.IViewport, shift: number): void; + + /** VR lighting (shade/ambient/diffuse/specular). */ + setVolumeLighting(viewport: CoreTypes.IViewport, options: VolumeLightingOptions): void; +} diff --git a/extensions/cornerstone/src/services/ViewportService/backends/IViewportServiceInternals.ts b/extensions/cornerstone/src/services/ViewportService/backends/IViewportServiceInternals.ts new file mode 100644 index 000000000..752c31bc2 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/backends/IViewportServiceInternals.ts @@ -0,0 +1,47 @@ +import type { Types } from '@cornerstonejs/core'; +import type ViewportInfo from '../Viewport'; +import type { Presentations } from '../../../types/Presentation'; +import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService'; +import type { OverlayMountTask } from './IViewportBackend'; + +/** + * The narrow slice of CornerstoneViewportService that a viewport backend is + * allowed to reach (migration plan §4.3 access pattern). The service `implements` + * this and passes `this` to each backend, so a backend can dispatch the per-family + * mount work back to the shared service methods without reaching into unrelated + * internals. Keeping this interface narrow is what stops the off (legacy) path + * from drifting as the next backend grows. + */ +export interface IViewportServiceInternals { + _setStackViewport( + viewport: Types.IStackViewport, + viewportData: StackViewportData, + viewportInfo: ViewportInfo, + presentations?: Presentations + ): Promise; + + _setVolumeViewport( + viewport: Types.IVolumeViewport, + viewportData: VolumeViewportData, + viewportInfo: ViewportInfo, + presentations?: Presentations + ): Promise; + + _setEcgViewport(viewport: Types.IECGViewport, viewportData: StackViewportData): Promise; + + _setOtherViewport( + viewport: Types.IStackViewport, + viewportData: StackViewportData, + viewportInfo: ViewportInfo, + presentations?: Presentations + ): Promise; + + /** Applies lut/position/segmentation presentations to a mounted viewport. */ + setPresentations(viewportId: string, presentations: Presentations): void; + + /** Runs the pending overlay (SEG/RTSTRUCT) adds produced by the mount prelude. */ + _addOverlayRepresentations(overlayProcessingResults?: OverlayMountTask[]): Promise; + + /** Records which display sets a viewport shows (service bookkeeping). */ + _trackViewportDisplaySets(viewportId: string, displaySetInstanceUIDs: string[]): void; +} diff --git a/extensions/cornerstone/src/services/ViewportService/backends/LegacyViewportBackend.ts b/extensions/cornerstone/src/services/ViewportService/backends/LegacyViewportBackend.ts new file mode 100644 index 000000000..529e9ff5d --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/backends/LegacyViewportBackend.ts @@ -0,0 +1,264 @@ +import { Enums as csEnums, Types, metaData } from '@cornerstonejs/core'; +import { + getLegacyViewportType, + isStackViewportType, + isVolume3DViewportType, + isVolumeViewportType, +} from '../../../utils/getLegacyViewportType'; +import type ViewportInfo from '../Viewport'; +import type { + Presentations, + PositionPresentation, + LutPresentation, +} from '../../../types/Presentation'; +import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService'; +import type { IViewportBackend, StackMountContext, VolumeMountContext } from './IViewportBackend'; +import type { IViewportServiceInternals } from './IViewportServiceInternals'; +import { DataIdRegistry } from './dataIdRegistry'; + +// Mirrors WITH_ORIENTATION in CornerstoneViewportService (inlined to avoid a +// value import that would create a backend -> service circular dependency). +const WITH_ORIENTATION = { withNavigation: true, withOrientation: true }; + +/** + * Legacy (default) viewport backend. Selected when `appConfig.useNextViewports` + * is off. Routing mirrors today's CornerstoneViewportService._setDisplaySets legacy + * branch exactly (dispatch by runtime cornerstone viewport type) and delegates the + * per-family mount to the unchanged service methods, so the off path stays + * byte-identical. The one legacy family that touches the GenericViewport metadata + * provider is WSI (mountOther); those registrations go through the same + * ref-counted registry as the native backend so they are released on viewport + * disable/destroy instead of leaking across viewport reuse. + */ +export class LegacyViewportBackend implements IViewportBackend { + private readonly registry = new DataIdRegistry(); + + constructor(private readonly service: IViewportServiceInternals) {} + + dispatchMount( + viewport: Types.IViewport, + viewportData: StackViewportData | VolumeViewportData, + viewportInfo: ViewportInfo, + presentations: Presentations = {} + ): Promise { + if (isStackViewportType(viewport)) { + return this.service._setStackViewport( + viewport as Types.IStackViewport, + viewportData as StackViewportData, + viewportInfo, + presentations + ); + } + + if (isVolumeViewportType(viewport)) { + return this.service._setVolumeViewport( + viewport as Types.IVolumeViewport, + viewportData as VolumeViewportData, + viewportInfo, + presentations + ); + } + + if (getLegacyViewportType(viewport) === csEnums.ViewportType.ECG) { + return this.service._setEcgViewport( + viewport as unknown as Types.IECGViewport, + viewportData as StackViewportData + ); + } + + return this.service._setOtherViewport( + viewport as Types.IStackViewport, + viewportData as StackViewportData, + viewportInfo, + presentations + ); + } + + async mountStack(viewport: Types.IStackViewport, context: StackMountContext): Promise { + const { + imageIds, + initialImageIndex, + properties, + displayArea, + rotation, + flipHorizontal, + presentations, + overlayProcessingResults, + } = context; + + await viewport.setStack(imageIds, initialImageIndex); + viewport.setProperties({ ...properties }); + this.service.setPresentations(viewport.id, presentations); + + await this.service._addOverlayRepresentations(overlayProcessingResults); + + if (displayArea) { + viewport.setDisplayArea(displayArea as Types.DisplayArea); + } + if (rotation) { + viewport.setProperties({ rotation } as Parameters[0]); + } + if (flipHorizontal) { + viewport.setCamera({ flipHorizontal: true }); + } + } + + async mountVolumes(): Promise { + // Legacy volumes mount through the service's shared volume tail + // (setVolumes/addVolumes optimization, property application, presentations). + return false; + } + + async mountOverlayOnlyVolumes( + viewport: Types.IViewport, + volumeInputArray: unknown[] + ): Promise { + await (viewport as Types.IVolumeViewport).setVolumes(volumeInputArray as Types.IVolumeInput[]); + } + + async mountEcg( + viewport: Types.IECGViewport, + _displaySet: { displaySetInstanceUID: string; imageIds?: string[] }, + imageId: string + ): Promise { + return viewport.setEcg(imageId); + } + + async mountOther( + viewport: Types.IViewport, + displaySet: { displaySetInstanceUID: string; imageIds: string[] } + ): Promise { + // CS3D's "redo viewports" replaced setDataIds with the generic + // setDisplaySets({ displaySetId }) API; the legacy adapters key off + // imageIds[0] as the displaySetId, so do the same here. + const displaySetId = displaySet.imageIds[0]; + // Register the WSI dataset so the viewport can resolve its imageIds + + // webClient by display-set id, then mount via setDisplaySets. The webClient + // was registered under the WADO_WEB_CLIENT module (keyed by imageIds[0]) by + // the SM SOP class handler. CS3D's "redo viewports" reads this same registry + // (genericViewportDisplaySetMetadataProvider) from its WSI data provider; + // without this entry setDisplaySets throws "No registered WSI dataset" and + // the viewport renders gray. + const webClient = metaData.get(csEnums.MetadataModules.WADO_WEB_CLIENT, displaySetId); + // Ref-counted registration so the provider entry is removed when the last + // viewport showing this WSI display set is disabled (or on service destroy). + this.registry.register(viewport.id, displaySetId, { + kind: 'wsi', + imageIds: displaySet.imageIds, + options: { webClient }, + }); + await ( + viewport as unknown as { + setDisplaySets: (args: { displaySetId: string }) => Promise; + } + ).setDisplaySets({ displaySetId }); + } + + remount( + viewport: Types.IViewport, + viewportData: StackViewportData | VolumeViewportData, + viewportInfo: ViewportInfo, + keepCamera: boolean + ): Promise | undefined { + let displaySetPromise: Promise | undefined; + + if (isVolumeViewportType(viewport)) { + // Snapshot the camera only for the family that uses it; taking it before + // the family checks would throw for families with no re-mount path. + const vp = viewport as Types.IVolumeViewport; + const viewportCamera = keepCamera ? vp.getCamera() : undefined; + displaySetPromise = this.service + ._setVolumeViewport( + viewport as Types.IVolumeViewport, + viewportData as VolumeViewportData, + viewportInfo + ) + .then(() => { + if (viewportCamera) { + vp.setCamera(viewportCamera); + vp.render(); + } + }); + } + + if (isStackViewportType(viewport)) { + displaySetPromise = this.service._setStackViewport( + viewport as Types.IStackViewport, + viewportData as StackViewportData, + viewportInfo + ); + } + + return displaySetPromise; + } + + getPositionPresentation( + csViewport: Types.IViewport, + viewportInfo: ViewportInfo, + viewportId: string + ): PositionPresentation { + const vp = csViewport as Types.IStackViewport; + return { + viewportType: viewportInfo.getViewportType(), + viewReference: isVolume3DViewportType(csViewport) ? null : vp.getViewReference(), + viewPresentation: vp.getViewPresentation({ pan: true, zoom: true }), + viewportId, + }; + } + + setPositionPresentation( + viewport: Types.IViewport, + positionPresentation: PositionPresentation + ): void { + const vp = viewport as Types.IStackViewport | Types.IVolumeViewport; + const viewRef = positionPresentation?.viewReference; + if (viewRef) { + // The orientation can be updated here to navigate to the specified + // measurement or previous item, but this will not switch to volume + // or to stack from the other type + if (vp.isReferenceViewable(viewRef, WITH_ORIENTATION)) { + vp.setViewReference(viewRef); + } else { + console.warn('Unable to apply reference viewable', viewRef); + } + } + + const viewPresentation = positionPresentation?.viewPresentation; + if (viewPresentation) { + vp.setViewPresentation(viewPresentation); + } + } + + setLutPresentation(viewport: Types.IViewport, lutPresentation: LutPresentation): void { + if (!lutPresentation) { + return; + } + + const vp = viewport as Types.IStackViewport | Types.IVolumeViewport; + const { properties } = lutPresentation; + if (isVolumeViewportType(vp)) { + if (properties instanceof Map) { + properties.forEach((propertiesEntry, volumeId) => { + (vp as Types.IVolumeViewport).setProperties(propertiesEntry, volumeId); + }); + } else { + vp.setProperties(properties); + } + } else { + vp.setProperties(properties); + } + } + + registerDataId(): void { + // Legacy mounts do not register dataIds through the backend interface; the + // one provider-backed family (WSI) registers inline in mountOther. + } + + onViewportDisabled(viewportId: string): void { + this.registry.releaseViewport(viewportId); + } + + destroy(): void { + this.registry.destroy(); + } +} diff --git a/extensions/cornerstone/src/services/ViewportService/backends/LegacyViewportOperations.ts b/extensions/cornerstone/src/services/ViewportService/backends/LegacyViewportOperations.ts new file mode 100644 index 000000000..af3d180d4 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/backends/LegacyViewportOperations.ts @@ -0,0 +1,272 @@ +import { utilities as csUtils, Types as CoreTypes } from '@cornerstonejs/core'; +import { mat4, vec3 } from 'gl-matrix'; +import { + isStackViewportType, + isVolumeViewportType, + isOrthographicViewportType, +} from '../../../utils/getLegacyViewportType'; +import { getCenterExtent } from '../../../utils/getCenterExtent'; +import { isMeasurementWithinViewport } from '../../../utils/isMeasurementWithinViewport'; +import type { + IViewportOperations, + FlipValue, + RotationMode, + VolumeLightingOptions, + WindowLevelParams, + ColormapParams, +} from './IViewportOperations'; + +// Loose view of the VTK actor/mapper/property chain used by the 3D VR ops. These +// live on vtk.js objects, not cornerstone types, so they are accessed structurally +// (mirrors the previously-untyped commandsModule bodies). +type VtkActorChain = { + actor: { + getMapper: () => Record unknown>; + getProperty: () => Record unknown>; + }; +}; + +/** + * Legacy lane of IViewportOperations: every method is the corresponding + * commandsModule body lifted verbatim, using the legacy cornerstone APIs directly + * (getCamera/setCamera, getProperties/setProperties, getViewPresentation/ + * setViewPresentation, resetCamera, getActors). This is the byte-identical flag-off + * path; the dispatcher only routes non-generic viewports here. + * + * No method calls viewport.render() — the command renders (matching per-command + * render timing). + */ +export const legacyViewportOperations: IViewportOperations = { + flipHorizontal(viewport: CoreTypes.IViewport, newValue: FlipValue = 'toggle'): void { + const vp = viewport as CoreTypes.IStackViewport; + let flipHorizontal: boolean; + if (newValue === 'toggle') { + flipHorizontal = !vp.getCamera().flipHorizontal; + } else { + flipHorizontal = newValue; + } + vp.setCamera({ flipHorizontal }); + }, + + flipVertical(viewport: CoreTypes.IViewport, newValue: FlipValue = 'toggle'): void { + const vp = viewport as CoreTypes.IStackViewport; + let flipVertical: boolean; + if (newValue === 'toggle') { + flipVertical = !vp.getCamera().flipVertical; + } else { + flipVertical = newValue; + } + vp.setCamera({ flipVertical }); + }, + + invert(viewport: CoreTypes.IViewport): void { + const vp = viewport as CoreTypes.IStackViewport; + const { invert } = vp.getProperties(); + vp.setProperties({ invert: !invert }); + }, + + rotate(viewport: CoreTypes.IViewport, rotation: number, mode: RotationMode = 'apply'): void { + if (isVolumeViewportType(viewport)) { + const vp = viewport as CoreTypes.IVolumeViewport; + const camera = vp.getCamera(); + const rotAngle = (rotation * Math.PI) / 180; + const rotMat = mat4.identity(new Float32Array(16)); + mat4.rotate(rotMat, rotMat, rotAngle, camera.viewPlaneNormal); + const rotatedViewUp = vec3.transformMat4(vec3.create(), camera.viewUp, rotMat); + vp.setCamera({ viewUp: rotatedViewUp as CoreTypes.Point3 }); + return; + } + + const vp = viewport as CoreTypes.IStackViewport; + if (vp.getRotation !== undefined) { + const { rotation: currentRotation } = vp.getViewPresentation(); + const newRotation = + mode === 'apply' + ? (currentRotation + rotation + 360) % 360 + : (() => { + // In 'set' mode, account for the effect horizontal/vertical flips + // have on the perceived rotation direction. A single flip mirrors + // the image and inverses rotation direction, while two flips + // restore the original parity. We therefore invert the rotation + // angle when an odd number of flips are applied so that the + // requested absolute rotation matches the user expectation. + const { flipHorizontal = false, flipVertical = false } = vp.getViewPresentation(); + + const flipsParity = (flipHorizontal ? 1 : 0) + (flipVertical ? 1 : 0); + const effectiveRotation = flipsParity % 2 === 1 ? -rotation : rotation; + + return (effectiveRotation + 360) % 360; + })(); + vp.setViewPresentation({ rotation: newRotation }); + } + }, + + reset(viewport: CoreTypes.IViewport): void { + const vp = viewport as CoreTypes.IStackViewport; + vp.resetProperties?.(); + vp.resetCamera(); + }, + + scaleBy(viewport: CoreTypes.IViewport, direction: number): void { + const scaleFactor = direction > 0 ? 0.9 : 1.1; + if (isStackViewportType(viewport)) { + const vp = viewport as CoreTypes.IStackViewport; + if (direction) { + const { parallelScale } = vp.getCamera(); + vp.setCamera({ parallelScale: parallelScale * scaleFactor }); + } else { + vp.resetCamera(); + } + } + }, + + getViewPlaneNormal(viewport: CoreTypes.IViewport): CoreTypes.Point3 | undefined { + return (viewport as CoreTypes.IStackViewport).getCamera().viewPlaneNormal; + }, + + centerOnMeasurement( + viewport: CoreTypes.IViewport, + measurement: Record + ): boolean { + if (isMeasurementWithinViewport(viewport, measurement)) { + return false; + } + + const vp = viewport as CoreTypes.IStackViewport; + const camera = vp.getCamera(); + const { focalPoint: cameraFocalPoint, position: cameraPosition } = camera; + const { center, extent } = getCenterExtent(measurement); + const position = vec3.sub(vec3.create(), cameraPosition, cameraFocalPoint); + vec3.add(position, position, center); + vp.setCamera({ focalPoint: center, position: position as unknown as CoreTypes.Point3 }); + // Zoom out if the measurement is too large + const measurementSize = vec3.dist(extent.min, extent.max); + if (measurementSize > camera.parallelScale) { + const scaleFactor = measurementSize / camera.parallelScale; + vp.setZoom(vp.getZoom() / scaleFactor); + } + return true; + }, + + setWindowLevel(viewport: CoreTypes.IViewport, params: WindowLevelParams): void { + const { lower, upper } = csUtils.windowLevel.toLowHighRange( + params.windowWidth, + params.windowCenter + ); + if (isVolumeViewportType(viewport)) { + (viewport as CoreTypes.IVolumeViewport).setProperties( + { voiRange: { upper, lower } }, + params.volumeId + ); + } else { + (viewport as CoreTypes.IStackViewport).setProperties({ voiRange: { upper, lower } }); + } + }, + + setColormap(viewport: CoreTypes.IViewport, params: ColormapParams): void { + const { colormap, displaySetInstanceUID } = params; + if (isStackViewportType(viewport)) { + (viewport as CoreTypes.IStackViewport).setProperties({ colormap }); + } + + if (isOrthographicViewportType(viewport)) { + const vp = viewport as CoreTypes.IVolumeViewport; + // ToDo: Find a better way of obtaining the volumeId that corresponds to the displaySetInstanceUID + const volumeId = + vp.getAllVolumeIds().find((_volumeId: string) => _volumeId.includes(displaySetInstanceUID)) ?? + vp.getVolumeId(); + vp.setProperties({ colormap }, volumeId); + } + }, + + setPreset(viewport: CoreTypes.IViewport, preset: string): void { + (viewport as CoreTypes.IVolumeViewport).setProperties({ preset }); + }, + + setVolumeRenderingQuality(viewport: CoreTypes.IViewport, volumeQuality: number): void { + const actorEntry = (viewport as unknown as CoreTypes.IVolumeViewport).getActors()[0]; + if (!actorEntry) { + return; + } + const { actor } = actorEntry; + const mapper = (actor as unknown as VtkActorChain['actor']).getMapper(); + const image = mapper.getInputData() as { + getDimensions: () => number[]; + getSpacing: () => number[]; + }; + const dims = image.getDimensions(); + const spacing = image.getSpacing(); + const spatialDiagonal = vec3.length( + vec3.fromValues(dims[0] * spacing[0], dims[1] * spacing[1], dims[2] * spacing[2]) + ); + + let sampleDistance = spacing.reduce((a, b) => a + b) / 3.0; + sampleDistance /= volumeQuality > 1 ? 0.5 * volumeQuality ** 2 : 1.0; + const samplesPerRay = spatialDiagonal / sampleDistance + 1; + mapper.setMaximumSamplesPerRay(samplesPerRay); + mapper.setSampleDistance(sampleDistance); + }, + + shiftVolumeOpacityPoints(viewport: CoreTypes.IViewport, shift: number): void { + const actorEntry = (viewport as unknown as CoreTypes.IVolumeViewport).getActors()[0]; + if (!actorEntry) { + return; + } + const { actor } = actorEntry; + const ofun = (actor as unknown as VtkActorChain['actor']).getProperty().getScalarOpacity(0) as { + getSize: () => number; + getNodeValue: (i: number, v: number[]) => void; + removeAllPoints: () => void; + addPoint: (...args: number[]) => void; + }; + + const opacityPointValues: number[][] = []; // Array to hold values + // Gather Existing Values + const size = ofun.getSize(); + for (let pointIdx = 0; pointIdx < size; pointIdx++) { + const opacityPointValue = [0, 0, 0, 0]; + ofun.getNodeValue(pointIdx, opacityPointValue); + // opacityPointValue now holds [xLocation, opacity, midpoint, sharpness] + opacityPointValues.push(opacityPointValue); + } + // Add offset + opacityPointValues.forEach(opacityPointValue => { + opacityPointValue[0] += shift; // Change the location value + }); + // Set new values + ofun.removeAllPoints(); + opacityPointValues.forEach(opacityPointValue => { + ofun.addPoint(...opacityPointValue); + }); + }, + + setVolumeLighting(viewport: CoreTypes.IViewport, options: VolumeLightingOptions): void { + const actorEntry = (viewport as unknown as CoreTypes.IVolumeViewport).getActors()[0]; + if (!actorEntry) { + return; + } + const { actor } = actorEntry; + const property = (actor as unknown as VtkActorChain['actor']).getProperty() as { + setShade: (v: boolean) => void; + setAmbient: (v: number) => void; + setDiffuse: (v: number) => void; + setSpecular: (v: number) => void; + }; + + if (options.shade !== undefined) { + property.setShade(options.shade); + } + + if (options.ambient !== undefined) { + property.setAmbient(options.ambient); + } + + if (options.diffuse !== undefined) { + property.setDiffuse(options.diffuse); + } + + if (options.specular !== undefined) { + property.setSpecular(options.specular); + } + }, +}; diff --git a/extensions/cornerstone/src/services/ViewportService/backends/NextViewportBackend.ts b/extensions/cornerstone/src/services/ViewportService/backends/NextViewportBackend.ts new file mode 100644 index 000000000..241ca621c --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/backends/NextViewportBackend.ts @@ -0,0 +1,671 @@ +import { + Enums as csEnums, + Types, + metaData, + utilities as csUtils, + CONSTANTS as csConstants, + isRegisteredRenderBackend, +} from '@cornerstonejs/core'; +import type { RenderBackendValue } from '@cornerstonejs/core'; +import { utilities as csToolsUtils } from '@cornerstonejs/tools'; +import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType'; +import { getViewportRenderingOverride } from '../../../utils/nextViewports'; +import type ViewportInfo from '../Viewport'; +import type { + Presentations, + PositionPresentation, + LutPresentation, +} from '../../../types/Presentation'; +import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService'; +import type { IViewportBackend, StackMountContext, VolumeMountContext } from './IViewportBackend'; +import type { IViewportServiceInternals } from './IViewportServiceInternals'; +import { DataIdRegistry, type DataIdPayload } from './dataIdRegistry'; + +// Mirrors WITH_ORIENTATION in CornerstoneViewportService (inlined to avoid a +// value import that would create a backend -> service circular dependency). +const WITH_ORIENTATION = { withNavigation: true, withOrientation: true }; + +/** + * Per-mount render backend override for a planar mount, resolved from the + * `.viewportRendering` URL param / appConfig captured at init + * (e.g. `?orthographic.viewportRendering=cpu`). Validated at mount time (not + * init) because extension backends may call registerRenderBackend after the + * cornerstone extension initializes; an unregistered value is dropped with a + * warning rather than failing the mount. + */ +function getMountRenderBackend(viewportTypeKey: string): RenderBackendValue | undefined { + const backend = getViewportRenderingOverride(viewportTypeKey); + if (!backend) { + return undefined; + } + if (backend !== 'auto' && !isRegisteredRenderBackend(backend)) { + console.warn( + `${viewportTypeKey}.viewportRendering: "${backend}" is not a registered render backend; ignoring.` + ); + return undefined; + } + return backend as RenderBackendValue; +} + +// The PlanarViewState fields that encode pan/zoom/rotation/flip. Slice and +// orientation are deliberately EXCLUDED — they are restored via the view reference, +// and a partial setViewState patch that omits them leaves them untouched (the merge +// at PlanarViewport.setViewState preserves unspecified fields). +const NATIVE_VIEW_PRESENTATION_KEYS = [ + 'displayArea', + 'anchorWorld', + 'anchorCanvas', + 'scale', + 'scaleMode', + 'rotation', + 'flipHorizontal', + 'flipVertical', +] as const; + +// Minimal structural view of a native PlanarViewport's semantic accessors. These +// live on IGenericViewport (not IStackViewport/IViewport), so we cast at the boundary +// rather than import core-internal PlanarViewport/PlanarViewState types. +type NativePlanarViewport = Types.IViewport & { + getViewState: () => Record; + setViewState: (patch: Record) => void; + getViewReference: () => Types.ViewReference; + setViewReference: (ref: Types.ViewReference) => void; + isReferenceViewable?: (ref: Types.ViewReference, opts?: unknown) => boolean; +}; + +/** Picks the pan/zoom/rotation/flip subset out of a (deep-cloned) getViewState() result. */ +function pickNativeViewPresentation(viewState: Record): Record { + const out: Record = {}; + for (const key of NATIVE_VIEW_PRESENTATION_KEYS) { + if (viewState[key] !== undefined) { + out[key] = viewState[key]; + } + } + return out; +} + +/** + * Derives a default VOI window/level range from an image's DICOM voiLutModule + * metadata (first WindowCenter/WindowWidth pair). Used to seed native viewport + * windowing, which (unlike legacy StackViewport) is not auto-applied from + * metadata. Returns undefined when the metadata has no usable window. + */ +function getDefaultVoiRangeFromMetadata( + imageId: string +): { lower: number; upper: number } | undefined { + if (!imageId) { + return; + } + const voiLutModule = metaData.get('voiLutModule', imageId); + const wc = Array.isArray(voiLutModule?.windowCenter) + ? voiLutModule.windowCenter[0] + : voiLutModule?.windowCenter; + const ww = Array.isArray(voiLutModule?.windowWidth) + ? voiLutModule.windowWidth[0] + : voiLutModule?.windowWidth; + if (wc == null || ww == null) { + return; + } + return csUtils.windowLevel.toLowHighRange(ww, wc); +} + +/** + * Native GenericViewport ("next") backend. Selected when `appConfig.useNextViewports` + * is on. Routes the mount by the bound data shape (native stack and volume content + * both report a single PLANAR_NEXT type, so the legacy runtime-type checks cannot + * classify them — §4.4), owns the per-family native MOUNT BODIES + * (mountStack/mountVolumes/mountEcg/mountOther/remount), and owns the ref-counted + * dataId lifecycle (§4.7) over cornerstone's global GenericViewport metadata + * provider. The service's shared methods hold no native branches. + */ +export class NextViewportBackend implements IViewportBackend { + private readonly registry = new DataIdRegistry(); + + constructor(private readonly service: IViewportServiceInternals) {} + + dispatchMount( + viewport: Types.IViewport, + viewportData: StackViewportData | VolumeViewportData, + viewportInfo: ViewportInfo, + presentations: Presentations = {} + ): Promise { + // Non-planar native families (video / WSI / ECG) route by viewport TYPE to their + // dedicated mounts: the bound data shape cannot distinguish them, and each needs + // family-specific dataId registration. Mirrors the legacy backend's type dispatch. + const type = (viewport as { type?: string }).type; + + if (type === csEnums.ViewportType.ECG_NEXT) { + return this.service._setEcgViewport( + viewport as unknown as Types.IECGViewport, + viewportData as StackViewportData + ); + } + + if ( + type === csEnums.ViewportType.VIDEO_NEXT || + type === csEnums.ViewportType.WHOLE_SLIDE_NEXT + ) { + return this.service._setOtherViewport( + viewport as unknown as Types.IStackViewport, + viewportData as StackViewportData, + viewportInfo, + presentations + ); + } + + // Planar stack vs volume content both report PLANAR_NEXT, so infer from the + // persisted dataShapeType contract (§4.4) — the canonical discriminator set by + // CornerstoneCacheService and used everywhere else. Don't probe `'volume' in + // firstData`: that field can be lazily initialized after the data object is built, + // so the presence check is unreliable. Fall back to the probe only when an older + // viewportData has no dataShapeType. + const dataShapeType = (viewportData as { dataShapeType?: csEnums.ViewportType }).dataShapeType; + const firstData = (viewportData?.data?.[0] ?? {}) as Record; + const isVolumeContent = + dataShapeType === csEnums.ViewportType.ORTHOGRAPHIC || + dataShapeType === csEnums.ViewportType.VOLUME_3D || + (dataShapeType === undefined && 'volume' in firstData); + + if (isVolumeContent) { + return this.service._setVolumeViewport( + viewport as unknown as Types.IVolumeViewport, + viewportData as VolumeViewportData, + viewportInfo, + presentations + ); + } + + return this.service._setStackViewport( + viewport as unknown as Types.IStackViewport, + viewportData as StackViewportData, + viewportInfo, + presentations + ); + } + + /** + * Native stack mount: register the display set and mount it with + * setDisplaySets (render path inferred from the data), then apply + * VOI/colormap via setDisplaySetPresentation and pan/zoom/rotate/flip/ + * displayArea via setViewState — instead of the legacy setStack/setProperties/ + * setCamera surface, which a direct PLANAR_NEXT viewport does not expose. + */ + async mountStack(viewport: Types.IStackViewport, context: StackMountContext): Promise { + const { + displaySetInstanceUIDs, + imageIds, + initialImageIndex, + properties, + displayArea, + rotation, + flipHorizontal, + presentations, + overlayProcessingResults, + } = context; + + // Native stacks arrive as PLANAR_NEXT and bypass the legacy STACK-typed + // invalid-stack guard upstream, so guard empty/malformed stack data here + // before registering and indexing imageIds below. + if (!imageIds?.length) { + return; + } + + const vp = viewport as unknown as NativePlanarViewport & { + setDisplaySets: (args: { + displaySetId: string; + options: Record; + }) => Promise; + setDisplaySetPresentation: (props: Record) => void; + element: HTMLDivElement; + id: string; + }; + const dataId = displaySetInstanceUIDs[0]; + + // Register through the ref-counted registry (§4.7) instead of the raw + // provider.add, so the registration is released on unmount and shared + // (MPR) registrations are not double-added or prematurely removed. + this.registerDataId(vp.id, dataId, { + kind: 'planar', + imageIds, + initialImageIdIndex: initialImageIndex, + }); + + const stackRenderBackend = getMountRenderBackend('stack'); + await vp.setDisplaySets({ + displaySetId: dataId, + options: { + orientation: csEnums.OrientationAxis.ACQUISITION, + role: 'source', + ...(stackRenderBackend ? { renderBackend: stackRenderBackend } : {}), + }, + }); + + // Native viewports are presentation-driven and do NOT auto-derive a default + // window/level from the image's DICOM VOI metadata the way legacy StackViewport + // does, so without this the image renders with a raw/full-range VOI (too dark). + // When no explicit VOI is provided, seed it from the voiLutModule metadata. + if (!properties.voiRange) { + const defaultVoi = getDefaultVoiRangeFromMetadata(imageIds[initialImageIndex] ?? imageIds[0]); + if (defaultVoi) { + properties.voiRange = defaultVoi; + } + } + + const presentationProps: Record = {}; + if (properties.voiRange) { + presentationProps.voiRange = properties.voiRange; + } + if (properties.invert !== undefined) { + presentationProps.invert = properties.invert; + } + if (properties.colormap) { + presentationProps.colormap = properties.colormap; + } + if (Object.keys(presentationProps).length > 0) { + vp.setDisplaySetPresentation(presentationProps); + } + + const viewStatePatch: Record = {}; + if (displayArea) { + viewStatePatch.displayArea = displayArea; + } + if (rotation) { + viewStatePatch.rotation = rotation; + } + if (flipHorizontal) { + viewStatePatch.flipHorizontal = true; + } + if (Object.keys(viewStatePatch).length > 0) { + vp.setViewState(viewStatePatch); + } + + // Enable stack-context prefetch for the native path. setDisplaySets above has + // already populated imageIds via genericViewportDisplaySetMetadataProvider, so + // getStackData returns a valid stack at enable() time. Scroll re-prefetch is + // driven by the native STACK_NEW_IMAGE event. + csToolsUtils.stackContextPrefetch.enable(vp.element); + + // Restore persisted pan/zoom/rotation/flip (+ view reference) on top of the + // HP-derived defaults applied above, so a returning display set recovers its + // camera presentation. The LUT was already applied inline above, so restore + // position + segmentation only. Replaying segmentationPresentation re-adds + // hydrated representations (RTSS contour / SEG labelmap) on this native re-mount; + // without it a hydrated overlay silently disappears (the contour-vanishes-on- + // hydrate bug), because the overlay display set is no longer in the viewport's + // display-set list after hydration. Native-safe: position via setViewReference/ + // setViewState, and the replayed addSegmentationRepresentation routes through the + // native segmentation backend (no convertStackToVolumeViewport / getViewPresentation). + if (presentations?.positionPresentation || presentations?.segmentationPresentation) { + this.service.setPresentations(vp.id, { + positionPresentation: presentations.positionPresentation, + segmentationPresentation: presentations.segmentationPresentation, + }); + } + + await this.service._addOverlayRepresentations(overlayProcessingResults); + } + + /** + * Native volume/MPR mount: a direct PLANAR_NEXT viewport renders a volume by + * registering the dataset (with the already-cached volumeId) and calling + * setDisplaySets with the requested orientation; cornerstone selects the image + * vs reformatted-volume render path from that orientation. Returns true so the + * service skips the legacy setVolumes/setProperties tail; an overlay-only mount + * (no base volumes) returns false and traverses the shared tail, whose + * legacy-surface steps are lane-guarded via mountOverlayOnlyVolumes. + */ + async mountVolumes(viewport: Types.IViewport, context: VolumeMountContext): Promise { + const { + filteredVolumeInputArray, + volumesProperties, + viewportInfo, + overlayProcessingResults, + presentations, + } = context; + + if (!filteredVolumeInputArray.length) { + return false; + } + + await this._setNativeVolumeDisplaySets( + viewport, + filteredVolumeInputArray, + volumesProperties, + viewportInfo, + overlayProcessingResults + ); + + // Restore persisted pan/zoom/rotation/flip (+ view reference) so a returning + // MPR/volume pane recovers its camera. Also replay the stored lutPresentation so + // user window/level, colormap, invert, opacity or threshold edits survive the + // re-mount instead of resetting to the hanging-protocol defaults applied + // per-binding above (applied via setDisplaySetPresentation). And replay + // segmentationPresentation so hydrated overlays (SEG labelmap / RTSS contour) + // reappear on this native re-mount instead of vanishing. Native-safe: position + // via setViewReference/setViewState, segmentation via the native backend. + if ( + presentations?.positionPresentation || + presentations?.lutPresentation || + presentations?.segmentationPresentation + ) { + this.service.setPresentations(viewport.id, { + positionPresentation: presentations.positionPresentation, + lutPresentation: presentations.lutPresentation, + segmentationPresentation: presentations.segmentationPresentation, + }); + } + return true; + } + + async mountOverlayOnlyVolumes(): Promise { + // Generic ("next") viewports don't expose the legacy setVolumes surface. A + // native overlay-only mount adds its overlays via _addOverlayRepresentations + // in the shared tail; there is nothing to mount here. + } + + /** + * Native ECG_NEXT has no setEcg; register the waveform under the display set's + * dataId and mount it through the generic setDisplaySets API (the native ECG data + * provider reads sourceDataId). Ref-counted via the registry (§4.7). + */ + async mountEcg( + viewport: Types.IECGViewport, + displaySet: { displaySetInstanceUID: string; imageIds?: string[] }, + imageId: string + ): Promise { + const dataId = displaySet.displaySetInstanceUID; + this.registerDataId(viewport.id, dataId, { kind: 'ecg', sourceDataId: imageId }); + this.service._trackViewportDisplaySets(viewport.id, [dataId]); + await ( + viewport as unknown as { + setDisplaySets: (args: { displaySetId: string }) => Promise; + } + ).setDisplaySets({ displaySetId: dataId }); + } + + /** + * Native VIDEO_NEXT / WHOLE_SLIDE_NEXT: register the family-specific dataId, then + * mount through the generic setDisplaySets API. The native video data provider + * reads sourceDataId; the WSI provider reads imageIds + a DICOMweb client, which + * we resolve from the WADO_WEB_CLIENT metadata exactly as the legacy WSI adapter + * does. Ref-counted via the registry (§4.7). + */ + async mountOther( + viewport: Types.IViewport, + displaySet: { displaySetInstanceUID: string; imageIds: string[] } + ): Promise { + const dataId = displaySet.displaySetInstanceUID; + const imageId = displaySet.imageIds[0]; + const isWsi = (viewport as { type?: string }).type === csEnums.ViewportType.WHOLE_SLIDE_NEXT; + const payload = isWsi + ? { + kind: 'wsi' as const, + imageIds: displaySet.imageIds, + options: { + webClient: metaData.get(csEnums.MetadataModules.WADO_WEB_CLIENT, imageId), + }, + } + : { kind: 'video' as const, sourceDataId: imageId }; + this.registerDataId(viewport.id, dataId, payload); + this.service._trackViewportDisplaySets(viewport.id, [dataId]); + await ( + viewport as unknown as { + setDisplaySets: (args: { displaySetId: string }) => Promise; + } + ).setDisplaySets({ displaySetId: dataId }); + } + + /** + * Native re-mount: no getCamera/setCamera. Snapshot/restore the camera via the + * semantic view state, and route the mount through dispatchMount (it routes by + * data shape, since native stack and volume both report PLANAR_NEXT). + */ + remount( + viewport: Types.IViewport, + viewportData: StackViewportData | VolumeViewportData, + viewportInfo: ViewportInfo, + keepCamera: boolean + ): Promise { + const vp = viewport as NativePlanarViewport; + const viewState = keepCamera ? (vp.getViewState?.() ?? {}) : undefined; + return this.dispatchMount(viewport, viewportData, viewportInfo).then(() => { + if (keepCamera && viewState) { + vp.setViewState?.(viewState); + viewport.render(); + } + }); + } + + /** + * Mounts one or more volumes on a native viewport for volume/MPR rendering. + * Each base volume is registered with its already-cached volumeId and bound via + * setDisplaySets at the viewport's requested orientation; the first base volume + * is the source binding, any others are overlays (fusion). VOI/colormap/invert + * are applied per-binding via setDisplaySetPresentation. + */ + private async _setNativeVolumeDisplaySets( + viewport: Types.IViewport, + filteredVolumeInputArray: VolumeMountContext['filteredVolumeInputArray'], + volumesProperties: VolumeMountContext['volumesProperties'], + viewportInfo: ViewportInfo, + overlayProcessingResults: VolumeMountContext['overlayProcessingResults'] + ): Promise { + const orientation = viewportInfo.getOrientation(); + // A native VOLUME_3D_NEXT viewport renders the volume as a 3D VTK volume + // (renderMode 'vtkVolume3d'), not a reformatted planar slice; its appearance is + // driven by a volume-rendering preset, not orientation/role. + const is3D = (viewport as { type?: string }).type === csEnums.ViewportType.VOLUME_3D_NEXT; + const nativeViewport = viewport as unknown as { + setDisplaySets: ( + ...entries: Array<{ displaySetId: string; options: Record }> + ) => Promise; + setDisplaySetPresentation: (dataId: string, props: Record) => void; + getDefaultActor?: () => { actor?: unknown } | undefined; + render: () => void; + }; + const setDisplaySetsEntries: Array<{ + displaySetId: string; + options: Record; + }> = []; + const volumeRenderBackend = is3D ? undefined : getMountRenderBackend('orthographic'); + + // First pass: register each dataId and build the COMPLETE entry set. The native + // PlanarViewport.setDisplaySets has replace semantics (removeReplaceableData), so + // it must receive ALL entries in ONE call - the first entry (role 'source') + // resolves the source binding and the rest are overlays. Calling it once per + // volume instead drops the previously-set source (e.g. the fusion CT): the next + // single-entry overlay call (PT) finds no source entry, falls back to entries[0] + // (= PT), and removeReplaceableData tears down CT - leaving only the PT colormap. + for (const [index, { volumeInput }] of filteredVolumeInputArray.entries()) { + const { imageIds, volumeId, displaySetInstanceUID } = volumeInput; + const dataId = displaySetInstanceUID; + + // Ref-counted registration (§4.7): the MPR triptych shares one dataId across + // panes, so register() adds to the provider once and release() removes only + // when the last pane unmounts. (kind is stored but ignored by the volume3d + // data provider, which reads imageIds/volumeId.) + this.registerDataId(viewport.id, dataId, { + kind: 'planar', + imageIds, + volumeId, + }); + + setDisplaySetsEntries.push({ + displaySetId: dataId, + options: is3D + ? { renderMode: 'vtkVolume3d' } + : { + orientation, + role: index === 0 ? 'source' : 'overlay', + // Volume/MPR panes are 'orthographic' viewports at the OHIF level; + // renderBackend is a planar mount option, so the 3D branch is exempt. + ...(volumeRenderBackend ? { renderBackend: volumeRenderBackend } : {}), + }, + }); + } + + // Single replace call with the full entry set so the source (CT) is resolved + // and preserved instead of being torn down by per-volume calls. + await nativeViewport.setDisplaySets(...setDisplaySetsEntries); + + // Second pass: per-dataId presentations and the 3D preset. + for (const [index, { volumeInput }] of filteredVolumeInputArray.entries()) { + const dataId = volumeInput.displaySetInstanceUID; + const props = volumesProperties[index]?.properties; + if (props) { + const presentationProps: Record = {}; + if (props.voiRange) { + presentationProps.voiRange = props.voiRange; + } + if (props.invert !== undefined) { + presentationProps.invert = props.invert; + } + // colormap is a planar (LUT) concept; 3D appearance comes from the preset. + if (props.colormap && !is3D) { + presentationProps.colormap = props.colormap; + } + // Slab/blend for projection viewports (e.g. the TMTV MIP pane: blendMode + // 'MIP' + slabThickness 'fullVolume'). The native volume-slice render path + // maps blendMode -> reslice SlabType (MAX/MIN/MEAN) and applies the slab + // thickness on the reslice mapper; without them the mapper renders a single + // slice instead of a projection. 3D volume rendering derives its look from + // the preset, not a slab, so this is planar-only. blendMode was already + // normalized from the HP string ('MIP') to a BlendModes enum by + // ViewportInfo.mapDisplaySetOptions, and slabThickness was resolved to a + // number by _getSlabThickness ('fullVolume' -> volume diagonal). + if (!is3D && volumeInput.blendMode !== undefined) { + presentationProps.blendMode = volumeInput.blendMode; + } + if (!is3D && volumeInput.slabThickness !== undefined) { + presentationProps.slabThickness = volumeInput.slabThickness; + } + if (Object.keys(presentationProps).length > 0) { + nativeViewport.setDisplaySetPresentation(dataId, presentationProps); + } + } + + // 3D volume rendering needs an RGBA transfer function (preset) to be visible; + // the bare native VolumeViewport3D has no setProperties, so apply the preset to + // the volume actor directly (mirrors the legacy adapter's applyPresetToBinding). + if (is3D && index === 0 && props?.preset) { + const preset = csConstants.VIEWPORT_PRESETS?.find(p => p.name === props.preset); + const actor = nativeViewport.getDefaultActor?.()?.actor; + if (preset && actor) { + csUtils.applyPreset(actor as Parameters[0], preset); + } + } + } + + // Do NOT overwrite the service's viewport display-set bookkeeping here. The + // caller (_setVolumeViewport) already populated it with the COMPLETE set (base + // volumes + SEG/RT/fusion overlays) before this native mount; writing back the + // base-volume-only ids would drop the overlay UIDs from getViewportDisplaySets() + // and the later presentation/hydration flows. + + await this.service._addOverlayRepresentations(overlayProcessingResults); + nativeViewport.render(); + } + + getPositionPresentation( + csViewport: Types.IViewport, + viewportInfo: ViewportInfo, + viewportId: string + ): PositionPresentation { + const is3D = isVolume3DViewportType(csViewport); + const vp = csViewport as NativePlanarViewport; + + // A direct PLANAR_NEXT viewport has no getViewPresentation; pan/zoom/rotation/flip + // live in the semantic view state. getViewState() is already deep-cloned, normalized + // and JSON-serializable, so snapshot the pan/zoom subset (slice/orientation come back + // via the view reference). + const viewState = + !is3D && typeof vp.getViewState === 'function' ? vp.getViewState() : undefined; + + return { + viewportType: viewportInfo.getViewportType(), + viewReference: is3D ? null : vp.getViewReference(), + // Opaque native pan/zoom blob; cast at the boundary (legacy stores a Types.ViewPresentation). + viewPresentation: (viewState + ? pickNativeViewPresentation(viewState) + : undefined) as unknown as Types.ViewPresentation, + viewportId, + }; + } + + setPositionPresentation( + viewport: Types.IViewport, + positionPresentation: PositionPresentation + ): void { + const vp = viewport as NativePlanarViewport; + + // 1) Slice + orientation first, via the view reference. + const viewRef = positionPresentation?.viewReference; + if (viewRef && vp.isReferenceViewable?.(viewRef, WITH_ORIENTATION)) { + vp.setViewReference(viewRef); + } + + // 2) Pan/zoom/rotation/flip second, as a partial setViewState patch that omits + // slice/orientation so step 1 is preserved (the merge keeps unspecified fields). + const vpres = positionPresentation?.viewPresentation as unknown as + | Record + | undefined; + if (vpres && typeof vp.setViewState === 'function') { + const patch: Record = {}; + for (const key of NATIVE_VIEW_PRESENTATION_KEYS) { + if (vpres[key] !== undefined) { + patch[key] = vpres[key]; + } + } + if (Object.keys(patch).length > 0) { + // When the snapshot held live anchor/scale pan/zoom, displayArea was omitted; + // clear any stale displayArea explicitly so anchor/scale take effect (setViewState + // only rewrites displayArea when it is an own key of the patch). + if (!('displayArea' in patch)) { + patch.displayArea = undefined; + } + vp.setViewState(patch); + } + } + } + + setLutPresentation(viewport: Types.IViewport, lutPresentation: LutPresentation): void { + if (!lutPresentation) { + return; + } + const { properties } = lutPresentation; + // Native LUT presentation is the getDisplaySetPresentation shape (voiRange/ + // colormap/invert), not a per-volumeId Map; a PLANAR_NEXT viewport applies it via + // setDisplaySetPresentation (it has no legacy setProperties). + if (!properties || properties instanceof Map) { + return; + } + const nativeViewport = viewport as unknown as { + setDisplaySetPresentation: (props: Record) => void; + }; + const presentationProps: Record = {}; + if (properties.voiRange) { + presentationProps.voiRange = properties.voiRange; + } + if (properties.invert !== undefined) { + presentationProps.invert = properties.invert; + } + if (properties.colormap) { + presentationProps.colormap = properties.colormap; + } + if (Object.keys(presentationProps).length > 0) { + nativeViewport.setDisplaySetPresentation(presentationProps); + } + } + + registerDataId(viewportId: string, dataId: string, payload: DataIdPayload): void { + this.registry.register(viewportId, dataId, payload); + } + + onViewportDisabled(viewportId: string): void { + this.registry.releaseViewport(viewportId); + } + + destroy(): void { + this.registry.destroy(); + } +} diff --git a/extensions/cornerstone/src/services/ViewportService/backends/NextViewportOperations.ts b/extensions/cornerstone/src/services/ViewportService/backends/NextViewportOperations.ts new file mode 100644 index 000000000..a37fc9d41 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/backends/NextViewportOperations.ts @@ -0,0 +1,169 @@ +import { + utilities as csUtils, + CONSTANTS as csConstants, + Types as CoreTypes, +} from '@cornerstonejs/core'; +import { getViewportAdapter } from '../adapter'; +import { legacyViewportOperations } from './LegacyViewportOperations'; +import type { + IViewportOperations, + FlipValue, + RotationMode, + VolumeLightingOptions, + WindowLevelParams, + ColormapParams, +} from './IViewportOperations'; + +// Native PLANAR_NEXT semantic accessors not covered by the viewport adapter. +// They live on IGenericViewport, so cast structurally at the boundary. +type NativePlanarViewport = CoreTypes.IViewport & { + resetViewState?: () => void; + resetDisplaySetPresentation?: (dataId?: string) => void; + getZoom?: () => number; + setZoom?: (zoom: number) => void; +}; + +/** + * Native ("next") lane of IViewportOperations for direct PLANAR_NEXT viewports. + * Appearance and camera/view-state ops go through the viewport adapter (which + * encapsulates the native getViewState/setViewState and + * getDisplaySetPresentation/setDisplaySetPresentation primitives, including the + * active-binding dataId default); the remaining ops use the native semantic API + * directly. The dispatcher only routes generic viewports here. + * + * No method calls viewport.render() — the command renders. + */ +export const nextViewportOperations: IViewportOperations = { + flipHorizontal(viewport: CoreTypes.IViewport, newValue: FlipValue = 'toggle'): void { + const adapter = getViewportAdapter(viewport); + const flipHorizontal = + newValue === 'toggle' ? !adapter.getViewState().flipHorizontal : newValue; + adapter.setViewState({ flipHorizontal }); + }, + + flipVertical(viewport: CoreTypes.IViewport, newValue: FlipValue = 'toggle'): void { + const adapter = getViewportAdapter(viewport); + const flipVertical = newValue === 'toggle' ? !adapter.getViewState().flipVertical : newValue; + adapter.setViewState({ flipVertical }); + }, + + invert(viewport: CoreTypes.IViewport): void { + const adapter = getViewportAdapter(viewport); + const { invert } = adapter.getPresentation(); + adapter.setPresentation({ invert: !invert }); + }, + + rotate(viewport: CoreTypes.IViewport, rotation: number, mode: RotationMode = 'apply'): void { + // rotation/flip live in the semantic view state; getViewPresentation is absent. + const adapter = getViewportAdapter(viewport); + const state = adapter.getViewState(); + const currentRotation = (state.rotation as number) ?? 0; + const newRotation = + mode === 'apply' + ? (currentRotation + rotation + 360) % 360 + : (() => { + const flipsParity = (state.flipHorizontal ? 1 : 0) + (state.flipVertical ? 1 : 0); + const effectiveRotation = flipsParity % 2 === 1 ? -rotation : rotation; + return (effectiveRotation + 360) % 360; + })(); + adapter.setViewState({ rotation: newRotation }); + }, + + reset(viewport: CoreTypes.IViewport): void { + const vp = viewport as NativePlanarViewport; + // Reset the per-display-set presentation (VOI/colormap/invert) to defaults. + vp.resetDisplaySetPresentation?.(); + // No resetCamera on PLANAR_NEXT; resetViewState resets pan/zoom/rotation/orientation/flip. + vp.resetViewState?.(); + }, + + scaleBy(viewport: CoreTypes.IViewport, direction: number): void { + // parallelScale and zoom are inversely related (smaller parallelScale = more + // zoomed in = larger zoom), so divide by scaleFactor to match the legacy direction. + const scaleFactor = direction > 0 ? 0.9 : 1.1; + const vp = viewport as unknown as { + getZoom?: () => number; + setZoom?: (zoom: number) => void; + resetViewState?: (options?: { resetOrientation?: boolean }) => void; + }; + if (direction) { + // Zoom is only meaningful on planar (stack / volume-slice / MPR) native viewports. + // VolumeViewport3D is also a generic viewport but exposes no getZoom/setZoom, so + // guard before calling — a no-op there matches the legacy lane, which only zoomed + // stack viewports (otherwise the zoom hotkey would throw on a native 3D viewport). + if (vp.getZoom && vp.setZoom) { + vp.setZoom(vp.getZoom() / scaleFactor); + } + } else { + // direction === 0 is the fitViewportToWindow command. Legacy resetCamera() + // resets pan/zoom/rotation/flip but never the viewing orientation, while a + // full native resetViewState() would also snap an MPR back to its requested + // axis — so keep the orientation. + vp.resetViewState?.({ resetOrientation: false }); + } + }, + + getViewPlaneNormal(viewport: CoreTypes.IViewport): CoreTypes.Point3 | undefined { + return getViewportAdapter(viewport).getViewPlaneNormal(); + }, + + centerOnMeasurement(): boolean { + // CS-14: native PLANAR_NEXT has no getCamera/setCamera for in-plane pan; the + // caller's setViewReference already navigated to the measurement's slice. + // TODO(next): port in-plane centering via the camera bridge + setViewState pan. + return false; + }, + + setWindowLevel(viewport: CoreTypes.IViewport, params: WindowLevelParams): void { + const { lower, upper } = csUtils.windowLevel.toLowHighRange( + params.windowWidth, + params.windowCenter + ); + // Target the binding for params.displaySetInstanceUID so a PT/CT *fusion* W/L lands + // on the intended layer (e.g. the PT overlay) instead of always the source (CT) — + // mirroring setColormap. When no id is given (single stack/volume) the adapter falls + // back to the source binding. + getViewportAdapter(viewport).setPresentation( + { voiRange: { upper, lower } }, + params.displaySetInstanceUID + ); + }, + + setColormap(viewport: CoreTypes.IViewport, params: ColormapParams): void { + // Target the binding for params.displaySetInstanceUID. OHIF's dataId scheme maps a + // display set 1:1 onto its native dataId (bare UID), so a PT/CT *fusion* colormap lands + // on the overlay (PT) binding instead of defaulting to the source (CT). When no id is + // given (single-volume / plain stack colormap) the adapter falls back to the source. + getViewportAdapter(viewport).setPresentation( + { colormap: params.colormap }, + params.displaySetInstanceUID + ); + }, + + setPreset(viewport: CoreTypes.IViewport, preset: string): void { + // The native VolumeViewport3D has no setProperties; apply the volume-rendering + // preset (RGBA transfer function) to the volume actor directly. + const presetObj = csConstants.VIEWPORT_PRESETS?.find(p => p.name === preset); + const actor = ( + viewport as unknown as { getDefaultActor?: () => { actor?: unknown } | undefined } + ).getDefaultActor?.()?.actor; + if (presetObj && actor) { + csUtils.applyPreset(actor as Parameters[0], presetObj); + } + }, + + // VR sample-distance / opacity-points / lighting operate on the vtk volume actor via + // getActors, which the native VolumeViewport3D exposes; the work is lane-agnostic, so + // reuse the legacy actor-based implementations. + setVolumeRenderingQuality(viewport: CoreTypes.IViewport, volumeQuality: number): void { + legacyViewportOperations.setVolumeRenderingQuality(viewport, volumeQuality); + }, + + shiftVolumeOpacityPoints(viewport: CoreTypes.IViewport, shift: number): void { + legacyViewportOperations.shiftVolumeOpacityPoints(viewport, shift); + }, + + setVolumeLighting(viewport: CoreTypes.IViewport, options: VolumeLightingOptions): void { + legacyViewportOperations.setVolumeLighting(viewport, options); + }, +}; diff --git a/extensions/cornerstone/src/services/ViewportService/backends/README.md b/extensions/cornerstone/src/services/ViewportService/backends/README.md new file mode 100644 index 000000000..8dde22aef --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/backends/README.md @@ -0,0 +1,62 @@ +# Next-viewport dispatch: how legacy-vs-native is decided + +The `useNextViewports` opt-in routes OHIF onto cornerstone3D's native +("next" / GenericViewport) API. This document is the single description of +where and how the two lanes are selected. If you are adding a new legacy/native +divergence, one of the homes below is the right place — never an inline +`isGenericViewport` / flag check at the call site (enforced by +`scripts/check-next-viewport-boundaries.sh`). + +## The three homes for divergence + +| Home | What belongs there | +|---|---| +| `../adapter/` (`IViewportAdapter`) | API-surface bridging on a live viewport: reads/writes that exist on both lanes with different spellings (camera vs view state, properties vs display-set presentation, volumeId vs dataId, classification). The contract is next-shaped; the legacy adapter does the adapting. | +| `./` (`IViewportBackend`, `IViewportOperations`, and the segmentation twins in `../../SegmentationService/backends/`) | Lifecycle and interaction bodies: mount/re-mount, presentation capture/restore, dataId registration, per-command operations (flip/rotate/W-L/...), labelmap add/assembly. | +| `../../../utils/nextViewportPolicies.ts` | Behavioral policy: appearance defaults and workflow rules that differ by choice, not by API (fusion opacity flattening, overlay opacity, RTSTRUCT hydrate stack-pin). | + +Pre-mount classification of `viewportData` (no live viewport yet) lives in +`../../../utils/viewportDataShape.ts`; the persisted `dataShapeType` field set +by `CornerstoneCacheService` is the sanctioned way to survive the native +type collapse (stack/volume/MPR all report `PLANAR_NEXT` at runtime). + +## Two dispatch strategies (deliberately different) + +- **Session flag, selected once** — `isNextViewportsEnabled()`. Used where no + viewport exists yet, or for per-session lifecycle: + `IViewportBackend` (the `get backend()` getter on CornerstoneViewportService), + `getCornerstoneViewportType` (viewport-type resolution), the SEG assembly + path, and the policies module. +- **Per-viewport predicate** — `isNextViewport(viewport)` (the adapter module's + wrapper around `csUtils.isGenericViewport`). Used where a self-describing + viewport is in hand, because a flag-on session can hold BOTH native and + legacy viewports: `getViewportAdapter`, `viewportOperations`, and the + segmentation backend twins. + +Do not "unify" these: lifecycle genuinely is per-session; everything else +genuinely is per-viewport. + +## Sanctioned flag reads (`isNextViewportsEnabled`) — the exhaustive list + +1. `utils/getCornerstoneViewportType.ts` — maps requested OHIF viewport types + to native types. +2. `services/ViewportService/CornerstoneViewportService.ts` — the lazy + `get backend()` selection. +3. `services/SegmentationService/SegmentationService.ts` — + `assembleSegmentationDataForSEG` (dispatched at SEG-load time, before any + target viewport exists). +4. `utils/nextViewportPolicies.ts` — policy rules that apply before viewports + exist (e.g. the RTSTRUCT hydrate stack-pin). +5. `extensions/tmtv/src/getHangingProtocolModule.ts` — applies the + `NEXT_FUSION_PT_OPACITY` policy when the HP module is gathered (the flag is + settled by then; the legacy ramp in `hpViewports` stays untouched). + +Adding a sixth read requires updating this list AND the whitelist in +`scripts/check-next-viewport-boundaries.sh` — if you can express the change as +an adapter capability, a backend method, or a policy entry instead, do that. + +## Sanctioned `csUtils.isGenericViewport` calls + +Only `../adapter/getViewportAdapter.ts` (which also exports the +`isNextViewport` predicate for the dispatchers above). Everyone else consumes +`IViewportAdapter` methods. diff --git a/extensions/cornerstone/src/services/ViewportService/backends/dataIdRegistry.ts b/extensions/cornerstone/src/services/ViewportService/backends/dataIdRegistry.ts new file mode 100644 index 000000000..9457cd461 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/backends/dataIdRegistry.ts @@ -0,0 +1,121 @@ +import { utilities as csUtils } from '@cornerstonejs/core'; + +/** + * Payload registered with cornerstone's global GenericViewport dataset metadata + * provider. The shape is family-specific and mirrors what each native viewport's + * data provider reads (see the cornerstone genericViewport examples): + * - planar : imageIds (+ optional volumeId) — stack / volume-slice / MPR / 3D + * - video : sourceDataId (the video imageId) + * - ecg : sourceDataId (the waveform imageId) + * - wsi : imageIds + a DICOMweb webClient used to fetch tiles + */ +export type DataIdPayload = + | { kind: 'planar'; imageIds: string[]; volumeId?: string; initialImageIdIndex?: number } + | { kind: 'video' | 'ecg'; sourceDataId: string } + | { kind: 'wsi'; imageIds: string[]; options: { webClient: unknown } }; + +/** + * Owns the lifecycle of OHIF's `dataId` registrations against cornerstone's + * process-global `genericViewportDisplaySetMetadataProvider` (see migration plan §4.7). + * + * Why this exists: cornerstone's `removeData`/`setDisplaySets` do NOT garbage-collect + * the global registration store (upstream blocker CS-18), so OHIF must own add/remove. + * The MPR triptych mounts the SAME `dataId` (the displaySetInstanceUID-derived volume) + * from N panes, so a naive add-on-every-mount / never-remove both over-registers and + * leaks. This registry ref-counts per `dataId` and keeps a per-viewport ledger so: + * - `provider.add` fires only on the 0 -> 1 transition, + * - `provider.remove` fires only on the 1 -> 0 transition, + * - unmounting one pane of a shared-volume triptych does not unregister data the + * other panes still need. + * + * Used by the native ("next") backend for all families, and by the legacy + * backend for its one provider-backed family (WSI mounts via mountOther). + */ +export class DataIdRegistry { + // Global ref-count keyed by dataId (the provider store is a single global namespace). + private readonly refCounts = new Map(); + // Last payload registered per dataId, so a re-registration that promotes a + // stack-only dataId to volume-backed can be detected and forwarded to the provider. + private readonly payloads = new Map(); + // Per-viewport ledger of the dataIds it registered, to drive release on unmount. + private readonly byViewport = new Map(); + + /** + * Builds the registration dataId for a display set. PT/CT *fusion* overlays are + * distinct display sets with their own UIDs, so the bare displaySetInstanceUID is + * already collision-free and is used for both source and overlay bindings (the LUT + * presentation store keys by the same UID, giving a clean 1:1 dataId mapping). The + * `'overlay'` suffix is reserved for the case where a source and an overlay share the + * SAME displaySetInstanceUID but need distinct registrations — i.e. derived labelmap + * overlays (segmentation / M4), which are not yet on the native path. + */ + static dataIdFor(displaySetInstanceUID: string, role?: 'overlay'): string { + return role === 'overlay' ? `${displaySetInstanceUID}::overlay` : displaySetInstanceUID; + } + + /** + * Registers (or ref-bumps) a dataId for a viewport. Adds to the cornerstone + * provider only on the first reference. Idempotent payloads across panes that + * share a dataId are expected (same imageIds/volumeId), so first-writer wins — + * EXCEPT when a later payload promotes a previously stack-only registration to + * a volume-backed one (gains a `volumeId`). That happens when a data overlay + * (fusion) is added to a viewport whose source was first mounted as a vtkImage + * stack: the source is re-registered with its volumeId so it can render as a + * volume slice alongside the overlay. Without updating the provider here, the + * source would keep its volumeId-less payload and stay vtkImage while the + * overlay is a vtkVolumeSlice (broken fusion). + */ + register(viewportId: string, dataId: string, payload: DataIdPayload): void { + const prev = this.refCounts.get(dataId) ?? 0; + const existing = this.payloads.get(dataId); + const promotesToVolume = + !!(payload as { volumeId?: string }).volumeId && + !(existing as { volumeId?: string } | undefined)?.volumeId; + + if (prev === 0 || promotesToVolume) { + csUtils.genericViewportDisplaySetMetadataProvider.add(dataId, payload); + this.payloads.set(dataId, payload); + } + this.refCounts.set(dataId, prev + 1); + + const list = this.byViewport.get(viewportId) ?? []; + list.push(dataId); + this.byViewport.set(viewportId, list); + } + + /** + * Releases every dataId a viewport registered (called on element disable). + * Removes from the provider only when the last reference is gone. + */ + releaseViewport(viewportId: string): void { + const dataIds = this.byViewport.get(viewportId); + if (!dataIds) { + return; + } + for (const dataId of dataIds) { + const next = (this.refCounts.get(dataId) ?? 1) - 1; + if (next <= 0) { + this.refCounts.delete(dataId); + this.payloads.delete(dataId); + csUtils.genericViewportDisplaySetMetadataProvider.remove(dataId); + } else { + this.refCounts.set(dataId, next); + } + } + this.byViewport.delete(viewportId); + } + + /** + * Flushes all remaining registrations (called on service destroy). Removes each + * dataId individually rather than `provider.clear()`, which would wipe + * registrations owned by other rendering contexts / service instances. + */ + destroy(): void { + for (const dataId of this.refCounts.keys()) { + csUtils.genericViewportDisplaySetMetadataProvider.remove(dataId); + } + this.refCounts.clear(); + this.payloads.clear(); + this.byViewport.clear(); + } +} diff --git a/extensions/cornerstone/src/services/ViewportService/backends/viewportOperations.ts b/extensions/cornerstone/src/services/ViewportService/backends/viewportOperations.ts new file mode 100644 index 000000000..f421ffe90 --- /dev/null +++ b/extensions/cornerstone/src/services/ViewportService/backends/viewportOperations.ts @@ -0,0 +1,71 @@ +import { Types as CoreTypes } from '@cornerstonejs/core'; +import { isNextViewport } from '../adapter'; +import type { + IViewportOperations, + FlipValue, + RotationMode, + VolumeLightingOptions, + WindowLevelParams, + ColormapParams, +} from './IViewportOperations'; +import { legacyViewportOperations } from './LegacyViewportOperations'; +import { nextViewportOperations } from './NextViewportOperations'; + +/** + * Picks the operations lane for a SPECIFIC viewport. Unlike the IViewportBackend + * lifecycle backend (selected once by the appConfig flag because it owns the + * per-session mount), operations route per viewport: the viewport is already created + * and self-describing, and a session can hold both legacy and native viewports. + */ +function backendFor(viewport: CoreTypes.IViewport): IViewportOperations { + return isNextViewport(viewport) ? nextViewportOperations : legacyViewportOperations; +} + +/** + * Singleton facade over the legacy/next operations backends. commandsModule (and any + * other caller) imports this and calls e.g. `viewportOperations.flipHorizontal(viewport)`, + * keeping native-vs-legacy interaction logic out of the command bodies (migration §4.3). + * Stateless and dependency-free (each op takes an already-resolved viewport), so it is a + * plain singleton rather than a registered service. + */ +export const viewportOperations: IViewportOperations = { + flipHorizontal: (viewport: CoreTypes.IViewport, newValue?: FlipValue) => + backendFor(viewport).flipHorizontal(viewport, newValue), + + flipVertical: (viewport: CoreTypes.IViewport, newValue?: FlipValue) => + backendFor(viewport).flipVertical(viewport, newValue), + + invert: (viewport: CoreTypes.IViewport) => backendFor(viewport).invert(viewport), + + rotate: (viewport: CoreTypes.IViewport, rotation: number, mode?: RotationMode) => + backendFor(viewport).rotate(viewport, rotation, mode), + + reset: (viewport: CoreTypes.IViewport) => backendFor(viewport).reset(viewport), + + scaleBy: (viewport: CoreTypes.IViewport, direction: number) => + backendFor(viewport).scaleBy(viewport, direction), + + getViewPlaneNormal: (viewport: CoreTypes.IViewport) => + backendFor(viewport).getViewPlaneNormal(viewport), + + centerOnMeasurement: (viewport: CoreTypes.IViewport, measurement: Record) => + backendFor(viewport).centerOnMeasurement(viewport, measurement), + + setWindowLevel: (viewport: CoreTypes.IViewport, params: WindowLevelParams) => + backendFor(viewport).setWindowLevel(viewport, params), + + setColormap: (viewport: CoreTypes.IViewport, params: ColormapParams) => + backendFor(viewport).setColormap(viewport, params), + + setPreset: (viewport: CoreTypes.IViewport, preset: string) => + backendFor(viewport).setPreset(viewport, preset), + + setVolumeRenderingQuality: (viewport: CoreTypes.IViewport, volumeQuality: number) => + backendFor(viewport).setVolumeRenderingQuality(viewport, volumeQuality), + + shiftVolumeOpacityPoints: (viewport: CoreTypes.IViewport, shift: number) => + backendFor(viewport).shiftVolumeOpacityPoints(viewport, shift), + + setVolumeLighting: (viewport: CoreTypes.IViewport, options: VolumeLightingOptions) => + backendFor(viewport).setVolumeLighting(viewport, options), +}; diff --git a/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx b/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx index 84958713c..f4fbd96b3 100644 --- a/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx +++ b/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx @@ -54,8 +54,17 @@ class ImageOverlayViewerTool extends AnnotationDisplayTool { return; } + // A direct Generic ("next") viewport returns a falsy view-reference id until + // it has data bound (e.g. while it is being enabled, before setDisplaySets). + // getTargetId() would throw in that case and break the whole render pass, so + // skip overlay rendering until a reference is resolvable. Legacy viewports + // always return a string here, so this leaves their behavior unchanged. + if (!viewport.getViewReferenceId?.()) { + return; + } + const targetId = this.getTargetId(viewport); - return targetId.split('imageId:')[1]; + return targetId?.split('imageId:')[1]; } renderAnnotation = (enabledElement, svgDrawingHelper) => { diff --git a/extensions/cornerstone/src/types/CornerstoneCacheService.ts b/extensions/cornerstone/src/types/CornerstoneCacheService.ts index 261d1b848..19a5967b5 100644 --- a/extensions/cornerstone/src/types/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/types/CornerstoneCacheService.ts @@ -21,11 +21,19 @@ type VolumeData = { type StackViewportData = { viewportType: Enums.ViewportType; + // The legacy stack/volume data-shape decision (STACK vs ORTHOGRAPHIC/VOLUME_3D), + // preserved even when `viewportType` is a native Generic type (PLANAR_NEXT) that + // collapses both. Consumers that must distinguish stack from volume content + // (data re-build on invalidation, orientation markers) read this instead of + // `viewportType`, which is ambiguous on the native path. See migration plan §4.7. + dataShapeType?: Enums.ViewportType; data: StackData[]; }; type VolumeViewportData = { viewportType: Enums.ViewportType; + /** See StackViewportData.dataShapeType. */ + dataShapeType?: Enums.ViewportType; data: VolumeData[]; }; diff --git a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx index 61627f126..f7a977efc 100644 --- a/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx +++ b/extensions/cornerstone/src/utils/CornerstoneViewportDownloadForm.tsx @@ -4,7 +4,7 @@ import html2canvas from 'html2canvas'; import { getEnabledElement } from '@cornerstonejs/core'; import { ToolGroupManager, segmentation, Enums } from '@cornerstonejs/tools'; import { getEnabledElement as OHIFgetEnabledElement } from '../state'; -import { isStackViewportType, isVolumeViewportType } from './getLegacyViewportType'; +import { getViewportAdapter } from '../services/ViewportService/adapter'; import { useSystem } from '@ohif/core/src'; const { downloadUrl } = utils; @@ -119,35 +119,12 @@ const CornerstoneViewportDownloadForm = ({ const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID); try { - // Capture current viewport state - // - properties: VOI, colormap, interpolation, etc. - // - viewPresentation: flip/rotate/zoom presentation state added for - // saving flip and rotation for capture - // - viewReference: image/volume reference - const properties = viewport.getProperties(); - const viewPresentation = viewport.getViewPresentation?.(); - const viewRef = viewport.getViewReference?.(); - - if (isStackViewportType(downloadViewport)) { - const imageId = viewport.getCurrentImageId(); - await downloadViewport.setStack([imageId]); - } else if (isVolumeViewportType(downloadViewport)) { - const volumeIds = viewport.getAllVolumeIds(); - await downloadViewport.setVolumes([{ volumeId: volumeIds[0] }]); - } - - // Apply presentation state so captured image preserves flip/rotate - if (viewPresentation && downloadViewport.setViewPresentation) { - downloadViewport.setViewPresentation(viewPresentation); - } - - // Apply viewport display properties - downloadViewport.setProperties(properties); - - // Ensure correct image/volume reference - if (viewRef && downloadViewport.setViewReference) { - downloadViewport.setViewReference(viewRef); - } + // Capture current viewport state. The download (capture) viewport is created + // with the SAME type as the source (see handleEnableViewport), so source and + // capture are both legacy or both native, and the source's adapter can mount + // its displayed content (data + appearance + view state) onto the capture + // viewport directly. + await getViewportAdapter(viewport).copyDisplayedContentTo(downloadViewport); downloadViewport.render(); diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts index b6d02f02b..f13e03c2e 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts @@ -11,6 +11,11 @@ jest.mock('@cornerstonejs/core', () => ({ ORTHOGRAPHIC: 'orthographic', VOLUME_3D: 'volume3d', ECG: 'ecg', + PLANAR_NEXT: 'planarNext', + VOLUME_3D_NEXT: 'volume3dNext', + VIDEO_NEXT: 'videoNext', + WHOLE_SLIDE_NEXT: 'wholeSlideNext', + ECG_NEXT: 'ecgNext', }, }, })); @@ -58,7 +63,7 @@ describe('getCornerstoneViewportType', () => { it('should throw error for invalid viewport type', () => { expect(() => getCornerstoneViewportType('invalid')).toThrow( - 'Invalid viewport type: invalid. Valid types are: stack, volume, video, wholeslide, ecg' + 'Invalid viewport type: invalid. Valid types are: stack, volume, orthographic, volume3d, video, wholeslide, ecg' ); }); @@ -94,4 +99,71 @@ describe('getCornerstoneViewportType', () => { const result = getCornerstoneViewportType('wholeslide', undefined); expect(result).toBe(Enums.ViewportType.WHOLE_SLIDE); }); + + describe('useNextViewports (native Generic Viewport types)', () => { + it('maps stack to PLANAR_NEXT', () => { + expect(getCornerstoneViewportType('stack', undefined, true)).toBe( + Enums.ViewportType.PLANAR_NEXT + ); + }); + + it('maps volume and orthographic to PLANAR_NEXT', () => { + expect(getCornerstoneViewportType('volume', undefined, true)).toBe( + Enums.ViewportType.PLANAR_NEXT + ); + expect(getCornerstoneViewportType('orthographic', undefined, true)).toBe( + Enums.ViewportType.PLANAR_NEXT + ); + }); + + it('maps volume3d / video / wholeslide / ecg to their *_NEXT types', () => { + expect(getCornerstoneViewportType('volume3d', undefined, true)).toBe( + Enums.ViewportType.VOLUME_3D_NEXT + ); + expect(getCornerstoneViewportType('video', undefined, true)).toBe( + Enums.ViewportType.VIDEO_NEXT + ); + expect(getCornerstoneViewportType('wholeslide', undefined, true)).toBe( + Enums.ViewportType.WHOLE_SLIDE_NEXT + ); + expect(getCornerstoneViewportType('ecg', undefined, true)).toBe( + Enums.ViewportType.ECG_NEXT + ); + }); + + it('honors the displaySet viewportType override under the flag', () => { + const displaySets = [{ viewportType: 'volume' }] as Types.DisplaySet[]; + expect(getCornerstoneViewportType('stack', displaySets, true)).toBe( + Enums.ViewportType.PLANAR_NEXT + ); + }); + + it('throws for an invalid viewport type under the flag', () => { + expect(() => + getCornerstoneViewportType('invalid', undefined, true) + ).toThrow('Invalid viewport type: invalid'); + }); + + it('leaves the legacy mapping unchanged when the flag is off', () => { + expect(getCornerstoneViewportType('stack', undefined, false)).toBe( + Enums.ViewportType.STACK + ); + expect(getCornerstoneViewportType('volume', undefined, false)).toBe( + Enums.ViewportType.ORTHOGRAPHIC + ); + }); + + it('is idempotent for already-native types regardless of the flag', () => { + // A viewport's stored cs type can be re-fed into the mapper. + expect(getCornerstoneViewportType('planarNext', undefined, false)).toBe( + Enums.ViewportType.PLANAR_NEXT + ); + expect(getCornerstoneViewportType('planarNext', undefined, true)).toBe( + Enums.ViewportType.PLANAR_NEXT + ); + expect(getCornerstoneViewportType('volume3dNext', undefined, true)).toBe( + Enums.ViewportType.VOLUME_3D_NEXT + ); + }); + }); }); diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts index fd89f0193..e04d71ddf 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts @@ -1,5 +1,6 @@ import type { Types } from '@ohif/core'; import { Enums } from '@cornerstonejs/core'; +import { isNextViewportsEnabled } from './nextViewports'; const STACK = 'stack'; const VOLUME = 'volume'; @@ -11,10 +12,53 @@ const ECG = 'ecg'; export default function getCornerstoneViewportType( viewportType: string, - displaySets?: Types.DisplaySet[] + displaySets?: Types.DisplaySet[], + useNextViewports = isNextViewportsEnabled() ): Enums.ViewportType { const lowerViewportType = displaySets?.[0]?.viewportType?.toLowerCase() || viewportType.toLowerCase(); + + // Already a native Generic ("next") type — e.g. re-derived from a viewport's + // stored cornerstone type (ViewportInfo.viewportType). Pass through + // idempotently, exactly as the legacy types below map to themselves; this must + // hold regardless of the flag so re-entrant callers don't throw. + switch (lowerViewportType) { + case 'planarnext': + return Enums.ViewportType.PLANAR_NEXT; + case 'volume3dnext': + return Enums.ViewportType.VOLUME_3D_NEXT; + case 'videonext': + return Enums.ViewportType.VIDEO_NEXT; + case 'wholeslidenext': + return Enums.ViewportType.WHOLE_SLIDE_NEXT; + case 'ecgnext': + return Enums.ViewportType.ECG_NEXT; + } + + // Native Generic Viewport ("next") path (appConfig.useNextViewports). Stack and + // volume/orthographic both collapse to PLANAR_NEXT — the render path (image vs + // volume slice) is inferred from the data shape, not from the viewport type. + if (useNextViewports) { + switch (lowerViewportType) { + case STACK: + case VOLUME: + case ORTHOGRAPHIC: + return Enums.ViewportType.PLANAR_NEXT; + case VOLUME_3D: + return Enums.ViewportType.VOLUME_3D_NEXT; + case VIDEO: + return Enums.ViewportType.VIDEO_NEXT; + case WHOLESLIDE: + return Enums.ViewportType.WHOLE_SLIDE_NEXT; + case ECG: + return Enums.ViewportType.ECG_NEXT; + default: + throw new Error( + `Invalid viewport type: ${viewportType}. Valid types are: stack, volume, orthographic, volume3d, video, wholeslide, ecg` + ); + } + } + if (lowerViewportType === STACK) { return Enums.ViewportType.STACK; } @@ -39,6 +83,6 @@ export default function getCornerstoneViewportType( } throw new Error( - `Invalid viewport type: ${viewportType}. Valid types are: stack, volume, video, wholeslide, ecg` + `Invalid viewport type: ${viewportType}. Valid types are: stack, volume, orthographic, volume3d, video, wholeslide, ecg` ); } diff --git a/extensions/cornerstone/src/utils/getDataIdForViewport.ts b/extensions/cornerstone/src/utils/getDataIdForViewport.ts deleted file mode 100644 index 3f9111c14..000000000 --- a/extensions/cornerstone/src/utils/getDataIdForViewport.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Resolves the data ID (e.g. volumeId) for a viewport and display set. - * For viewports with multiple volumes/actors, returns the id that matches the display set; otherwise undefined. - * Use this to call viewport.getProperties(dataId) in a viewport-type-agnostic way. - * - * @param viewport - Viewport instance (stack, volume, or future types with optional getAllVolumeIds) - * @param displaySetInstanceUID - Display set instance UID to match - * @returns volumeId (or equivalent) for multi-actor viewports, undefined for single-actor - */ -export function getDataIdForViewport( - viewport: unknown, - displaySetInstanceUID: string -): string | undefined { - const vp = viewport as { getAllVolumeIds?: () => string[] }; - if (typeof vp.getAllVolumeIds !== 'function') { - return undefined; - } - const volumeIds = vp.getAllVolumeIds() || []; - return volumeIds.length > 0 - ? volumeIds.find(id => id.includes(displaySetInstanceUID)) ?? undefined - : undefined; -} diff --git a/extensions/cornerstone/src/utils/getLegacyViewportType.ts b/extensions/cornerstone/src/utils/getLegacyViewportType.ts index c36c32049..3407b1e2b 100644 --- a/extensions/cornerstone/src/utils/getLegacyViewportType.ts +++ b/extensions/cornerstone/src/utils/getLegacyViewportType.ts @@ -23,44 +23,42 @@ type ViewportLike = { * type. For "does this viewport support operation X" questions, prefer the * cornerstone capability guards (`utilities.viewportSupports*`) instead. */ -export function getLegacyViewportType( - viewport: unknown -): csEnums.ViewportType | undefined { +export function getLegacyViewportType(viewport: unknown): csEnums.ViewportType | undefined { const vp = viewport as ViewportLike | null | undefined; return vp?.requestedType ?? vp?.type; } /** Legacy STACK viewport (image stack). Replaces `instanceof StackViewport`. */ -export function isStackViewportType( - viewport: unknown -): viewport is csTypes.IStackViewport { +export function isStackViewportType(viewport: unknown): viewport is csTypes.IStackViewport { return getLegacyViewportType(viewport) === ViewportType.STACK; } /** Legacy ORTHOGRAPHIC (MPR) viewport. Replaces `instanceof VolumeViewport`. */ -export function isOrthographicViewportType( - viewport: unknown -): viewport is csTypes.IVolumeViewport { +export function isOrthographicViewportType(viewport: unknown): viewport is csTypes.IVolumeViewport { return getLegacyViewportType(viewport) === ViewportType.ORTHOGRAPHIC; } -/** Legacy VOLUME_3D viewport. Replaces `instanceof VolumeViewport3D`. */ -export function isVolume3DViewportType( - viewport: unknown -): viewport is csTypes.IVolumeViewport { - return getLegacyViewportType(viewport) === ViewportType.VOLUME_3D; +/** + * 3D volume viewport. Replaces `instanceof VolumeViewport3D`. + * + * Matches both the legacy `VOLUME_3D` type and the native ("next") `VOLUME_3D_NEXT`. + * Under `useNextViewports`, OHIF requests `VOLUME_3D_NEXT` directly, so cornerstone + * leaves `requestedType` unset and `getLegacyViewportType` returns the native type + * (cornerstone only rewrites `requestedType` back to the legacy type for its own + * compat remap of a legacy `VOLUME_3D` request). Checking only `VOLUME_3D` would miss + * native 3D viewports and misroute them through the planar (getViewReference/getViewState) + * branch, and would leave 3D gates such as `is3DVolume` false. + */ +export function isVolume3DViewportType(viewport: unknown): viewport is csTypes.IVolumeViewport { + const legacyType = getLegacyViewportType(viewport); + return legacyType === ViewportType.VOLUME_3D || legacyType === ViewportType.VOLUME_3D_NEXT; } /** * Legacy ORTHOGRAPHIC or VOLUME_3D viewport (i.e. a `BaseVolumeViewport`). * Replaces `instanceof BaseVolumeViewport`. */ -export function isVolumeViewportType( - viewport: unknown -): viewport is csTypes.IVolumeViewport { +export function isVolumeViewportType(viewport: unknown): viewport is csTypes.IVolumeViewport { const legacyType = getLegacyViewportType(viewport); - return ( - legacyType === ViewportType.ORTHOGRAPHIC || - legacyType === ViewportType.VOLUME_3D - ); + return legacyType === ViewportType.ORTHOGRAPHIC || legacyType === ViewportType.VOLUME_3D; } diff --git a/extensions/cornerstone/src/utils/nextViewportPolicies.ts b/extensions/cornerstone/src/utils/nextViewportPolicies.ts new file mode 100644 index 000000000..7d34dc209 --- /dev/null +++ b/extensions/cornerstone/src/utils/nextViewportPolicies.ts @@ -0,0 +1,41 @@ +import { isNextViewportsEnabled } from './nextViewports'; + +/** + * The named home for every BEHAVIORAL POLICY difference between the legacy and + * native ("next") viewport paths — appearance defaults and workflow rules that + * are not API bridging (that's `services/ViewportService/adapter/`) and not + * mount lifecycle (that's `services/ViewportService/backends/`). Keeping them + * in one greppable file is the point: a policy divergence that lives inline in + * a mode or hanging protocol is invisible to the next reader. + */ + +/** + * Initial PT opacity for TMTV fusion viewports on the native path. Legacy (in + * tmtv's hpViewports) uses a per-value opacity ramp; native applies a ramp + * literally through its flat 2D blend (which would keep the background + * transparent), so the native path replaces the ramp with this single flat, + * more CT-weighted starting blend. + */ +export const NEXT_FUSION_PT_OPACITY = 0.4; + +/** + * Initial opacity for data overlays (e.g. colormapped foreground layers) on + * the native path. Native viewports composite an overlay as a 2D image slice + * with a flat alpha blend and no volume ray-cast opacity attenuation: the + * legacy nominal 0.9 renders at ~40% effective through the ray-cast path but + * reads ~80-90% on native, so native starts at the legacy-equivalent + * effective value. + */ +export const NEXT_OVERLAY_OPACITY = 0.4; + +/** + * Viewport type to pin when hydrating a segmentation's referenced display set. + * RTSTRUCT contours render correctly on a native stack/vtkImage viewport and + * scroll fast, so the referenced image stays in stack mode on hydrate rather + * than being promoted to a volume slice (which the perf acceptance criteria + * forbid). Scoped to RTSTRUCT + the next path; SEG and legacy keep the default + * (undefined = no pin). + */ +export function getHydrationViewportTypeForModality(modality: string): 'stack' | undefined { + return modality === 'RTSTRUCT' && isNextViewportsEnabled() ? 'stack' : undefined; +} diff --git a/extensions/cornerstone/src/utils/nextViewports.ts b/extensions/cornerstone/src/utils/nextViewports.ts new file mode 100644 index 000000000..c61718ef1 --- /dev/null +++ b/extensions/cornerstone/src/utils/nextViewports.ts @@ -0,0 +1,157 @@ +/** + * Module-level accessor for the `appConfig.genericViewports.enabled` opt-in flag. + * + * The flag is captured once at extension init (from the `useNextViewports` URL + * query param, else appConfig) and read by the + * two viewport-type chokepoints (`getCornerstoneViewportType` and the + * `CornerstoneViewportService` backend split) without threading appConfig + * through every service/viewport constructor. Defaults to `false` so the legacy + * path is unchanged until an app opts in. + */ +let nextViewportsEnabled = false; + +export function setNextViewportsEnabled(value: boolean): void { + nextViewportsEnabled = Boolean(value); +} + +export function isNextViewportsEnabled(): boolean { + return nextViewportsEnabled; +} + +/** + * Resolves the effective flag at init. A `useNextViewports` URL query parameter + * takes precedence over the appConfig value, so the native backend can be opted + * into per-session via the URL (e.g. `?useNextViewports=true`) without editing + * the deployed config. `?useNextViewports` (no value), `=true`, or `=1` enable + * it; any other value disables it. When the param is absent, appConfig wins. + */ +export function resolveNextViewportsEnabled(appConfigValue: unknown): boolean { + return resolveBooleanUrlOptIn('useNextViewports', appConfigValue); +} + +/** + * Aliases accepted by the `viewportRendering` param/config, mapped to + * cornerstone render backend wire ids ('gpu' is the VTK/WebGL backend). + * Values not listed here pass through untouched so backends registered via + * cornerstone's `registerRenderBackend()` (e.g. a webgpu backend) can be + * selected by their wire id. + */ +const RENDER_BACKEND_ALIASES: Record = { + webgl: 'gpu', + gpu: 'gpu', + cpu: 'cpu', + auto: 'auto', +}; + +function normalizeRenderBackend(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return RENDER_BACKEND_ALIASES[trimmed.toLowerCase()] ?? trimmed; +} + +export interface ViewportRenderingSelection { + /** Global render backend for all viewports (cornerstone `setRenderBackend`). */ + renderBackend?: string; + /** + * Per-viewport-type overrides (per-mount `renderBackend` option), keyed by + * the lowercased OHIF viewport type (e.g. 'stack', 'orthographic'). + */ + renderBackendByViewportType: Record; +} + +/** + * Resolves the render backend selection at init. + * + * `?viewportRendering=cpu|webgl|webgpu|auto` selects the render backend for + * all viewports per-session, and `?.viewportRendering=` + * (e.g. `?orthographic.viewportRendering=cpu`) overrides it for a single + * viewport type via the per-mount `renderBackend` option. URL params take + * precedence over `appConfig.genericViewports.viewportRendering`, which accepts + * either a backend string or `{ default?, stack?, orthographic? }`. + * + * 'webgl' is an alias for cornerstone's 'gpu' (VTK/WebGL) backend; 'cpu' and + * 'auto' map to the same-named backends; any other value is passed through as + * the wire id of a backend registered with `registerRenderBackend()` (e.g. a + * webgpu backend). Unlike a boolean CPU flag, this lets a session force GPU + * rendering when the deployed config defaults to CPU, and vice versa. + */ +export function resolveViewportRendering(appConfigValue: unknown): ViewportRenderingSelection { + const selection: ViewportRenderingSelection = { renderBackendByViewportType: {} }; + + if (typeof appConfigValue === 'string') { + selection.renderBackend = normalizeRenderBackend(appConfigValue); + } else if (appConfigValue && typeof appConfigValue === 'object') { + for (const [key, value] of Object.entries(appConfigValue)) { + const backend = normalizeRenderBackend(value); + if (!backend) { + continue; + } + if (key === 'default') { + selection.renderBackend = backend; + } else { + selection.renderBackendByViewportType[key.toLowerCase()] = backend; + } + } + } + + try { + const params = new URLSearchParams(window.location.search); + for (const [key, value] of params.entries()) { + const backend = normalizeRenderBackend(value); + if (!backend) { + continue; + } + if (key === 'viewportRendering') { + selection.renderBackend = backend; + } else if (key.endsWith('.viewportRendering')) { + const viewportType = key.slice(0, -'.viewportRendering'.length).toLowerCase(); + if (viewportType) { + selection.renderBackendByViewportType[viewportType] = backend; + } + } + } + } catch { + // window/URL unavailable (SSR/non-browser) — keep the config-derived selection. + } + + return selection; +} + +/** + * Per-viewport-type render backend overrides, captured once at extension init + * (like the `useNextViewports` flag above) and read by the native mount paths + * in NextViewportBackend, which pass the override as the per-mount + * `renderBackend` option on `setDisplaySets`. + */ +let renderBackendByViewportType: Record = {}; + +export function setViewportRenderingOverrides(overrides: Record): void { + renderBackendByViewportType = overrides ?? {}; +} + +export function getViewportRenderingOverride(viewportType: string): string | undefined { + return renderBackendByViewportType[viewportType?.toLowerCase()]; +} + +/** + * Reads a boolean opt-in URL query param, falling back to a config value when + * the param is absent. `?param` (no value), `=true`, or `=1` enable it; any + * other value disables it. + */ +function resolveBooleanUrlOptIn(paramName: string, fallbackValue: unknown): boolean { + try { + const params = new URLSearchParams(window.location.search); + if (params.has(paramName)) { + const value = params.get(paramName); + return value === '' || value === 'true' || value === '1'; + } + } catch { + // window/URL unavailable (SSR/non-browser) — fall back to the config value. + } + return Boolean(fallbackValue); +} diff --git a/extensions/cornerstone/src/utils/viewportDataShape.ts b/extensions/cornerstone/src/utils/viewportDataShape.ts new file mode 100644 index 000000000..d24326436 --- /dev/null +++ b/extensions/cornerstone/src/utils/viewportDataShape.ts @@ -0,0 +1,85 @@ +import { Enums } from '@cornerstonejs/core'; + +/** + * Pre-mount classification of a viewport's bound data (viewportData from + * CornerstoneCacheService), for code that runs before — or independently of — + * a live viewport instance (overlays, scrollbars). Native ("next") viewports + * collapse stack/volume onto a single PLANAR_NEXT viewportType, so these + * helpers classify by the persisted dataShapeType and the data shape itself + * (imageIds = stack, volume/volumeId = volume) instead of the runtime type. + * + * For classification of a LIVE viewport instance, use + * `getViewportAdapter(viewport).getShape()` instead. + */ + +type ViewportDatum = { + imageIds?: string[]; + volume?: unknown; + volumeId?: string; + [key: string]: unknown; +}; + +type ViewportDataLike = { + viewportType?: Enums.ViewportType; + dataShapeType?: Enums.ViewportType; + data?: ViewportDatum | ViewportDatum[]; +}; + +/** The primary (non-overlay) datum of a viewportData. */ +export function getPrimaryViewportDatum(viewportData: ViewportDataLike): ViewportDatum | undefined { + return Array.isArray(viewportData?.data) ? viewportData.data[0] : viewportData?.data; +} + +/** True when the primary datum is volume-shaped (volume/volumeId present). */ +export function isVolumeViewportData(viewportData: ViewportDataLike): boolean { + const firstData = getPrimaryViewportDatum(viewportData); + return !!(firstData && (firstData.volume || firstData.volumeId)); +} + +/** + * The stack/volume shape a viewportData was built for, transparent across the + * native type collapse: the persisted dataShapeType when present (set by + * CornerstoneCacheService on the native path), else the legacy viewportType. + */ +export function getViewportDataShapeType( + viewportData: ViewportDataLike +): Enums.ViewportType | undefined { + return viewportData?.dataShapeType ?? viewportData?.viewportType; +} + +/** + * The slice-navigation event for this viewport's content. Resolved from the + * legacy viewportType when it is meaningful, else from the bound data shape — + * which is known immediately, unlike a runtime content-mode check that may not + * be ready while a native viewport is still binding. Native viewports emit the + * same STACK_NEW_IMAGE / VOLUME_NEW_IMAGE events as legacy. + */ +export function getSliceEventName(viewportData: ViewportDataLike): string { + const { viewportType } = viewportData; + const firstData = getPrimaryViewportDatum(viewportData); + return ( + (viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) || + (viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) || + (isVolumeViewportData(viewportData) && Enums.Events.VOLUME_NEW_IMAGE) || + (firstData?.imageIds && Enums.Events.STACK_NEW_IMAGE) || + Enums.Events.IMAGE_RENDERED + ); +} + +/** + * The slice count for a viewport. `viewport.getNumberOfSlices()` can be + * premature while a native viewport is still binding its data (it returns 1 + * until then). For an image stack the count is known from the bound data, so + * prefer that and only fall back to the viewport for volume/MPR (where the + * count depends on orientation). + */ +export function getViewportSliceCount( + viewportData: ViewportDataLike, + viewport: { getNumberOfSlices: () => number } +): number { + const firstData = getPrimaryViewportDatum(viewportData); + return ( + (!isVolumeViewportData(viewportData) && firstData?.imageIds?.length) || + viewport.getNumberOfSlices() + ); +} diff --git a/extensions/default/src/utils/layerConfigurationUtils.ts b/extensions/default/src/utils/layerConfigurationUtils.ts index 4d8dda488..04b5b1c78 100644 --- a/extensions/default/src/utils/layerConfigurationUtils.ts +++ b/extensions/default/src/utils/layerConfigurationUtils.ts @@ -1,3 +1,5 @@ +import { isNextViewport, NEXT_OVERLAY_OPACITY } from '@ohif/extension-cornerstone'; + export const DERIVED_OVERLAY_MODALITIES = ['SEG', 'RTSTRUCT']; export const DEFAULT_COLORMAP = 'hsv'; export const DEFAULT_OPACITY = 0.9; @@ -93,6 +95,11 @@ export function configureViewportForLayerAddition(params: { } } + // Native ("next") viewports need the legacy-equivalent initial overlay opacity + // (see the NEXT_OVERLAY_OPACITY policy in @ohif/extension-cornerstone). + const liveCsViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); + const isLiveViewportNext = !!liveCsViewport && isNextViewport(liveCsViewport); + // create same amount of display set options as the number of display set UIDs const displaySetOptions = allDisplaySetInstanceUIDs.map((uid, index) => { // There is already a display set option for this display set, so return it. @@ -106,7 +113,17 @@ export function configureViewportForLayerAddition(params: { } const displaySet = displaySetService.getDisplaySetByUID(uid); - return createColormapOverlayDisplaySetOptions(displaySet, 90, customizationService); + const overlayOptions = createColormapOverlayDisplaySetOptions( + displaySet, + 90, + customizationService + ); + + if (isLiveViewportNext && typeof overlayOptions.colormap?.opacity === 'number') { + overlayOptions.colormap.opacity = NEXT_OVERLAY_OPACITY; + } + + return overlayOptions; }); viewport.displaySetOptions = displaySetOptions; diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index d5a44d4b0..145f54453 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -27,8 +27,8 @@ "start": "pnpm run dev" }, "peerDependencies": { - "@cornerstonejs/core": "5.4.13", - "@cornerstonejs/tools": "5.4.13", + "@cornerstonejs/core": "5.4.17", + "@cornerstonejs/tools": "5.4.17", "@ohif/core": "workspace:*", "@ohif/extension-cornerstone-dicom-sr": "workspace:*", "@ohif/extension-default": "workspace:*", diff --git a/extensions/tmtv/src/commandsModule.ts b/extensions/tmtv/src/commandsModule.ts index bce7a706f..9d457c2f1 100644 --- a/extensions/tmtv/src/commandsModule.ts +++ b/extensions/tmtv/src/commandsModule.ts @@ -10,6 +10,7 @@ import dicomRTAnnotationExport from './utils/dicomRTAnnotationExport/RTStructure import { Enums } from '@cornerstonejs/tools'; import { utils } from '@ohif/core'; +import { getViewportFocalPoint } from '@ohif/extension-cornerstone'; const { SegmentationRepresentations } = Enums; const { formatPN } = utils; @@ -261,7 +262,15 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }: setStartSliceForROIThresholdTool: () => { const { viewport } = _getActiveViewportsEnabledElement(); - const { focalPoint } = viewport.getCamera(); + // Native ("next") viewports have no getCamera; the slice-center focal point + // comes from the view reference. Bridged so both lanes work. + const focalPoint = getViewportFocalPoint(viewport); + + // Native viewports may not resolve a focal point; writing undefined into the + // annotation would invalidate its ROI-threshold coordinates. + if (!focalPoint) { + return; + } const selectedAnnotationUIDs = _getAnnotationsSelectedByToolNames( ROI_THRESHOLD_MANUAL_TOOL_IDS @@ -289,8 +298,11 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }: const annotation = csTools.annotation.state.getAnnotation(annotationUID); - // get the current focal point - const focalPointToEnd = viewport.getCamera().focalPoint; + // get the current focal point (bridged: native viewports use the view reference) + const focalPointToEnd = getViewportFocalPoint(viewport); + if (!focalPointToEnd) { + return; + } annotation.data.endCoordinate = focalPointToEnd; // IMPORTANT: invalidate the toolData for the cached stat to get updated diff --git a/extensions/tmtv/src/getHangingProtocolModule.ts b/extensions/tmtv/src/getHangingProtocolModule.ts index d4f8d9bf6..81cf18322 100644 --- a/extensions/tmtv/src/getHangingProtocolModule.ts +++ b/extensions/tmtv/src/getHangingProtocolModule.ts @@ -1,3 +1,4 @@ +import { isNextViewportsEnabled, NEXT_FUSION_PT_OPACITY } from '@ohif/extension-cornerstone'; import { ctAXIAL, ctCORONAL, @@ -339,6 +340,20 @@ const ptCT: AppTypes.HangingProtocol.Protocol = { }; function getHangingProtocolModule() { + // Replace the fusion PT opacity ramp with a flat scalar for the native ("next") + // path only, leaving the legacy ramp in hpViewports untouched. Done here (not at + // module load) because the useNextViewports flag is set during cornerstone + // preRegistration, which runs before this module is gathered. + if (isNextViewportsEnabled()) { + [fusionAXIAL, fusionSAGITTAL, fusionCORONAL].forEach(viewport => { + const ptDisplaySet = viewport.displaySets?.find(ds => ds.id === 'ptDisplaySet'); + const colormap = ptDisplaySet?.options?.colormap as { opacity?: unknown } | undefined; + if (colormap) { + colormap.opacity = NEXT_FUSION_PT_OPACITY; + } + }); + } + return [ { name: ptCT.id, diff --git a/extensions/tmtv/src/utils/hpViewports.ts b/extensions/tmtv/src/utils/hpViewports.ts index c206c5c52..ba82d1dfb 100644 --- a/extensions/tmtv/src/utils/hpViewports.ts +++ b/extensions/tmtv/src/utils/hpViewports.ts @@ -286,6 +286,13 @@ const fusionAXIAL: AppTypes.HangingProtocol.Viewport = { options: { colormap: { name: 'hsv', + // Legacy fusion PT opacity ramp (unchanged): low PT values stay mostly + // transparent so the CT shows through. Do NOT flatten this to a scalar — + // the legacy viewport renders this ramp and depends on it. The native + // ("next") viewport would apply the ramp literally (keeping the + // background transparent and preventing the 100% opacity slider from + // fully covering the CT), so the next path replaces this ramp with a + // flat scalar in getHangingProtocolModule instead of changing it here. opacity: [ { value: 0, opacity: 0 }, { value: 0.1, opacity: 0.8 }, @@ -349,6 +356,13 @@ const fusionSAGITTAL = { options: { colormap: { name: 'hsv', + // Legacy fusion PT opacity ramp (unchanged): low PT values stay mostly + // transparent so the CT shows through. Do NOT flatten this to a scalar — + // the legacy viewport renders this ramp and depends on it. The native + // ("next") viewport would apply the ramp literally (keeping the + // background transparent and preventing the 100% opacity slider from + // fully covering the CT), so the next path replaces this ramp with a + // flat scalar in getHangingProtocolModule instead of changing it here. opacity: [ { value: 0, opacity: 0 }, { value: 0.1, opacity: 0.8 }, @@ -412,6 +426,13 @@ const fusionCORONAL = { options: { colormap: { name: 'hsv', + // Legacy fusion PT opacity ramp (unchanged): low PT values stay mostly + // transparent so the CT shows through. Do NOT flatten this to a scalar — + // the legacy viewport renders this ramp and depends on it. The native + // ("next") viewport would apply the ramp literally (keeping the + // background transparent and preventing the 100% opacity slider from + // fully covering the CT), so the next path replaces this ramp with a + // flat scalar in getHangingProtocolModule instead of changing it here. opacity: [ { value: 0, opacity: 0 }, { value: 0.1, opacity: 0.8 }, diff --git a/extensions/usAnnotation/package.json b/extensions/usAnnotation/package.json index b10a01e88..20b0ff208 100644 --- a/extensions/usAnnotation/package.json +++ b/extensions/usAnnotation/package.json @@ -36,8 +36,8 @@ }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/core": "5.4.13", - "@cornerstonejs/tools": "5.4.13", + "@cornerstonejs/core": "5.4.17", + "@cornerstonejs/tools": "5.4.17", "@ohif/core": "workspace:*", "@ohif/extension-cornerstone": "workspace:*", "@ohif/extension-default": "workspace:*", diff --git a/modes/usAnnotation/package.json b/modes/usAnnotation/package.json index b3ebc1277..1fb909995 100644 --- a/modes/usAnnotation/package.json +++ b/modes/usAnnotation/package.json @@ -29,8 +29,8 @@ }, "dependencies": { "@babel/runtime": "7.29.7", - "@cornerstonejs/core": "5.4.13", - "@cornerstonejs/tools": "5.4.13", + "@cornerstonejs/core": "5.4.17", + "@cornerstonejs/tools": "5.4.17", "@ohif/core": "workspace:*", "@ohif/extension-cornerstone-dicom-sr": "workspace:*", "@ohif/extension-ultrasound-pleura-bline": "workspace:*", diff --git a/platform/app/package.json b/platform/app/package.json index 8ebf715c5..0220555db 100644 --- a/platform/app/package.json +++ b/platform/app/package.json @@ -51,7 +51,7 @@ "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2", "@cornerstonejs/codec-openjpeg": "1.3.0", "@cornerstonejs/codec-openjph": "2.4.7", - "@cornerstonejs/dicom-image-loader": "5.4.13", + "@cornerstonejs/dicom-image-loader": "5.4.17", "@emotion/serialize": "1.3.3", "@ohif/core": "workspace:*", "@ohif/i18n": "workspace:*", diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index d8e1a64b2..35307320e 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -37,6 +37,22 @@ window.config = { // '/remote/': 'https://cdn.example.com/ohif-custom/', // ?customization=/remote/siteA // }, // ---------------------------------------------------------------------------- + + // --- Native ("next") Generic Viewport -------------------------------------- + // OFF by default. Set `enabled: true` (or pass ?useNextViewports=true in the + // URL) to drive viewports through cornerstone's native GenericViewport + // ("next") API instead of the legacy Stack/Volume viewport classes. + genericViewports: { + enabled: false, + // Render backend selection: 'cpu' | 'webgl' | 'auto' | a backend id + // registered via cornerstone's registerRenderBackend (e.g. a webgpu + // backend), or a map with per-viewport-type overrides, e.g. + // { default: 'webgl', orthographic: 'cpu' }. The matching URL params take + // precedence per-session: ?viewportRendering=cpu and + // ?orthographic.viewportRendering=cpu. + // viewportRendering: 'auto', + }, + // ---------------------------------------------------------------------------- showStudyList: true, // some windows systems have issues with more than 3 web workers maxNumberOfWebWorkers: 3, diff --git a/platform/app/src/utils/preserveQueryParameters.ts b/platform/app/src/utils/preserveQueryParameters.ts index 47e006a94..1261a4996 100644 --- a/platform/app/src/utils/preserveQueryParameters.ts +++ b/platform/app/src/utils/preserveQueryParameters.ts @@ -13,6 +13,8 @@ export const preserveKeys = [ 'hangingProtocolId', 'customization', 'theme', + 'debug', + 'useNextViewports', ]; function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) { diff --git a/platform/core/package.json b/platform/core/package.json index e5d7e5497..84b21fb25 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -35,9 +35,9 @@ "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.2", "@cornerstonejs/codec-openjpeg": "1.3.0", "@cornerstonejs/codec-openjph": "2.4.7", - "@cornerstonejs/core": "5.4.13", - "@cornerstonejs/dicom-image-loader": "5.4.13", - "@cornerstonejs/metadata": "5.4.13", + "@cornerstonejs/core": "5.4.17", + "@cornerstonejs/dicom-image-loader": "5.4.17", + "@cornerstonejs/metadata": "5.4.17", "@ohif/ui": "workspace:*", "cornerstone-math": "0.1.10", "dicom-parser": "1.8.21" diff --git a/platform/core/src/types/AppTypes.ts b/platform/core/src/types/AppTypes.ts index 7a5817d04..7aad659b1 100644 --- a/platform/core/src/types/AppTypes.ts +++ b/platform/core/src/types/AppTypes.ts @@ -126,6 +126,45 @@ declare global { * Requires @cornerstonejs/core >= 5.0.0-beta (GenericViewport architecture). */ useGenericViewport?: boolean; + /** + * Opt-in: drive viewports through the DIRECT native GenericViewport ("next") + * API surface — `PLANAR_NEXT` / `VOLUME_3D_NEXT` / etc. created natively and + * driven with `setDisplaySets`, `setDisplaySetPresentation`, `setViewState` + * and view references — rather than the legacy `setStack` / `setVolumes` / + * `setProperties` / `getCamera` / `setCamera` methods. + * + * This is distinct from (and overrides) `useGenericViewport`: that flag only + * routes legacy viewport types through cornerstone's compatibility adapters + * (`rendering.useGenericViewport`), keeping the legacy method surface. This + * flag instead selects native viewport types in `getCornerstoneViewportType` + * and the native data/presentation/view-state APIs inside + * `CornerstoneViewportService` (the `nextBackend`). It does NOT set + * cornerstone's `rendering.useGenericViewport`. + * + * Defaults to false (the legacy path, byte-identical to today). Opt-in only; + * the community is not force-migrated. + * Requires @cornerstonejs/core >= 5.0.x with the GenericViewport "next" APIs. + */ + genericViewports?: { + /** + * Opt-in for the native path described above. Equivalent to (and + * overridable by) the `?useNextViewports=true` URL param. + */ + enabled?: boolean; + /** + * Render backend selection for viewports. Either a backend for all + * viewports ('cpu' | 'webgl' | 'auto' | a wire id registered via + * cornerstone's `registerRenderBackend`, e.g. a webgpu backend) or a map + * of per-viewport-type overrides plus an optional 'default', e.g. + * `{ default: 'webgl', orthographic: 'cpu' }`. The matching URL params + * (`?viewportRendering=cpu`, `?orthographic.viewportRendering=cpu`) take + * precedence per-session. 'webgl' aliases cornerstone's 'gpu' backend. + * The default/global value also drives the legacy useCPURendering flag + * ('cpu' → true, 'webgl' → false) so legacy viewports follow the same + * selection. + */ + viewportRendering?: string | Record; + }; useCursors?: boolean; maxCacheSize?: number; max3DTextureSize?: number; diff --git a/platform/ui-next/src/components/SegmentationTable/SegmentationTableConfig.tsx b/platform/ui-next/src/components/SegmentationTable/SegmentationTableConfig.tsx index b98fb6b50..43dcf9ae2 100644 --- a/platform/ui-next/src/components/SegmentationTable/SegmentationTableConfig.tsx +++ b/platform/ui-next/src/components/SegmentationTable/SegmentationTableConfig.tsx @@ -133,10 +133,13 @@ export const SegmentationTableConfig: React.FC<{ children?: React.ReactNode }> = mode="singleRange" min={0} max={10} - step={0.1} + step={1} value={outlineWidth} onChange={value => - setOutlineWidth({ type: segmentationRepresentationTypes?.[0] }, value as number) + setOutlineWidth( + { type: segmentationRepresentationTypes?.[0] }, + Math.round(value as number) + ) } > =0.1.90'} - '@cornerstonejs/adapters@5.4.13': - resolution: {integrity: sha512-XZcIt0n7APz135OO4m4aSuf9bzLyxQL6shZM3Y9VzhsQ/NB40iU1OlDe2obva3dBZklUneOjkynt4UL5UfUsYA==} + '@cornerstonejs/adapters@5.4.17': + resolution: {integrity: sha512-DDQBEKEQSXh9e/e7aWAD/HlE/B0vbQGNBWpjtx9qYSfC1GFxwK2DnXiBDMrcMoHd6tapkrIr+a9MrOVb1i1dHQ==} peerDependencies: - '@cornerstonejs/core': 5.4.13 - '@cornerstonejs/tools': 5.4.13 + '@cornerstonejs/core': 5.4.17 + '@cornerstonejs/tools': 5.4.17 - '@cornerstonejs/ai@5.4.13': - resolution: {integrity: sha512-rt1ZYTM5RctBqP3oPd2QrQjaVtiXklS10jcTb/XnWQU1P+8pW6t6NZ95w35moXjignSPBIA8/gNKgrWQJ8uZfg==} + '@cornerstonejs/ai@5.4.17': + resolution: {integrity: sha512-yGzFNfEn75NzNqxqVIu77Ndm0h0Ju6ndYoFpoiDRuFhLw1FuOG8RaOQuOdudOnKJ0yvtciAGEbX7d6blFwh+vg==} peerDependencies: - '@cornerstonejs/core': 5.4.13 - '@cornerstonejs/tools': 5.4.13 + '@cornerstonejs/core': 5.4.17 + '@cornerstonejs/tools': 5.4.17 '@cornerstonejs/calculate-suv@1.0.3': resolution: {integrity: sha512-2SwVJKzC1DzyxdxJtCht9dhTND2GFjLwhhkDyyC7vJq5tIgbhxgPk1CSwovO1pxmoybAXzjOxnaubllxLgoT+w==} @@ -3434,40 +3434,40 @@ packages: resolution: {integrity: sha512-qvP4q4JDib7mi9r7LqKOwqz7YZ8gjtDX4ZCezeYf8+eb7MBXCz5uXAMeVF3yz9Axw4XiIMdB/pqXkm8tqCl13w==} engines: {node: '>=0.14'} - '@cornerstonejs/core@5.4.13': - resolution: {integrity: sha512-VeBsrUla3xsb01veMDaHqR3Yel3M00Fzk7mvNv6aQj/lgnsySoj+HozvL/mlYmmHEGoFMNnLzt+/tTOfvIHoBw==} + '@cornerstonejs/core@5.4.17': + resolution: {integrity: sha512-9o346NeccyRMz6eOAESuuY/vV4cm2Iuf3aQVM5aB8IJRZOaWtVf4wWqBtwzb9ytTlTxJQe0NlX6sEr7R8b1Ngw==} peerDependencies: - '@cornerstonejs/metadata': 5.4.13 - '@cornerstonejs/utils': 5.4.13 + '@cornerstonejs/metadata': 5.4.17 + '@cornerstonejs/utils': 5.4.17 - '@cornerstonejs/dicom-image-loader@5.4.13': - resolution: {integrity: sha512-0zJfn77fNhvsTYheV63ZjkKtoz/id9+Sw1Tiz/dzriJsWswC9lPx0uJ+1XPbbmBFTcFVFNmm4RIEak+GY2Kcsw==} + '@cornerstonejs/dicom-image-loader@5.4.17': + resolution: {integrity: sha512-SoNy4Dj09xxs/9qImeMLfwbb1lIB3WQc6DlR3Cyi6F8gQzCjgmtg1SKgUgeJ6E2yVXWQgMWthG79WVzHBr6pJg==} peerDependencies: - '@cornerstonejs/core': 5.4.13 - '@cornerstonejs/metadata': 5.4.13 + '@cornerstonejs/core': 5.4.17 + '@cornerstonejs/metadata': 5.4.17 dicom-parser: 1.8.21 - '@cornerstonejs/labelmap-interpolation@5.4.13': - resolution: {integrity: sha512-wRa6qaz5CYeI0Am0QXB0/2OdpEBy9pUBuPr2gW+fUkDng4Ape2arGVTcUoiaFUPLOGMxc22Vv5FY9YlMiLsmig==} + '@cornerstonejs/labelmap-interpolation@5.4.17': + resolution: {integrity: sha512-U9i3dRlydN0e+ve1/fweSLrKnxoqpFt0CEL91Je4Jb2YYw2Zcb97PFm/yq4byxKVoT4c7mZHnGepQFWAmbPHFA==} peerDependencies: - '@cornerstonejs/core': 5.4.13 - '@cornerstonejs/tools': 5.4.13 + '@cornerstonejs/core': 5.4.17 + '@cornerstonejs/tools': 5.4.17 '@kitware/vtk.js': 35.5.3 - '@cornerstonejs/metadata@5.4.13': - resolution: {integrity: sha512-4D3lQEfw6xys1nVZd784p2enQ55rslU58MsYRC5WiJak0dpKYjV6h05xa+nB5LUl/tKA7AZNSEPzMrpZhqgopQ==} + '@cornerstonejs/metadata@5.4.17': + resolution: {integrity: sha512-sDJRghnkGJhpLPa/Qkh7bCFHx0ZNOlxj6AGkLe4iNohlFk7w485Ty5JYRuOBvwS7lwz2EFDJt+Dv70xHWlGQAA==} - '@cornerstonejs/polymorphic-segmentation@5.4.13': - resolution: {integrity: sha512-H6vL+xX7EvcHkv27lyBdOkgEYjmWbMz64tewz5kHVuRpb8i71RBh0qzbjtTGp6g/+X6FBlQdAZ9f4jHMQsM15g==} + '@cornerstonejs/polymorphic-segmentation@5.4.17': + resolution: {integrity: sha512-icbgS1ULmqr8y0Qg1KVRP3E661ZXzFOVCD5Ufvfaxq7wex34EyxnWoR/lmq4/eLpJZT1brKVSpudh4BefAcgWA==} peerDependencies: - '@cornerstonejs/core': 5.4.13 - '@cornerstonejs/tools': 5.4.13 + '@cornerstonejs/core': 5.4.17 + '@cornerstonejs/tools': 5.4.17 '@kitware/vtk.js': 35.5.3 - '@cornerstonejs/tools@5.4.13': - resolution: {integrity: sha512-YLzJDwDwpaDYAFA2gFs5KXj1svLvHmk2n3Wc8XUl0tUQCNEjX4i+yDRN33muW/WETyfX5e33vGWfhly37FfWOQ==} + '@cornerstonejs/tools@5.4.17': + resolution: {integrity: sha512-ebsdaxDcJ0ezLLDqEtl3hIFtCWEDIU6OonqOKb62GuEBAWm+ZbLjQgsixHhRa9+rLWxHAoBhgRwmglcxlc/lOw==} peerDependencies: - '@cornerstonejs/core': 5.4.13 + '@cornerstonejs/core': 5.4.17 '@kitware/vtk.js': 35.5.3 '@types/d3-array': 3.2.1 '@types/d3-interpolate': 3.0.4 @@ -3475,8 +3475,8 @@ packages: d3-interpolate: 3.0.1 gl-matrix: 3.4.3 - '@cornerstonejs/utils@5.4.13': - resolution: {integrity: sha512-V2GQCeqgJc51132rfQEfUCZCVEm4e8nAaXDSQUjiyverXFZ0T0Y4qik2+VZ2F5RKJ1A3sHKiv53iobB02M0A+w==} + '@cornerstonejs/utils@5.4.17': + resolution: {integrity: sha512-ER8izPlNNMes6orLQ4Z7vm3fHmDOroukz+pF0A/3dp98Ro8KV0Wj39etkz4rInPhL6AL/gl/moi9/HqlKxXyFQ==} '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} @@ -16298,21 +16298,21 @@ snapshots: '@colors/colors@1.5.0': optional: true - '@cornerstonejs/adapters@5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@cornerstonejs/tools@5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3))': + '@cornerstonejs/adapters@5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@cornerstonejs/tools@5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3))': dependencies: '@babel/runtime-corejs2': 7.26.10 - '@cornerstonejs/core': 5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) - '@cornerstonejs/tools': 5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3) + '@cornerstonejs/core': 5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) + '@cornerstonejs/tools': 5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3) buffer: 6.0.3 dcmjs: 0.52.0 gl-matrix: 3.4.3 ndarray: 1.0.19 - '@cornerstonejs/ai@5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@cornerstonejs/tools@5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3))': + '@cornerstonejs/ai@5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@cornerstonejs/tools@5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3))': dependencies: '@babel/runtime-corejs2': 7.26.10 - '@cornerstonejs/core': 5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) - '@cornerstonejs/tools': 5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3) + '@cornerstonejs/core': 5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) + '@cornerstonejs/tools': 5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3) buffer: 6.0.3 dcmjs: 0.52.0 gl-matrix: 3.4.3 @@ -16333,10 +16333,10 @@ snapshots: '@cornerstonejs/codec-openjph@2.4.7': {} - '@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0)': + '@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0)': dependencies: - '@cornerstonejs/metadata': 5.4.13 - '@cornerstonejs/utils': 5.4.13 + '@cornerstonejs/metadata': 5.4.17 + '@cornerstonejs/utils': 5.4.17 '@kitware/vtk.js': 35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) comlink: 4.4.2 gl-matrix: 3.4.3 @@ -16346,24 +16346,24 @@ snapshots: - autoprefixer - wslink - '@cornerstonejs/dicom-image-loader@5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@cornerstonejs/metadata@5.4.13)(dicom-parser@1.8.21)': + '@cornerstonejs/dicom-image-loader@5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@cornerstonejs/metadata@5.4.17)(dicom-parser@1.8.21)': dependencies: '@cornerstonejs/codec-charls': 1.2.3 '@cornerstonejs/codec-libjpeg-turbo-8bit': 1.2.2 '@cornerstonejs/codec-openjpeg': 1.3.0 '@cornerstonejs/codec-openjph': 2.4.7 - '@cornerstonejs/core': 5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) - '@cornerstonejs/metadata': 5.4.13 + '@cornerstonejs/core': 5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) + '@cornerstonejs/metadata': 5.4.17 comlink: 4.4.2 dicom-parser: 1.8.21 jpeg-lossless-decoder-js: 2.1.2 pako: 2.1.0 uuid: 9.0.1 - '@cornerstonejs/labelmap-interpolation@5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@cornerstonejs/tools@5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))': + '@cornerstonejs/labelmap-interpolation@5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@cornerstonejs/tools@5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))': dependencies: - '@cornerstonejs/core': 5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) - '@cornerstonejs/tools': 5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3) + '@cornerstonejs/core': 5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) + '@cornerstonejs/tools': 5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3) '@itk-wasm/morphological-contour-interpolation': 1.1.0 '@kitware/vtk.js': 35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) itk-wasm: 1.0.0-b.165 @@ -16371,23 +16371,23 @@ snapshots: - debug - supports-color - '@cornerstonejs/metadata@5.4.13': + '@cornerstonejs/metadata@5.4.17': dependencies: '@cornerstonejs/calculate-suv': 1.0.3 - '@cornerstonejs/utils': 5.4.13 + '@cornerstonejs/utils': 5.4.17 dcmjs: 0.52.0 gl-matrix: 3.4.3 - '@cornerstonejs/polymorphic-segmentation@5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@cornerstonejs/tools@5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))': + '@cornerstonejs/polymorphic-segmentation@5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@cornerstonejs/tools@5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))': dependencies: - '@cornerstonejs/core': 5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) - '@cornerstonejs/tools': 5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3) + '@cornerstonejs/core': 5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) + '@cornerstonejs/tools': 5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3) '@icr/polyseg-wasm': 0.4.0 '@kitware/vtk.js': 35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) - '@cornerstonejs/tools@5.4.13(@cornerstonejs/core@5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3)': + '@cornerstonejs/tools@5.4.17(@cornerstonejs/core@5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@kitware/vtk.js@35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0))(@types/d3-array@3.2.1)(@types/d3-interpolate@3.0.4)(d3-array@3.2.4)(d3-interpolate@3.0.1)(gl-matrix@3.4.3)': dependencies: - '@cornerstonejs/core': 5.4.13(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.13)(@cornerstonejs/utils@5.4.13)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) + '@cornerstonejs/core': 5.4.17(@babel/preset-env@7.29.7(@babel/core@7.29.7))(@cornerstonejs/metadata@5.4.17)(@cornerstonejs/utils@5.4.17)(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) '@kitware/vtk.js': 35.5.3(@babel/preset-env@7.29.7(@babel/core@7.29.7))(autoprefixer@10.4.27(postcss@8.5.14))(wslink@2.5.0) '@types/d3-array': 3.2.1 '@types/d3-interpolate': 3.0.4 @@ -16399,7 +16399,7 @@ snapshots: gl-matrix: 3.4.3 lodash.get: 4.4.2 - '@cornerstonejs/utils@5.4.13': + '@cornerstonejs/utils@5.4.17': dependencies: dcmjs: 0.52.0 diff --git a/scripts/check-next-viewport-boundaries.sh b/scripts/check-next-viewport-boundaries.sh new file mode 100755 index 000000000..fd20f39c2 --- /dev/null +++ b/scripts/check-next-viewport-boundaries.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Boundary check for the next-viewport migration seams (see +# extensions/cornerstone/src/services/ViewportService/backends/README.md). +# +# Enforces: +# 1. csUtils.isGenericViewport is called ONLY from the adapter dispatch module. +# 2. isNextViewportsEnabled() is called ONLY from the sanctioned flag-read list. +# 3. CornerstoneViewportService and the UI layer (hooks/, Viewport/, components/) +# contain no per-lane branching at all. +# +# Run from the repo root: ./scripts/check-next-viewport-boundaries.sh +set -u + +cd "$(dirname "$0")/.." + +fail=0 + +report() { + echo "BOUNDARY VIOLATION: $1" + echo "$2" | sed 's/^/ /' + echo "Add the divergence to the adapter, a backend, or nextViewportPolicies instead;" + echo "if a new sanctioned site is genuinely required, update backends/README.md and this script." + fail=1 +} + +# 1. isGenericViewport calls outside the adapter dispatch module (tests excluded). +generic_hits=$(grep -rn "isGenericViewport(" \ + --include='*.ts' --include='*.tsx' \ + extensions platform modes 2>/dev/null | + grep -v '\.test\.' | + grep -v 'services/ViewportService/adapter/getViewportAdapter\.ts' || true) +if [ -n "$generic_hits" ]; then + report "isGenericViewport called outside adapter/getViewportAdapter.ts" "$generic_hits" +fi + +# 2. isNextViewportsEnabled() calls outside the sanctioned list. +flag_hits=$(grep -rn "isNextViewportsEnabled()" \ + --include='*.ts' --include='*.tsx' \ + extensions platform modes 2>/dev/null | + grep -v '\.test\.' | + grep -v 'extensions/cornerstone/src/utils/nextViewports\.ts' | + grep -v 'extensions/cornerstone/src/utils/nextViewportPolicies\.ts' | + grep -v 'extensions/cornerstone/src/utils/getCornerstoneViewportType\.ts' | + grep -v 'extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService\.ts' | + grep -v 'extensions/cornerstone/src/services/SegmentationService/SegmentationService\.ts' | + grep -v 'extensions/tmtv/src/getHangingProtocolModule\.ts' || true) +if [ -n "$flag_hits" ]; then + report "isNextViewportsEnabled() called outside the sanctioned flag-read list" "$flag_hits" +fi + +# 3. Per-lane predicates in the UI layer and the viewport service (must be zero). +ui_hits=$(grep -rn "isNextViewport(\|isGenericViewport(" \ + --include='*.ts' --include='*.tsx' \ + extensions/cornerstone/src/hooks \ + extensions/cornerstone/src/Viewport \ + extensions/cornerstone/src/components \ + extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts 2>/dev/null | + grep -v '\.test\.' || true) +if [ -n "$ui_hits" ]; then + report "per-lane predicate in the UI layer / CornerstoneViewportService" "$ui_hits" +fi + +if [ "$fail" -eq 0 ]; then + echo "next-viewport boundaries OK" +fi +exit "$fail" diff --git a/tests/OverlappingSegmentationRendering.spec.ts b/tests/OverlappingSegmentationRendering.spec.ts new file mode 100644 index 000000000..e92de657b --- /dev/null +++ b/tests/OverlappingSegmentationRendering.spec.ts @@ -0,0 +1,36 @@ +import { + checkForScreenshot, + expect, + screenShotPaths, + test, + visitStudy, + waitForViewportRenderCycle, + waitForViewportsRendered, +} from './utils'; + +test.beforeEach(async ({ page }) => { + const studyInstanceUID = '1.3.6.1.4.1.14519.5.2.1.3671.4754.298665348758363466150039312520'; + await visitStudy(page, `${studyInstanceUID}&useNextViewports=true`, 'viewer', 2000); +}); + +test('should render overlapping segments from SEG series 1004', async ({ + page, + DOMOverlayPageObject, + leftPanelPageObject, + viewportPageObject, +}) => { + await leftPanelPageObject.loadSeriesBySeriesNumber(1004); + + await expect(DOMOverlayPageObject.viewport.segmentationHydration.locator).toBeVisible(); + + const viewportRenderCycle = waitForViewportRenderCycle(page); + await DOMOverlayPageObject.viewport.segmentationHydration.yes.click(); + await viewportRenderCycle; + await waitForViewportsRendered(page); + + await checkForScreenshot({ + page, + locator: viewportPageObject.grid, + screenshotPath: screenShotPaths.overlappingSegmentationRendering.overlappingSegmentsDisplayed, + }); +}); diff --git a/tests/Scoord3dProbe.spec.ts b/tests/Scoord3dProbe.spec.ts index 7db9670e9..dccaa6c2a 100644 --- a/tests/Scoord3dProbe.spec.ts +++ b/tests/Scoord3dProbe.spec.ts @@ -5,7 +5,8 @@ import { screenShotPaths, test, visitStudy, - waitForPaintToSettle, waitForViewportRenderCycle, + waitForPaintToSettle, + waitForViewportRenderCycle, waitForViewportsRendered, } from './utils'; @@ -35,7 +36,7 @@ test.beforeEach(async ({ page }) => { } }); }); -test.describe.configure({ retries: 1 }); + test('should hydrate SCOORD3D probe measurements correctly', async ({ page, DOMOverlayPageObject, @@ -43,19 +44,17 @@ test('should hydrate SCOORD3D probe measurements correctly', async ({ rightPanelPageObject, viewportPageObject, }) => { - // Wait for the side panel to be visible and clickable - await page.waitForTimeout(3000); - // Navigate to the tracked measurements panel await rightPanelPageObject.toggle(); await rightPanelPageObject.measurementsPanel.select(); // Double-click on the study browser thumbnail to load the SR await leftPanelPageObject.loadSeriesByModality('SR'); - await page.waitForTimeout(2000); - // Wait for the SR to load and stabilize before taking screenshot - await page.waitForTimeout(1000); + await DOMOverlayPageObject.viewport.segmentationHydration.yes.button.waitFor({ + state: 'visible', + timeout: 15000, + }); const activeViewport = await viewportPageObject.active; @@ -85,25 +84,22 @@ test('should hydrate SCOORD3D probe measurements correctly', async ({ viewport.render(); } }); - - // Wait for rendering to complete - await page.waitForTimeout(1000); - - // Wait for the hydrate button to be visible and clickable - await DOMOverlayPageObject.viewport.segmentationHydration.yes.button.waitFor({ - state: 'visible', - timeout: 15000, - }); + await waitForViewportsRendered(page); // Click the hydrate button to load the SCOORD3D probe measurements await DOMOverlayPageObject.viewport.segmentationHydration.yes.click(); - // Wait for hydration to complete and rendering to stabilize. SCOORD3D - // hydration can swap the displayed series (referenced vs. current volume), - // so we must wait for the new image set to render before screenshotting. - await waitForViewportsRendered(page); - await page.waitForTimeout(3000); - await waitForPaintToSettle(page); + // SR hydration runs through the tracking state machine after the button click. + // Waiting for "rendered" immediately can resolve on the old viewport before + // that async work starts, so first wait for the hydrated measurements and + // target series to be observable, then wait for rendering to complete. + await expect(rightPanelPageObject.measurementsPanel.panel.rows).not.toHaveCount(0, { + timeout: 30000, + }); + await expect(activeViewport.overlayText.topLeft.locator).toContainText('t2_tse_sag', { + timeout: 30000, + }); + await waitForViewportsRendered(page, { timeout: 30000 }); // Take screenshot after hydration showing the probe measurements - use viewport locator await checkForScreenshot( @@ -152,7 +148,6 @@ test('should hydrate SCOORD3D probe measurements correctly', async ({ await jumpRenderCycle; await waitForPaintToSettle(page); - // Take screenshot showing the jump to measurement functionality - use viewport locator await checkForScreenshot( page, activeViewport.pane, @@ -167,14 +162,10 @@ test('should display SCOORD3D probe measurements correctly', async ({ rightPanelPageObject, viewportPageObject, }) => { - // Wait for the side panel to be visible and clickable - await page.waitForTimeout(3000); - // First hydrate the SR to load the measurements await rightPanelPageObject.toggle(); await rightPanelPageObject.measurementsPanel.select(); await leftPanelPageObject.loadSeriesByModality('SR'); - await page.waitForTimeout(2000); // Wait for the hydrate button to be visible and clickable await DOMOverlayPageObject.viewport.segmentationHydration.yes.button.waitFor({ @@ -182,7 +173,15 @@ test('should display SCOORD3D probe measurements correctly', async ({ timeout: 15000, }); await DOMOverlayPageObject.viewport.segmentationHydration.yes.click(); - await page.waitForTimeout(2000); + + const activeViewport = await viewportPageObject.active; + await expect(rightPanelPageObject.measurementsPanel.panel.rows).not.toHaveCount(0, { + timeout: 30000, + }); + await expect(activeViewport.overlayText.topLeft.locator).toContainText('t2_tse_sag', { + timeout: 30000, + }); + await waitForViewportsRendered(page, { timeout: 30000 }); // Zoom to show the probe measurements clearly await page.evaluate(() => { @@ -202,15 +201,7 @@ test('should display SCOORD3D probe measurements correctly', async ({ viewport.render(); } }); - - // Wait for the zoom re-render to actually settle before capturing. A fixed - // delay can capture a partially rendered viewport on a slow/loaded runner, - // which shows up as a near-total screenshot diff. await waitForViewportsRendered(page); - await waitForPaintToSettle(page); - await page.waitForTimeout(3000); - - const activeViewport = await viewportPageObject.active; // Take screenshot showing the SCOORD3D probe measurements rendered correctly - use viewport locator await checkForScreenshot( diff --git a/tests/pages/LeftPanelPageObject.ts b/tests/pages/LeftPanelPageObject.ts index b188dcba7..a727e49e0 100644 --- a/tests/pages/LeftPanelPageObject.ts +++ b/tests/pages/LeftPanelPageObject.ts @@ -32,4 +32,11 @@ export class LeftPanelPageObject { .nth(nth); await matchingThumbnail.dblclick(); } + + async loadSeriesBySeriesNumber(seriesNumber: string | number) { + const matchingThumbnail = this.page.locator( + `[data-cy^="study-browser-thumbnail"][data-series="${seriesNumber}"]` + ); + await matchingThumbnail.dblclick(); + } } diff --git a/tests/pages/ViewportPageObject.ts b/tests/pages/ViewportPageObject.ts index 4e8a2861f..f234edd0e 100644 --- a/tests/pages/ViewportPageObject.ts +++ b/tests/pages/ViewportPageObject.ts @@ -24,12 +24,14 @@ type NormalizedPathDragParams = { }; export interface IOverlayText { + locator: Locator; get windowLevel(): Locator; get instanceNumber(): Locator; } function overlayTextFactory(viewport: Locator, id: string): IOverlayText { const locator = viewport.getByTestId(id); return { + locator, get windowLevel() { return locator.getByTitle('Window Level'); }, @@ -321,9 +323,7 @@ export class ViewportPageObject { return this.getSvg(viewport, innerElement); }, getSvgAnnotationStatTextLines: (uid: string) => { - return this.getSvg(viewport) - .locator(`g[data-annotation-uid="${uid}"]`) - .locator('tspan'); + return this.getSvg(viewport).locator(`g[data-annotation-uid="${uid}"]`).locator('tspan'); }, navigationArrows: this.getNavigationArrows(viewport), sliceNavigation: this.getSliceNavigation(viewport), diff --git a/tests/screenshots/chromium/OverlappingSegmentationRendering.spec.ts/overlappingSegmentsDisplayed.png b/tests/screenshots/chromium/OverlappingSegmentationRendering.spec.ts/overlappingSegmentsDisplayed.png new file mode 100644 index 000000000..9f28ed588 Binary files /dev/null and b/tests/screenshots/chromium/OverlappingSegmentationRendering.spec.ts/overlappingSegmentsDisplayed.png differ diff --git a/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbeDisplayedCorrectly.png b/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbeDisplayedCorrectly.png index 49603cd09..36d74d931 100644 Binary files a/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbeDisplayedCorrectly.png and b/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbeDisplayedCorrectly.png differ diff --git a/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbeJumpToMeasurement.png b/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbeJumpToMeasurement.png index 7df0682f2..723d06b91 100644 Binary files a/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbeJumpToMeasurement.png and b/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbeJumpToMeasurement.png differ diff --git a/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbePostHydration.png b/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbePostHydration.png index 520a378cd..373ef63dd 100644 Binary files a/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbePostHydration.png and b/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbePostHydration.png differ diff --git a/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbePreHydration.png b/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbePreHydration.png index f20703423..08076fed9 100644 Binary files a/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbePreHydration.png and b/tests/screenshots/chromium/Scoord3dProbe.spec.ts/scoord3dProbePreHydration.png differ diff --git a/tests/utils/screenShotPaths.ts b/tests/utils/screenShotPaths.ts index e53e36998..9c9b80d0c 100644 --- a/tests/utils/screenShotPaths.ts +++ b/tests/utils/screenShotPaths.ts @@ -221,6 +221,9 @@ const screenShotPaths = { overlaysDisplayed: 'overlaysDisplayed.png', overlaySEGsAndRTDisplayed: 'overlaySEGsAndRTDisplayed.png', }, + overlappingSegmentationRendering: { + overlappingSegmentsDisplayed: 'overlappingSegmentsDisplayed.png', + }, contourCombineOperations: { subtractBigSphereMinusSmallSphereResult: 'subtractBigSphereMinusSmallSphereResult.png', intersectBigSphereSmallSphereResult: 'intersectBigSphereSmallSphereResult.png',