Compare commits

..

No commits in common. "master" and "v3.13.0-beta.119" have entirely different histories.

152 changed files with 1310 additions and 7458 deletions

View File

@ -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.** `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`.
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).
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.

View File

@ -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/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 |
| Hydration | Segmentation/RT not interactive | Ensure the `segmentationHydration.yes.click()` fired; add `waitForTimeout(3000)` after `loadSeriesByModality('SEG'\|'RTSTRUCT'\|'SR')` |
| 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 `TEST_ENV=true pnpm exec 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 `yarn 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,8 +33,6 @@ 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:
@ -66,7 +64,7 @@ await press({ page, key: 'ArrowDown', nTimes: 50 }); // object param, not (page,
Screenshots live under `tests/screenshots/chromium/<testFilePath>/`. To accept new output as the baseline:
```sh
TEST_ENV=true pnpm exec playwright test tests/YourSpec.spec.ts --update-snapshots
yarn 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.

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-pmap",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "DICOM Parametric Map read workflow",
"author": "OHIF",
"license": "MIT",
@ -41,8 +41,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "5.4.17",
"@cornerstonejs/core": "5.4.17",
"@cornerstonejs/adapters": "5.4.12",
"@cornerstonejs/core": "5.4.12",
"@kitware/vtk.js": "35.5.3"
},
"devDependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-rt",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "DICOM RT read workflow",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-seg",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "DICOM SEG read workflow",
"author": "OHIF",
"license": "MIT",
@ -41,8 +41,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "5.4.17",
"@cornerstonejs/core": "5.4.17",
"@cornerstonejs/adapters": "5.4.12",
"@cornerstonejs/core": "5.4.12",
"@kitware/vtk.js": "35.5.3"
},
"devDependencies": {

View File

@ -6,11 +6,7 @@ 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,
LABELMAP_SEG_SOP_CLASS_UID,
BITMAP_SEG_SOP_CLASS_UID,
} from './utils/segmentationConfig';
import { getSegmentationSaveOptions } from './utils/segmentationConfig';
const getTargetViewport = ({ viewportId, viewportGridService }) => {
const { viewports, activeViewportId } = viewportGridService.getState();
@ -33,6 +29,7 @@ const {
},
} = adaptersRT;
const commandsModule = ({
servicesManager,
extensionManager,
@ -108,69 +105,57 @@ const commandsModule = ({
: extensionManager.getActiveDataSourceDefinition();
const dataSourceStoreOverride = dataSourceDefinition?.configuration?.segmentation?.store;
const labelmapData = segmentation.representationData.Labelmap;
const { imageIds } = segmentation.representationData.Labelmap;
// 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 = [];
const segImages = imageIds.map(imageId => cache.getImage(imageId));
const referencedImages = segImages.map((segImage, sliceIndex) => {
const referencedImage = cache.getImage(segImage.referencedImageId);
// 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;
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);
}
}
const frameIndex = referencedFrameIndexById
? referencedFrameIndexById.get(segImage.referencedImageId) ?? -1
: z++;
if (frameIndex < 0) {
continue;
}
labelmaps2D[frameIndex] = {
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
pixelData,
rows,
columns,
};
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.`
);
}
const allSegmentsOnLabelmap = labelmaps2D
.filter(Boolean)
.map(labelmap => labelmap.segmentsOnLabelmap);
return referencedImage;
});
return {
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
metadata,
labelmaps2D,
const labelmaps2D = [];
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);
}
}
labelmaps2D[z++] = {
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
pixelData,
rows,
columns,
};
}
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
@ -191,7 +176,7 @@ const commandsModule = ({
color.slice(0, 3).map(value => value / 255)
).map(value => Math.round(value));
metadata[segmentIndex] = {
const segmentMetadata = {
SegmentNumber: segmentIndex.toString(),
SegmentLabel: label,
SegmentAlgorithmType: segment?.algorithmType || 'MANUAL',
@ -208,76 +193,14 @@ const commandsModule = ({
CodeMeaning: 'Tissue',
},
};
labelmap3D.metadata[segmentIndex] = segmentMetadata;
});
// 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 = {
const generatedSegmentation = generateSegmentation(referencedImages, labelmap3D, metaData, {
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;
},
@ -343,7 +266,9 @@ 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,

View File

@ -70,10 +70,7 @@ function SegmentSelector({
onValueChange={onValueChange}
value={value}
>
<SelectTrigger
className="overflow-hidden"
data-cy={`logical-contour-segment-${label.toLowerCase()}-trigger`}
>
<SelectTrigger className="overflow-hidden">
<SelectValue placeholder={t(placeholder)} />
</SelectTrigger>
<SelectContent>
@ -183,7 +180,6 @@ function LogicalContourOperationOptions() {
value={value}
key={`logical-contour-operation-${value}`}
onClick={() => setOperation(option)}
data-cy={`logical-contour-operation-${value}`}
>
<Icons.ByName name={icon}></Icons.ByName>
</TabsTrigger>
@ -211,7 +207,6 @@ function LogicalContourOperationOptions() {
/>
<div className="flex justify-end pl-[34px]">
<Button
data-cy="apply-logical-contour-operation"
className="border-primary/60 grow border"
variant="ghost"
onClick={() => {
@ -226,7 +221,6 @@ function LogicalContourOperationOptions() {
<div className="flex items-center justify-start gap-2">
<Switch
id="logical-contour-operations-create-new-segment-switch"
data-cy="logical-contour-create-new-segment-switch"
onCheckedChange={setCreateNewSegment}
></Switch>
<Label htmlFor="logical-contour-operations-create-new-segment-switch">

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-sr",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "OHIF extension for an SR Cornerstone Viewport",
"author": "OHIF",
"license": "MIT",
@ -40,9 +40,9 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/adapters": "5.4.17",
"@cornerstonejs/core": "5.4.17",
"@cornerstonejs/tools": "5.4.17",
"@cornerstonejs/adapters": "5.4.12",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"classnames": "2.5.1"
},
"devDependencies": {

View File

@ -142,22 +142,15 @@ 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 = getReferencedDisplaySet(
displaySet,
displaySetsFOR,
FrameOfReferenceUID,
displaySetService
);
const ds = chooseDisplaySet(displaySetsFOR, FrameOfReferenceUID);
if (!ds) {
continue;
}
displaySetsByFrameOfReferenceUID.set(FrameOfReferenceUID, ds);
if (!SeriesInstanceUIDs.includes(ds.SeriesInstanceUID)) {
SeriesInstanceUIDs.push(ds.SeriesInstanceUID);
}
@ -181,7 +174,7 @@ export default function hydrateStructuredReport(
const imageId = sopInstanceUIDToImageId[`${toolData.sopInstanceUid}:${frameNumber}`];
if (!imageId) {
return getReferenceData3D(toolData, servicesManager, displaySetsByFrameOfReferenceUID);
return getReferenceData3D(toolData, servicesManager);
}
const instance = metaData.get('instance', imageId);
@ -323,60 +316,21 @@ function chooseDisplaySet(displaySets, reference) {
console.warn('No display set found for', reference);
return;
}
const sortedDisplaySets = OHIF.utils.sortDisplaySetsCopy(displaySets);
if (sortedDisplaySets.length === 1) {
return sortedDisplaySets[0];
if (displaySets.length === 1) {
return displaySets[0];
}
const volumeDs = sortedDisplaySets.find(ds => ds.isReconstructable);
const volumeDs = displaySets.find(ds => ds.isReconstructable);
if (volumeDs) {
return volumeDs;
}
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);
return displaySets[0];
}
/**
* 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,
displaySetsByFrameOfReferenceUID = new Map()
) {
function getReferenceData3D(toolData, servicesManager: Types.ServicesManager) {
const { FrameOfReferenceUID } = toolData.annotation.metadata;
const { points } = toolData.annotation.data.handles;
const { displaySetService } = servicesManager.services;
@ -388,9 +342,7 @@ function getReferenceData3D(
FrameOfReferenceUID,
};
}
const ds =
displaySetsByFrameOfReferenceUID.get(FrameOfReferenceUID) ||
chooseDisplaySet(displaySetsFOR, toolData.annotation);
const ds = chooseDisplaySet(displaySetsFOR, toolData.annotation);
const cameraView = chooseCameraView(ds, points);
const viewReference = {

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dynamic-volume",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "OHIF extension for 4D volumes data",
"author": "OHIF",
"license": "MIT",
@ -45,8 +45,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/core": "5.4.17",
"@cornerstonejs/tools": "5.4.17",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"classnames": "2.5.1"
},
"devDependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "OHIF extension for Cornerstone",
"author": "OHIF",
"license": "MIT",
@ -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.17",
"@cornerstonejs/dicom-image-loader": "5.4.12",
"@ohif/core": "workspace:*",
"@ohif/extension-default": "workspace:*",
"@ohif/ui": "workspace:*",
@ -52,13 +52,13 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@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",
"@cornerstonejs/adapters": "5.4.12",
"@cornerstonejs/ai": "5.4.12",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/labelmap-interpolation": "5.4.12",
"@cornerstonejs/metadata": "5.4.12",
"@cornerstonejs/polymorphic-segmentation": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"@icr/polyseg-wasm": "0.4.0",
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
"@kitware/vtk.js": "35.5.3",

View File

@ -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, eventTarget } from '@cornerstonejs/core';
import { metaData, Enums, utilities, 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,8 +9,6 @@ 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';
@ -271,7 +269,7 @@ function getDisplaySets(viewportData, displaySetService) {
const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => {
let instanceNumber;
switch (getViewportDataShapeType(viewportData)) {
switch (viewportData.viewportType) {
case Enums.ViewportType.STACK:
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
break;
@ -338,11 +336,8 @@ function _getInstanceNumberFromVolume(
return;
}
const viewPlaneNormal = getViewportAdapter(cornerstoneViewport).getViewPlaneNormal();
if (!viewPlaneNormal) {
return;
}
const camera = cornerstoneViewport.getCamera();
const { viewPlaneNormal } = camera;
// checking if camera is looking at the acquisition plane (defined by the direction on the volume)
const scanAxisNormal = direction.slice(6, 9);

View File

@ -1,9 +1,8 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { utilities as csUtils } from '@cornerstonejs/core';
import { Enums, 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,
@ -48,10 +47,10 @@ function CornerstoneImageScrollbar({
try {
const imageIndex = viewport.getCurrentImageIdIndex();
const numberOfSlices = getViewportSliceCount(viewportData, viewport);
const numberOfSlices = viewport.getNumberOfSlices();
setImageSliceData({
imageIndex,
imageIndex: imageIndex,
numberOfSlices,
});
} catch (error) {
@ -63,7 +62,11 @@ function CornerstoneImageScrollbar({
if (!viewportData) {
return;
}
const eventId = getSliceEventName(viewportData);
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 updateIndex = event => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);

View File

@ -6,7 +6,6 @@ import { vec3 } from 'gl-matrix';
import './ViewportOrientationMarkers.css';
import { useViewportRendering } from '../../hooks';
import { getViewportDataShapeType } from '../../utils/viewportDataShape';
const { getOrientationStringLPS, invertOrientationStringLPS } = utilities.orientation;
function ViewportOrientationMarkers({
@ -47,9 +46,7 @@ function ViewportOrientationMarkers({
return '';
}
// 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) {
if (viewportData.viewportType === 'stack') {
const imageIndex = imageSliceData.imageIndex;
const imageId = viewportData.data[0].imageIds?.[imageIndex];

View File

@ -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,17 +24,12 @@ export function isProgressFullMode(viewportData: ViewportData, viewport): boolea
return false;
}
// 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') {
if (viewportData.viewportType === Enums.ViewportType.STACK) {
return true;
}
if (shape === 'volume') {
return adapter.isInAcquisitionPlane();
if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
return !!viewport.isInAcquisitionPlane?.();
}
return false;

View File

@ -1,8 +1,12 @@
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';
@ -93,48 +97,26 @@ export function useViewportSliceSync({
return;
}
// 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;
}
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (viewport && !isVolume3DViewportType(viewport)) {
try {
const currentImageIndex = viewport.getCurrentImageIdIndex();
const currentNumberOfSlices = getViewportSliceCount(viewportData, viewport);
const currentNumberOfSlices = viewport.getNumberOfSlices();
pushSliceData(currentImageIndex, currentNumberOfSlices);
setImageSliceData({
imageIndex: currentImageIndex,
numberOfSlices: currentNumberOfSlices,
});
} catch (error) {
console.warn(error);
}
};
}
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 { 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 updateIndex = event => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
@ -148,22 +130,16 @@ export function useViewportSliceSync({
}
const nextNumberOfSlices = viewport.getNumberOfSlices();
pushSliceData(nextImageIndex, nextNumberOfSlices);
setImageSliceData({
imageIndex: nextImageIndex,
numberOfSlices: 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]);
}

View File

@ -29,10 +29,8 @@ 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';
@ -43,8 +41,6 @@ import {
isVolume3DViewportType,
isVolumeViewportType,
} from './utils/getLegacyViewportType';
import { viewportOperations as ops } from './services/ViewportService/backends/viewportOperations';
import { getViewportAdapter } from './services/ViewportService/adapter';
import {
usePositionPresentationStore,
useSegmentationPresentationStore,
@ -56,6 +52,8 @@ 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';
@ -148,15 +146,6 @@ 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);
@ -249,11 +238,23 @@ function commandsModule({
viewport.setViewReference(metadata);
viewport.render();
// 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)) {
/**
* 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);
}
viewport.render();
}
@ -371,9 +372,6 @@ 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(
@ -923,28 +921,34 @@ function commandsModule({
const windowWidthNum = Number(windowWidth);
const windowCenterNum = Number(windowCenter);
// get actor from the viewport
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
const viewport = renderingEngine.getViewport(viewportId);
// Stale/invalid viewport ids resolve to undefined; bail out before the VOI
// apply + render below would throw.
if (!viewport) {
return;
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,
},
});
}
// 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 = {} }) => {
@ -1183,11 +1187,25 @@ function commandsModule({
viewportId?: string;
newValue?: 'toggle' | boolean;
}) => {
const viewport = _resolveViewport(viewportId);
if (!viewport) {
const enabledElement = viewportId
? _getViewportEnabledElement(viewportId)
: _getActiveViewportEnabledElement();
if (!enabledElement) {
return;
}
ops.flipHorizontal(viewport, newValue);
const { viewport } = enabledElement;
let flipHorizontal: boolean;
if (newValue === 'toggle') {
const { flipHorizontal: currentHorizontalFlip } = viewport.getCamera();
flipHorizontal = !currentHorizontalFlip;
} else {
flipHorizontal = newValue;
}
viewport.setCamera({ flipHorizontal });
viewport.render();
},
flipViewportVertical: ({
@ -1197,36 +1215,78 @@ function commandsModule({
viewportId?: string;
newValue?: 'toggle' | boolean;
}) => {
const viewport = _resolveViewport(viewportId);
if (!viewport) {
const enabledElement = viewportId
? _getViewportEnabledElement(viewportId)
: _getActiveViewportEnabledElement();
if (!enabledElement) {
return;
}
ops.flipVertical(viewport, newValue);
const { viewport } = enabledElement;
let flipVertical: boolean;
if (newValue === 'toggle') {
const { flipVertical: currentVerticalFlip } = viewport.getCamera();
flipVertical = !currentVerticalFlip;
} else {
flipVertical = newValue;
}
viewport.setCamera({ flipVertical });
viewport.render();
},
invertViewport: ({ element }) => {
const viewport = element === undefined ? _resolveViewport() : element.viewport;
if (!viewport) {
let enabledElement;
if (element === undefined) {
enabledElement = _getActiveViewportEnabledElement();
} else {
enabledElement = element;
}
if (!enabledElement) {
return;
}
ops.invert(viewport);
const { viewport } = enabledElement;
const { invert } = viewport.getProperties();
viewport.setProperties({ invert: !invert });
viewport.render();
},
resetViewport: () => {
const viewport = _resolveViewport();
if (!viewport) {
const enabledElement = _getActiveViewportEnabledElement();
if (!enabledElement) {
return;
}
ops.reset(viewport);
const { viewport } = enabledElement;
viewport.resetProperties?.();
viewport.resetCamera();
viewport.render();
},
scaleViewport: ({ direction }) => {
const viewport = _resolveViewport();
if (!viewport) {
const enabledElement = _getActiveViewportEnabledElement();
const scaleFactor = direction > 0 ? 0.9 : 1.1;
if (!enabledElement) {
return;
}
ops.scaleBy(viewport, direction);
viewport.render();
const { viewport } = enabledElement;
if (isStackViewportType(viewport)) {
if (direction) {
const { parallelScale } = viewport.getCamera();
viewport.setCamera({ parallelScale: parallelScale * scaleFactor });
viewport.render();
} else {
viewport.resetCamera();
viewport.render();
}
}
},
/** Jumps the active viewport or the specified one to the given slice index */
@ -1305,15 +1365,24 @@ function commandsModule({
// HP takes priority over the default opacity
colormap = { ...colormap, opacity: hpOpacity || opacity };
// 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 (isStackViewportType(viewport)) {
viewport.setProperties({ colormap });
}
ops.setColormap(viewport, { colormap, displaySetInstanceUID });
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);
}
if (immediate) {
viewport.render();
@ -1419,7 +1488,9 @@ function commandsModule({
if (!viewport) {
return;
}
ops.setPreset(viewport, preset);
viewport.setProperties({
preset,
});
viewport.render();
},
@ -1431,10 +1502,20 @@ function commandsModule({
setVolumeRenderingQulaity: ({ viewportId, volumeQuality }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return;
}
ops.setVolumeRenderingQuality(viewport, volumeQuality);
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);
viewport.render();
},
@ -1445,10 +1526,27 @@ function commandsModule({
*/
shiftVolumeOpacityPoints: ({ viewportId, shift }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return;
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);
}
ops.shiftVolumeOpacityPoints(viewport, shift);
// Add offset
opacityPointValues.forEach(opacityPointValue => {
opacityPointValue[0] += shift; // Change the location value
});
// Set new values
ofun.removeAllPoints();
opacityPointValues.forEach(opacityPointValue => {
ofun.addPoint(...opacityPointValue);
});
viewport.render();
},
@ -1464,10 +1562,25 @@ function commandsModule({
setVolumeLighting: ({ viewportId, options }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport) {
return;
const { actor } = viewport.getActors()[0];
const property = actor.getProperty();
if (options.shade !== undefined) {
property.setShade(options.shade);
}
ops.setVolumeLighting(viewport, options);
if (options.ambient !== undefined) {
property.setAmbient(options.ambient);
}
if (options.diffuse !== undefined) {
property.setDiffuse(options.diffuse);
}
if (options.specular !== undefined) {
property.setSpecular(options.specular);
}
viewport.render();
},
resetCrosshairs: ({ viewportId }) => {
@ -1475,13 +1588,7 @@ function commandsModule({
const getCrosshairInstances = toolGroupId => {
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
// 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'));
}
crosshairInstances.push(toolGroup.getToolInstance('Crosshairs'));
};
if (!viewportId) {
@ -1489,9 +1596,7 @@ function commandsModule({
toolGroupIds.forEach(getCrosshairInstances);
} else {
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
if (toolGroup) {
getCrosshairInstances(toolGroup.id);
}
getCrosshairInstances(toolGroup.id);
}
crosshairInstances.forEach(ins => {
@ -2060,11 +2165,7 @@ function commandsModule({
}
segmentationService.addSegment(activeSegmentation.segmentationId);
},
loadSegmentationDisplaySetsForViewport: ({
viewportId,
displaySetInstanceUIDs,
viewportType,
}) => {
loadSegmentationDisplaySetsForViewport: ({ viewportId, displaySetInstanceUIDs }) => {
const updatedViewports = getUpdatedViewportsForSegmentation({
viewportId,
servicesManager,
@ -2084,21 +2185,13 @@ 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);
// Accept any viewport already rendering volume content (legacy ORTHOGRAPHIC
// or a native viewport in volume mode) — both expose setOrientation().
if (!viewport || !getViewportAdapter(viewport).canReorientInPlace()) {
if (!viewport || !isOrthographicViewportType(viewport)) {
console.warn('Orientation can only be set on volume viewports');
return;
}
@ -2170,12 +2263,50 @@ function commandsModule({
viewportId?: string;
rotationMode?: 'apply' | 'set';
}) => {
const viewport = _resolveViewport(viewportId);
if (!viewport) {
const enabledElement = viewportId
? _getViewportEnabledElement(viewportId)
: _getActiveViewportEnabledElement();
if (!enabledElement) {
return;
}
ops.rotate(viewport, rotation, rotationMode);
viewport.render();
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();
}
},
startRecordingForAnnotationGroup: () => {
cornerstoneTools.AnnotationTool.startGroupRecording();

View File

@ -83,16 +83,10 @@ 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 === 0 || opacity == null
? backgroundDisplaySet?.displaySetInstanceUID
: foregroundDisplaySets[0]?.displaySetInstanceUID;
: foregroundDisplaySets[0].displaySetInstanceUID;
return dsUID === targetUID;
});

View File

@ -2,7 +2,6 @@ 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({
@ -37,6 +36,8 @@ function ViewportOrientationMenu({
const handleOrientationChange = (orientation: string) => {
setCurrentOrientation(orientation);
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportIdToUse);
const currentViewportType = viewportInfo?.getViewportType();
if (!displaySets.length) {
return;
@ -71,17 +72,8 @@ function ViewportOrientationMenu({
const displaySetUIDs = displaySets.map(ds => ds.displaySetInstanceUID);
// 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) {
// If viewport is not already a volume type, we need to convert it
if (currentViewportType !== Enums.ViewportType.ORTHOGRAPHIC) {
// Configure the viewport to be a volume viewport with current display sets
const updatedViewport = {
viewportId: viewportIdToUse,

View File

@ -2,7 +2,6 @@ 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
@ -102,18 +101,8 @@ export const getWindowLevelsData = async (
return [];
}
// 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 volumeIds = (viewport as Types.IBaseVolumeViewport).getAllVolumeIds();
const viewportProperties = viewport.getProperties();
const { voiRange } = viewportProperties || {};
const viewportVoi = voiRange
? {

View File

@ -1,54 +1,15 @@
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, 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 { viewportDisplaySets } = useViewportRendering(viewportId);
const [activeDisplaySetUID, setActiveDisplaySetUID] = useState<string | undefined>(
defaultDisplaySetUID
viewportDisplaySets?.[0]?.displaySetInstanceUID
);
// 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, {
@ -88,7 +49,6 @@ export function WindowLevel({ viewportId }: { viewportId?: string } = {}): React
<Tabs
value={activeDisplaySetUID}
onValueChange={displaySetUID => {
userSelectedRef.current = true;
setActiveDisplaySetUID(displaySetUID);
}}
>

View File

@ -421,23 +421,18 @@ const segmentationToolbarButtons: Button[] = [
},
},
{
id: 'ClickSegment',
id: 'RegionSegmentPlus',
uiType: 'ohif.toolBoxButton',
props: {
icon: 'icon-tool-click-segment',
label: i18n.t('Buttons:Click to Segment'),
label: i18n.t('Buttons:One Click Segment'),
tooltip: i18n.t(
'Buttons:PET only. Click-to-segment lesions with no configuration. Hover for visual feedback—click when a plus sign appears to segment the lesion.'
'Buttons:Detects segmentable regions with one click. Hover for visual feedback—click when a plus sign appears to auto-segment the lesion.'
),
evaluate: [
{
name: 'evaluate.modality.supported',
supportedModalities: ['PT'],
disabledText: i18n.t('Buttons:Tool not available for this modality'),
},
{
name: 'evaluate.cornerstone.segmentation',
toolNames: ['ClickSegment'],
toolNames: ['RegionSegmentPlus'],
disabledText: i18n.t('Buttons:Create new segmentation to enable this tool.'),
},
{
@ -873,7 +868,7 @@ export const segmentationToolboxSections: Record<string, string[]> = {
'LabelmapSlicePropagation',
'BrushTools',
'MarkerLabelmap',
'ClickSegment',
'RegionSegmentPlus',
'Shapes',
'LabelMapEditWithContour',
],

View File

@ -148,7 +148,7 @@ function getToolGroupToolsCustomization({ commandsManager }) {
...brushInstances,
{ toolName: toolNames.LabelmapSlicePropagation },
{ toolName: toolNames.MarkerLabelmap },
{ toolName: toolNames.ClickSegment },
{ toolName: toolNames.RegionSegmentPlus },
{ toolName: toolNames.LabelMapEditWithContourTool },
{ toolName: toolNames.SegmentSelect },
{ toolName: toolNames.CircleScissors },

View File

@ -8,11 +8,7 @@ import { buildEcgModule } from './utils/ecgMetadata';
const { MetadataModules } = csEnums;
const { utils } = OHIF;
const { denaturalizeDataset } = dcmjs.data.DicomMetaDictionary;
// 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 { transferDenaturalizedDataset, fixMultiValueKeys } = dicomWebUtils;
const SOP_CLASS_UIDS = {
VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.6',
@ -143,8 +139,8 @@ function getDICOMwebMetadata(instanceMap, imageId) {
console.warn('Metadata not already found for', imageId, 'in', instanceMap);
return this.super.getDICOMwebMetadata(imageId);
}
return dicomWebUtils.transferDenaturalizedDataset(
denaturalizeDataset(dicomWebUtils.fixMultiValueKeys(instanceMap.get(imageId)))
return transferDenaturalizedDataset(
denaturalizeDataset(fixMultiValueKeys(instanceMap.get(imageId)))
);
}

View File

@ -1,6 +1,5 @@
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';
@ -310,11 +309,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
};
}
// 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)) {
if (viewport.type !== 'orthographic') {
return {
disabled: true,
};
@ -337,7 +332,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
evaluate: ({ viewportId }) => {
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
if (!viewport || !isVolumeRenderingViewport(viewport)) {
if (!viewport || viewport.type !== 'orthographic') {
return {
disabled: true,
};
@ -454,7 +449,8 @@ 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,
@ -463,7 +459,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
icon:
isToggled && hasModifierKey && toggledOnIcon
? toggledOnIcon
: (defaultIcon ?? button.props.icon),
: defaultIcon ?? button.props.icon,
};
},
},
@ -547,9 +543,8 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
const propId = button.id;
const adapter = getViewportAdapter(viewport);
const properties = adapter.getPresentation();
const camera = adapter.getViewState();
const properties = viewport.getProperties();
const camera = viewport.getCamera();
const prop = camera?.[propId] || properties?.[propId];

View File

@ -1,9 +1,13 @@
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { useSystem } from '@ohif/core';
import { useViewportDisplaySets } from './useViewportDisplaySets';
import { Types, utilities, Enums } from '@cornerstonejs/core';
import { isVolume3DViewportType } from '../utils/getLegacyViewportType';
import { getViewportAdapter, LEGACY_OPACITY_GAMMA } from '../services/ViewportService/adapter';
import { Types, utilities, Enums, cache } from '@cornerstonejs/core';
import { getDataIdForViewport } from '../utils/getDataIdForViewport';
import {
isStackViewportType,
isVolumeViewportType,
isVolume3DViewportType,
} from '../utils/getLegacyViewportType';
import { WindowLevelPreset } from '../types/WindowLevel';
import { ColorbarPositionType, ColorbarOptions, ColorbarProperties } from '../types/Colorbar';
import { VolumeRenderingConfig } from '../types/VolumeRenderingConfig';
@ -88,26 +92,14 @@ const getPosition = (location: number): ColorbarPositionType => {
}
};
/**
* 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 GAMMA = 1 / 5;
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 linearToOpacity = (linearValue: number): number => {
return Math.pow(linearValue, GAMMA);
};
return opacityVal as number;
const opacityToLinear = (opacityValue: number): number => {
return Math.pow(opacityValue, 1.0 / GAMMA);
};
/**
@ -136,28 +128,12 @@ 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<number | undefined>();
const [opacityLinear, setOpacityLinearState] = useState<number | undefined>();
const [threshold, setThresholdState] = useState<number | undefined>();
const [pixelValueRange, setPixelValueRange] = useState<PixelValueRange>({ min: 0, max: 255 });
const { viewportDisplaySets, foregroundDisplaySets } = useViewportDisplaySets(viewportId);
const { viewportDisplaySets } = useViewportDisplaySets(viewportId);
const { displaySetService } = servicesManager.services;
// Determine the active display set instance UID (internal only, not exposed)
@ -166,21 +142,12 @@ 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, foregroundDisplaySets]);
}, [options?.displaySetInstanceUID, viewportDisplaySets]);
const viewportInfo = viewportId ? cornerstoneViewportService.getViewportInfo(viewportId) : null;
@ -249,14 +216,28 @@ export function useViewportRendering(
return;
}
const voxelManager = getViewportAdapter(viewport).getVoxelManagerForDisplaySet(
activeDisplaySetInstanceUID
);
if (!voxelManager?.getRange) {
if (!isVolumeViewportType(viewport)) {
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] });
@ -286,9 +267,12 @@ export function useViewportRendering(
return;
}
try {
const adapter = getViewportAdapter(viewport);
const dataId = adapter.getDataIdForDisplaySet(activeDisplaySetInstanceUID);
const properties = adapter.getPresentation(dataId ?? activeDisplaySetInstanceUID);
const dataId = getDataIdForViewport(viewport as unknown, activeDisplaySetInstanceUID);
const properties =
dataId != null
? (viewport as Types.IBaseVolumeViewport).getProperties(dataId)
: viewport.getProperties();
if (!properties) {
return;
@ -297,26 +281,18 @@ 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 opacity = resolveOpacityScalar(properties.colormap.opacity);
if (opacity !== undefined) {
setOpacityState(opacity);
setOpacityLinearState(opacityToLinear(opacity));
}
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));
}
if (properties.colormap?.threshold !== undefined) {
@ -386,11 +362,8 @@ export function useViewportRendering(
}
if (colormap.opacity !== undefined) {
const opacity = resolveOpacityScalar(colormap.opacity);
if (opacity !== undefined) {
setOpacityState(opacity);
setOpacityLinearState(opacityToLinear(opacity));
}
setOpacityState(colormap.opacity);
setOpacityLinearState(opacityToLinear(colormap.opacity));
}
};
@ -597,7 +570,7 @@ export function useViewportRendering(
const setOpacity = useCallback(
(opacityValue: number) => {
if (!viewport) {
if (!viewport || !isVolumeViewportType(viewport)) {
return;
}
@ -607,10 +580,32 @@ export function useViewportRendering(
setOpacityLinearState(opacityToLinear(opacityValue));
const displaySetInstanceUID = validateActiveDisplaySet();
const volumeIds = viewport.getAllVolumeIds();
const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID));
if (getViewportAdapter(viewport).setLayerOpacity(displaySetInstanceUID, opacityValue)) {
viewport.render();
if (!volumeId) {
return;
}
// 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]
);
@ -626,16 +621,32 @@ export function useViewportRendering(
const setThreshold = useCallback(
(thresholdValue: number) => {
if (!viewport) {
if (!viewport || !isVolumeViewportType(viewport)) {
return;
}
const displaySetInstanceUID = validateActiveDisplaySet();
setThresholdState(thresholdValue);
if (getViewportAdapter(viewport).setLayerThreshold(displaySetInstanceUID, thresholdValue)) {
viewport.render();
const displaySetInstanceUID = validateActiveDisplaySet();
const volumeIds = viewport.getAllVolumeIds();
const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID));
if (!volumeId) {
return;
}
console.debug('🚀 ~ thresholdValue:', thresholdValue);
viewport.setProperties(
{
colormap: {
threshold: thresholdValue,
},
},
volumeId
);
viewport.render();
},
[validateActiveDisplaySet, viewport]
);
@ -651,13 +662,41 @@ export function useViewportRendering(
return null;
}
const colormap = getViewportAdapter(viewport).getColormap(activeDisplaySetInstanceUID);
if (isStackViewportType(viewport)) {
const { colormap } = viewport.getProperties();
if (!colormap) {
return (
colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') ||
colorbarProperties?.colormaps?.[0]
);
}
return colormap;
}
return (
colormap ||
colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') ||
colorbarProperties?.colormaps?.[0]
const actorEntries = viewport.getActors();
const actorEntry = actorEntries?.find(entry =>
entry.referencedId?.includes(activeDisplaySetInstanceUID)
);
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 (

View File

@ -8,7 +8,7 @@ import {
} from '@cornerstonejs/core';
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata';
import { Types, AnnotationPersistenceService } from '@ohif/core';
import { Types } from '@ohif/core';
import Enums from './enums';
import init from './init';
@ -37,18 +37,6 @@ 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';
@ -115,7 +103,11 @@ const cornerstoneExtension: Types.Extensions.Extension = {
*/
id,
onModeEnter: ({ servicesManager, commandsManager, extensionManager }: withAppTypes): void => {
onModeEnter: ({
servicesManager,
commandsManager,
extensionManager,
}: withAppTypes): void => {
const { cornerstoneViewportService, toolbarService, segmentationService } =
servicesManager.services;
@ -161,10 +153,7 @@ 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,
@ -216,7 +205,6 @@ const cornerstoneExtension: Types.Extensions.Extension = {
servicesManager.registerService(CornerstoneCacheService.REGISTRATION);
servicesManager.registerService(ColorbarService.REGISTRATION);
servicesManager.registerService(ViewedDataService.REGISTRATION);
servicesManager.registerService(AnnotationPersistenceService.REGISTRATION);
const { syncGroupService } = servicesManager.services;
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);
@ -298,14 +286,6 @@ export {
getEnabledElement,
ImageOverlayViewerTool,
getSOPInstanceAttributes,
getViewportAdapter,
getViewportFocalPoint,
isNextViewport,
isVolumeRenderingViewport,
isNextViewportsEnabled,
NEXT_FUSION_PT_OPACITY,
NEXT_OVERLAY_OPACITY,
getHydrationViewportTypeForModality,
dicomLoaderService,
// Export all stores
useLutPresentationStore,

View File

@ -27,12 +27,6 @@ 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';
@ -68,52 +62,13 @@ 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
// `?<viewportType>.viewportRendering=<backend>` (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: {
@ -126,14 +81,6 @@ 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) {

View File

@ -40,7 +40,7 @@ import {
OrientationMarkerTool,
WindowLevelRegionTool,
SegmentSelectTool,
ClickSegmentTool,
RegionSegmentPlusTool,
SegmentLabelTool,
LivewireContourSegmentationTool,
SculptorTool,
@ -114,7 +114,7 @@ export default function initCornerstoneTools(configuration = {}) {
addTool(SegmentLabelTool);
addTool(LabelmapSlicePropagationTool);
addTool(MarkerLabelmapTool);
addTool(ClickSegmentTool);
addTool(RegionSegmentPlusTool);
addTool(LivewireContourSegmentationTool);
addTool(SculptorTool);
addTool(SplineContourSegmentationTool);
@ -178,7 +178,7 @@ const toolNames = {
SegmentLabel: SegmentLabelTool.toolName,
LabelmapSlicePropagation: LabelmapSlicePropagationTool.toolName,
MarkerLabelmap: MarkerLabelmapTool.toolName,
ClickSegment: ClickSegmentTool.toolName,
RegionSegmentPlus: RegionSegmentPlusTool.toolName,
LivewireContourSegmentation: LivewireContourSegmentationTool.toolName,
SculptorTool: SculptorTool.toolName,
SplineContourSegmentation: SplineContourSegmentationTool.toolName,

View File

@ -462,12 +462,6 @@ const connectMeasurementServiceToTools = ({
}
const { referenceSeriesUID, referenceStudyUID, SOPInstanceUID, metadata } = measurement;
const persistedAnnotation = data?.annotation || {};
const persistedData = persistedAnnotation.data || {};
const persistedHandles = persistedData.handles || {};
const handlePoints = Array.isArray(persistedHandles.points)
? persistedHandles.points.filter(point => Array.isArray(point) && point.length >= 2)
: [];
const instance = DicomMetadataStore.getInstance(
referenceStudyUID,
@ -512,11 +506,11 @@ const connectMeasurementServiceToTools = ({
* Don't remove this destructuring of data here.
* This is used to pass annotation specific data forward e.g. contour
*/
...(persistedData || {}),
text: persistedData.text,
handles: { ...persistedHandles, points: handlePoints },
cachedStats: { ...(persistedData.cachedStats || {}) },
label: persistedData.label,
...(data.annotation.data || {}),
text: data.annotation.data.text,
handles: { ...data.annotation.data.handles },
cachedStats: { ...data.annotation.data.cachedStats },
label: data.annotation.data.label,
frameNumber,
},
};
@ -540,9 +534,7 @@ const connectMeasurementServiceToTools = ({
commandsManager.run('cancelMeasurement');
const removedAnnotation = annotation.state.getAnnotation(removedMeasurementId);
if (removedAnnotation) {
removeAnnotation(removedMeasurementId);
}
removeAnnotation(removedMeasurementId);
// Ensure `removedAnnotation` is available before triggering the memo,
// as it can be undefined during an undo operation
if (removedAnnotation) {

View File

@ -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 { getViewportAdapter } from '../ViewportService/adapter';
import { getDataIdForViewport } from '../../utils/getDataIdForViewport';
import { ColorbarOptions, ChangeTypes } from '../../types/Colorbar';
export default class ColorbarService extends PubSubService {
@ -60,8 +60,8 @@ export default class ColorbarService extends PubSubService {
return;
}
const adapter = getViewportAdapter(viewport);
if (!adapter.hasContent()) {
const actorEntries = viewport.getActors();
if (!actorEntries || actorEntries.length === 0) {
return;
}
@ -74,8 +74,8 @@ export default class ColorbarService extends PubSubService {
return;
}
const dataId = adapter.getDataIdForDisplaySet(displaySetInstanceUID);
const properties = adapter.getPresentation(dataId);
const dataId = getDataIdForViewport(viewport, displaySetInstanceUID);
const properties = dataId ? viewport.getProperties(dataId) : viewport.getProperties();
const colormap = properties?.colormap;
if (activeColormapName && !colormap) {
@ -222,18 +222,16 @@ export default class ColorbarService extends PubSubService {
private setViewportColormap(viewportId, displaySetInstanceUID, colormap, immediate = false) {
const renderingEngine = getRenderingEngine(RENDERING_ENGINE_ID);
const viewport = renderingEngine.getViewport(viewportId);
if (!viewport) {
return;
}
const adapter = getViewportAdapter(viewport);
if (!adapter.hasContent()) {
const actorEntries = viewport?.getActors();
if (!viewport || !actorEntries || actorEntries.length === 0) {
return;
}
// 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);
// 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);
if (immediate) {
viewport.render();

View File

@ -41,39 +41,12 @@ 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 (
isReconstructableFusion &&
dataShapeType === Enums.ViewportType.STACK &&
cs3DViewportType === Enums.ViewportType.PLANAR_NEXT
) {
dataShapeType = Enums.ViewportType.ORTHOGRAPHIC;
}
if (
dataShapeType === Enums.ViewportType.ORTHOGRAPHIC ||
dataShapeType === Enums.ViewportType.VOLUME_3D
cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC ||
cs3DViewportType === Enums.ViewportType.VOLUME_3D
) {
viewportData = await this._getVolumeViewportData(dataSource, displaySets, cs3DViewportType);
} else if (dataShapeType === Enums.ViewportType.STACK) {
} else if (cs3DViewportType === Enums.ViewportType.STACK) {
// Everything else looks like a stack
viewportData = await this._getStackViewportData(
dataSource,
@ -91,9 +64,6 @@ 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;
}
@ -104,13 +74,7 @@ class CornerstoneCacheService {
dataSource,
displaySetService
): Promise<VolumeViewportData | StackViewportData> {
// 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) {
if (viewportData.viewportType === Enums.ViewportType.STACK) {
const displaySet = displaySetService.getDisplaySetByUID(invalidatedDisplaySetInstanceUID);
const imageIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
@ -122,10 +86,7 @@ class CornerstoneCacheService {
});
return {
// 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,
viewportType: Enums.ViewportType.STACK,
data: {
StudyInstanceUID: displaySet.StudyInstanceUID,
displaySetInstanceUID: invalidatedDisplaySetInstanceUID,
@ -167,7 +128,6 @@ class CornerstoneCacheService {
displaySets,
viewportData.viewportType
);
newViewportData.dataShapeType = dataShapeType;
return newViewportData;
}

View File

@ -19,7 +19,6 @@ 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'),
@ -915,101 +914,6 @@ 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
@ -1567,136 +1471,6 @@ 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<string, any>;
// 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<string, any>;
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<string, any>;
expect(data.labelmaps).toBeUndefined();
expect(data.imageIds).toEqual(['g0i1', 'g0i2', 'g1i1', 'g1i2']);
});
});
});
describe('createSegmentationForRTDisplaySet', () => {
@ -2924,47 +2698,6 @@ 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;

View File

@ -10,7 +10,10 @@ import {
metaData,
} from '@cornerstonejs/core';
import { ViewportType } from '@cornerstonejs/core/enums';
import { isVolume3DViewportType } from '../../utils/getLegacyViewportType';
import {
isVolume3DViewportType,
isVolumeViewportType,
} from '../../utils/getLegacyViewportType';
import {
Enums as csToolsEnums,
@ -27,17 +30,6 @@ 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;
@ -50,7 +42,7 @@ const {
const {
getLabelmapImageIds,
helpers: { convertStackToVolumeLabelmap },
state: { addColorLUT },
state: { addColorLUT, updateLabelmapSegmentationImageReferences },
triggerSegmentationEvents: { triggerSegmentationRepresentationModified },
} = cstSegmentation;
@ -106,7 +98,7 @@ const EVENTS = {
const VALUE_TYPES = {};
class SegmentationService extends PubSubService implements ISegmentationServiceInternals {
class SegmentationService extends PubSubService {
static REGISTRATION = {
name: 'segmentationService',
altName: 'SegmentationService',
@ -117,8 +109,6 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
private _segmentationIdToColorLUTIndexMap: Map<string, number>;
private _segmentationGroupStatsMap: Map<string, any>;
private readonly _legacySegBackend: ISegmentationBackend;
private readonly _nextSegBackend: ISegmentationBackend;
readonly servicesManager: AppTypes.ServicesManager;
highlightIntervalId = null;
readonly EVENTS = EVENTS;
@ -131,24 +121,6 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
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 {
@ -325,7 +297,6 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
type?: csToolsEnums.SegmentationRepresentations;
config?: {
blendMode?: csEnums.BlendModes;
useSliceRendering?: boolean;
};
suppressEvents?: boolean;
}
@ -348,15 +319,6 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
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;
@ -366,35 +328,20 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
let representationTypeToUse = type || defaultRepresentationType;
if (representationTypeToUse === LABELMAP) {
({ representationTypeToUse, isConverted } = await this._segBackend(
csViewport
).classifyAndPrepareLabelmapAdd(
const { isVolumeViewport, isVolumeSegmentation } = this.determineViewportAndSegmentationType(
csViewport,
segmentation
) || { isVolumeViewport: false, isVolumeSegmentation: false };
({ representationTypeToUse, isConverted } = await this.handleViewportConversion(
isVolumeViewport,
isVolumeSegmentation,
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(
@ -645,21 +592,22 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
const colorLUTIndex = addColorLUT(colorLUT);
this._segmentationIdToColorLUTIndexMap.set(segmentationId, colorLUTIndex);
// 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({
const seg: cstTypes.SegmentationPublicInput = {
segmentationId,
segDisplaySet,
derivedImageIds,
referencedImageIds: imageIds as string[],
label: segDisplaySet.SeriesDescription,
fallbackLabel: `S:${segDisplaySet.SeriesNumber} ${segDisplaySet.Modality}`,
segments,
});
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.isLoaded = true;
@ -1567,17 +1515,13 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
viewportIds.forEach(viewportId => {
const { viewport } = getEnabledElementByViewportId(viewportId);
if (!viewport) {
if (!viewport?.jumpToWorld) {
return;
}
// 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);
viewport.jumpToWorld(world);
didJump &&
highlightSegment &&
highlightSegment &&
this.highlightSegment(
segmentationId,
segmentIndex,
@ -1682,11 +1626,87 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
);
}
// 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 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 };
}
private async _addSegmentationRepresentation(
viewportId: string,
@ -1696,7 +1716,6 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
isConverted: boolean,
config?: {
blendMode?: csEnums.BlendModes;
useSliceRendering?: boolean;
}
): Promise<void> {
const representation = {
@ -1724,7 +1743,7 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
addRepresentation();
}
}
public async handleVolumeViewport(
private async handleVolumeViewport(
viewport: csTypes.IVolumeViewport,
segmentation: SegmentationData,
isVolumeSegmentation: boolean
@ -1742,7 +1761,7 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
}
}
public async convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise<boolean> {
private async convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise<boolean> {
const { viewportGridService, cornerstoneViewportService } = this.servicesManager.services;
const state = viewportGridService.getState();
const gridViewport = state.viewports.get(viewport.id);
@ -1781,7 +1800,7 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
return true;
}
public async attemptStackToVolumeConversion(
private async attemptStackToVolumeConversion(
viewport: csTypes.IStackViewport,
segmentation: SegmentationData,
viewportId: string,
@ -1801,10 +1820,6 @@ class SegmentationService extends PubSubService implements ISegmentationServiceI
return isConverted;
}
// Frame-of-reference mismatch (or missing): no conversion happened. Return an
// explicit boolean so the Promise<boolean> contract holds for callers.
return false;
}
private addSegmentationToSource(segmentationPublicInput: cstTypes.SegmentationPublicInput) {

View File

@ -1,102 +0,0 @@
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<number> };
}
/** 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<LabelmapAddClassification>;
/**
* 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;
}

View File

@ -1,44 +0,0 @@
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<boolean>;
/**
* 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<boolean>;
/**
* 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<void>;
}

View File

@ -1,139 +0,0 @@
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<LabelmapAddClassification> {
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<LabelmapAddClassification> {
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<LabelmapAddClassification> {
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;
}
}

View File

@ -1,159 +0,0 @@
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<LabelmapAddClassification> {
// 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<number, { labelmapId: string; labelValue: number }> = {};
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<number>();
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;
}
}

View File

@ -1,4 +0,0 @@
export type { ISegmentationBackend, LabelmapAddClassification } from './ISegmentationBackend';
export type { ISegmentationServiceInternals } from './ISegmentationServiceInternals';
export { LegacySegmentationBackend } from './LegacySegmentationBackend';
export { NextSegmentationBackend } from './NextSegmentationBackend';

View File

@ -35,19 +35,13 @@ 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',
@ -114,10 +108,7 @@ 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, IViewportServiceInternals
{
class CornerstoneViewportService extends PubSubService implements IViewportService {
static REGISTRATION = {
name: 'cornerstoneViewportService',
altName: 'CornerstoneViewportService',
@ -142,32 +133,12 @@ class CornerstoneViewportService
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;
@ -265,8 +236,6 @@ class CornerstoneViewportService
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) {
@ -288,10 +257,6 @@ class CornerstoneViewportService
* @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
@ -307,12 +272,6 @@ class CornerstoneViewportService
* @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
@ -420,9 +379,12 @@ class CornerstoneViewportService
const viewportInfo = this.viewportsById.get(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);
return {
viewportType: viewportInfo.getViewportType(),
viewReference: isVolume3DViewportType(csViewport) ? null : csViewport.getViewReference(),
viewPresentation: csViewport.getViewPresentation({ pan: true, zoom: true }),
viewportId,
};
}
private _getLutPresentation(viewportId: string): LutPresentation {
@ -449,7 +411,7 @@ class CornerstoneViewportService
const properties = isVolumeViewportType(csViewport)
? new Map()
: cleanProperties(getViewportAdapter(csViewport).getPresentation());
: cleanProperties(csViewport.getProperties());
if (properties instanceof Map) {
const volumeIds = (csViewport as Types.IBaseVolumeViewport).getAllVolumeIds();
@ -699,8 +661,7 @@ class CornerstoneViewportService
for (const id of this.viewportsById.keys()) {
const viewport = this.getCornerstoneViewport(id);
// Lane-appropriate view-plane normal (legacy getCamera vs native getViewReference).
const viewPlaneNormal = viewportOperations.getViewPlaneNormal(viewport);
const { viewPlaneNormal } = viewport.getCamera();
if (!viewPlaneNormal) {
continue;
@ -869,8 +830,7 @@ class CornerstoneViewportService
/**
* Sets the image data for the given viewport.
*/
// Public so the viewport backends (IViewportServiceInternals) can dispatch to it.
async _setEcgViewport(
private async _setEcgViewport(
viewport: Types.IECGViewport,
viewportData: StackViewportData
): Promise<void> {
@ -880,29 +840,38 @@ class CornerstoneViewportService
console.error('[CornerstoneViewportService] ECG display set has no imageId');
return;
}
return this.backend.mountEcg(viewport, displaySet, imageId);
return viewport.setEcg(imageId);
}
// Public so the viewport backends (IViewportServiceInternals) can dispatch to it.
async _setOtherViewport(
private async _setOtherViewport(
viewport: Types.IStackViewport,
viewportData: StackViewportData,
viewportInfo: ViewportInfo,
_presentations: Presentations = {}
): Promise<void> {
const [displaySet] = viewportData.data;
await this.backend.mountOther(viewport, displaySet);
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 });
const viewReference = viewportInfo.getViewReference();
if (viewReference) {
viewport.setViewReference(viewReference);
}
}
// Public so the viewport backends (IViewportServiceInternals) can dispatch to it.
async _setStackViewport(
private async _setStackViewport(
viewport: Types.IStackViewport,
viewportData: StackViewportData,
viewportInfo: ViewportInfo,
@ -955,7 +924,7 @@ class CornerstoneViewportService
const overlayProcessingResults = this._processExtraDisplaySetsForViewport(viewport);
const referencedImageId = presentations?.positionPresentation?.viewReference?.referencedImageId;
if (referencedImageId && imageIds) {
if (referencedImageId) {
initialImageIndexToUse = imageIds.indexOf(referencedImageId);
}
@ -967,20 +936,21 @@ class CornerstoneViewportService
initialImageIndexToUse = this._getInitialImageIndexForViewport(viewportInfo, imageIds) || 0;
}
// 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,
});
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 });
}
}
private _getInitialImageIndexForViewport(
@ -1179,24 +1149,6 @@ class CornerstoneViewportService
// 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) {
@ -1261,10 +1213,7 @@ class CornerstoneViewportService
await viewport.setVolumes(baseVolumeInputs);
}
} else if (volumeInputArray.length) {
// 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 viewport.setVolumes(volumeInputArray);
}
await this._addOverlayRepresentations(overlayProcessingResults);
@ -1463,9 +1412,7 @@ class CornerstoneViewportService
return applyRepresentation();
}
// Public so the viewport backends (IViewportServiceInternals) can run the
// pending overlay adds from their mount bodies.
async _addOverlayRepresentations(
private async _addOverlayRepresentations(
overlayProcessingResults?: Array<{ addOverlayFn?: () => Promise<void> }>
): Promise<void> {
if (!overlayProcessingResults?.length) {
@ -1483,20 +1430,21 @@ class CornerstoneViewportService
public updateViewport(viewportId: string, viewportData, keepCamera = false) {
const viewportInfo = this.getViewportInfo(viewportId);
const viewport = this.getCornerstoneViewport(viewportId);
const viewportCamera = viewport.getCamera();
// 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
);
let displaySetPromise;
// remount() returns undefined for viewport families with no re-mount path
// (matching legacy behavior); nothing changed, so skip the event broadcast.
if (!displaySetPromise) {
return;
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);
}
displaySetPromise.then(() => {
@ -1513,11 +1461,37 @@ class CornerstoneViewportService
viewportInfo: ViewportInfo,
presentations: Presentations = {}
): Promise<void> {
// 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);
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
);
}
/**
@ -1652,20 +1626,44 @@ class CornerstoneViewportService
viewport: Types.IStackViewport | Types.IVolumeViewport,
lutPresentation: LutPresentation
): void {
// 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);
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);
}
}
private _setPositionPresentation(
viewport: Types.IStackViewport | Types.IVolumeViewport,
positionPresentation: PositionPresentation
): void {
// 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);
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);
}
}
private _setSegmentationPresentation(

View File

@ -227,16 +227,9 @@ 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 (
dataShapeType === Enums.ViewportType.ORTHOGRAPHIC ||
dataShapeType === Enums.ViewportType.VOLUME_3D
viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC ||
viewportData.viewportType === Enums.ViewportType.VOLUME_3D
) {
viewportData = viewportData as VolumeViewportData;
return viewportData.data.some(

View File

@ -1,443 +0,0 @@
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<string, unknown> = {}): 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<string, unknown> = {}): 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<string, unknown> = {}): 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 });
});
});

View File

@ -1,179 +0,0 @@
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<string, unknown>;
/**
* 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<void>;
}

View File

@ -1,240 +0,0 @@
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<string, unknown> | undefined;
setCamera?: (patch: Record<string, unknown>) => 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<unknown>;
setVolumes?: (volumes: Array<{ volumeId: string }>) => Promise<unknown>;
};
/**
* 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<void> {
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);
}
}
}

View File

@ -1,233 +0,0 @@
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<string, unknown>;
}) => Promise<void>;
};
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<void> {
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());
}
}

View File

@ -1,62 +0,0 @@
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<object, IViewportAdapter>();
/**
* 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<typeof NextViewportAdapter>[0])
: new LegacyViewportAdapter(
viewport as ConstructorParameters<typeof LegacyViewportAdapter>[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;
}

View File

@ -1,16 +0,0 @@
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';

View File

@ -1,195 +0,0 @@
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<void>;
};
/**
* 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<string, unknown>;
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<string, unknown>; 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<void>;
/**
* 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<void>;
/**
* 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<boolean>;
/**
* 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<void>;
/**
* 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<void>;
/**
* 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<void>;
/**
* 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<void> | 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;
}

View File

@ -1,98 +0,0 @@
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<string, unknown>;
/** 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<string, unknown>): 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;
}

View File

@ -1,47 +0,0 @@
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<void>;
_setVolumeViewport(
viewport: Types.IVolumeViewport,
viewportData: VolumeViewportData,
viewportInfo: ViewportInfo,
presentations?: Presentations
): Promise<void>;
_setEcgViewport(viewport: Types.IECGViewport, viewportData: StackViewportData): Promise<void>;
_setOtherViewport(
viewport: Types.IStackViewport,
viewportData: StackViewportData,
viewportInfo: ViewportInfo,
presentations?: Presentations
): Promise<void>;
/** 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<void>;
/** Records which display sets a viewport shows (service bookkeeping). */
_trackViewportDisplaySets(viewportId: string, displaySetInstanceUIDs: string[]): void;
}

View File

@ -1,264 +0,0 @@
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<void> {
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<void> {
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<typeof viewport.setProperties>[0]);
}
if (flipHorizontal) {
viewport.setCamera({ flipHorizontal: true });
}
}
async mountVolumes(): Promise<boolean> {
// 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<void> {
await (viewport as Types.IVolumeViewport).setVolumes(volumeInputArray as Types.IVolumeInput[]);
}
async mountEcg(
viewport: Types.IECGViewport,
_displaySet: { displaySetInstanceUID: string; imageIds?: string[] },
imageId: string
): Promise<void> {
return viewport.setEcg(imageId);
}
async mountOther(
viewport: Types.IViewport,
displaySet: { displaySetInstanceUID: string; imageIds: string[] }
): Promise<void> {
// 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<void>;
}
).setDisplaySets({ displaySetId });
}
remount(
viewport: Types.IViewport,
viewportData: StackViewportData | VolumeViewportData,
viewportInfo: ViewportInfo,
keepCamera: boolean
): Promise<void> | undefined {
let displaySetPromise: Promise<void> | 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();
}
}

View File

@ -1,272 +0,0 @@
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<string, (...args: unknown[]) => unknown>;
getProperty: () => Record<string, (...args: unknown[]) => 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<string, unknown>
): 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);
}
},
};

View File

@ -1,671 +0,0 @@
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
* `<viewportType>.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<string, unknown>;
setViewState: (patch: Record<string, unknown>) => 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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<void> {
// 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<string, unknown>;
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<void> {
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<string, unknown>;
}) => Promise<void>;
setDisplaySetPresentation: (props: Record<string, unknown>) => 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<string, unknown> = {};
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<string, unknown> = {};
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<boolean> {
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<void> {
// 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<void> {
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<void>;
}
).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<void> {
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<void>;
}
).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<void> {
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<void> {
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<string, unknown> }>
) => Promise<void>;
setDisplaySetPresentation: (dataId: string, props: Record<string, unknown>) => void;
getDefaultActor?: () => { actor?: unknown } | undefined;
render: () => void;
};
const setDisplaySetsEntries: Array<{
displaySetId: string;
options: Record<string, unknown>;
}> = [];
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<string, unknown> = {};
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<typeof csUtils.applyPreset>[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<string, unknown>
| undefined;
if (vpres && typeof vp.setViewState === 'function') {
const patch: Record<string, unknown> = {};
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<string, unknown>) => void;
};
const presentationProps: Record<string, unknown> = {};
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();
}
}

View File

@ -1,169 +0,0 @@
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<typeof csUtils.applyPreset>[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);
},
};

View File

@ -1,62 +0,0 @@
# 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.

View File

@ -1,121 +0,0 @@
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<string, number>();
// 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<string, DataIdPayload>();
// Per-viewport ledger of the dataIds it registered, to drive release on unmount.
private readonly byViewport = new Map<string, string[]>();
/**
* 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();
}
}

View File

@ -1,71 +0,0 @@
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<string, unknown>) =>
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),
};

View File

@ -54,17 +54,8 @@ 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) => {

View File

@ -21,19 +21,11 @@ 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[];
};

View File

@ -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 { getViewportAdapter } from '../services/ViewportService/adapter';
import { isStackViewportType, isVolumeViewportType } from './getLegacyViewportType';
import { useSystem } from '@ohif/core/src';
const { downloadUrl } = utils;
@ -119,12 +119,35 @@ const CornerstoneViewportDownloadForm = ({
const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID);
try {
// 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);
// 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);
}
downloadViewport.render();

View File

@ -11,11 +11,6 @@ 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',
},
},
}));
@ -63,7 +58,7 @@ describe('getCornerstoneViewportType', () => {
it('should throw error for invalid viewport type', () => {
expect(() => getCornerstoneViewportType('invalid')).toThrow(
'Invalid viewport type: invalid. Valid types are: stack, volume, orthographic, volume3d, video, wholeslide, ecg'
'Invalid viewport type: invalid. Valid types are: stack, volume, video, wholeslide, ecg'
);
});
@ -99,71 +94,4 @@ 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
);
});
});
});

View File

@ -1,6 +1,5 @@
import type { Types } from '@ohif/core';
import { Enums } from '@cornerstonejs/core';
import { isNextViewportsEnabled } from './nextViewports';
const STACK = 'stack';
const VOLUME = 'volume';
@ -12,53 +11,10 @@ const ECG = 'ecg';
export default function getCornerstoneViewportType(
viewportType: string,
displaySets?: Types.DisplaySet[],
useNextViewports = isNextViewportsEnabled()
displaySets?: Types.DisplaySet[]
): 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;
}
@ -83,6 +39,6 @@ export default function getCornerstoneViewportType(
}
throw new Error(
`Invalid viewport type: ${viewportType}. Valid types are: stack, volume, orthographic, volume3d, video, wholeslide, ecg`
`Invalid viewport type: ${viewportType}. Valid types are: stack, volume, video, wholeslide, ecg`
);
}

View File

@ -0,0 +1,22 @@
/**
* 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;
}

View File

@ -23,42 +23,44 @@ 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;
}
/**
* 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 VOLUME_3D viewport. Replaces `instanceof VolumeViewport3D`. */
export function isVolume3DViewportType(
viewport: unknown
): viewport is csTypes.IVolumeViewport {
return getLegacyViewportType(viewport) === ViewportType.VOLUME_3D;
}
/**
* 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
);
}

View File

@ -1,41 +0,0 @@
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;
}

View File

@ -1,157 +0,0 @@
/**
* 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<string, string> = {
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<string, string>;
}
/**
* Resolves the render backend selection at init.
*
* `?viewportRendering=cpu|webgl|webgpu|auto` selects the render backend for
* all viewports per-session, and `?<viewportType>.viewportRendering=<backend>`
* (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<string, string> = {};
export function setViewportRenderingOverrides(overrides: Record<string, string>): 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);
}

View File

@ -1,85 +0,0 @@
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()
);
}

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-default",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "Common/default features and functionality for basic image viewing",
"author": "OHIF Core Team",
"license": "MIT",

View File

@ -165,11 +165,8 @@ const DicomTagBrowser = ({
</div>
{shouldShowInstanceList && (
<div className="mx-auto mt-0.5 flex w-1/4 flex-col">
<span className="text-muted-foreground flex h-6 min-w-0 items-center whitespace-nowrap pb-2 text-base">
<span className="truncate">Instance Number</span>
<span className="shrink-0">
&nbsp;({instanceNumber} of {activeDisplaySet?.images?.length})
</span>
<span className="text-muted-foreground flex h-6 items-center pb-2 text-base">
Instance Number ({instanceNumber} of {activeDisplaySet?.images?.length})
</span>
<Slider
value={[instanceNumber]}

View File

@ -25,7 +25,6 @@ import {
} from '../utils/dicomWriter';
import { getGetThumbnailSrc, ThumbnailContext } from './retrieveThumbnail';
import { getRenderedURL } from './retrieveRendered';
import retrieveBulkData from './retrieveBulkData';
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
@ -148,54 +147,6 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
// this is part of hte base standard.
dicomWebConfig.bulkDataURI ||= { enabled: true };
/**
* Adds the retrieve bulkdata function to naturalized DICOM data.
* This is done recursively, for sub-sequences. Shared by both the lazy
* (async) and non-lazy (sync) series-metadata retrieval paths.
*/
const addRetrieveBulkDataNaturalized = (naturalized, instance = naturalized) => {
if (!naturalized) {
return naturalized;
}
for (const key of Object.keys(naturalized)) {
const value = naturalized[key];
if (Array.isArray(value) && typeof value[0] === 'object') {
// Fix recursive values
const validValues = value.filter(Boolean);
validValues.forEach(child => addRetrieveBulkDataNaturalized(child, instance));
continue;
}
// The value.Value will be set with the bulkdata read value
// in which case it isn't necessary to re-read this.
if (value && value.BulkDataURI && !value.Value) {
// handle the scenarios where bulkDataURI is relative path
fixBulkDataURI(value, instance, dicomWebConfig);
// Provide a method to fetch bulkdata
value.retrieveBulkData = retrieveBulkData.bind(qidoDicomWebClient, value);
}
}
return naturalized;
};
/**
* naturalizes the dataset, and adds a retrieve bulkdata method
* to any values containing BulkDataURI.
* @param {*} instance
* @returns naturalized dataset, with retrieveBulkData methods
*/
const addRetrieveBulkData = instance => {
const naturalized = naturalizeDataset(instance);
// if we know the server doesn't use bulkDataURI, then don't
if (!dicomWebConfig.bulkDataURI?.enabled) {
return naturalized;
}
return addRetrieveBulkDataNaturalized(naturalized);
};
const implementation = {
initialize: ({ params, query }) => {
if (dicomWebConfig.onConfiguration && typeof dicomWebConfig.onConfiguration === 'function') {
@ -550,16 +501,8 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
dicomWebConfig
);
// first naturalize the data, attaching bulkdata retrieve methods so that
// bulkdata-valued tags can be resolved (matching the lazy-load path).
const naturalizedInstancesMetadata = data.map(addRetrieveBulkData);
// Resolve the registered bulkdata tags (e.g. the Philips SUV Scale
// Factor) delivered as bulkdata into plain numbers BEFORE
// INSTANCES_ADDED fires. retrieveBulkData is bound to qidoDicomWebClient,
// so refresh its auth headers first (matching every other qido op here).
qidoDicomWebClient.headers = getAuthorizationHeader();
await utils.resolveBulkDataTags(naturalizedInstancesMetadata);
// first naturalize the data
const naturalizedInstancesMetadata = data.map(naturalizeDataset);
const seriesSummaryMetadata = {};
const instancesPerSeries = {};
@ -633,18 +576,56 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
dicomWebConfig
);
// Async load series, store as retrieved
async function storeInstances(instances) {
const naturalizedInstances = instances.map(addRetrieveBulkData);
/**
* Adds the retrieve bulkdata function to naturalized DICOM data.
* This is done recursively, for sub-sequences.
*/
const addRetrieveBulkDataNaturalized = (naturalized, instance = naturalized) => {
if (!naturalized) {
return naturalized;
}
for (const key of Object.keys(naturalized)) {
const value = naturalized[key];
// Resolve the registered bulkdata tags (e.g. the Philips SUV Scale
// Factor) that the server delivered as bulkdata into plain numbers
// BEFORE INSTANCES_ADDED fires, so SUV scaling and every other
// subscriber read a fully-resolved value rather than an unresolved
// { BulkDataURI }. retrieveBulkData is bound to qidoDicomWebClient, so
// refresh its auth headers first (matching every other qido op here).
qidoDicomWebClient.headers = getAuthorizationHeader();
await utils.resolveBulkDataTags(naturalizedInstances);
if (Array.isArray(value) && typeof value[0] === 'object') {
// Fix recursive values
const validValues = value.filter(Boolean);
validValues.forEach(child => addRetrieveBulkDataNaturalized(child, instance));
continue;
}
// The value.Value will be set with the bulkdata read value
// in which case it isn't necessary to re-read this.
if (value && value.BulkDataURI && !value.Value) {
// handle the scenarios where bulkDataURI is relative path
fixBulkDataURI(value, instance, dicomWebConfig);
// Provide a method to fetch bulkdata
value.retrieveBulkData = retrieveBulkData.bind(qidoDicomWebClient, value);
}
}
return naturalized;
};
/**
* naturalizes the dataset, and adds a retrieve bulkdata method
* to any values containing BulkDataURI.
* @param {*} instance
* @returns naturalized dataset, with retrieveBulkData methods
*/
const addRetrieveBulkData = instance => {
const naturalized = naturalizeDataset(instance);
// if we know the server doesn't use bulkDataURI, then don't
if (!dicomWebConfig.bulkDataURI?.enabled) {
return naturalized;
}
return addRetrieveBulkDataNaturalized(naturalized);
};
// Async load series, store as retrieved
function storeInstances(instances) {
const naturalizedInstances = instances.map(addRetrieveBulkData);
// Adding instanceMetadata to OHIF MetadataProvider
naturalizedInstances.forEach(instance => {
@ -697,44 +678,20 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
let completedSeriesCount = 0;
const seriesDeliveredPromises = seriesPromises.map(promise => {
let deliveredPromise;
return {
metadata: promise.metadata,
start: () => {
if (!deliveredPromise) {
deliveredPromise = promise.start().then(async instances => {
await storeInstances(instances);
completedSeriesCount++;
if (returnPromises && completedSeriesCount === seriesPromises.length) {
setSuccessFlag();
}
return instances;
});
}
return deliveredPromise;
},
};
if (!returnPromises) {
promise?.start();
}
return promise.then(instances => {
storeInstances(instances);
});
});
if (returnPromises) {
if (!seriesDeliveredPromises.length) {
setSuccessFlag();
}
// The route starts only the series required by the hanging protocol,
// then starts the remainder in the background. Return wrappers whose
// start() resolves after async metadata post-processing has stored the
// instances and fired INSTANCES_ADDED; resolving the raw retrieval here
// races hanging-protocol application against display-set creation.
return seriesDeliveredPromises;
Promise.all(seriesDeliveredPromises).then(() => setSuccessFlag());
return seriesPromises;
} else {
await Promise.all(seriesDeliveredPromises.map(promise => promise.start()));
await Promise.all(seriesDeliveredPromises);
setSuccessFlag();
}
@ -804,4 +761,34 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
return IWebApiDataSource.create(implementation);
}
/**
* A bindable function that retrieves the bulk data against this as the
* dicomweb client, and on the given value element.
*
* @param value - a bind value that stores the retrieve value to short circuit the
* next retrieve instance.
* @param options - to allow specifying the content type.
*/
function retrieveBulkData(value, options = {}) {
const { mediaType } = options;
const useOptions = {
// The bulkdata fetches work with either multipart or
// singlepart, so set multipart to false to let the server
// decide which type to respond with.
multipart: false,
BulkDataURI: value.BulkDataURI,
mediaTypes: mediaType ? [{ mediaType }, { mediaType: 'application/octet-stream' }] : undefined,
...options,
};
return this.retrieveBulkData(useOptions).then(val => {
// There are DICOM PDF cases where the first ArrayBuffer in the array is
// the bulk data and DICOM video cases where the second ArrayBuffer is
// the bulk data. Here we play it safe and do a find.
const ret =
(val instanceof Array && val.find(arrayBuffer => arrayBuffer?.byteLength)) || undefined;
value.Value = ret;
return ret;
});
}
export { createDicomWebApi };

View File

@ -1,35 +0,0 @@
import retrieveBulkData from './retrieveBulkData';
describe('retrieveBulkData', () => {
it('returns and caches a single-part ArrayBuffer response', async () => {
const buffer = new Uint8Array([1]).buffer;
const client = {
retrieveBulkData: jest.fn().mockResolvedValue(buffer),
};
const value: { BulkDataURI: string; Value?: ArrayBuffer } = {
BulkDataURI: 'https://example.com/bulk/70531000',
};
await expect(retrieveBulkData.call(client, value)).resolves.toBe(buffer);
expect(value.Value).toBe(buffer);
expect(client.retrieveBulkData).toHaveBeenCalledWith(
expect.objectContaining({
multipart: false,
BulkDataURI: value.BulkDataURI,
})
);
});
it('still selects the non-empty buffer from a multipart response', async () => {
const buffer = new Uint8Array([2]).buffer;
const client = {
retrieveBulkData: jest.fn().mockResolvedValue([new ArrayBuffer(0), buffer]),
};
const value: { BulkDataURI: string; Value?: ArrayBuffer } = {
BulkDataURI: 'https://example.com/bulk/70531009',
};
await expect(retrieveBulkData.call(client, value)).resolves.toBe(buffer);
expect(value.Value).toBe(buffer);
});
});

View File

@ -1,31 +0,0 @@
/**
* A bindable function that retrieves bulkdata against `this` DICOMweb client
* and caches the resolved buffer on the given value element.
*
* @param value - A bound value that stores the retrieved buffer.
* @param options - Options such as the requested content type.
*/
export default function retrieveBulkData(value, options = {}) {
const { mediaType } = options;
const useOptions = {
// The bulkdata fetches work with either multipart or singlepart, so set
// multipart to false to let the server decide which type to respond with.
multipart: false,
BulkDataURI: value.BulkDataURI,
mediaTypes: mediaType ? [{ mediaType }, { mediaType: 'application/octet-stream' }] : undefined,
...options,
};
return this.retrieveBulkData(useOptions).then(val => {
// Single-part clients return a bare ArrayBuffer. Multipart clients return
// an array; DICOM PDF/video payloads may occupy different positions, so
// select the first non-empty part in that response shape.
const ret = Array.isArray(val)
? val.find(arrayBuffer => arrayBuffer?.byteLength)
: val?.byteLength
? val
: undefined;
value.Value = ret;
return ret;
});
}

View File

@ -3,7 +3,7 @@ import classnames from 'classnames';
import { useNavigate } from 'react-router-dom';
import { useAppConfig } from '@state';
import { Button } from '@ohif/ui-next';
import { Button, ButtonEnums } from '@ohif/ui';
function DataSourceSelector() {
const [appConfig] = useAppConfig();
@ -31,8 +31,8 @@ function DataSourceSelector() {
{ds.configuration?.friendlyName || ds.friendlyName}
</h1>
<Button
variant="secondary"
className={classnames('ml-2', 'mt-1')}
type={ButtonEnums.type.primary}
className={classnames('ml-2')}
onClick={() => {
navigate({
pathname: '/',

View File

@ -0,0 +1,42 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { ToolbarButton } from '@ohif/ui';
function NestedMenu({ children, label = 'More', icon = 'tool-more-menu', isActive }) {
const [isOpen, setIsOpen] = useState(false);
const toggleNestedMenu = () => setIsOpen(!isOpen);
const closeNestedMenu = () => {
if (isOpen) {
setIsOpen(false);
}
};
useEffect(() => {
window.addEventListener('click', closeNestedMenu);
return () => {
window.removeEventListener('click', closeNestedMenu);
};
}, [isOpen]);
return (
<ToolbarButton
id="NestedMenu"
label={label}
icon={icon}
onClick={toggleNestedMenu}
dropdownContent={isOpen && children}
isActive={isActive || isOpen}
type="primary"
/>
);
}
NestedMenu.propTypes = {
children: PropTypes.any.isRequired,
icon: PropTypes.string,
label: PropTypes.string,
};
export default NestedMenu;

View File

@ -1,5 +1,5 @@
import { ContextMenuViewport } from '@ohif/ui-next';
import { ContextMenu } from '@ohif/ui';
export default {
'ui.contextMenu': ContextMenuViewport,
'ui.contextMenu': ContextMenu,
};

View File

@ -1,102 +0,0 @@
const mockGet = jest.fn();
// Minimal @ohif/core mock: the real utils.toNumber (used by coerceNumber) and a
// stubbed MetadataProvider.get.
jest.mock('@ohif/core', () => {
const toNumber = (val: unknown) =>
Array.isArray(val)
? val.map(v => (v !== undefined ? Number(v) : v))
: val !== undefined
? Number(val)
: val;
const core = {
classes: { MetadataProvider: { get: (...args: unknown[]) => mockGet(...args) } },
utils: { toNumber },
};
return { __esModule: true, default: core, utils: core.utils };
});
import getPTImageIdInstanceMetadata from './getPTImageIdInstanceMetadata';
// A valid PT instance with the radiopharmaceutical sequence in dcmjs array form.
const baseInstance = () => ({
Modality: 'PT',
Units: 'CNTS',
CorrectedImage: ['DECY', 'ATTN'],
SeriesDate: '20130606',
SeriesTime: '120000',
AcquisitionDate: '20130606',
AcquisitionTime: '120000',
DecayCorrection: 'START',
PatientWeight: 70,
RadiopharmaceuticalInformationSequence: [
{
RadionuclideHalfLife: 6586.2,
RadionuclideTotalDose: 370000000,
RadiopharmaceuticalStartTime: '110000',
},
],
});
afterEach(() => mockGet.mockReset());
describe('getPTImageIdInstanceMetadata', () => {
it('throws when no metadata is available', () => {
mockGet.mockReturnValue(undefined);
expect(() => getPTImageIdInstanceMetadata('img:1')).toThrow('dicom metadata are required');
});
it('throws when required metadata is missing', () => {
const inst = baseInstance();
delete inst.Units;
mockGet.mockReturnValue(inst);
expect(() => getPTImageIdInstanceMetadata('img:1')).toThrow('required metadata are missing');
});
it('reads RadionuclideHalfLife/TotalDose from the array-shaped sequence (firstSequenceItem)', () => {
mockGet.mockReturnValue(baseInstance());
const result = getPTImageIdInstanceMetadata('img:1');
expect(result.RadionuclideHalfLife).toBe(6586.2);
expect(result.RadionuclideTotalDose).toBe(370000000);
});
it('still reads the sequence when flattened to a single object', () => {
const inst = baseInstance();
// some servers/paths deliver a flattened (non-array) sequence
inst.RadiopharmaceuticalInformationSequence =
inst.RadiopharmaceuticalInformationSequence[0];
mockGet.mockReturnValue(inst);
expect(getPTImageIdInstanceMetadata('img:1').RadionuclideHalfLife).toBe(6586.2);
});
it('populates PhilipsPETPrivateGroup when the private tags are numbers', () => {
const inst = baseInstance();
inst['70531000'] = 0.00038;
inst['70531009'] = 1.881732;
mockGet.mockReturnValue(inst);
const result = getPTImageIdInstanceMetadata('img:1');
expect(result.PhilipsPETPrivateGroup).toEqual({
SUVScaleFactor: 0.00038,
ActivityConcentrationScaleFactor: 1.881732,
});
});
it('drops an unresolved bulkdata { BulkDataURI } private tag (coerceNumber backstop)', () => {
const inst = baseInstance();
inst['70531000'] = { BulkDataURI: 'http://x/bulk/70531000' };
inst['70531009'] = { BulkDataURI: 'http://x/bulk/70531009' };
mockGet.mockReturnValue(inst);
// Neither tag coerces to a number, so the group is not attached at all -
// an object can never reach calculate-suv.
expect(getPTImageIdInstanceMetadata('img:1').PhilipsPETPrivateGroup).toBeUndefined();
});
it('coerces a numeric DS string private tag to a number', () => {
const inst = baseInstance();
inst['70531000'] = '0.00038';
mockGet.mockReturnValue(inst);
expect(getPTImageIdInstanceMetadata('img:1').PhilipsPETPrivateGroup.SUVScaleFactor).toBe(
0.00038
);
});
});

View File

@ -1,6 +1,6 @@
import OHIF, { utils } from '@ohif/core';
import OHIF from '@ohif/core';
import type { InstanceMetadata, PhilipsPETPrivateGroup } from '@cornerstonejs/calculate-suv';
import type { InstanceMetadata, PhilipsPETPrivateGroup } from '@cornerstonejs/calculate-suv/src/types';
const metadataProvider = OHIF.classes.MetadataProvider;
@ -11,25 +11,21 @@ export default function getPTImageIdInstanceMetadata(imageId: string): InstanceM
throw new Error('dicom metadata are required');
}
const radiopharmaceuticalInfo = firstSequenceItem<Record<string, unknown>>(
dicomMetaData.RadiopharmaceuticalInformationSequence
);
const radionuclideHalfLife = coerceNumber(radiopharmaceuticalInfo?.RadionuclideHalfLife);
const radionuclideTotalDose = coerceNumber(radiopharmaceuticalInfo?.RadionuclideTotalDose);
if (
dicomMetaData.SeriesDate === undefined ||
dicomMetaData.SeriesTime === undefined ||
dicomMetaData.CorrectedImage === undefined ||
dicomMetaData.Units === undefined ||
!radiopharmaceuticalInfo ||
radionuclideHalfLife === undefined ||
radionuclideTotalDose === undefined ||
!dicomMetaData.RadiopharmaceuticalInformationSequence ||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife === undefined ||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose === undefined ||
dicomMetaData.DecayCorrection === undefined ||
dicomMetaData.AcquisitionDate === undefined ||
dicomMetaData.AcquisitionTime === undefined ||
(radiopharmaceuticalInfo.RadiopharmaceuticalStartDateTime === undefined &&
radiopharmaceuticalInfo.RadiopharmaceuticalStartTime === undefined)
(dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartDateTime ===
undefined &&
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime ===
undefined)
) {
throw new Error('required metadata are missing');
}
@ -41,84 +37,73 @@ export default function getPTImageIdInstanceMetadata(imageId: string): InstanceM
const instanceMetadata: InstanceMetadata = {
CorrectedImage: dicomMetaData.CorrectedImage,
Units: dicomMetaData.Units,
RadionuclideHalfLife: radionuclideHalfLife,
RadionuclideTotalDose: radionuclideTotalDose,
RadiopharmaceuticalStartDateTime: radiopharmaceuticalInfo.RadiopharmaceuticalStartDateTime,
RadiopharmaceuticalStartTime: radiopharmaceuticalInfo.RadiopharmaceuticalStartTime,
RadionuclideHalfLife: dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife,
RadionuclideTotalDose:
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose,
RadiopharmaceuticalStartDateTime:
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartDateTime,
RadiopharmaceuticalStartTime:
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime,
DecayCorrection: dicomMetaData.DecayCorrection,
PatientWeight: coerceNumber(dicomMetaData.PatientWeight),
PatientWeight: dicomMetaData.PatientWeight,
SeriesDate: dicomMetaData.SeriesDate,
SeriesTime: dicomMetaData.SeriesTime,
AcquisitionDate: dicomMetaData.AcquisitionDate,
AcquisitionTime: dicomMetaData.AcquisitionTime,
};
// Philips PET private group. Only populated with values that coerce to real
// numbers; an unresolved bulkdata object yields undefined and is dropped so it
// can never corrupt the SUV calculation. SUVScaleFactor is (7053,1000) and
// ActivityConcentrationScaleFactor is (7053,1009). These are resolved from
// bulkdata upstream during ingestion (utils.resolveBulkDataTags).
const suvScaleFactor = coerceNumber(dicomMetaData['70531000']);
const activityConcentrationScaleFactor = coerceNumber(dicomMetaData['70531009']);
if (suvScaleFactor !== undefined || activityConcentrationScaleFactor !== undefined) {
if (
dicomMetaData['70531000'] ||
dicomMetaData['70531000'] !== undefined ||
dicomMetaData['70531009'] ||
dicomMetaData['70531009'] !== undefined
) {
const philipsPETPrivateGroup: PhilipsPETPrivateGroup = {
SUVScaleFactor: suvScaleFactor,
ActivityConcentrationScaleFactor: activityConcentrationScaleFactor,
SUVScaleFactor: dicomMetaData['70531000'],
ActivityConcentrationScaleFactor: dicomMetaData['70531009'],
};
instanceMetadata.PhilipsPETPrivateGroup = philipsPETPrivateGroup;
}
if (dicomMetaData['0009100d'] !== undefined) {
if (dicomMetaData['0009100d'] && dicomMetaData['0009100d'] !== undefined) {
instanceMetadata.GEPrivatePostInjectionDateTime = dicomMetaData['0009100d'];
}
const frameReferenceTime = coerceNumber(dicomMetaData.FrameReferenceTime);
if (frameReferenceTime !== undefined) {
instanceMetadata.FrameReferenceTime = frameReferenceTime;
if (dicomMetaData.FrameReferenceTime && dicomMetaData.FrameReferenceTime !== undefined) {
instanceMetadata.FrameReferenceTime = dicomMetaData.FrameReferenceTime;
}
const actualFrameDuration = coerceNumber(dicomMetaData.ActualFrameDuration);
if (actualFrameDuration !== undefined) {
instanceMetadata.ActualFrameDuration = actualFrameDuration;
if (dicomMetaData.ActualFrameDuration && dicomMetaData.ActualFrameDuration !== undefined) {
instanceMetadata.ActualFrameDuration = dicomMetaData.ActualFrameDuration;
}
if (dicomMetaData.PatientSex !== undefined) {
if (dicomMetaData.PatientSex && dicomMetaData.PatientSex !== undefined) {
instanceMetadata.PatientSex = dicomMetaData.PatientSex;
}
const patientSize = coerceNumber(dicomMetaData.PatientSize);
if (patientSize !== undefined) {
instanceMetadata.PatientSize = patientSize;
if (dicomMetaData.PatientSize && dicomMetaData.PatientSize !== undefined) {
instanceMetadata.PatientSize = dicomMetaData.PatientSize;
}
return instanceMetadata;
}
function convertInterfaceTimeToString(time): string {
const hours = `${time.hours || '00'}`.padStart(2, '0');
const minutes = `${time.minutes || '00'}`.padStart(2, '0');
const seconds = `${time.seconds || '00'}`.padStart(2, '0');
const fractionalSeconds = `${time.fractionalSeconds || '000000'}`.padEnd(6, '0');
const timeString = `${hours}${minutes}${seconds}.${fractionalSeconds}`;
return timeString;
}
function convertInterfaceDateToString(date): string {
const month = `${date.month}`.padStart(2, '0');
const day = `${date.day}`.padStart(2, '0');
const dateString = `${date.year}${month}${day}`;
return dateString;
}
export { getPTImageIdInstanceMetadata };
/**
* Coerces a naturalized DICOM value into a finite number, or returns undefined.
*
* Delegates to OHIF's `utils.toNumber` and then requires a finite scalar, so an
* object value - such as an unresolved bulkdata reference `{ BulkDataURI }` or
* an array - becomes undefined. This is the final backstop ensuring such a value
* can never reach calculate-suv (which treats it as truthy and silently corrupts
* the SUV factors). Bulkdata is meant to be resolved upstream during ingestion
* (see utils.resolveBulkDataTags); this guard catches anything that slips
* through.
*/
function coerceNumber(value: unknown): number | undefined {
const n = utils.toNumber(value);
return typeof n === 'number' && Number.isFinite(n) ? n : undefined;
}
/**
* Returns the first item of a DICOM sequence, tolerating either the dcmjs
* naturalized array shape or an already-flattened single-object shape.
*/
function firstSequenceItem<T = Record<string, unknown>>(seq: unknown): T | undefined {
if (seq == null || typeof seq !== 'object') {
return undefined;
}
return (Array.isArray(seq) ? seq[0] : seq) as T;
}

View File

@ -1,5 +1,3 @@
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;
@ -95,11 +93,6 @@ 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.
@ -113,17 +106,7 @@ export function configureViewportForLayerAddition(params: {
}
const displaySet = displaySetService.getDisplaySetByUID(uid);
const overlayOptions = createColormapOverlayDisplaySetOptions(
displaySet,
90,
customizationService
);
if (isLiveViewportNext && typeof overlayOptions.colormap?.opacity === 'number') {
overlayOptions.colormap.opacity = NEXT_OVERLAY_OPACITY;
}
return overlayOptions;
return createColormapOverlayDisplaySetOptions(displaySet, 90, customizationService);
});
viewport.displaySetOptions = displaySetOptions;

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-dicom-microscopy",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "OHIF extension for DICOM microscopy",
"author": "Bill Wallace, md-prog",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-dicom-pdf",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "OHIF extension for PDF display",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-dicom-video",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "OHIF extension for video display",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-measurement-tracking",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "Tracking features and functionality for basic image viewing",
"author": "OHIF Core Team",
"license": "MIT",
@ -27,8 +27,8 @@
"start": "pnpm run dev"
},
"peerDependencies": {
"@cornerstonejs/core": "5.4.17",
"@cornerstonejs/tools": "5.4.17",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"@ohif/core": "workspace:*",
"@ohif/extension-cornerstone-dicom-sr": "workspace:*",
"@ohif/extension-default": "workspace:*",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-test",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "OHIF extension used inside e2e testing",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-tmtv",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "OHIF extension for Total Metabolic Tumor Volume",
"author": "OHIF",
"license": "MIT",

View File

@ -10,7 +10,6 @@ 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;
@ -262,15 +261,7 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }:
setStartSliceForROIThresholdTool: () => {
const { viewport } = _getActiveViewportsEnabledElement();
// 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 { focalPoint } = viewport.getCamera();
const selectedAnnotationUIDs = _getAnnotationsSelectedByToolNames(
ROI_THRESHOLD_MANUAL_TOOL_IDS
@ -298,11 +289,8 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }:
const annotation = csTools.annotation.state.getAnnotation(annotationUID);
// get the current focal point (bridged: native viewports use the view reference)
const focalPointToEnd = getViewportFocalPoint(viewport);
if (!focalPointToEnd) {
return;
}
// get the current focal point
const focalPointToEnd = viewport.getCamera().focalPoint;
annotation.data.endCoordinate = focalPointToEnd;
// IMPORTANT: invalidate the toolData for the cached stat to get updated

View File

@ -1,4 +1,3 @@
import { isNextViewportsEnabled, NEXT_FUSION_PT_OPACITY } from '@ohif/extension-cornerstone';
import {
ctAXIAL,
ctCORONAL,
@ -340,20 +339,6 @@ 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,

View File

@ -286,13 +286,6 @@ 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 },
@ -356,13 +349,6 @@ 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 },
@ -426,13 +412,6 @@ 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 },

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-ultrasound-pleura-bline",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "",
"author": "Rodrigo Basilio",
"license": "MIT",
@ -36,8 +36,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/core": "5.4.17",
"@cornerstonejs/tools": "5.4.17",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"@ohif/core": "workspace:*",
"@ohif/extension-cornerstone": "workspace:*",
"@ohif/extension-default": "workspace:*",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-basic-dev-mode",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "Basic OHIF Viewer Using Cornerstone",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-test",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "Basic mode for testing",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-basic",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "A basic mode used to build other modes on top of",
"author": "OHIF Contributors",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-longitudinal",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "Longitudinal Workflow",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-microscopy",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "OHIF mode for DICOM microscopy",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-preclinical-4d",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "4D Workflow",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-segmentation",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "OHIF segmentation mode which enables labelmap segmentation read/edit/export",
"author": "@ohif",
"license": "MIT",

View File

@ -45,7 +45,7 @@ function createTools({ utilityModule, commandsManager }) {
toolName: toolNames.MarkerLabelmap,
},
{
toolName: toolNames.ClickSegment,
toolName: toolNames.RegionSegmentPlus,
},
{
toolName: 'CircularEraser',

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-tmtv",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "Total Metabolic Tumor Volume Workflow",
"author": "OHIF",
"license": "MIT",

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-ultrasound-pleura-bline",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"description": "Allows users to annotate ultrasound images with pleura B-line annotations.",
"author": "OHIF",
"license": "MIT",
@ -29,8 +29,8 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@cornerstonejs/core": "5.4.17",
"@cornerstonejs/tools": "5.4.17",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"@ohif/core": "workspace:*",
"@ohif/extension-cornerstone-dicom-sr": "workspace:*",
"@ohif/extension-ultrasound-pleura-bline": "workspace:*",

View File

@ -124,5 +124,5 @@
"git add"
]
},
"version": "3.13.0-beta.125"
"version": "3.13.0-beta.119"
}

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/app",
"version": "3.13.0-beta.125",
"version": "3.13.0-beta.119",
"productVersion": "3.4.0",
"description": "OHIF Viewer",
"author": "OHIF Contributors",
@ -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.17",
"@cornerstonejs/dicom-image-loader": "5.4.12",
"@emotion/serialize": "1.3.3",
"@ohif/core": "workspace:*",
"@ohif/i18n": "workspace:*",

Some files were not shown because too many files have changed in this diff Show More