Compare commits
11 Commits
v3.13.0-be
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| bbe37ff388 | |||
|
|
f7612cdd0a | ||
|
|
125a174298 | ||
|
|
b880d2acf9 | ||
|
|
03e17d764a | ||
|
|
3d70db2d3b | ||
|
|
110b293aa0 | ||
|
|
70421225d7 | ||
|
|
3c0e245398 | ||
|
|
00bb77d940 | ||
|
|
01939e2236 |
@ -17,7 +17,7 @@ This package follows the agentskills.io SKILL.md convention. `SKILL.md` is the e
|
||||
2. **Read the seed spec.** Consult [references/patterns-by-feature.md](references/patterns-by-feature.md) to find the canonical existing spec for that area. Read it end-to-end before writing. This is the single most important step — OHIF specs follow consistent idioms that are easier to mimic than to reconstruct from first principles. (This mirrors Playwright's own agent guidance: use seed tests as the example for generated tests.)
|
||||
3. **Scaffold from the template.** Start from [assets/spec-template.ts](assets/spec-template.ts) — or copy the seed spec and adapt.
|
||||
4. **Look up specifics in the source, not from memory.** The reference files [page-objects.md](references/page-objects.md) and [utilities.md](references/utilities.md) capture the **stable rules** — fixture keys, import conventions, access idioms, the reasons certain things trip people up. They deliberately do not enumerate methods. For the current method surface or a utility's exact signature, open the relevant file under `tests/pages/` or `tests/utils/` — the source evolves, and the source is always right. The seed spec you picked in step 2 is usually the fastest second source, because it co-evolves with the API.
|
||||
5. **Run the test when execution is available.** `yarn test:e2e:ci` runs the whole suite, but for iteration use `yarn playwright test tests/YourNew.spec.ts` (or via the Playwright VS Code extension).
|
||||
5. **Run the test when execution is available.** `pnpm run test:e2e:ci` runs the whole suite, but for iteration use `TEST_ENV=true pnpm exec playwright test tests/YourNew.spec.ts` (or the Playwright VS Code extension). Invoke Playwright directly for targeted flags; `pnpm run test:e2e -- ...` inserts a `--` separator that can prevent Playwright from parsing options such as `--update-snapshots` and `--reporter`.
|
||||
6. **If runtime execution is unavailable, do static validation.** Validate import source, fixture keys, normalized viewport usage, UID/mode pairing, and hydration/tracking prompt handling. Then report clearly that execution was not performed.
|
||||
7. **If it fails, triage before debugging.** Use [references/failure-triage.md](references/failure-triage.md) — most OHIF test failures are timing / hydration, not real regressions.
|
||||
|
||||
|
||||
@ -6,9 +6,9 @@ Before debugging, classify. Most OHIF test failures are timing or hydration —
|
||||
|----------|---------|-----|
|
||||
| Timing | Element not visible, action timeout | Add / increase the `delay` param of `visitStudy`; for actions that re-render viewports, use `waitForViewportRenderCycle(page)` (started before the action) instead of `waitForTimeout`; wrap the assertion in `expect.toPass({ timeout })` |
|
||||
| Selector | Element not found | Verify `data-cy` on the target; confirm the panel is open (`toggle()` / `select()` before interacting); check for capital `D` in `DOMOverlayPageObject` when destructuring |
|
||||
| Hydration | Segmentation/RT not interactive | Ensure the `segmentationHydration.yes.click()` fired; add `waitForTimeout(3000)` after `loadSeriesByModality('SEG'\|'RTSTRUCT'\|'SR')` |
|
||||
| Hydration | Segmentation/RT/SR not interactive | Ensure the `segmentationHydration.yes.click()` fired; wait for an observable hydrated state such as measurement/segment rows or the target series overlay, then wait for the resulting viewport render |
|
||||
| Data | Study not found, empty viewport | Confirm the UID is in the canonical list (see [patterns-by-feature.md](patterns-by-feature.md)); confirm the mode supports the feature (segmentation tools aren't in `viewer` mode) |
|
||||
| Visual drift | Screenshot mismatch but feature works | Have a human review the diff, then regenerate the baseline with `yarn playwright test --update-snapshots`. Do not adjust `maxDiffPixelRatio` or `threshold` to make a failing screenshot pass. |
|
||||
| Visual drift | Screenshot mismatch but feature works | Have a human review the diff, then regenerate the baseline with `TEST_ENV=true pnpm exec playwright test --update-snapshots`. Do not adjust `maxDiffPixelRatio` or `threshold` to make a failing screenshot pass. |
|
||||
| Real regression | Feature is actually broken | Report as a bug — this is the test doing its job |
|
||||
|
||||
## Prefer render-cycle waits over sleeps
|
||||
@ -33,6 +33,8 @@ Symptom: the test fails inside `waitForAnyViewportNeedsRender` after 5s, with th
|
||||
|
||||
Don't react by raising the cycle's timeout — the transition isn't coming. Replace the cycle wrapper with an auto-retrying DOM/SVG assertion, or `expect.toPass({ timeout })` around the assertion block.
|
||||
|
||||
An immediate `waitForViewportsRendered(page)` can also return too early when a click dispatches work through an asynchronous state machine: the old viewport is already `rendered` before the new series or annotations are applied. In that case, first wait for the target state (for example, hydrated measurement rows or the expected series overlay), then call `waitForViewportsRendered(page)` to settle that state's render.
|
||||
|
||||
## The `toPass` pattern
|
||||
|
||||
When an assertion needs to wait for async render / propagation:
|
||||
@ -64,7 +66,7 @@ await press({ page, key: 'ArrowDown', nTimes: 50 }); // object param, not (page,
|
||||
Screenshots live under `tests/screenshots/chromium/<testFilePath>/`. To accept new output as the baseline:
|
||||
|
||||
```sh
|
||||
yarn playwright test tests/YourSpec.spec.ts --update-snapshots
|
||||
TEST_ENV=true pnpm exec playwright test tests/YourSpec.spec.ts --update-snapshots
|
||||
```
|
||||
|
||||
Review the resulting PNGs carefully — an agent-accepted baseline that's subtly wrong is worse than a failing test.
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone-dicom-pmap",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "DICOM Parametric Map read workflow",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
@ -41,8 +41,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@cornerstonejs/adapters": "5.4.13",
|
||||
"@cornerstonejs/core": "5.4.13",
|
||||
"@cornerstonejs/adapters": "5.4.17",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@kitware/vtk.js": "35.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone-dicom-rt",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "DICOM RT read workflow",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone-dicom-seg",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "DICOM SEG read workflow",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
@ -41,8 +41,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@cornerstonejs/adapters": "5.4.13",
|
||||
"@cornerstonejs/core": "5.4.13",
|
||||
"@cornerstonejs/adapters": "5.4.17",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@kitware/vtk.js": "35.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -6,7 +6,11 @@ import { adaptersRT, adaptersSEG } from '@cornerstonejs/adapters';
|
||||
import { createReportDialogPrompt, useUIStateStore } from '@ohif/extension-default';
|
||||
|
||||
import PROMPT_RESPONSES from '../../default/src/utils/_shared/PROMPT_RESPONSES';
|
||||
import { getSegmentationSaveOptions } from './utils/segmentationConfig';
|
||||
import {
|
||||
getSegmentationSaveOptions,
|
||||
LABELMAP_SEG_SOP_CLASS_UID,
|
||||
BITMAP_SEG_SOP_CLASS_UID,
|
||||
} from './utils/segmentationConfig';
|
||||
|
||||
const getTargetViewport = ({ viewportId, viewportGridService }) => {
|
||||
const { viewports, activeViewportId } = viewportGridService.getState();
|
||||
@ -29,7 +33,6 @@ const {
|
||||
},
|
||||
} = adaptersRT;
|
||||
|
||||
|
||||
const commandsModule = ({
|
||||
servicesManager,
|
||||
extensionManager,
|
||||
@ -105,57 +108,69 @@ const commandsModule = ({
|
||||
: extensionManager.getActiveDataSourceDefinition();
|
||||
const dataSourceStoreOverride = dataSourceDefinition?.configuration?.segmentation?.store;
|
||||
|
||||
const { imageIds } = segmentation.representationData.Labelmap;
|
||||
const labelmapData = segmentation.representationData.Labelmap;
|
||||
|
||||
const segImages = imageIds.map(imageId => cache.getImage(imageId));
|
||||
const referencedImages = segImages.map((segImage, sliceIndex) => {
|
||||
const referencedImage = cache.getImage(segImage.referencedImageId);
|
||||
// Build a labelmap3D (one labelmaps2D entry per source slice) from a list of
|
||||
// derived labelmap image ids. When `referencedImageIds` is supplied (the
|
||||
// multi-layer/overlap path) each frame is indexed by its source slice so the
|
||||
// layers align to the same frames; otherwise frames are sequential (the legacy
|
||||
// single-layer behavior, kept byte-identical).
|
||||
const buildLabelmap3D = (segImageIds: string[], metadata, referencedImageIds?: string[]) => {
|
||||
const segImages = segImageIds.map(imageId => cache.getImage(imageId));
|
||||
const labelmaps2D = [];
|
||||
|
||||
if (!referencedImage) {
|
||||
throw new Error(
|
||||
`Referenced source image not in cache for segmentation slice ${sliceIndex} ` +
|
||||
`(referencedImageId: ${segImage.referencedImageId}). Ensure the referenced series is fully loaded before storing.`
|
||||
);
|
||||
}
|
||||
// Map each source imageId to its frame index once (O(n)) so the per-slice lookup
|
||||
// below is O(1) — avoids the O(slices^2) indexOf scan on the multi-layer path.
|
||||
const referencedFrameIndexById = referencedImageIds
|
||||
? new Map(referencedImageIds.map((imageId, index) => [imageId, index]))
|
||||
: undefined;
|
||||
|
||||
return referencedImage;
|
||||
});
|
||||
let z = 0;
|
||||
|
||||
const labelmaps2D = [];
|
||||
for (const segImage of segImages) {
|
||||
const segmentsOnLabelmap = new Set();
|
||||
const pixelData = segImage.getPixelData();
|
||||
const { rows, columns } = segImage;
|
||||
|
||||
let z = 0;
|
||||
|
||||
for (const segImage of segImages) {
|
||||
const segmentsOnLabelmap = new Set();
|
||||
const pixelData = segImage.getPixelData();
|
||||
const { rows, columns } = segImage;
|
||||
|
||||
// Use a single pass through the pixel data
|
||||
for (let i = 0; i < pixelData.length; i++) {
|
||||
const segment = pixelData[i];
|
||||
if (segment !== 0) {
|
||||
segmentsOnLabelmap.add(segment);
|
||||
// Use a single pass through the pixel data
|
||||
for (let i = 0; i < pixelData.length; i++) {
|
||||
const segment = pixelData[i];
|
||||
if (segment !== 0) {
|
||||
segmentsOnLabelmap.add(segment);
|
||||
}
|
||||
}
|
||||
|
||||
const frameIndex = referencedFrameIndexById
|
||||
? referencedFrameIndexById.get(segImage.referencedImageId) ?? -1
|
||||
: z++;
|
||||
|
||||
if (frameIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
labelmaps2D[frameIndex] = {
|
||||
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
|
||||
pixelData,
|
||||
rows,
|
||||
columns,
|
||||
};
|
||||
}
|
||||
|
||||
labelmaps2D[z++] = {
|
||||
segmentsOnLabelmap: Array.from(segmentsOnLabelmap),
|
||||
pixelData,
|
||||
rows,
|
||||
columns,
|
||||
const allSegmentsOnLabelmap = labelmaps2D
|
||||
.filter(Boolean)
|
||||
.map(labelmap => labelmap.segmentsOnLabelmap);
|
||||
|
||||
return {
|
||||
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
|
||||
metadata,
|
||||
labelmaps2D,
|
||||
};
|
||||
}
|
||||
|
||||
const allSegmentsOnLabelmap = labelmaps2D.map(labelmap => labelmap.segmentsOnLabelmap);
|
||||
|
||||
const labelmap3D = {
|
||||
segmentsOnLabelmap: Array.from(new Set(allSegmentsOnLabelmap.flat())),
|
||||
metadata: [],
|
||||
labelmaps2D,
|
||||
};
|
||||
|
||||
// Segment metadata (shared across all layers).
|
||||
const segmentationInOHIF = segmentationService.getSegmentation(segmentationId);
|
||||
const representations = segmentationService.getRepresentationsForSegmentation(segmentationId);
|
||||
const metadata = [];
|
||||
|
||||
Object.entries(segmentationInOHIF.segments).forEach(([segmentIndex, segment]) => {
|
||||
// segmentation service already has a color for each segment
|
||||
@ -176,7 +191,7 @@ const commandsModule = ({
|
||||
color.slice(0, 3).map(value => value / 255)
|
||||
).map(value => Math.round(value));
|
||||
|
||||
const segmentMetadata = {
|
||||
metadata[segmentIndex] = {
|
||||
SegmentNumber: segmentIndex.toString(),
|
||||
SegmentLabel: label,
|
||||
SegmentAlgorithmType: segment?.algorithmType || 'MANUAL',
|
||||
@ -193,14 +208,76 @@ const commandsModule = ({
|
||||
CodeMeaning: 'Tissue',
|
||||
},
|
||||
};
|
||||
labelmap3D.metadata[segmentIndex] = segmentMetadata;
|
||||
});
|
||||
|
||||
const generatedSegmentation = generateSegmentation(referencedImages, labelmap3D, metaData, {
|
||||
// Multi-layer (overlapping) SEGs register one labelmap layer per conflict-free
|
||||
// group. Export each layer as its own labelmap3D against the UNIQUE referenced
|
||||
// source series, so cornerstone writes overlapping segments as separate frames
|
||||
// that reference the same source slice (the DICOM SEG overlap encoding). The
|
||||
// cs3D adapter's fillSegmentation accepts an array of labelmap3D for exactly
|
||||
// this. Single-layer SEGs keep the original single-labelmap3D path unchanged.
|
||||
const layers = labelmapData.labelmaps ? Object.values(labelmapData.labelmaps) : undefined;
|
||||
|
||||
// The referenced source images must be fully loaded (in cache) before we can
|
||||
// build the SEG dataset against them; fail loudly rather than passing undefined
|
||||
// frames to the adapter.
|
||||
const resolveReferencedImage = (referencedImageId: string, sliceIndex: number) => {
|
||||
const referencedImage = cache.getImage(referencedImageId);
|
||||
if (!referencedImage) {
|
||||
throw new Error(
|
||||
`Referenced source image not in cache for segmentation slice ${sliceIndex} ` +
|
||||
`(referencedImageId: ${referencedImageId}). Ensure the referenced series is fully loaded before storing.`
|
||||
);
|
||||
}
|
||||
return referencedImage;
|
||||
};
|
||||
|
||||
let referencedImages;
|
||||
let labelmaps3D;
|
||||
|
||||
if (layers && layers.length > 1) {
|
||||
const referencedImageIds =
|
||||
layers[0].referencedImageIds ?? labelmapData.referencedImageIds ?? [];
|
||||
referencedImages = referencedImageIds.map(resolveReferencedImage);
|
||||
labelmaps3D = layers.map(layer =>
|
||||
buildLabelmap3D(layer.imageIds ?? [], metadata, referencedImageIds)
|
||||
);
|
||||
} else {
|
||||
const { imageIds } = labelmapData;
|
||||
const segImages = imageIds.map(imageId => cache.getImage(imageId));
|
||||
referencedImages = segImages.map((image, sliceIndex) =>
|
||||
resolveReferencedImage(image.referencedImageId, sliceIndex)
|
||||
);
|
||||
labelmaps3D = buildLabelmap3D(imageIds, metadata);
|
||||
}
|
||||
|
||||
const saveOptions = {
|
||||
predecessorImageId,
|
||||
...getSegmentationSaveOptions(customizationService, dataSourceStoreOverride),
|
||||
...generateOptions,
|
||||
});
|
||||
};
|
||||
|
||||
// A LABELMAP SEG frame stores a single label per voxel, so the labelmap
|
||||
// encoder cannot represent overlapping segments — it keeps only the last
|
||||
// layer written to each voxel. Overlapping segmentations arrive here as
|
||||
// multiple layers, so switch those to the binary SEG encoding, which
|
||||
// writes overlapping segments as separate frames referencing the same
|
||||
// source slice.
|
||||
const hasOverlappingLayers = Boolean(layers && layers.length > 1);
|
||||
if (hasOverlappingLayers && saveOptions.sopClassUID === LABELMAP_SEG_SOP_CLASS_UID) {
|
||||
console.warn(
|
||||
'generateSegmentation: overlapping segments cannot be stored as a LABELMAP SEG; ' +
|
||||
'switching to the binary SEG encoding for this store.'
|
||||
);
|
||||
saveOptions.sopClassUID = BITMAP_SEG_SOP_CLASS_UID;
|
||||
}
|
||||
|
||||
const generatedSegmentation = generateSegmentation(
|
||||
referencedImages,
|
||||
labelmaps3D,
|
||||
metaData,
|
||||
saveOptions
|
||||
);
|
||||
|
||||
return generatedSegmentation;
|
||||
},
|
||||
@ -266,9 +343,7 @@ const commandsModule = ({
|
||||
}
|
||||
|
||||
const defaultFileName =
|
||||
modality === 'RTSTRUCT'
|
||||
? `rtss-${segmentationId}.dcm`
|
||||
: `${label || 'segmentation'}.dcm`;
|
||||
modality === 'RTSTRUCT' ? `rtss-${segmentationId}.dcm` : `${label || 'segmentation'}.dcm`;
|
||||
|
||||
const storeFn = commandsManager.runCommand('createStoreFunction', {
|
||||
dataSource: dataSourceName,
|
||||
|
||||
@ -70,7 +70,10 @@ function SegmentSelector({
|
||||
onValueChange={onValueChange}
|
||||
value={value}
|
||||
>
|
||||
<SelectTrigger className="overflow-hidden">
|
||||
<SelectTrigger
|
||||
className="overflow-hidden"
|
||||
data-cy={`logical-contour-segment-${label.toLowerCase()}-trigger`}
|
||||
>
|
||||
<SelectValue placeholder={t(placeholder)} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -180,6 +183,7 @@ 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>
|
||||
@ -207,6 +211,7 @@ 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={() => {
|
||||
@ -221,6 +226,7 @@ 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">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone-dicom-sr",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"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.13",
|
||||
"@cornerstonejs/core": "5.4.13",
|
||||
"@cornerstonejs/tools": "5.4.13",
|
||||
"@cornerstonejs/adapters": "5.4.17",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@cornerstonejs/tools": "5.4.17",
|
||||
"classnames": "2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -142,15 +142,22 @@ export default function hydrateStructuredReport(
|
||||
hydratableMeasurementsInSR,
|
||||
sopInstanceUIDToImageId
|
||||
);
|
||||
const displaySetsByFrameOfReferenceUID = new Map();
|
||||
|
||||
for (const FrameOfReferenceUID of frameOfReferenceUIDs) {
|
||||
const displaySetsFOR = displaySetService.getDisplaySetsBy(
|
||||
ds => ds.FrameOfReferenceUID === FrameOfReferenceUID && !ds.isDerivedDisplaySet
|
||||
);
|
||||
const ds = chooseDisplaySet(displaySetsFOR, FrameOfReferenceUID);
|
||||
const ds = getReferencedDisplaySet(
|
||||
displaySet,
|
||||
displaySetsFOR,
|
||||
FrameOfReferenceUID,
|
||||
displaySetService
|
||||
);
|
||||
if (!ds) {
|
||||
continue;
|
||||
}
|
||||
displaySetsByFrameOfReferenceUID.set(FrameOfReferenceUID, ds);
|
||||
if (!SeriesInstanceUIDs.includes(ds.SeriesInstanceUID)) {
|
||||
SeriesInstanceUIDs.push(ds.SeriesInstanceUID);
|
||||
}
|
||||
@ -174,7 +181,7 @@ export default function hydrateStructuredReport(
|
||||
const imageId = sopInstanceUIDToImageId[`${toolData.sopInstanceUid}:${frameNumber}`];
|
||||
|
||||
if (!imageId) {
|
||||
return getReferenceData3D(toolData, servicesManager);
|
||||
return getReferenceData3D(toolData, servicesManager, displaySetsByFrameOfReferenceUID);
|
||||
}
|
||||
|
||||
const instance = metaData.get('instance', imageId);
|
||||
@ -316,21 +323,60 @@ function chooseDisplaySet(displaySets, reference) {
|
||||
console.warn('No display set found for', reference);
|
||||
return;
|
||||
}
|
||||
if (displaySets.length === 1) {
|
||||
return displaySets[0];
|
||||
const sortedDisplaySets = OHIF.utils.sortDisplaySetsCopy(displaySets);
|
||||
if (sortedDisplaySets.length === 1) {
|
||||
return sortedDisplaySets[0];
|
||||
}
|
||||
const volumeDs = displaySets.find(ds => ds.isReconstructable);
|
||||
const volumeDs = sortedDisplaySets.find(ds => ds.isReconstructable);
|
||||
if (volumeDs) {
|
||||
return volumeDs;
|
||||
}
|
||||
return displaySets[0];
|
||||
return sortedDisplaySets[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* SCOORD3D only identifies a frame of reference, so many series can be valid
|
||||
* candidates. The SR loader has already selected and recorded a stable display
|
||||
* set for each measurement. Reuse that selection during hydration so the
|
||||
* viewport series and annotation volume cannot depend on display-set load order.
|
||||
*/
|
||||
function getReferencedDisplaySet(
|
||||
srDisplaySet,
|
||||
displaySets,
|
||||
FrameOfReferenceUID,
|
||||
displaySetService
|
||||
) {
|
||||
const referencedDisplaySetInstanceUID = srDisplaySet.measurements?.find(measurement =>
|
||||
measurement.coords?.some(
|
||||
coord =>
|
||||
coord.ValueType === 'SCOORD3D' &&
|
||||
coord.ReferencedFrameOfReferenceSequence === FrameOfReferenceUID
|
||||
)
|
||||
)?.displaySetInstanceUID;
|
||||
|
||||
const referencedDisplaySet = referencedDisplaySetInstanceUID
|
||||
? displaySetService.getDisplaySetByUID(referencedDisplaySetInstanceUID)
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
referencedDisplaySet?.FrameOfReferenceUID === FrameOfReferenceUID &&
|
||||
!referencedDisplaySet.isDerivedDisplaySet
|
||||
) {
|
||||
return referencedDisplaySet;
|
||||
}
|
||||
|
||||
return chooseDisplaySet(displaySets, FrameOfReferenceUID);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the additional reference data appropriate for a 3d reference.
|
||||
* This will choose a volume id, frame of reference and a plane restriction.
|
||||
*/
|
||||
function getReferenceData3D(toolData, servicesManager: Types.ServicesManager) {
|
||||
function getReferenceData3D(
|
||||
toolData,
|
||||
servicesManager: Types.ServicesManager,
|
||||
displaySetsByFrameOfReferenceUID = new Map()
|
||||
) {
|
||||
const { FrameOfReferenceUID } = toolData.annotation.metadata;
|
||||
const { points } = toolData.annotation.data.handles;
|
||||
const { displaySetService } = servicesManager.services;
|
||||
@ -342,7 +388,9 @@ function getReferenceData3D(toolData, servicesManager: Types.ServicesManager) {
|
||||
FrameOfReferenceUID,
|
||||
};
|
||||
}
|
||||
const ds = chooseDisplaySet(displaySetsFOR, toolData.annotation);
|
||||
const ds =
|
||||
displaySetsByFrameOfReferenceUID.get(FrameOfReferenceUID) ||
|
||||
chooseDisplaySet(displaySetsFOR, toolData.annotation);
|
||||
const cameraView = chooseCameraView(ds, points);
|
||||
|
||||
const viewReference = {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone-dynamic-volume",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"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.13",
|
||||
"@cornerstonejs/tools": "5.4.13",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@cornerstonejs/tools": "5.4.17",
|
||||
"classnames": "2.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-cornerstone",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"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.13",
|
||||
"@cornerstonejs/dicom-image-loader": "5.4.17",
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/extension-default": "workspace:*",
|
||||
"@ohif/ui": "workspace:*",
|
||||
@ -52,13 +52,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@cornerstonejs/adapters": "5.4.13",
|
||||
"@cornerstonejs/ai": "5.4.13",
|
||||
"@cornerstonejs/core": "5.4.13",
|
||||
"@cornerstonejs/labelmap-interpolation": "5.4.13",
|
||||
"@cornerstonejs/metadata": "5.4.13",
|
||||
"@cornerstonejs/polymorphic-segmentation": "5.4.13",
|
||||
"@cornerstonejs/tools": "5.4.13",
|
||||
"@cornerstonejs/adapters": "5.4.17",
|
||||
"@cornerstonejs/ai": "5.4.17",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@cornerstonejs/labelmap-interpolation": "5.4.17",
|
||||
"@cornerstonejs/metadata": "5.4.17",
|
||||
"@cornerstonejs/polymorphic-segmentation": "5.4.17",
|
||||
"@cornerstonejs/tools": "5.4.17",
|
||||
"@icr/polyseg-wasm": "0.4.0",
|
||||
"@itk-wasm/morphological-contour-interpolation": "1.1.0",
|
||||
"@kitware/vtk.js": "35.5.3",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { vec3 } from 'gl-matrix';
|
||||
import PropTypes from 'prop-types';
|
||||
import { metaData, Enums, utilities, eventTarget } from '@cornerstonejs/core';
|
||||
import { metaData, Enums, eventTarget } from '@cornerstonejs/core';
|
||||
import { Enums as csToolsEnums, UltrasoundPleuraBLineTool } from '@cornerstonejs/tools';
|
||||
import type { ImageSliceData } from '@cornerstonejs/core/types';
|
||||
import { ViewportOverlay, formatDICOMDate } from '@ohif/ui-next';
|
||||
@ -9,6 +9,8 @@ import type { InstanceMetadata } from '@ohif/core/src/types';
|
||||
import { formatDICOMTime, formatNumberPrecision } from './utils';
|
||||
import { utils } from '@ohif/core';
|
||||
import { StackViewportData, VolumeViewportData } from '../../types/CornerstoneCacheService';
|
||||
import { getViewportAdapter } from '../../services/ViewportService/adapter';
|
||||
import { getViewportDataShapeType } from '../../utils/viewportDataShape';
|
||||
|
||||
import './CustomizableViewportOverlay.css';
|
||||
import { useViewportRendering } from '../../hooks';
|
||||
@ -269,7 +271,7 @@ function getDisplaySets(viewportData, displaySetService) {
|
||||
const getInstanceNumber = (viewportData, viewportId, imageIndex, cornerstoneViewportService) => {
|
||||
let instanceNumber;
|
||||
|
||||
switch (viewportData.viewportType) {
|
||||
switch (getViewportDataShapeType(viewportData)) {
|
||||
case Enums.ViewportType.STACK:
|
||||
instanceNumber = _getInstanceNumberFromStack(viewportData, imageIndex);
|
||||
break;
|
||||
@ -336,8 +338,11 @@ function _getInstanceNumberFromVolume(
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = cornerstoneViewport.getCamera();
|
||||
const { viewPlaneNormal } = camera;
|
||||
const viewPlaneNormal = getViewportAdapter(cornerstoneViewport).getViewPlaneNormal();
|
||||
|
||||
if (!viewPlaneNormal) {
|
||||
return;
|
||||
}
|
||||
// checking if camera is looking at the acquisition plane (defined by the direction on the volume)
|
||||
|
||||
const scanAxisNormal = direction.slice(6, 9);
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Enums, utilities as csUtils } from '@cornerstonejs/core';
|
||||
import { utilities as csUtils } from '@cornerstonejs/core';
|
||||
import { ImageScrollbar } from '@ohif/ui-next';
|
||||
import { isVolume3DViewportType } from '../../utils/getLegacyViewportType';
|
||||
import { getSliceEventName, getViewportSliceCount } from '../../utils/viewportDataShape';
|
||||
|
||||
function CornerstoneImageScrollbar({
|
||||
viewportData,
|
||||
@ -47,10 +48,10 @@ function CornerstoneImageScrollbar({
|
||||
|
||||
try {
|
||||
const imageIndex = viewport.getCurrentImageIdIndex();
|
||||
const numberOfSlices = viewport.getNumberOfSlices();
|
||||
const numberOfSlices = getViewportSliceCount(viewportData, viewport);
|
||||
|
||||
setImageSliceData({
|
||||
imageIndex: imageIndex,
|
||||
imageIndex,
|
||||
numberOfSlices,
|
||||
});
|
||||
} catch (error) {
|
||||
@ -62,11 +63,7 @@ function CornerstoneImageScrollbar({
|
||||
if (!viewportData) {
|
||||
return;
|
||||
}
|
||||
const { viewportType } = viewportData;
|
||||
const eventId =
|
||||
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) ||
|
||||
(viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) ||
|
||||
Enums.Events.IMAGE_RENDERED;
|
||||
const eventId = getSliceEventName(viewportData);
|
||||
|
||||
const updateIndex = event => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
@ -6,6 +6,7 @@ import { vec3 } from 'gl-matrix';
|
||||
|
||||
import './ViewportOrientationMarkers.css';
|
||||
import { useViewportRendering } from '../../hooks';
|
||||
import { getViewportDataShapeType } from '../../utils/viewportDataShape';
|
||||
const { getOrientationStringLPS, invertOrientationStringLPS } = utilities.orientation;
|
||||
|
||||
function ViewportOrientationMarkers({
|
||||
@ -46,7 +47,9 @@ function ViewportOrientationMarkers({
|
||||
return '';
|
||||
}
|
||||
|
||||
if (viewportData.viewportType === 'stack') {
|
||||
// Use the persisted data shape, not viewportType: a native stack reports
|
||||
// PLANAR_NEXT, which would skip this synthetic-IOP default-cosine guard.
|
||||
if (getViewportDataShapeType(viewportData) === Enums.ViewportType.STACK) {
|
||||
const imageIndex = imageSliceData.imageIndex;
|
||||
const imageId = viewportData.data[0].imageIds?.[imageIndex];
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Enums } from '@cornerstonejs/core';
|
||||
import { ViewportData } from './types';
|
||||
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
|
||||
import { getViewportAdapter } from '../../../services/ViewportService/adapter';
|
||||
|
||||
export function getImageIndexFromEvent(event): number | undefined {
|
||||
const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail;
|
||||
@ -24,12 +24,17 @@ export function isProgressFullMode(viewportData: ViewportData, viewport): boolea
|
||||
return false;
|
||||
}
|
||||
|
||||
if (viewportData.viewportType === Enums.ViewportType.STACK) {
|
||||
// A stack renders the full progress UI; an acquisition-plane volume is the
|
||||
// volume-mode equivalent. The adapter classifies both lanes (legacy by
|
||||
// viewport type / isInAcquisitionPlane; native by content mode + view-state
|
||||
// orientation, since PLANAR_NEXT collapses the runtime type).
|
||||
const adapter = getViewportAdapter(viewport);
|
||||
const shape = adapter.getShape();
|
||||
if (shape === 'stack') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC) {
|
||||
return !!viewport.isInAcquisitionPlane?.();
|
||||
if (shape === 'volume') {
|
||||
return adapter.isInAcquisitionPlane();
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
cache as cornerstoneCache,
|
||||
Enums,
|
||||
eventTarget,
|
||||
utilities,
|
||||
} from '@cornerstonejs/core';
|
||||
import { cache as cornerstoneCache, Enums, eventTarget, utilities } from '@cornerstonejs/core';
|
||||
import { useByteArray } from '@ohif/ui-next';
|
||||
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
|
||||
import { getSliceEventName, getViewportSliceCount } from '../../../utils/viewportDataShape';
|
||||
import { getImageIdFromCacheEvent, getImageIndexFromEvent, isProgressFullMode } from './helpers';
|
||||
import { ImageSliceData, ViewportData } from './types';
|
||||
|
||||
@ -97,26 +93,48 @@ export function useViewportSliceSync({
|
||||
return;
|
||||
}
|
||||
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
if (viewport && !isVolume3DViewportType(viewport)) {
|
||||
// Last values we pushed, so re-seeding on camera changes does not churn React
|
||||
// state on pure pan/zoom (which keep the slice geometry unchanged).
|
||||
const lastSlice = { imageIndex: -1, numberOfSlices: -1 };
|
||||
|
||||
const pushSliceData = (imageIndex: number, numberOfSlices: number) => {
|
||||
if (imageIndex === lastSlice.imageIndex && numberOfSlices === lastSlice.numberOfSlices) {
|
||||
return;
|
||||
}
|
||||
lastSlice.imageIndex = imageIndex;
|
||||
lastSlice.numberOfSlices = numberOfSlices;
|
||||
setImageSliceData({ imageIndex, numberOfSlices });
|
||||
};
|
||||
|
||||
// Seeds the shared slice state from the live viewport. Re-run on the initial
|
||||
// effect and on camera/orientation changes (below).
|
||||
const syncFromViewport = () => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
if (!viewport || isVolume3DViewportType(viewport)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const currentImageIndex = viewport.getCurrentImageIdIndex();
|
||||
const currentNumberOfSlices = viewport.getNumberOfSlices();
|
||||
const currentNumberOfSlices = getViewportSliceCount(viewportData, viewport);
|
||||
|
||||
setImageSliceData({
|
||||
imageIndex: currentImageIndex,
|
||||
numberOfSlices: currentNumberOfSlices,
|
||||
});
|
||||
pushSliceData(currentImageIndex, currentNumberOfSlices);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const { viewportType } = viewportData;
|
||||
const eventId =
|
||||
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) ||
|
||||
(viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) ||
|
||||
Enums.Events.IMAGE_RENDERED;
|
||||
syncFromViewport();
|
||||
|
||||
// A post-mount camera carry (e.g. the layout-selector MPR protocol restoring
|
||||
// the prior stack slice onto the freshly-mounted volume viewport) moves the
|
||||
// camera and fires its slice events synchronously during the mount — before
|
||||
// these listeners attach and around the initial seed above — so the scrollbar
|
||||
// can latch the mount-time index instead of the carried slice. Re-seed once on
|
||||
// the next frame, after the mount+carry settles; pushSliceData makes it a
|
||||
// no-op when nothing changed (no churn/flicker).
|
||||
const reseedRaf = requestAnimationFrame(syncFromViewport);
|
||||
|
||||
const eventId = getSliceEventName(viewportData);
|
||||
|
||||
const updateIndex = event => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
@ -130,16 +148,22 @@ export function useViewportSliceSync({
|
||||
}
|
||||
const nextNumberOfSlices = viewport.getNumberOfSlices();
|
||||
|
||||
setImageSliceData({
|
||||
imageIndex: nextImageIndex,
|
||||
numberOfSlices: nextNumberOfSlices,
|
||||
});
|
||||
pushSliceData(nextImageIndex, nextNumberOfSlices);
|
||||
};
|
||||
|
||||
element.addEventListener(eventId, updateIndex);
|
||||
// Native ("next") viewports keep the same viewportData across a stack->volume
|
||||
// transition or an orientation change, so this effect does not re-run and the
|
||||
// slice-navigation event above may not fire until the first scroll, leaving the
|
||||
// scrollbar unseeded (or stale, with a now-wrong slice count). CAMERA_MODIFIED
|
||||
// fires on those orientation/geometry changes, so re-seed from the viewport
|
||||
// then; the pushSliceData guard makes pan/zoom (same geometry) a no-op.
|
||||
element.addEventListener(Enums.Events.CAMERA_MODIFIED, syncFromViewport);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(reseedRaf);
|
||||
element.removeEventListener(eventId, updateIndex);
|
||||
element.removeEventListener(Enums.Events.CAMERA_MODIFIED, syncFromViewport);
|
||||
};
|
||||
}, [viewportData, element, viewportId, cornerstoneViewportService, setImageSliceData]);
|
||||
}
|
||||
|
||||
@ -29,8 +29,10 @@ import {
|
||||
colorPickerDialog,
|
||||
callInputDialog,
|
||||
} from '@ohif/extension-default';
|
||||
import { vec3, mat4 } from 'gl-matrix';
|
||||
import toggleImageSliceSync from './utils/imageSliceSync/toggleImageSliceSync';
|
||||
// Sanctioned flag read: RTSTRUCT contour hydration pins the referenced image to
|
||||
// stack mode on the native ("next") path, a decision made before a target viewport exists.
|
||||
import { getHydrationViewportTypeForModality } from './utils/nextViewportPolicies';
|
||||
import { getFirstAnnotationSelected } from './utils/measurementServiceMappings/utils/selection';
|
||||
import { getViewportEnabledElement } from './utils/getViewportEnabledElement';
|
||||
import getActiveViewportEnabledElement from './utils/getActiveViewportEnabledElement';
|
||||
@ -41,6 +43,8 @@ import {
|
||||
isVolume3DViewportType,
|
||||
isVolumeViewportType,
|
||||
} from './utils/getLegacyViewportType';
|
||||
import { viewportOperations as ops } from './services/ViewportService/backends/viewportOperations';
|
||||
import { getViewportAdapter } from './services/ViewportService/adapter';
|
||||
import {
|
||||
usePositionPresentationStore,
|
||||
useSegmentationPresentationStore,
|
||||
@ -52,8 +56,6 @@ import { updateSegmentBidirectionalStats } from './utils/updateSegmentationStats
|
||||
import { generateSegmentationCSVReport } from './utils/generateSegmentationCSVReport';
|
||||
import { getUpdatedViewportsForSegmentation } from './utils/hydrationUtils';
|
||||
import { SegmentationRepresentations } from '@cornerstonejs/tools/enums';
|
||||
import { isMeasurementWithinViewport } from './utils/isMeasurementWithinViewport';
|
||||
import { getCenterExtent } from './utils/getCenterExtent';
|
||||
import { EasingFunctionEnum } from './utils/transitions';
|
||||
import { createSegmentationForViewport } from './utils/createSegmentationForViewport';
|
||||
import { utilities as segmentationUtilities } from '@cornerstonejs/tools/segmentation';
|
||||
@ -146,6 +148,15 @@ function commandsModule({
|
||||
return getViewportEnabledElement(viewportId);
|
||||
}
|
||||
|
||||
// Resolves the cornerstone viewport for a command: the given viewport id, else the
|
||||
// active one. Returns undefined when nothing is enabled.
|
||||
function _resolveViewport(viewportId?: string) {
|
||||
const enabledElement = viewportId
|
||||
? _getViewportEnabledElement(viewportId)
|
||||
: _getActiveViewportEnabledElement();
|
||||
return enabledElement?.viewport;
|
||||
}
|
||||
|
||||
function _getActiveViewportToolGroupId() {
|
||||
const viewport = _getActiveViewportEnabledElement();
|
||||
const toolGroup = viewport && toolGroupService.getToolGroupForViewport(viewport.id);
|
||||
@ -238,23 +249,11 @@ function commandsModule({
|
||||
viewport.setViewReference(metadata);
|
||||
viewport.render();
|
||||
|
||||
/**
|
||||
* If the measurement is not visible inside the current viewport,
|
||||
* we need to move the camera to the measurement.
|
||||
*/
|
||||
if (!isMeasurementWithinViewport(viewport, measurement)) {
|
||||
const camera = viewport.getCamera();
|
||||
const { focalPoint: cameraFocalPoint, position: cameraPosition } = camera;
|
||||
const { center, extent } = getCenterExtent(measurement);
|
||||
const position = vec3.sub(vec3.create(), cameraPosition, cameraFocalPoint);
|
||||
vec3.add(position, position, center);
|
||||
viewport.setCamera({ focalPoint: center, position: position as any });
|
||||
/** Zoom out if the measurement is too large */
|
||||
const measurementSize = vec3.dist(extent.min, extent.max);
|
||||
if (measurementSize > camera.parallelScale) {
|
||||
const scaleFactor = measurementSize / camera.parallelScale;
|
||||
viewport.setZoom(viewport.getZoom() / scaleFactor);
|
||||
}
|
||||
// If the measurement is not visible inside the current viewport, move the
|
||||
// camera to it. The operations backend handles the lane: legacy re-centers
|
||||
// in-plane (getCamera/setCamera), native skips it (no in-plane pan yet, CS-14)
|
||||
// since setViewReference above already navigated to the measurement's slice.
|
||||
if (ops.centerOnMeasurement(viewport, measurement)) {
|
||||
viewport.render();
|
||||
}
|
||||
|
||||
@ -372,6 +371,9 @@ function commandsModule({
|
||||
const results = commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', {
|
||||
viewportId,
|
||||
displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID],
|
||||
// RTSTRUCT-on-next pins the referenced image to stack mode on hydrate;
|
||||
// see the policy's rationale in utils/nextViewportPolicies.
|
||||
viewportType: getHydrationViewportTypeForModality(displaySet.Modality),
|
||||
});
|
||||
|
||||
const disableEditing = customizationService.getCustomization(
|
||||
@ -921,34 +923,28 @@ function commandsModule({
|
||||
const windowWidthNum = Number(windowWidth);
|
||||
const windowCenterNum = Number(windowCenter);
|
||||
|
||||
// get actor from the viewport
|
||||
const renderingEngine = cornerstoneViewportService.getRenderingEngine();
|
||||
const viewport = renderingEngine.getViewport(viewportId);
|
||||
|
||||
const { lower, upper } = csUtils.windowLevel.toLowHighRange(windowWidthNum, windowCenterNum);
|
||||
|
||||
if (isVolumeViewportType(viewport)) {
|
||||
const volumeId = actions.getVolumeIdForDisplaySet({
|
||||
viewportId,
|
||||
displaySetInstanceUID,
|
||||
});
|
||||
viewport.setProperties(
|
||||
{
|
||||
voiRange: {
|
||||
upper,
|
||||
lower,
|
||||
},
|
||||
},
|
||||
volumeId
|
||||
);
|
||||
} else {
|
||||
viewport.setProperties({
|
||||
voiRange: {
|
||||
upper,
|
||||
lower,
|
||||
},
|
||||
});
|
||||
// Stale/invalid viewport ids resolve to undefined; bail out before the VOI
|
||||
// apply + render below would throw.
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy volume viewports target a specific volume; the command owns that
|
||||
// resolution (it needs the service). The operations backend applies the VOI
|
||||
// (legacy setProperties vs native setDisplaySetPresentation on the active binding).
|
||||
const volumeId = isVolumeViewportType(viewport)
|
||||
? actions.getVolumeIdForDisplaySet({ viewportId, displaySetInstanceUID })
|
||||
: undefined;
|
||||
|
||||
ops.setWindowLevel(viewport, {
|
||||
windowWidth: windowWidthNum,
|
||||
windowCenter: windowCenterNum,
|
||||
volumeId,
|
||||
displaySetInstanceUID,
|
||||
});
|
||||
viewport.render();
|
||||
},
|
||||
toggleViewportColorbar: ({ viewportId, displaySetInstanceUIDs, options = {} }) => {
|
||||
@ -1187,25 +1183,11 @@ function commandsModule({
|
||||
viewportId?: string;
|
||||
newValue?: 'toggle' | boolean;
|
||||
}) => {
|
||||
const enabledElement = viewportId
|
||||
? _getViewportEnabledElement(viewportId)
|
||||
: _getActiveViewportEnabledElement();
|
||||
|
||||
if (!enabledElement) {
|
||||
const viewport = _resolveViewport(viewportId);
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewport } = enabledElement;
|
||||
|
||||
let flipHorizontal: boolean;
|
||||
if (newValue === 'toggle') {
|
||||
const { flipHorizontal: currentHorizontalFlip } = viewport.getCamera();
|
||||
flipHorizontal = !currentHorizontalFlip;
|
||||
} else {
|
||||
flipHorizontal = newValue;
|
||||
}
|
||||
|
||||
viewport.setCamera({ flipHorizontal });
|
||||
ops.flipHorizontal(viewport, newValue);
|
||||
viewport.render();
|
||||
},
|
||||
flipViewportVertical: ({
|
||||
@ -1215,78 +1197,36 @@ function commandsModule({
|
||||
viewportId?: string;
|
||||
newValue?: 'toggle' | boolean;
|
||||
}) => {
|
||||
const enabledElement = viewportId
|
||||
? _getViewportEnabledElement(viewportId)
|
||||
: _getActiveViewportEnabledElement();
|
||||
|
||||
if (!enabledElement) {
|
||||
const viewport = _resolveViewport(viewportId);
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewport } = enabledElement;
|
||||
|
||||
let flipVertical: boolean;
|
||||
if (newValue === 'toggle') {
|
||||
const { flipVertical: currentVerticalFlip } = viewport.getCamera();
|
||||
flipVertical = !currentVerticalFlip;
|
||||
} else {
|
||||
flipVertical = newValue;
|
||||
}
|
||||
viewport.setCamera({ flipVertical });
|
||||
ops.flipVertical(viewport, newValue);
|
||||
viewport.render();
|
||||
},
|
||||
invertViewport: ({ element }) => {
|
||||
let enabledElement;
|
||||
|
||||
if (element === undefined) {
|
||||
enabledElement = _getActiveViewportEnabledElement();
|
||||
} else {
|
||||
enabledElement = element;
|
||||
}
|
||||
|
||||
if (!enabledElement) {
|
||||
const viewport = element === undefined ? _resolveViewport() : element.viewport;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewport } = enabledElement;
|
||||
|
||||
const { invert } = viewport.getProperties();
|
||||
viewport.setProperties({ invert: !invert });
|
||||
ops.invert(viewport);
|
||||
viewport.render();
|
||||
},
|
||||
resetViewport: () => {
|
||||
const enabledElement = _getActiveViewportEnabledElement();
|
||||
|
||||
if (!enabledElement) {
|
||||
const viewport = _resolveViewport();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewport } = enabledElement;
|
||||
|
||||
viewport.resetProperties?.();
|
||||
viewport.resetCamera();
|
||||
|
||||
ops.reset(viewport);
|
||||
viewport.render();
|
||||
},
|
||||
scaleViewport: ({ direction }) => {
|
||||
const enabledElement = _getActiveViewportEnabledElement();
|
||||
const scaleFactor = direction > 0 ? 0.9 : 1.1;
|
||||
|
||||
if (!enabledElement) {
|
||||
const viewport = _resolveViewport();
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
const { viewport } = enabledElement;
|
||||
|
||||
if (isStackViewportType(viewport)) {
|
||||
if (direction) {
|
||||
const { parallelScale } = viewport.getCamera();
|
||||
viewport.setCamera({ parallelScale: parallelScale * scaleFactor });
|
||||
viewport.render();
|
||||
} else {
|
||||
viewport.resetCamera();
|
||||
viewport.render();
|
||||
}
|
||||
}
|
||||
ops.scaleBy(viewport, direction);
|
||||
viewport.render();
|
||||
},
|
||||
|
||||
/** Jumps the active viewport or the specified one to the given slice index */
|
||||
@ -1365,24 +1305,15 @@ function commandsModule({
|
||||
// HP takes priority over the default opacity
|
||||
colormap = { ...colormap, opacity: hpOpacity || opacity };
|
||||
|
||||
if (isStackViewportType(viewport)) {
|
||||
viewport.setProperties({ colormap });
|
||||
// The legacy orthographic branch resolves the volumeId from the display set;
|
||||
// fall back to the viewport's first display set (needs viewportGridService, so
|
||||
// it is resolved here in the command rather than in the operations backend).
|
||||
if (isOrthographicViewportType(viewport) && !displaySetInstanceUID) {
|
||||
const { viewports } = viewportGridService.getState();
|
||||
displaySetInstanceUID = viewports.get(viewportId)?.displaySetInstanceUIDs[0];
|
||||
}
|
||||
|
||||
if (isOrthographicViewportType(viewport)) {
|
||||
if (!displaySetInstanceUID) {
|
||||
const { viewports } = viewportGridService.getState();
|
||||
displaySetInstanceUID = viewports.get(viewportId)?.displaySetInstanceUIDs[0];
|
||||
}
|
||||
|
||||
// ToDo: Find a better way of obtaining the volumeId that corresponds to the displaySetInstanceUID
|
||||
const volumeId =
|
||||
viewport
|
||||
.getAllVolumeIds()
|
||||
.find((_volumeId: string) => _volumeId.includes(displaySetInstanceUID)) ??
|
||||
viewport.getVolumeId();
|
||||
viewport.setProperties({ colormap }, volumeId);
|
||||
}
|
||||
ops.setColormap(viewport, { colormap, displaySetInstanceUID });
|
||||
|
||||
if (immediate) {
|
||||
viewport.render();
|
||||
@ -1488,9 +1419,7 @@ function commandsModule({
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
viewport.setProperties({
|
||||
preset,
|
||||
});
|
||||
ops.setPreset(viewport, preset);
|
||||
viewport.render();
|
||||
},
|
||||
|
||||
@ -1502,20 +1431,10 @@ function commandsModule({
|
||||
|
||||
setVolumeRenderingQulaity: ({ viewportId, volumeQuality }) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const { actor } = viewport.getActors()[0];
|
||||
const mapper = actor.getMapper();
|
||||
const image = mapper.getInputData();
|
||||
const dims = image.getDimensions();
|
||||
const spacing = image.getSpacing();
|
||||
const spatialDiagonal = vec3.length(
|
||||
vec3.fromValues(dims[0] * spacing[0], dims[1] * spacing[1], dims[2] * spacing[2])
|
||||
);
|
||||
|
||||
let sampleDistance = spacing.reduce((a, b) => a + b) / 3.0;
|
||||
sampleDistance /= volumeQuality > 1 ? 0.5 * volumeQuality ** 2 : 1.0;
|
||||
const samplesPerRay = spatialDiagonal / sampleDistance + 1;
|
||||
mapper.setMaximumSamplesPerRay(samplesPerRay);
|
||||
mapper.setSampleDistance(sampleDistance);
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
ops.setVolumeRenderingQuality(viewport, volumeQuality);
|
||||
viewport.render();
|
||||
},
|
||||
|
||||
@ -1526,27 +1445,10 @@ function commandsModule({
|
||||
*/
|
||||
shiftVolumeOpacityPoints: ({ viewportId, shift }) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const { actor } = viewport.getActors()[0];
|
||||
const ofun = actor.getProperty().getScalarOpacity(0);
|
||||
|
||||
const opacityPointValues = []; // Array to hold values
|
||||
// Gather Existing Values
|
||||
const size = ofun.getSize();
|
||||
for (let pointIdx = 0; pointIdx < size; pointIdx++) {
|
||||
const opacityPointValue = [0, 0, 0, 0];
|
||||
ofun.getNodeValue(pointIdx, opacityPointValue);
|
||||
// opacityPointValue now holds [xLocation, opacity, midpoint, sharpness]
|
||||
opacityPointValues.push(opacityPointValue);
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
// Add offset
|
||||
opacityPointValues.forEach(opacityPointValue => {
|
||||
opacityPointValue[0] += shift; // Change the location value
|
||||
});
|
||||
// Set new values
|
||||
ofun.removeAllPoints();
|
||||
opacityPointValues.forEach(opacityPointValue => {
|
||||
ofun.addPoint(...opacityPointValue);
|
||||
});
|
||||
ops.shiftVolumeOpacityPoints(viewport, shift);
|
||||
viewport.render();
|
||||
},
|
||||
|
||||
@ -1562,25 +1464,10 @@ function commandsModule({
|
||||
|
||||
setVolumeLighting: ({ viewportId, options }) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const { actor } = viewport.getActors()[0];
|
||||
const property = actor.getProperty();
|
||||
|
||||
if (options.shade !== undefined) {
|
||||
property.setShade(options.shade);
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.ambient !== undefined) {
|
||||
property.setAmbient(options.ambient);
|
||||
}
|
||||
|
||||
if (options.diffuse !== undefined) {
|
||||
property.setDiffuse(options.diffuse);
|
||||
}
|
||||
|
||||
if (options.specular !== undefined) {
|
||||
property.setSpecular(options.specular);
|
||||
}
|
||||
|
||||
ops.setVolumeLighting(viewport, options);
|
||||
viewport.render();
|
||||
},
|
||||
resetCrosshairs: ({ viewportId }) => {
|
||||
@ -1588,7 +1475,13 @@ function commandsModule({
|
||||
|
||||
const getCrosshairInstances = toolGroupId => {
|
||||
const toolGroup = toolGroupService.getToolGroup(toolGroupId);
|
||||
crosshairInstances.push(toolGroup.getToolInstance('Crosshairs'));
|
||||
// Only fetch the instance when Crosshairs is registered in this tool
|
||||
// group. getToolInstance logs a warning for an unregistered tool, and a
|
||||
// viewport's default tool group does not always include Crosshairs (e.g.
|
||||
// next viewports), which made Reset Viewport log a spurious warning.
|
||||
if (toolGroup?.hasTool('Crosshairs')) {
|
||||
crosshairInstances.push(toolGroup.getToolInstance('Crosshairs'));
|
||||
}
|
||||
};
|
||||
|
||||
if (!viewportId) {
|
||||
@ -1596,7 +1489,9 @@ function commandsModule({
|
||||
toolGroupIds.forEach(getCrosshairInstances);
|
||||
} else {
|
||||
const toolGroup = toolGroupService.getToolGroupForViewport(viewportId);
|
||||
getCrosshairInstances(toolGroup.id);
|
||||
if (toolGroup) {
|
||||
getCrosshairInstances(toolGroup.id);
|
||||
}
|
||||
}
|
||||
|
||||
crosshairInstances.forEach(ins => {
|
||||
@ -2165,7 +2060,11 @@ function commandsModule({
|
||||
}
|
||||
segmentationService.addSegment(activeSegmentation.segmentationId);
|
||||
},
|
||||
loadSegmentationDisplaySetsForViewport: ({ viewportId, displaySetInstanceUIDs }) => {
|
||||
loadSegmentationDisplaySetsForViewport: ({
|
||||
viewportId,
|
||||
displaySetInstanceUIDs,
|
||||
viewportType,
|
||||
}) => {
|
||||
const updatedViewports = getUpdatedViewportsForSegmentation({
|
||||
viewportId,
|
||||
servicesManager,
|
||||
@ -2185,13 +2084,21 @@ function commandsModule({
|
||||
viewportsToUpdate: updatedViewports.map(viewport => ({
|
||||
viewportId: viewport.viewportId,
|
||||
displaySetInstanceUIDs: viewport.displaySetInstanceUIDs,
|
||||
// When the caller pins a viewportType (RTSTRUCT contour hydration on a
|
||||
// native "next" viewport requests 'stack'), force it so the referenced
|
||||
// image stays in that render mode instead of resolving to a volume slice.
|
||||
...(viewportType
|
||||
? { viewportOptions: { ...viewport.viewportOptions, viewportType } }
|
||||
: {}),
|
||||
})),
|
||||
});
|
||||
},
|
||||
setViewportOrientation: ({ viewportId, orientation }) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (!viewport || !isOrthographicViewportType(viewport)) {
|
||||
// Accept any viewport already rendering volume content (legacy ORTHOGRAPHIC
|
||||
// or a native viewport in volume mode) — both expose setOrientation().
|
||||
if (!viewport || !getViewportAdapter(viewport).canReorientInPlace()) {
|
||||
console.warn('Orientation can only be set on volume viewports');
|
||||
return;
|
||||
}
|
||||
@ -2263,50 +2170,12 @@ function commandsModule({
|
||||
viewportId?: string;
|
||||
rotationMode?: 'apply' | 'set';
|
||||
}) => {
|
||||
const enabledElement = viewportId
|
||||
? _getViewportEnabledElement(viewportId)
|
||||
: _getActiveViewportEnabledElement();
|
||||
|
||||
if (!enabledElement) {
|
||||
const viewport = _resolveViewport(viewportId);
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { viewport } = enabledElement;
|
||||
|
||||
if (isVolumeViewportType(viewport)) {
|
||||
const camera = viewport.getCamera();
|
||||
const rotAngle = (rotation * Math.PI) / 180;
|
||||
const rotMat = mat4.identity(new Float32Array(16));
|
||||
mat4.rotate(rotMat, rotMat, rotAngle, camera.viewPlaneNormal);
|
||||
const rotatedViewUp = vec3.transformMat4(vec3.create(), camera.viewUp, rotMat);
|
||||
viewport.setCamera({ viewUp: rotatedViewUp as CoreTypes.Point3 });
|
||||
viewport.render();
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewport.getRotation !== undefined) {
|
||||
const { rotation: currentRotation } = viewport.getViewPresentation();
|
||||
const newRotation =
|
||||
rotationMode === 'apply'
|
||||
? (currentRotation + rotation + 360) % 360
|
||||
: (() => {
|
||||
// In 'set' mode, account for the effect horizontal/vertical flips
|
||||
// have on the perceived rotation direction. A single flip mirrors
|
||||
// the image and inverses rotation direction, while two flips
|
||||
// restore the original parity. We therefore invert the rotation
|
||||
// angle when an odd number of flips are applied so that the
|
||||
// requested absolute rotation matches the user expectation.
|
||||
const { flipHorizontal = false, flipVertical = false } =
|
||||
viewport.getViewPresentation();
|
||||
|
||||
const flipsParity = (flipHorizontal ? 1 : 0) + (flipVertical ? 1 : 0);
|
||||
const effectiveRotation = flipsParity % 2 === 1 ? -rotation : rotation;
|
||||
|
||||
return (effectiveRotation + 360) % 360;
|
||||
})();
|
||||
viewport.setViewPresentation({ rotation: newRotation });
|
||||
viewport.render();
|
||||
}
|
||||
ops.rotate(viewport, rotation, rotationMode);
|
||||
viewport.render();
|
||||
},
|
||||
startRecordingForAnnotationGroup: () => {
|
||||
cornerstoneTools.AnnotationTool.startGroupRecording();
|
||||
|
||||
@ -83,10 +83,16 @@ const ViewportColorbarsContainer = memo(function ViewportColorbarsContainer({
|
||||
const { displaySetInstanceUID: dsUID } =
|
||||
displaySetService.getDisplaySetByUID(displaySetInstanceUID) ?? {};
|
||||
|
||||
// Default the fused (horizontal) colorbar to the foreground (e.g. the PT
|
||||
// in a PET/CT fusion), which is the meaningful layer. Only fall back to
|
||||
// the background (CT) colorbar when the foreground has been explicitly
|
||||
// faded to zero opacity. Previously a null/undefined opacity (e.g. before
|
||||
// the hook resolved it) also fell through to the background, so the
|
||||
// colorbar would flicker between CT and PT depending on timing.
|
||||
const targetUID =
|
||||
opacity === 0 || opacity == null
|
||||
opacity === 0
|
||||
? backgroundDisplaySet?.displaySetInstanceUID
|
||||
: foregroundDisplaySets[0].displaySetInstanceUID;
|
||||
: foregroundDisplaySets[0]?.displaySetInstanceUID;
|
||||
|
||||
return dsUID === targetUID;
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { cn, Icons, useIconPresentation } from '@ohif/ui-next';
|
||||
import { useSystem } from '@ohif/core';
|
||||
import { Enums } from '@cornerstonejs/core';
|
||||
import { getViewportAdapter } from '../../services/ViewportService/adapter';
|
||||
import { Popover, PopoverTrigger, PopoverContent, Button, useViewportGrid } from '@ohif/ui-next';
|
||||
|
||||
function ViewportOrientationMenu({
|
||||
@ -36,8 +37,6 @@ function ViewportOrientationMenu({
|
||||
|
||||
const handleOrientationChange = (orientation: string) => {
|
||||
setCurrentOrientation(orientation);
|
||||
const viewportInfo = cornerstoneViewportService.getViewportInfo(viewportIdToUse);
|
||||
const currentViewportType = viewportInfo?.getViewportType();
|
||||
|
||||
if (!displaySets.length) {
|
||||
return;
|
||||
@ -72,8 +71,17 @@ function ViewportOrientationMenu({
|
||||
|
||||
const displaySetUIDs = displaySets.map(ds => ds.displaySetInstanceUID);
|
||||
|
||||
// If viewport is not already a volume type, we need to convert it
|
||||
if (currentViewportType !== Enums.ViewportType.ORTHOGRAPHIC) {
|
||||
// A viewport already rendering in volume mode (legacy ORTHOGRAPHIC OR a next
|
||||
// viewport that reports planarNext but renders volume actors, e.g. a CT+PET
|
||||
// fusion) can be reoriented in place. Recreating it via setDisplaySetsForViewports
|
||||
// would pass empty displaySetOptions and drop per-display-set presentation such
|
||||
// as the PET overlay colormap/opacity. Only the genuine stack -> volume (MPR)
|
||||
// case needs recreation.
|
||||
const csViewport = cornerstoneViewportService.getCornerstoneViewport(viewportIdToUse);
|
||||
const isVolumeMode = !!csViewport && getViewportAdapter(csViewport).canReorientInPlace();
|
||||
|
||||
// If viewport is not already in volume mode, we need to convert it
|
||||
if (!isVolumeMode) {
|
||||
// Configure the viewport to be a volume viewport with current display sets
|
||||
const updatedViewport = {
|
||||
viewportId: viewportIdToUse,
|
||||
|
||||
@ -2,6 +2,7 @@ import { cache as cs3DCache, Types } from '@cornerstonejs/core';
|
||||
import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps';
|
||||
import { utilities as csUtils } from '@cornerstonejs/core';
|
||||
import { getViewportVolumeHistogram } from './getViewportVolumeHistogram';
|
||||
import { getViewportAdapter } from '../../services/ViewportService/adapter';
|
||||
|
||||
/**
|
||||
* Gets node opacity from volume actor
|
||||
@ -101,8 +102,18 @@ export const getWindowLevelsData = async (
|
||||
return [];
|
||||
}
|
||||
|
||||
const volumeIds = (viewport as Types.IBaseVolumeViewport).getAllVolumeIds();
|
||||
const viewportProperties = viewport.getProperties();
|
||||
// The per-volume histogram WL panel is a legacy volume-viewport feature; the
|
||||
// adapter reports no volumeIds for native viewports (and legacy stacks), so
|
||||
// those degrade gracefully to no histogram rows rather than erroring; the
|
||||
// native WL path is driven by setViewportWindowLevel.
|
||||
// TODO(next): port per-volume histograms to the native volume API.
|
||||
const adapter = getViewportAdapter(viewport);
|
||||
const volumeIds = adapter.getVolumeIds();
|
||||
if (!volumeIds.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const viewportProperties = adapter.getPresentation();
|
||||
const { voiRange } = viewportProperties || {};
|
||||
const viewportVoi = voiRange
|
||||
? {
|
||||
|
||||
@ -1,15 +1,54 @@
|
||||
import React, { ReactElement, useEffect, useRef, useState } from 'react';
|
||||
import { AllInOneMenu, ScrollArea, Switch, Tabs, TabsList, TabsTrigger } from '@ohif/ui-next';
|
||||
import { useViewportRendering } from '../../hooks/useViewportRendering';
|
||||
import { useViewportDisplaySets } from '../../hooks/useViewportDisplaySets';
|
||||
import { WindowLevelPreset } from '../../types/WindowLevel';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function WindowLevel({ viewportId }: { viewportId?: string } = {}): ReactElement {
|
||||
const { t } = useTranslation('WindowLevelActionMenu');
|
||||
const { viewportDisplaySets } = useViewportRendering(viewportId);
|
||||
const { viewportDisplaySets, foregroundDisplaySets } = useViewportDisplaySets(viewportId);
|
||||
// Default the active tab to the foreground layer (e.g. the PT in a PET/CT
|
||||
// fusion), matching the other window-level controls, instead of the grayscale
|
||||
// background (CT) at index 0. The CT/PT tabs still let the user switch.
|
||||
const defaultDisplaySetUID =
|
||||
foregroundDisplaySets?.length > 0
|
||||
? foregroundDisplaySets[foregroundDisplaySets.length - 1].displaySetInstanceUID
|
||||
: viewportDisplaySets?.[0]?.displaySetInstanceUID;
|
||||
const [activeDisplaySetUID, setActiveDisplaySetUID] = useState<string | undefined>(
|
||||
viewportDisplaySets?.[0]?.displaySetInstanceUID
|
||||
defaultDisplaySetUID
|
||||
);
|
||||
// Tracks whether the user has explicitly picked a tab, so the foreground-default
|
||||
// sync below stops overriding their choice.
|
||||
const userSelectedRef = useRef(false);
|
||||
|
||||
// Adopt the foreground default if the display sets resolve after first render
|
||||
// (and the user has not picked a tab yet). This must re-sync even when the
|
||||
// initial render already seeded `activeDisplaySetUID` with the CT fallback
|
||||
// (because `foregroundDisplaySets` was still empty at mount) — otherwise the
|
||||
// tab stays pinned to CT once the foreground (PT) layer resolves.
|
||||
useEffect(() => {
|
||||
// If the active tab's display set is no longer in the viewport (e.g. the
|
||||
// viewport switched to a different study/series), drop the now-stale
|
||||
// selection — including a user pick — so it re-defaults instead of leaving an
|
||||
// invalid UID that later fails validateActiveDisplaySet.
|
||||
const activeStillPresent = viewportDisplaySets?.some(
|
||||
ds => ds.displaySetInstanceUID === activeDisplaySetUID
|
||||
);
|
||||
if (activeDisplaySetUID && viewportDisplaySets?.length && !activeStillPresent) {
|
||||
userSelectedRef.current = false;
|
||||
setActiveDisplaySetUID(defaultDisplaySetUID);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!userSelectedRef.current &&
|
||||
defaultDisplaySetUID &&
|
||||
activeDisplaySetUID !== defaultDisplaySetUID
|
||||
) {
|
||||
setActiveDisplaySetUID(defaultDisplaySetUID);
|
||||
}
|
||||
}, [activeDisplaySetUID, defaultDisplaySetUID, viewportDisplaySets]);
|
||||
|
||||
// Use the hook with the active display set
|
||||
const { windowLevelPresets, setWindowLevel } = useViewportRendering(viewportId, {
|
||||
@ -49,6 +88,7 @@ export function WindowLevel({ viewportId }: { viewportId?: string } = {}): React
|
||||
<Tabs
|
||||
value={activeDisplaySetUID}
|
||||
onValueChange={displaySetUID => {
|
||||
userSelectedRef.current = true;
|
||||
setActiveDisplaySetUID(displaySetUID);
|
||||
}}
|
||||
>
|
||||
|
||||
@ -8,7 +8,11 @@ import { buildEcgModule } from './utils/ecgMetadata';
|
||||
const { MetadataModules } = csEnums;
|
||||
const { utils } = OHIF;
|
||||
const { denaturalizeDataset } = dcmjs.data.DicomMetaDictionary;
|
||||
const { transferDenaturalizedDataset, fixMultiValueKeys } = dicomWebUtils;
|
||||
// NOTE: access transferDenaturalizedDataset / fixMultiValueKeys lazily (at call
|
||||
// time) rather than destructuring here. This module and @ohif/extension-default
|
||||
// form a circular import, so dicomWebUtils can be undefined at module-eval time
|
||||
// depending on bundler eval order; a top-level destructure then throws and
|
||||
// crashes app boot.
|
||||
|
||||
const SOP_CLASS_UIDS = {
|
||||
VL_WHOLE_SLIDE_MICROSCOPY_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.6',
|
||||
@ -139,8 +143,8 @@ function getDICOMwebMetadata(instanceMap, imageId) {
|
||||
console.warn('Metadata not already found for', imageId, 'in', instanceMap);
|
||||
return this.super.getDICOMwebMetadata(imageId);
|
||||
}
|
||||
return transferDenaturalizedDataset(
|
||||
denaturalizeDataset(fixMultiValueKeys(instanceMap.get(imageId)))
|
||||
return dicomWebUtils.transferDenaturalizedDataset(
|
||||
denaturalizeDataset(dicomWebUtils.fixMultiValueKeys(instanceMap.get(imageId)))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Enums } from '@cornerstonejs/tools';
|
||||
import i18n from '@ohif/i18n';
|
||||
import { getViewportAdapter, isVolumeRenderingViewport } from './services/ViewportService/adapter';
|
||||
import { utils } from '@ohif/ui-next';
|
||||
import { ViewportDataOverlayMenuWrapper } from './components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper';
|
||||
import { ViewportOrientationMenuWrapper } from './components/ViewportOrientationMenu/ViewportOrientationMenuWrapper';
|
||||
@ -309,7 +310,11 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
|
||||
};
|
||||
}
|
||||
|
||||
if (viewport.type !== 'orthographic') {
|
||||
// Recognize native "next" volume viewports too. A next MPR/volume viewport
|
||||
// runs as PLANAR_NEXT (requestedType PLANAR_NEXT, not ORTHOGRAPHIC), so this
|
||||
// checks volume content via getCurrentMode(). Without it the PT threshold
|
||||
// control stayed disabled on the next backend (e.g. TMTV fusion/PT).
|
||||
if (!isVolumeRenderingViewport(viewport)) {
|
||||
return {
|
||||
disabled: true,
|
||||
};
|
||||
@ -332,7 +337,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
|
||||
evaluate: ({ viewportId }) => {
|
||||
const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
|
||||
if (!viewport || viewport.type !== 'orthographic') {
|
||||
if (!viewport || !isVolumeRenderingViewport(viewport)) {
|
||||
return {
|
||||
disabled: true,
|
||||
};
|
||||
@ -449,8 +454,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
|
||||
mode === Enums.ToolModes.Enabled;
|
||||
|
||||
const toolBindings = toolGroupService.getToolBindings(toolGroup.id, toolName);
|
||||
const hasModifierKey =
|
||||
toolBindings?.some(binding => binding.modifierKey != null) ?? false;
|
||||
const hasModifierKey = toolBindings?.some(binding => binding.modifierKey != null) ?? false;
|
||||
|
||||
return {
|
||||
disabled: false,
|
||||
@ -459,7 +463,7 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
|
||||
icon:
|
||||
isToggled && hasModifierKey && toggledOnIcon
|
||||
? toggledOnIcon
|
||||
: defaultIcon ?? button.props.icon,
|
||||
: (defaultIcon ?? button.props.icon),
|
||||
};
|
||||
},
|
||||
},
|
||||
@ -543,8 +547,9 @@ export default function getToolbarModule({ servicesManager, extensionManager }:
|
||||
|
||||
const propId = button.id;
|
||||
|
||||
const properties = viewport.getProperties();
|
||||
const camera = viewport.getCamera();
|
||||
const adapter = getViewportAdapter(viewport);
|
||||
const properties = adapter.getPresentation();
|
||||
const camera = adapter.getViewState();
|
||||
|
||||
const prop = camera?.[propId] || properties?.[propId];
|
||||
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||
import { useSystem } from '@ohif/core';
|
||||
import { useViewportDisplaySets } from './useViewportDisplaySets';
|
||||
import { Types, utilities, Enums, cache } from '@cornerstonejs/core';
|
||||
import { getDataIdForViewport } from '../utils/getDataIdForViewport';
|
||||
import {
|
||||
isStackViewportType,
|
||||
isVolumeViewportType,
|
||||
isVolume3DViewportType,
|
||||
} from '../utils/getLegacyViewportType';
|
||||
import { Types, utilities, Enums } from '@cornerstonejs/core';
|
||||
import { isVolume3DViewportType } from '../utils/getLegacyViewportType';
|
||||
import { getViewportAdapter, LEGACY_OPACITY_GAMMA } from '../services/ViewportService/adapter';
|
||||
import { WindowLevelPreset } from '../types/WindowLevel';
|
||||
import { ColorbarPositionType, ColorbarOptions, ColorbarProperties } from '../types/Colorbar';
|
||||
import { VolumeRenderingConfig } from '../types/VolumeRenderingConfig';
|
||||
@ -92,14 +88,26 @@ const getPosition = (location: number): ColorbarPositionType => {
|
||||
}
|
||||
};
|
||||
|
||||
const GAMMA = 1 / 5;
|
||||
/**
|
||||
* Normalizes a colormap opacity value to a single 0..1 scalar for the opacity
|
||||
* slider. `colormap.opacity` may be a plain number or an array of
|
||||
* `{ value, opacity }` points (e.g. the HP fusion opacity ramp); for the array
|
||||
* case we represent it by its maximum opacity. (A prior reduce ran over the point
|
||||
* objects directly, producing NaN and a mispositioned slider.)
|
||||
*/
|
||||
const resolveOpacityScalar = (opacityVal: unknown): number | undefined => {
|
||||
if (opacityVal === undefined || opacityVal === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const linearToOpacity = (linearValue: number): number => {
|
||||
return Math.pow(linearValue, GAMMA);
|
||||
};
|
||||
if (Array.isArray(opacityVal)) {
|
||||
return opacityVal.reduce((max: number, point) => {
|
||||
const value = typeof point === 'number' ? point : (point?.opacity ?? 0);
|
||||
return Math.max(max, value);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const opacityToLinear = (opacityValue: number): number => {
|
||||
return Math.pow(opacityValue, 1.0 / GAMMA);
|
||||
return opacityVal as number;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -128,12 +136,28 @@ export function useViewportRendering(
|
||||
viewportId ? (cornerstoneViewportService.getCornerstoneViewport(viewportId) ?? null) : null
|
||||
);
|
||||
const [is3DVolume, setIs3DVolume] = useState(isVolume3DViewportType(viewport));
|
||||
|
||||
// The opacity slider gamma follows the rendering path (linear on native,
|
||||
// the historical 1/5 curve on legacy), so the slider feel and its initial
|
||||
// position match what is rendered.
|
||||
const opacityGamma = viewport
|
||||
? getViewportAdapter(viewport).getOpacityGamma()
|
||||
: LEGACY_OPACITY_GAMMA;
|
||||
const linearToOpacity = useCallback(
|
||||
(linearValue: number): number => Math.pow(linearValue, opacityGamma),
|
||||
[opacityGamma]
|
||||
);
|
||||
const opacityToLinear = useCallback(
|
||||
(opacityValue: number): number => Math.pow(opacityValue, 1.0 / opacityGamma),
|
||||
[opacityGamma]
|
||||
);
|
||||
|
||||
const [opacity, setOpacityState] = useState<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 } = useViewportDisplaySets(viewportId);
|
||||
const { viewportDisplaySets, foregroundDisplaySets } = useViewportDisplaySets(viewportId);
|
||||
const { displaySetService } = servicesManager.services;
|
||||
|
||||
// Determine the active display set instance UID (internal only, not exposed)
|
||||
@ -142,12 +166,21 @@ export function useViewportRendering(
|
||||
return options.displaySetInstanceUID;
|
||||
}
|
||||
|
||||
// Window-level / colormap / threshold controls operate on the foreground
|
||||
// layer (e.g. the PT in a PET/CT fusion), not the grayscale background (CT).
|
||||
// Use the topmost foreground display set when present; otherwise fall back to
|
||||
// the (single) primary display set. SEG/derived overlays are already excluded
|
||||
// from foregroundDisplaySets.
|
||||
if (foregroundDisplaySets && foregroundDisplaySets.length > 0) {
|
||||
return foregroundDisplaySets[foregroundDisplaySets.length - 1].displaySetInstanceUID;
|
||||
}
|
||||
|
||||
if (viewportDisplaySets && viewportDisplaySets.length > 0) {
|
||||
return viewportDisplaySets[0].displaySetInstanceUID;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [options?.displaySetInstanceUID, viewportDisplaySets]);
|
||||
}, [options?.displaySetInstanceUID, viewportDisplaySets, foregroundDisplaySets]);
|
||||
|
||||
const viewportInfo = viewportId ? cornerstoneViewportService.getViewportInfo(viewportId) : null;
|
||||
|
||||
@ -216,28 +249,14 @@ export function useViewportRendering(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVolumeViewportType(viewport)) {
|
||||
const voxelManager = getViewportAdapter(viewport).getVoxelManagerForDisplaySet(
|
||||
activeDisplaySetInstanceUID
|
||||
);
|
||||
|
||||
if (!voxelManager?.getRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const volumeIds = viewport.getAllVolumeIds();
|
||||
const volumeId = volumeIds.find(id => id.includes(activeDisplaySetInstanceUID));
|
||||
|
||||
if (!volumeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// only handle volume viewports for now
|
||||
const imageData = viewport.getImageData(volumeId);
|
||||
|
||||
if (!imageData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageDataVtk = imageData.imageData;
|
||||
|
||||
const { voxelManager } = imageDataVtk.get('voxelManager');
|
||||
|
||||
const range = voxelManager.getRange();
|
||||
|
||||
setPixelValueRange({ min: range[0], max: range[1] });
|
||||
@ -267,12 +286,9 @@ export function useViewportRendering(
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const dataId = getDataIdForViewport(viewport as unknown, activeDisplaySetInstanceUID);
|
||||
|
||||
const properties =
|
||||
dataId != null
|
||||
? (viewport as Types.IBaseVolumeViewport).getProperties(dataId)
|
||||
: viewport.getProperties();
|
||||
const adapter = getViewportAdapter(viewport);
|
||||
const dataId = adapter.getDataIdForDisplaySet(activeDisplaySetInstanceUID);
|
||||
const properties = adapter.getPresentation(dataId ?? activeDisplaySetInstanceUID);
|
||||
|
||||
if (!properties) {
|
||||
return;
|
||||
@ -281,18 +297,26 @@ export function useViewportRendering(
|
||||
if (properties.voiRange) {
|
||||
setVoiRange(properties.voiRange);
|
||||
voiRangeRef.current = properties.voiRange;
|
||||
} else {
|
||||
// Native ("next") viewports store only explicit VOI overrides in the
|
||||
// per-display-set presentation; a freshly shown series has none, so fall
|
||||
// back to its computed default VOI (undefined on legacy, whose
|
||||
// getProperties always returns the applied VOI). Without this, changing
|
||||
// the series left the overlay showing the previous series' window level.
|
||||
const defaultVOIRange = adapter.getDefaultVOIRange(dataId ?? activeDisplaySetInstanceUID);
|
||||
|
||||
if (defaultVOIRange) {
|
||||
setVoiRange(defaultVOIRange);
|
||||
voiRangeRef.current = defaultVOIRange;
|
||||
}
|
||||
}
|
||||
|
||||
if (properties.colormap?.opacity !== undefined) {
|
||||
const opacityVal = properties.colormap.opacity;
|
||||
const opacity = Array.isArray(opacityVal)
|
||||
? (opacityVal as unknown as number[]).reduce(
|
||||
(max, current) => Math.max(max, current),
|
||||
0
|
||||
)
|
||||
: opacityVal;
|
||||
setOpacityState(opacity);
|
||||
setOpacityLinearState(opacityToLinear(opacity));
|
||||
const opacity = resolveOpacityScalar(properties.colormap.opacity);
|
||||
if (opacity !== undefined) {
|
||||
setOpacityState(opacity);
|
||||
setOpacityLinearState(opacityToLinear(opacity));
|
||||
}
|
||||
}
|
||||
|
||||
if (properties.colormap?.threshold !== undefined) {
|
||||
@ -362,8 +386,11 @@ export function useViewportRendering(
|
||||
}
|
||||
|
||||
if (colormap.opacity !== undefined) {
|
||||
setOpacityState(colormap.opacity);
|
||||
setOpacityLinearState(opacityToLinear(colormap.opacity));
|
||||
const opacity = resolveOpacityScalar(colormap.opacity);
|
||||
if (opacity !== undefined) {
|
||||
setOpacityState(opacity);
|
||||
setOpacityLinearState(opacityToLinear(opacity));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -570,7 +597,7 @@ export function useViewportRendering(
|
||||
|
||||
const setOpacity = useCallback(
|
||||
(opacityValue: number) => {
|
||||
if (!viewport || !isVolumeViewportType(viewport)) {
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -580,32 +607,10 @@ export function useViewportRendering(
|
||||
setOpacityLinearState(opacityToLinear(opacityValue));
|
||||
|
||||
const displaySetInstanceUID = validateActiveDisplaySet();
|
||||
const volumeIds = viewport.getAllVolumeIds();
|
||||
const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID));
|
||||
|
||||
if (!volumeId) {
|
||||
return;
|
||||
if (getViewportAdapter(viewport).setLayerOpacity(displaySetInstanceUID, opacityValue)) {
|
||||
viewport.render();
|
||||
}
|
||||
|
||||
// Get current properties including colormap
|
||||
const properties = viewport.getProperties(volumeId);
|
||||
const currentColormap = properties.colormap || {};
|
||||
|
||||
// Update colormap with new opacity
|
||||
const updatedColormap = {
|
||||
...currentColormap,
|
||||
opacity: opacityValue,
|
||||
};
|
||||
|
||||
// Apply updated colormap
|
||||
viewport.setProperties(
|
||||
{
|
||||
colormap: updatedColormap,
|
||||
},
|
||||
volumeId
|
||||
);
|
||||
|
||||
viewport.render();
|
||||
},
|
||||
[validateActiveDisplaySet, opacityToLinear, viewport]
|
||||
);
|
||||
@ -621,32 +626,16 @@ export function useViewportRendering(
|
||||
|
||||
const setThreshold = useCallback(
|
||||
(thresholdValue: number) => {
|
||||
if (!viewport || !isVolumeViewportType(viewport)) {
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
setThresholdState(thresholdValue);
|
||||
|
||||
const displaySetInstanceUID = validateActiveDisplaySet();
|
||||
const volumeIds = viewport.getAllVolumeIds();
|
||||
const volumeId = volumeIds.find(id => id.includes(displaySetInstanceUID));
|
||||
setThresholdState(thresholdValue);
|
||||
|
||||
if (!volumeId) {
|
||||
return;
|
||||
if (getViewportAdapter(viewport).setLayerThreshold(displaySetInstanceUID, thresholdValue)) {
|
||||
viewport.render();
|
||||
}
|
||||
|
||||
console.debug('🚀 ~ thresholdValue:', thresholdValue);
|
||||
|
||||
viewport.setProperties(
|
||||
{
|
||||
colormap: {
|
||||
threshold: thresholdValue,
|
||||
},
|
||||
},
|
||||
volumeId
|
||||
);
|
||||
|
||||
viewport.render();
|
||||
},
|
||||
[validateActiveDisplaySet, viewport]
|
||||
);
|
||||
@ -662,41 +651,13 @@ export function useViewportRendering(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isStackViewportType(viewport)) {
|
||||
const { colormap } = viewport.getProperties();
|
||||
if (!colormap) {
|
||||
return (
|
||||
colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') ||
|
||||
colorbarProperties?.colormaps?.[0]
|
||||
);
|
||||
}
|
||||
return colormap;
|
||||
}
|
||||
const colormap = getViewportAdapter(viewport).getColormap(activeDisplaySetInstanceUID);
|
||||
|
||||
const actorEntries = viewport.getActors();
|
||||
const actorEntry = actorEntries?.find(entry =>
|
||||
entry.referencedId?.includes(activeDisplaySetInstanceUID)
|
||||
return (
|
||||
colormap ||
|
||||
colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') ||
|
||||
colorbarProperties?.colormaps?.[0]
|
||||
);
|
||||
|
||||
if (!actorEntry) {
|
||||
return (
|
||||
colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') ||
|
||||
colorbarProperties?.colormaps?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
const { colormap } = (viewport as Types.IVolumeViewport).getProperties(
|
||||
actorEntry.referencedId
|
||||
);
|
||||
|
||||
if (!colormap) {
|
||||
return (
|
||||
colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') ||
|
||||
colorbarProperties?.colormaps?.[0]
|
||||
);
|
||||
}
|
||||
|
||||
return colormap;
|
||||
} catch (error) {
|
||||
console.error('Error getting viewport colormap:', error);
|
||||
return (
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
} from '@cornerstonejs/core';
|
||||
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
|
||||
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata';
|
||||
import { Types } from '@ohif/core';
|
||||
import { Types, AnnotationPersistenceService } from '@ohif/core';
|
||||
import Enums from './enums';
|
||||
|
||||
import init from './init';
|
||||
@ -37,6 +37,18 @@ import RectangleROI from './utils/measurementServiceMappings/RectangleROI';
|
||||
import type { PublicViewportOptions } from './services/ViewportService/Viewport';
|
||||
import ImageOverlayViewerTool from './tools/ImageOverlayViewerTool';
|
||||
import getSOPInstanceAttributes from './utils/measurementServiceMappings/utils/getSOPInstanceAttributes';
|
||||
import {
|
||||
getViewportAdapter,
|
||||
getViewportFocalPoint,
|
||||
isNextViewport,
|
||||
isVolumeRenderingViewport,
|
||||
} from './services/ViewportService/adapter';
|
||||
import { isNextViewportsEnabled } from './utils/nextViewports';
|
||||
import {
|
||||
NEXT_FUSION_PT_OPACITY,
|
||||
NEXT_OVERLAY_OPACITY,
|
||||
getHydrationViewportTypeForModality,
|
||||
} from './utils/nextViewportPolicies';
|
||||
import { findNearbyToolData } from './utils/findNearbyToolData';
|
||||
import { createFrameViewSynchronizer } from './synchronizers/frameViewSynchronizer';
|
||||
import { getSopClassHandlerModule } from './getSopClassHandlerModule';
|
||||
@ -103,11 +115,7 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
||||
*/
|
||||
id,
|
||||
|
||||
onModeEnter: ({
|
||||
servicesManager,
|
||||
commandsManager,
|
||||
extensionManager,
|
||||
}: withAppTypes): void => {
|
||||
onModeEnter: ({ servicesManager, commandsManager, extensionManager }: withAppTypes): void => {
|
||||
const { cornerstoneViewportService, toolbarService, segmentationService } =
|
||||
servicesManager.services;
|
||||
|
||||
@ -153,7 +161,10 @@ const cornerstoneExtension: Types.Extensions.Extension = {
|
||||
*/
|
||||
const sourceConfig = extensionManager?.getActiveDataSource?.()?.[0]?.getConfig?.() ?? {};
|
||||
const config = sourceConfig.stackRetrieveOptions ?? {};
|
||||
const stackOptions = update(DEFAULT_STACK_RETRIEVE_OPTIONS, toUpdateSpec(config)) as typeof DEFAULT_STACK_RETRIEVE_OPTIONS;
|
||||
const stackOptions = update(
|
||||
DEFAULT_STACK_RETRIEVE_OPTIONS,
|
||||
toUpdateSpec(config)
|
||||
) as typeof DEFAULT_STACK_RETRIEVE_OPTIONS;
|
||||
imageRetrieveMetadataProvider.add('stack', stackOptions);
|
||||
},
|
||||
getPanelModule,
|
||||
@ -205,6 +216,7 @@ 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);
|
||||
@ -286,6 +298,14 @@ export {
|
||||
getEnabledElement,
|
||||
ImageOverlayViewerTool,
|
||||
getSOPInstanceAttributes,
|
||||
getViewportAdapter,
|
||||
getViewportFocalPoint,
|
||||
isNextViewport,
|
||||
isVolumeRenderingViewport,
|
||||
isNextViewportsEnabled,
|
||||
NEXT_FUSION_PT_OPACITY,
|
||||
NEXT_OVERLAY_OPACITY,
|
||||
getHydrationViewportTypeForModality,
|
||||
dicomLoaderService,
|
||||
// Export all stores
|
||||
useLutPresentationStore,
|
||||
|
||||
@ -27,6 +27,12 @@ import initCornerstoneTools from './initCornerstoneTools';
|
||||
import { connectToolsToMeasurementService } from './initMeasurementService';
|
||||
import initCineService from './initCineService';
|
||||
import initStudyPrefetcherService from './initStudyPrefetcherService';
|
||||
import {
|
||||
setNextViewportsEnabled,
|
||||
resolveNextViewportsEnabled,
|
||||
resolveViewportRendering,
|
||||
setViewportRenderingOverrides,
|
||||
} from './utils/nextViewports';
|
||||
import interleaveCenterLoader from './utils/interleaveCenterLoader';
|
||||
import nthLoader from './utils/nthLoader';
|
||||
import interleaveTopToBottom from './utils/interleaveTopToBottom';
|
||||
@ -62,13 +68,52 @@ export default async function init({
|
||||
// Note: this should run first before initializing the cornerstone
|
||||
// DO NOT CHANGE THE ORDER
|
||||
|
||||
// Enable cornerstone's stats/debug overlay when `?debug=true` is in the URL.
|
||||
// Mirrors the cornerstone demo trigger so the same overlay is available inside
|
||||
// OHIF: FPS / MS / MB panels plus the per-viewport actor & mapper bindings.
|
||||
const statsOverlay =
|
||||
new URLSearchParams(window.location.search).get('debug') === 'true';
|
||||
|
||||
await cs3DInit({
|
||||
peerImport: appConfig.peerImport,
|
||||
debug: { statsOverlay },
|
||||
});
|
||||
|
||||
// For debugging e2e tests that are failing on CI
|
||||
cornerstone.setUseCPURendering(Boolean(appConfig.useCPURendering));
|
||||
|
||||
// All native ("next") Generic Viewport settings live under one config object:
|
||||
// appConfig.genericViewports = { enabled, viewportRendering }.
|
||||
const genericViewportsConfig = appConfig.genericViewports ?? {};
|
||||
|
||||
// viewportRendering selects the render backend per-session:
|
||||
// `?viewportRendering=cpu|webgl|webgpu|auto` for all viewports, plus
|
||||
// `?<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: {
|
||||
@ -81,6 +126,14 @@ export default async function init({
|
||||
},
|
||||
});
|
||||
|
||||
// Opt-in: drive viewports through the DIRECT native GenericViewport ("next")
|
||||
// API (PLANAR_NEXT, setDisplaySets, ...). Read by getCornerstoneViewportType
|
||||
// and the CornerstoneViewportService backend split. Distinct from
|
||||
// useGenericViewport above (which only enables cornerstone's compat remap).
|
||||
// resolveNextViewportsEnabled lets a `?useNextViewports=true` URL param opt in
|
||||
// per-session; when the param is absent, appConfig.genericViewports.enabled wins.
|
||||
setNextViewportsEnabled(resolveNextViewportsEnabled(genericViewportsConfig.enabled));
|
||||
|
||||
// For debugging large datasets, otherwise prefer the defaults
|
||||
const { maxCacheSize } = appConfig;
|
||||
if (maxCacheSize) {
|
||||
|
||||
@ -462,6 +462,12 @@ 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,
|
||||
@ -506,11 +512,11 @@ const connectMeasurementServiceToTools = ({
|
||||
* Don't remove this destructuring of data here.
|
||||
* This is used to pass annotation specific data forward e.g. contour
|
||||
*/
|
||||
...(data.annotation.data || {}),
|
||||
text: data.annotation.data.text,
|
||||
handles: { ...data.annotation.data.handles },
|
||||
cachedStats: { ...data.annotation.data.cachedStats },
|
||||
label: data.annotation.data.label,
|
||||
...(persistedData || {}),
|
||||
text: persistedData.text,
|
||||
handles: { ...persistedHandles, points: handlePoints },
|
||||
cachedStats: { ...(persistedData.cachedStats || {}) },
|
||||
label: persistedData.label,
|
||||
frameNumber,
|
||||
},
|
||||
};
|
||||
@ -534,7 +540,9 @@ const connectMeasurementServiceToTools = ({
|
||||
commandsManager.run('cancelMeasurement');
|
||||
|
||||
const removedAnnotation = annotation.state.getAnnotation(removedMeasurementId);
|
||||
removeAnnotation(removedMeasurementId);
|
||||
if (removedAnnotation) {
|
||||
removeAnnotation(removedMeasurementId);
|
||||
}
|
||||
// Ensure `removedAnnotation` is available before triggering the memo,
|
||||
// as it can be undefined during an undo operation
|
||||
if (removedAnnotation) {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { PubSubService, Types as OhifTypes } from '@ohif/core';
|
||||
import { RENDERING_ENGINE_ID } from '../ViewportService/constants';
|
||||
import { getRenderingEngine } from '@cornerstonejs/core';
|
||||
import { getDataIdForViewport } from '../../utils/getDataIdForViewport';
|
||||
import { getViewportAdapter } from '../ViewportService/adapter';
|
||||
import { ColorbarOptions, ChangeTypes } from '../../types/Colorbar';
|
||||
|
||||
export default class ColorbarService extends PubSubService {
|
||||
@ -60,8 +60,8 @@ export default class ColorbarService extends PubSubService {
|
||||
return;
|
||||
}
|
||||
|
||||
const actorEntries = viewport.getActors();
|
||||
if (!actorEntries || actorEntries.length === 0) {
|
||||
const adapter = getViewportAdapter(viewport);
|
||||
if (!adapter.hasContent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -74,8 +74,8 @@ export default class ColorbarService extends PubSubService {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataId = getDataIdForViewport(viewport, displaySetInstanceUID);
|
||||
const properties = dataId ? viewport.getProperties(dataId) : viewport.getProperties();
|
||||
const dataId = adapter.getDataIdForDisplaySet(displaySetInstanceUID);
|
||||
const properties = adapter.getPresentation(dataId);
|
||||
const colormap = properties?.colormap;
|
||||
|
||||
if (activeColormapName && !colormap) {
|
||||
@ -222,16 +222,18 @@ export default class ColorbarService extends PubSubService {
|
||||
private setViewportColormap(viewportId, displaySetInstanceUID, colormap, immediate = false) {
|
||||
const renderingEngine = getRenderingEngine(RENDERING_ENGINE_ID);
|
||||
const viewport = renderingEngine.getViewport(viewportId);
|
||||
const actorEntries = viewport?.getActors();
|
||||
if (!viewport || !actorEntries || actorEntries.length === 0) {
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
const adapter = getViewportAdapter(viewport);
|
||||
if (!adapter.hasContent()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the appropriate dataId for this viewport/displaySet combination
|
||||
const dataId = getDataIdForViewport(viewport, displaySetInstanceUID);
|
||||
|
||||
// Set properties with or without dataId based on what the viewport supports
|
||||
viewport.setProperties({ colormap }, dataId);
|
||||
// Address the display set's binding (volumeId on legacy multi-volume, bare
|
||||
// UID on native, active binding otherwise)
|
||||
const dataId = adapter.getDataIdForDisplaySet(displaySetInstanceUID);
|
||||
adapter.setPresentation({ colormap }, dataId);
|
||||
|
||||
if (immediate) {
|
||||
viewport.render();
|
||||
|
||||
@ -41,12 +41,39 @@ class CornerstoneCacheService {
|
||||
const cs3DViewportType = getCornerstoneViewportType(viewportType, displaySets);
|
||||
let viewportData: StackViewportData | VolumeViewportData;
|
||||
|
||||
// Native Generic ("next") viewport types (e.g. PLANAR_NEXT) intentionally
|
||||
// collapse the stack/volume distinction into a single type, so they cannot
|
||||
// drive the stack-vs-volume data-builder decision below. Resolve the data
|
||||
// shape from the legacy mapping (which preserves that distinction) and keep
|
||||
// the resolved native type as the produced viewportData's viewportType.
|
||||
let dataShapeType = getCornerstoneViewportType(viewportType, displaySets, false);
|
||||
|
||||
// A data overlay (fusion) of two or more reconstructable image display sets
|
||||
// must render as a volume viewport so the source and overlay share one
|
||||
// representation (volume slice). Without this, a next (PLANAR_NEXT) viewport
|
||||
// keeps the source in vtkImage (stack) mode while the added overlay is a
|
||||
// vtkVolumeSlice, producing the broken/unstable fusion. SEG/RT overlays are
|
||||
// non-reconstructable, so they are not affected.
|
||||
//
|
||||
// Scoped to the native (PLANAR_NEXT) path via cs3DViewportType — NOT the flag, and
|
||||
// NOT the legacy lane: a legacy stack-shaped reconstructable overlay must keep its
|
||||
// existing stack build so the flag-off path stays byte-identical.
|
||||
const isReconstructableFusion =
|
||||
displaySets.length > 1 && displaySets.every(ds => ds.isReconstructable);
|
||||
if (
|
||||
cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC ||
|
||||
cs3DViewportType === Enums.ViewportType.VOLUME_3D
|
||||
isReconstructableFusion &&
|
||||
dataShapeType === Enums.ViewportType.STACK &&
|
||||
cs3DViewportType === Enums.ViewportType.PLANAR_NEXT
|
||||
) {
|
||||
dataShapeType = Enums.ViewportType.ORTHOGRAPHIC;
|
||||
}
|
||||
|
||||
if (
|
||||
dataShapeType === Enums.ViewportType.ORTHOGRAPHIC ||
|
||||
dataShapeType === Enums.ViewportType.VOLUME_3D
|
||||
) {
|
||||
viewportData = await this._getVolumeViewportData(dataSource, displaySets, cs3DViewportType);
|
||||
} else if (cs3DViewportType === Enums.ViewportType.STACK) {
|
||||
} else if (dataShapeType === Enums.ViewportType.STACK) {
|
||||
// Everything else looks like a stack
|
||||
viewportData = await this._getStackViewportData(
|
||||
dataSource,
|
||||
@ -64,6 +91,9 @@ class CornerstoneCacheService {
|
||||
}
|
||||
|
||||
viewportData.viewportType = cs3DViewportType;
|
||||
// Persist the legacy stack/volume shape so consumers can distinguish stack from
|
||||
// volume content even when viewportType is a native Generic type (PLANAR_NEXT).
|
||||
viewportData.dataShapeType = dataShapeType;
|
||||
|
||||
return viewportData;
|
||||
}
|
||||
@ -74,7 +104,13 @@ class CornerstoneCacheService {
|
||||
dataSource,
|
||||
displaySetService
|
||||
): Promise<VolumeViewportData | StackViewportData> {
|
||||
if (viewportData.viewportType === Enums.ViewportType.STACK) {
|
||||
// Decide stack-vs-volume rebuild from the persisted data shape, NOT viewportType:
|
||||
// native viewports collapse both onto PLANAR_NEXT, so a native stack would
|
||||
// otherwise fall through to the volume rebuild and re-mount as volume data.
|
||||
// Falls back to viewportType for legacy/older viewportData (byte-identical off-path).
|
||||
const dataShapeType = viewportData.dataShapeType ?? viewportData.viewportType;
|
||||
|
||||
if (dataShapeType === Enums.ViewportType.STACK) {
|
||||
const displaySet = displaySetService.getDisplaySetByUID(invalidatedDisplaySetInstanceUID);
|
||||
const imageIds = this._getCornerstoneStackImageIds(displaySet, dataSource);
|
||||
|
||||
@ -86,7 +122,10 @@ class CornerstoneCacheService {
|
||||
});
|
||||
|
||||
return {
|
||||
viewportType: Enums.ViewportType.STACK,
|
||||
// Preserve the original viewportType (legacy STACK or native PLANAR_NEXT);
|
||||
// the rebuilt data shape, not this field, drives the native re-mount dispatch.
|
||||
viewportType: viewportData.viewportType,
|
||||
dataShapeType: Enums.ViewportType.STACK,
|
||||
data: {
|
||||
StudyInstanceUID: displaySet.StudyInstanceUID,
|
||||
displaySetInstanceUID: invalidatedDisplaySetInstanceUID,
|
||||
@ -128,6 +167,7 @@ class CornerstoneCacheService {
|
||||
displaySets,
|
||||
viewportData.viewportType
|
||||
);
|
||||
newViewportData.dataShapeType = dataShapeType;
|
||||
|
||||
return newViewportData;
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions';
|
||||
import * as MapROIContoursToRTStructData from './RTSTRUCT/mapROIContoursToRTStructData';
|
||||
import SegmentationServiceClass, { SegmentationRepresentation } from './SegmentationService';
|
||||
import { setNextViewportsEnabled } from '../../utils/nextViewports';
|
||||
|
||||
jest.mock('@cornerstonejs/core', () => ({
|
||||
...jest.requireActual('@cornerstonejs/core'),
|
||||
@ -914,6 +915,101 @@ describe('SegmentationService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('next (generic) viewport', () => {
|
||||
// A native GenericViewport (raw PlanarViewport) exposes setDisplaySets /
|
||||
// setDisplaySetPresentation / setViewState, so csUtils.isGenericViewport is
|
||||
// true and addSegmentationRepresentation routes to NextSegmentationBackend.
|
||||
// It deliberately has NO getViewPresentation: the native path must never reach
|
||||
// convertStackToVolumeViewport (the source of the observed
|
||||
// "getViewPresentation is not a function" / silent ORTHOGRAPHIC flip).
|
||||
const makeNextViewport = () => ({
|
||||
element: { addEventListener: jest.fn(), removeEventListener: jest.fn() },
|
||||
id: viewportId,
|
||||
type: ViewportType.PLANAR_NEXT,
|
||||
setDisplaySets: jest.fn(),
|
||||
setDisplaySetPresentation: jest.fn(),
|
||||
setViewState: jest.fn(),
|
||||
});
|
||||
|
||||
it('renders the labelmap in place and never promotes the viewport (resolver maps in place)', async () => {
|
||||
jest
|
||||
.spyOn(cstSegmentation.state, 'getSegmentation')
|
||||
.mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation);
|
||||
jest
|
||||
.spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport')
|
||||
.mockReturnValue(makeNextViewport() as unknown as csTypes.IStackViewport);
|
||||
jest
|
||||
.spyOn(cstSegmentation.state, 'updateLabelmapSegmentationImageReferences')
|
||||
.mockReturnValue('labelmapImageId');
|
||||
jest
|
||||
.spyOn(cstSegmentation, 'addSegmentationRepresentations')
|
||||
.mockReturnValueOnce(undefined);
|
||||
const convertSpy = jest.spyOn(service, 'convertStackToVolumeViewport');
|
||||
|
||||
const callback = jest.fn();
|
||||
service.subscribe(service.EVENTS.SEGMENTATION_REPRESENTATION_MODIFIED, callback);
|
||||
|
||||
await service.addSegmentationRepresentation(viewportId, {
|
||||
segmentationId: mockCornerstoneSegmentation.segmentationId,
|
||||
type: csToolsEnums.SegmentationRepresentations.Labelmap,
|
||||
});
|
||||
|
||||
// the native in-place resolver is consulted...
|
||||
expect(
|
||||
cstSegmentation.state.updateLabelmapSegmentationImageReferences
|
||||
).toHaveBeenCalledWith(viewportId, mockCornerstoneSegmentation.segmentationId);
|
||||
|
||||
// ...but the viewport is NEVER promoted to an ORTHOGRAPHIC volume viewport
|
||||
expect(convertSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
serviceManagerMock.services.viewportGridService.setDisplaySetsForViewport
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
// the representation is added in place, synchronously (isConverted === false)
|
||||
expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1);
|
||||
expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledWith(viewportId, [
|
||||
{
|
||||
type: csToolsEnums.SegmentationRepresentations.Labelmap,
|
||||
segmentationId: mockCornerstoneSegmentation.segmentationId,
|
||||
config: { colorLUTOrIndex: undefined },
|
||||
},
|
||||
]);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
segmentationId: mockCornerstoneSegmentation.segmentationId,
|
||||
});
|
||||
});
|
||||
|
||||
it('never promotes on native even when the in-place resolver cannot map (returns isConverted:false unconditionally)', async () => {
|
||||
jest
|
||||
.spyOn(cstSegmentation.state, 'getSegmentation')
|
||||
.mockReturnValue(mockCornerstoneSegmentation as cstTypes.Segmentation);
|
||||
jest
|
||||
.spyOn(serviceManagerMock.services.cornerstoneViewportService, 'getCornerstoneViewport')
|
||||
.mockReturnValue(makeNextViewport() as unknown as csTypes.IStackViewport);
|
||||
// resolver fails (FrameOfReference mismatch / mount-timing race): on legacy this
|
||||
// would fall through to convertStackToVolumeViewport; on native it must not.
|
||||
jest
|
||||
.spyOn(cstSegmentation.state, 'updateLabelmapSegmentationImageReferences')
|
||||
.mockReturnValue(undefined);
|
||||
jest
|
||||
.spyOn(cstSegmentation, 'addSegmentationRepresentations')
|
||||
.mockReturnValueOnce(undefined);
|
||||
const convertSpy = jest.spyOn(service, 'convertStackToVolumeViewport');
|
||||
|
||||
await service.addSegmentationRepresentation(viewportId, {
|
||||
segmentationId: mockCornerstoneSegmentation.segmentationId,
|
||||
type: csToolsEnums.SegmentationRepresentations.Labelmap,
|
||||
});
|
||||
|
||||
expect(convertSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
serviceManagerMock.services.viewportGridService.setDisplaySetsForViewport
|
||||
).not.toHaveBeenCalled();
|
||||
expect(cstSegmentation.addSegmentationRepresentations).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('volume viewport', () => {
|
||||
it('should add a segmentation representation to volume viewport without need for handling', async () => {
|
||||
jest
|
||||
@ -1471,6 +1567,136 @@ describe('SegmentationService', () => {
|
||||
|
||||
expect(retrievedSegmentationId).toEqual(segmentationId);
|
||||
});
|
||||
|
||||
describe('overlap / useSliceRendering (next backend)', () => {
|
||||
const segmentationId = 'segmentationId';
|
||||
|
||||
// The session flag selects the seg-backend lane at SEG-load; reset it so it
|
||||
// never leaks into the legacy-path tests that follow.
|
||||
afterEach(() => {
|
||||
setNextViewportsEnabled(false);
|
||||
});
|
||||
|
||||
const segMetadata = {
|
||||
data: [
|
||||
{},
|
||||
{ SegmentNumber: '1', SegmentLabel: 'Segment 1', rgba: [255, 0, 0, 255] },
|
||||
{ SegmentNumber: '2', SegmentLabel: 'Segment 2', rgba: [0, 255, 0, 255] },
|
||||
],
|
||||
};
|
||||
const centroids = new Map([
|
||||
[1, { image: { x: 0, y: 0, z: 0 }, world: { x: 0, y: 0, z: 0 } }],
|
||||
[2, { image: { x: 1, y: 1, z: 1 }, world: { x: 1, y: 1, z: 1 } }],
|
||||
]);
|
||||
|
||||
// labelMapImages is the adapter's array-of-GROUPS (one conflict-free group per
|
||||
// overlap layer). Two groups whose voxels carry values {1} and {2} respectively.
|
||||
const makeOverlapGroups = () => {
|
||||
const vm0 = { getScalarData: jest.fn().mockReturnValue([1, 0]), setScalarData: jest.fn() };
|
||||
const vm1 = { getScalarData: jest.fn().mockReturnValue([0, 2]), setScalarData: jest.fn() };
|
||||
return [
|
||||
[
|
||||
{ imageId: 'g0i1', referencedImageId: 'r1', voxelManager: vm0 },
|
||||
{ imageId: 'g0i2', referencedImageId: 'r2', voxelManager: vm0 },
|
||||
],
|
||||
[
|
||||
{ imageId: 'g1i1', referencedImageId: 'r1', voxelManager: vm1 },
|
||||
{ imageId: 'g1i2', referencedImageId: 'r2', voxelManager: vm1 },
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
const makeSegDisplaySet = (labelMapImages, overlappingSegments) => ({
|
||||
centroids,
|
||||
displaySetInstanceUID: 'display-set-uid',
|
||||
referencedDisplaySetInstanceUID: 'existent-display-set-uid',
|
||||
labelMapImages,
|
||||
overlappingSegments,
|
||||
segMetadata,
|
||||
SeriesDate: '2025-01-01',
|
||||
SeriesDescription: 'Series Description',
|
||||
Modality: 'SEG',
|
||||
SeriesNumber: 1,
|
||||
});
|
||||
|
||||
const primeMocks = () => {
|
||||
jest
|
||||
.spyOn(serviceManagerMock.services.displaySetService, 'getDisplaySetByUID')
|
||||
.mockReturnValue({ instances: [{ imageId: 'r1' }, { imageId: 'r2' }] });
|
||||
jest.spyOn(metaData, 'get').mockReturnValue({});
|
||||
jest.spyOn(service, 'addOrUpdateSegmentation').mockReturnValue(undefined);
|
||||
};
|
||||
|
||||
it('flag ON + overlapping SEG builds one labelmap layer per group + segmentBindings', async () => {
|
||||
setNextViewportsEnabled(true);
|
||||
primeMocks();
|
||||
|
||||
await service.createSegmentationForSEGDisplaySet(
|
||||
makeSegDisplaySet(makeOverlapGroups(), true),
|
||||
{ type: csToolsEnums.SegmentationRepresentations.Labelmap, segmentationId }
|
||||
);
|
||||
|
||||
const seg = jest.mocked(service.addOrUpdateSegmentation).mock.calls[0][0];
|
||||
const data = seg.representation.data as Record<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', () => {
|
||||
@ -2698,6 +2924,47 @@ describe('SegmentationService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates a native viewport via setViewReference (no jumpToWorld) and still highlights', () => {
|
||||
const segmentationId = 'segmentationId';
|
||||
const segmentIndex = 1;
|
||||
const viewportId = 'viewportId';
|
||||
// A native PlanarViewport: csUtils.isGenericViewport is true (setDisplaySets +
|
||||
// setDisplaySetPresentation + setViewState) and it has setViewReference but NO
|
||||
// jumpToWorld, so jumpToSegmentCenter routes to the next twin.
|
||||
const viewport = {
|
||||
setDisplaySets: jest.fn(),
|
||||
setDisplaySetPresentation: jest.fn(),
|
||||
setViewState: jest.fn(),
|
||||
setViewReference: jest.fn(),
|
||||
render: jest.fn(),
|
||||
};
|
||||
|
||||
const segmentationWithCenter = {
|
||||
...mockCornerstoneSegmentation,
|
||||
segments: {
|
||||
...mockCornerstoneSegmentation.segments,
|
||||
[segmentIndex]: {
|
||||
...mockCornerstoneSegmentation.segments[segmentIndex],
|
||||
cachedStats: { center: { image: [1, 1, 1], world: [10, 10, 10] } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(cstSegmentation.state, 'getSegmentation').mockReturnValue(segmentationWithCenter);
|
||||
// @ts-expect-error - mock only needed properties
|
||||
getEnabledElementByViewportId.mockReturnValue({ viewport });
|
||||
jest.spyOn(service, 'highlightSegment').mockReturnValue(undefined);
|
||||
|
||||
service.jumpToSegmentCenter(segmentationId, segmentIndex, viewportId);
|
||||
|
||||
// native jump: a view reference centered on the segment world point, then render
|
||||
expect(viewport.setViewReference).toHaveBeenCalledTimes(1);
|
||||
expect(viewport.setViewReference).toHaveBeenCalledWith({ cameraFocalPoint: [10, 10, 10] });
|
||||
expect(viewport.render).toHaveBeenCalledTimes(1);
|
||||
// the recenter happened, so the highlight still runs
|
||||
expect(service.highlightSegment).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should correctly handle custom animation parameters', () => {
|
||||
const segmentationId = 'segmentationId';
|
||||
const segmentIndex = 1;
|
||||
|
||||
@ -10,10 +10,7 @@ import {
|
||||
metaData,
|
||||
} from '@cornerstonejs/core';
|
||||
import { ViewportType } from '@cornerstonejs/core/enums';
|
||||
import {
|
||||
isVolume3DViewportType,
|
||||
isVolumeViewportType,
|
||||
} from '../../utils/getLegacyViewportType';
|
||||
import { isVolume3DViewportType } from '../../utils/getLegacyViewportType';
|
||||
|
||||
import {
|
||||
Enums as csToolsEnums,
|
||||
@ -30,6 +27,17 @@ import { mapROIContoursToRTStructData } from './RTSTRUCT/mapROIContoursToRTStruc
|
||||
import { SegmentationPresentation, SegmentationPresentationItem } from '../../types/Presentation';
|
||||
import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions';
|
||||
import { ViewReference } from '@cornerstonejs/core/types';
|
||||
import {
|
||||
LegacySegmentationBackend,
|
||||
NextSegmentationBackend,
|
||||
type ISegmentationBackend,
|
||||
type ISegmentationServiceInternals,
|
||||
} from './backends';
|
||||
// Sanctioned flag read: the SEG data shape (single- vs multi-layer) is fixed at
|
||||
// load time, before any target viewport exists, so this one seg-backend dispatch
|
||||
// cannot use a per-viewport capability check and reads the session flag instead.
|
||||
import { isNextViewportsEnabled } from '../../utils/nextViewports';
|
||||
import { isNextViewport } from '../ViewportService/adapter';
|
||||
|
||||
const { DefaultHistoryMemo } = csUtils.HistoryMemo;
|
||||
|
||||
@ -42,7 +50,7 @@ const {
|
||||
const {
|
||||
getLabelmapImageIds,
|
||||
helpers: { convertStackToVolumeLabelmap },
|
||||
state: { addColorLUT, updateLabelmapSegmentationImageReferences },
|
||||
state: { addColorLUT },
|
||||
triggerSegmentationEvents: { triggerSegmentationRepresentationModified },
|
||||
} = cstSegmentation;
|
||||
|
||||
@ -98,7 +106,7 @@ const EVENTS = {
|
||||
|
||||
const VALUE_TYPES = {};
|
||||
|
||||
class SegmentationService extends PubSubService {
|
||||
class SegmentationService extends PubSubService implements ISegmentationServiceInternals {
|
||||
static REGISTRATION = {
|
||||
name: 'segmentationService',
|
||||
altName: 'SegmentationService',
|
||||
@ -109,6 +117,8 @@ class SegmentationService extends PubSubService {
|
||||
|
||||
private _segmentationIdToColorLUTIndexMap: Map<string, number>;
|
||||
private _segmentationGroupStatsMap: Map<string, any>;
|
||||
private readonly _legacySegBackend: ISegmentationBackend;
|
||||
private readonly _nextSegBackend: ISegmentationBackend;
|
||||
readonly servicesManager: AppTypes.ServicesManager;
|
||||
highlightIntervalId = null;
|
||||
readonly EVENTS = EVENTS;
|
||||
@ -121,6 +131,24 @@ class SegmentationService extends PubSubService {
|
||||
this.servicesManager = servicesManager;
|
||||
|
||||
this._segmentationGroupStatsMap = new Map();
|
||||
|
||||
// Segmentation backend twins (mirror the viewport backend family). Routed PER
|
||||
// VIEWPORT via _segBackend() using the adapter's isNextViewport predicate,
|
||||
// because a flag-on session can mix native and legacy viewports. Both are
|
||||
// constructed eagerly:
|
||||
// per-viewport dispatch has no per-session flag to defer on, and the twins read
|
||||
// state at call time (post-init), not at construction.
|
||||
this._legacySegBackend = new LegacySegmentationBackend(this);
|
||||
this._nextSegBackend = new NextSegmentationBackend();
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the segmentation backend lane for a specific viewport: the native
|
||||
* ("next") twin for a raw GenericViewport (PlanarViewport), the legacy twin
|
||||
* otherwise. Mirrors viewportOperations' per-viewport dispatch.
|
||||
*/
|
||||
private _segBackend(viewport: csTypes.IViewport): ISegmentationBackend {
|
||||
return isNextViewport(viewport) ? this._nextSegBackend : this._legacySegBackend;
|
||||
}
|
||||
|
||||
public onModeEnter(): void {
|
||||
@ -297,6 +325,7 @@ class SegmentationService extends PubSubService {
|
||||
type?: csToolsEnums.SegmentationRepresentations;
|
||||
config?: {
|
||||
blendMode?: csEnums.BlendModes;
|
||||
useSliceRendering?: boolean;
|
||||
};
|
||||
suppressEvents?: boolean;
|
||||
}
|
||||
@ -319,6 +348,15 @@ class SegmentationService extends PubSubService {
|
||||
return;
|
||||
}
|
||||
|
||||
// A stale/invalid segmentationId yields no segmentation; fail fast with a clear
|
||||
// message instead of dereferencing representationData deep inside the backend
|
||||
// classification below.
|
||||
if (!segmentation) {
|
||||
throw new Error(
|
||||
`SegmentationService: cannot add representation - segmentation "${segmentationId}" not found.`
|
||||
);
|
||||
}
|
||||
|
||||
const colorLUTIndex = this._segmentationIdToColorLUTIndexMap.get(segmentationId);
|
||||
|
||||
let isConverted = false;
|
||||
@ -328,20 +366,35 @@ class SegmentationService extends PubSubService {
|
||||
let representationTypeToUse = type || defaultRepresentationType;
|
||||
|
||||
if (representationTypeToUse === LABELMAP) {
|
||||
const { isVolumeViewport, isVolumeSegmentation } = this.determineViewportAndSegmentationType(
|
||||
csViewport,
|
||||
segmentation
|
||||
) || { isVolumeViewport: false, isVolumeSegmentation: false };
|
||||
|
||||
({ representationTypeToUse, isConverted } = await this.handleViewportConversion(
|
||||
isVolumeViewport,
|
||||
isVolumeSegmentation,
|
||||
({ representationTypeToUse, isConverted } = await this._segBackend(
|
||||
csViewport
|
||||
).classifyAndPrepareLabelmapAdd(
|
||||
csViewport,
|
||||
segmentation,
|
||||
viewportId,
|
||||
segmentationId,
|
||||
representationTypeToUse
|
||||
));
|
||||
|
||||
// Overlap precondition: an overlapping SEG is registered as multiple labelmap
|
||||
// layers, but cornerstone only stacks them (slice rendering) when the viewport
|
||||
// renders as a volume slice (VTK_VOLUME_SLICE) — i.e. an MPR/volume viewport. On
|
||||
// a stack/acquisition viewport the render plan falls back to a single layer, so
|
||||
// only the primary group is visible. Warn rather than fail silently.
|
||||
const labelmapLayers = segmentation?.representationData?.[LABELMAP]?.labelmaps;
|
||||
const isOverlapping = labelmapLayers && Object.keys(labelmapLayers).length > 1;
|
||||
if (
|
||||
isOverlapping &&
|
||||
isNextViewport(csViewport) &&
|
||||
!csUtils.viewportIsInVolumeMode(csViewport)
|
||||
) {
|
||||
console.warn(
|
||||
`Overlapping segmentation ${segmentationId} has multiple labelmap layers, but ` +
|
||||
`viewport ${viewportId} does not render as a volume slice (VTK_VOLUME_SLICE); ` +
|
||||
`only the primary layer will be visible. Display the segmentation in an ` +
|
||||
`MPR/volume layout to see all overlapping segments.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this._addSegmentationRepresentation(
|
||||
@ -592,22 +645,21 @@ class SegmentationService extends PubSubService {
|
||||
const colorLUTIndex = addColorLUT(colorLUT);
|
||||
this._segmentationIdToColorLUTIndexMap.set(segmentationId, colorLUTIndex);
|
||||
|
||||
const seg: cstTypes.SegmentationPublicInput = {
|
||||
// Build the segmentation input via the backend twin. At SEG-load there is no
|
||||
// target viewport yet, so the lane is chosen by the session flag (the one
|
||||
// viewport-less seg-backend dispatch): the next twin registers overlapping SEGs
|
||||
// as multiple labelmap layers (slice rendering); the legacy twin keeps the single
|
||||
// flattened layer (byte-identical).
|
||||
const segBackend = isNextViewportsEnabled() ? this._nextSegBackend : this._legacySegBackend;
|
||||
const seg = segBackend.assembleSegmentationDataForSEG({
|
||||
segmentationId,
|
||||
representation: {
|
||||
type: LABELMAP,
|
||||
data: {
|
||||
imageIds: derivedImageIds,
|
||||
// referencedVolumeId: this._getVolumeIdForDisplaySet(referencedDisplaySet),
|
||||
referencedImageIds: imageIds as string[],
|
||||
},
|
||||
},
|
||||
config: {
|
||||
label: segDisplaySet.SeriesDescription,
|
||||
fallbackLabel: `S:${segDisplaySet.SeriesNumber} ${segDisplaySet.Modality}`,
|
||||
segments,
|
||||
},
|
||||
};
|
||||
segDisplaySet,
|
||||
derivedImageIds,
|
||||
referencedImageIds: imageIds as string[],
|
||||
label: segDisplaySet.SeriesDescription,
|
||||
fallbackLabel: `S:${segDisplaySet.SeriesNumber} ${segDisplaySet.Modality}`,
|
||||
segments,
|
||||
});
|
||||
|
||||
segDisplaySet.isLoaded = true;
|
||||
|
||||
@ -1515,13 +1567,17 @@ class SegmentationService extends PubSubService {
|
||||
|
||||
viewportIds.forEach(viewportId => {
|
||||
const { viewport } = getEnabledElementByViewportId(viewportId);
|
||||
if (!viewport?.jumpToWorld) {
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
viewport.jumpToWorld(world);
|
||||
// Recenter via the backend twin: legacy jumpToWorld, or native setViewReference
|
||||
// (a native PlanarViewport has no jumpToWorld). Skip the highlight when the
|
||||
// recenter did not happen, matching the previous guarded behavior.
|
||||
const didJump = this._segBackend(viewport).jumpToSegmentCenter(viewport, world);
|
||||
|
||||
highlightSegment &&
|
||||
didJump &&
|
||||
highlightSegment &&
|
||||
this.highlightSegment(
|
||||
segmentationId,
|
||||
segmentIndex,
|
||||
@ -1626,87 +1682,11 @@ class SegmentationService extends PubSubService {
|
||||
);
|
||||
}
|
||||
|
||||
private determineViewportAndSegmentationType(csViewport, segmentation) {
|
||||
const isVolumeViewport = isVolumeViewportType(csViewport);
|
||||
const labelmapData = segmentation?.representationData?.[LABELMAP];
|
||||
|
||||
if (!labelmapData) {
|
||||
return { isVolumeViewport, isVolumeSegmentation: false };
|
||||
}
|
||||
|
||||
const isVolumeSegmentation = 'volumeId' in labelmapData;
|
||||
return { isVolumeViewport, isVolumeSegmentation };
|
||||
}
|
||||
|
||||
private async handleViewportConversion(
|
||||
isVolumeViewport: boolean,
|
||||
isVolumeSegmentation: boolean,
|
||||
csViewport: csTypes.IViewport,
|
||||
segmentation: cstTypes.Segmentation,
|
||||
viewportId: string,
|
||||
segmentationId: string,
|
||||
representationType: csToolsEnums.SegmentationRepresentations
|
||||
) {
|
||||
let representationTypeToUse = representationType;
|
||||
let isConverted = false;
|
||||
|
||||
const handler = isVolumeViewport ? this.handleVolumeViewportCase : this.handleStackViewportCase;
|
||||
|
||||
({ representationTypeToUse, isConverted } = await handler.apply(this, [
|
||||
csViewport,
|
||||
segmentation,
|
||||
isVolumeSegmentation,
|
||||
viewportId,
|
||||
segmentationId,
|
||||
]));
|
||||
|
||||
return { representationTypeToUse, isConverted };
|
||||
}
|
||||
|
||||
private async handleVolumeViewportCase(csViewport, segmentation, isVolumeSegmentation) {
|
||||
if (isVolume3DViewportType(csViewport)) {
|
||||
return {
|
||||
representationTypeToUse: SURFACE,
|
||||
isConverted: false,
|
||||
};
|
||||
} else {
|
||||
await this.handleVolumeViewport(
|
||||
csViewport as csTypes.IVolumeViewport,
|
||||
segmentation,
|
||||
isVolumeSegmentation
|
||||
);
|
||||
return { representationTypeToUse: LABELMAP, isConverted: false };
|
||||
}
|
||||
}
|
||||
|
||||
private async handleStackViewportCase(
|
||||
csViewport: csTypes.IViewport,
|
||||
segmentation: cstTypes.Segmentation,
|
||||
isVolumeSegmentation: boolean,
|
||||
viewportId: string,
|
||||
segmentationId: string
|
||||
): Promise<{
|
||||
representationTypeToUse: csToolsEnums.SegmentationRepresentations;
|
||||
isConverted: boolean;
|
||||
}> {
|
||||
if (isVolumeSegmentation) {
|
||||
const isConverted = await this.convertStackToVolumeViewport(csViewport);
|
||||
return { representationTypeToUse: LABELMAP, isConverted };
|
||||
}
|
||||
|
||||
if (updateLabelmapSegmentationImageReferences(viewportId, segmentationId)) {
|
||||
return { representationTypeToUse: LABELMAP, isConverted: false };
|
||||
}
|
||||
|
||||
const isConverted = await this.attemptStackToVolumeConversion(
|
||||
csViewport as csTypes.IStackViewport,
|
||||
segmentation,
|
||||
viewportId,
|
||||
segmentationId
|
||||
);
|
||||
|
||||
return { representationTypeToUse: LABELMAP, isConverted };
|
||||
}
|
||||
// Labelmap-add classification (determineViewportAndSegmentationType +
|
||||
// handleViewportConversion + the stack/volume case handlers) now lives in the
|
||||
// segmentation backend twins (backends/{Legacy,Next}SegmentationBackend), routed
|
||||
// per viewport via _segBackend(). The legacy twin reaches the viewport-recreation
|
||||
// and data-volume-conversion helpers below through ISegmentationServiceInternals.
|
||||
|
||||
private async _addSegmentationRepresentation(
|
||||
viewportId: string,
|
||||
@ -1716,6 +1696,7 @@ class SegmentationService extends PubSubService {
|
||||
isConverted: boolean,
|
||||
config?: {
|
||||
blendMode?: csEnums.BlendModes;
|
||||
useSliceRendering?: boolean;
|
||||
}
|
||||
): Promise<void> {
|
||||
const representation = {
|
||||
@ -1743,7 +1724,7 @@ class SegmentationService extends PubSubService {
|
||||
addRepresentation();
|
||||
}
|
||||
}
|
||||
private async handleVolumeViewport(
|
||||
public async handleVolumeViewport(
|
||||
viewport: csTypes.IVolumeViewport,
|
||||
segmentation: SegmentationData,
|
||||
isVolumeSegmentation: boolean
|
||||
@ -1761,7 +1742,7 @@ class SegmentationService extends PubSubService {
|
||||
}
|
||||
}
|
||||
|
||||
private async convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise<boolean> {
|
||||
public async convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise<boolean> {
|
||||
const { viewportGridService, cornerstoneViewportService } = this.servicesManager.services;
|
||||
const state = viewportGridService.getState();
|
||||
const gridViewport = state.viewports.get(viewport.id);
|
||||
@ -1800,7 +1781,7 @@ class SegmentationService extends PubSubService {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async attemptStackToVolumeConversion(
|
||||
public async attemptStackToVolumeConversion(
|
||||
viewport: csTypes.IStackViewport,
|
||||
segmentation: SegmentationData,
|
||||
viewportId: string,
|
||||
@ -1820,6 +1801,10 @@ class SegmentationService extends PubSubService {
|
||||
|
||||
return isConverted;
|
||||
}
|
||||
|
||||
// Frame-of-reference mismatch (or missing): no conversion happened. Return an
|
||||
// explicit boolean so the Promise<boolean> contract holds for callers.
|
||||
return false;
|
||||
}
|
||||
|
||||
private addSegmentationToSource(segmentationPublicInput: cstTypes.SegmentationPublicInput) {
|
||||
|
||||
@ -0,0 +1,102 @@
|
||||
import type { Types as csTypes } from '@cornerstonejs/core';
|
||||
import type { Enums as csToolsEnums, Types as cstTypes } from '@cornerstonejs/tools';
|
||||
|
||||
/** A derived labelmap image produced by the SEG adapter (one per referenced image,
|
||||
* per overlap group). `voxelManager.getScalarData()` yields the slice's label values. */
|
||||
export interface SegLabelmapImage {
|
||||
imageId: string;
|
||||
voxelManager?: { getScalarData: () => ArrayLike<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;
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import type { Types as csTypes } from '@cornerstonejs/core';
|
||||
import type { Types as cstTypes } from '@cornerstonejs/tools';
|
||||
|
||||
/**
|
||||
* The narrow slice of SegmentationService that a segmentation backend is allowed to
|
||||
* reach (mirrors `IViewportServiceInternals`). The service `implements` this and
|
||||
* passes `this` to each backend, so the legacy twin can delegate the viewport
|
||||
* (re)creation / data-volume conversion work back to the shared service methods
|
||||
* (which touch servicesManager-owned services) without the backends reaching into
|
||||
* unrelated internals. Keeping this surface narrow is what stops the legacy path
|
||||
* from drifting as the next backend grows.
|
||||
*
|
||||
* Only the LEGACY twin uses these; the NEXT twin renders in place and needs none of
|
||||
* them (it never converts).
|
||||
*/
|
||||
export interface ISegmentationServiceInternals {
|
||||
/**
|
||||
* Recreate the stack viewport as an ORTHOGRAPHIC volume viewport (legacy
|
||||
* promotion). Owned by the service because it drives viewportGridService /
|
||||
* cornerstoneViewportService. Returns true (converted).
|
||||
*/
|
||||
convertStackToVolumeViewport(viewport: csTypes.IViewport): Promise<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>;
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
import type { Types as csTypes } from '@cornerstonejs/core';
|
||||
import {
|
||||
Enums as csToolsEnums,
|
||||
segmentation as cstSegmentation,
|
||||
type Types as cstTypes,
|
||||
} from '@cornerstonejs/tools';
|
||||
import { isVolume3DViewportType, isVolumeViewportType } from '../../../utils/getLegacyViewportType';
|
||||
import type {
|
||||
AssembleSegmentationForSEGParams,
|
||||
ISegmentationBackend,
|
||||
LabelmapAddClassification,
|
||||
} from './ISegmentationBackend';
|
||||
import type { ISegmentationServiceInternals } from './ISegmentationServiceInternals';
|
||||
|
||||
const { Labelmap: LABELMAP, Surface: SURFACE } = csToolsEnums.SegmentationRepresentations;
|
||||
const {
|
||||
state: { updateLabelmapSegmentationImageReferences },
|
||||
} = cstSegmentation;
|
||||
|
||||
/**
|
||||
* Legacy (default) segmentation backend. Selected when the target viewport is NOT a
|
||||
* native GenericViewport (the off path / legacy StackViewport / VolumeViewport).
|
||||
* Holds the labelmap-add decision tree verbatim (determine + handleViewportConversion
|
||||
* + the stack/volume case handlers) and delegates the servicesManager-coupled work
|
||||
* (convertStackToVolumeViewport / attemptStackToVolumeConversion / handleVolumeViewport)
|
||||
* back to the service via ISegmentationServiceInternals, so behavior is byte-identical
|
||||
* to before the backend split.
|
||||
*/
|
||||
export class LegacySegmentationBackend implements ISegmentationBackend {
|
||||
constructor(private readonly service: ISegmentationServiceInternals) {}
|
||||
|
||||
async classifyAndPrepareLabelmapAdd(
|
||||
csViewport: csTypes.IViewport,
|
||||
segmentation: cstTypes.Segmentation,
|
||||
viewportId: string,
|
||||
segmentationId: string,
|
||||
// The case handlers return LABELMAP/SURFACE directly (byte-identical to the
|
||||
// pre-split handleViewportConversion), so the incoming type is unused here.
|
||||
_representationType: csToolsEnums.SegmentationRepresentations
|
||||
): Promise<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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
import type { Types as csTypes } from '@cornerstonejs/core';
|
||||
import {
|
||||
Enums as csToolsEnums,
|
||||
segmentation as cstSegmentation,
|
||||
type Types as cstTypes,
|
||||
} from '@cornerstonejs/tools';
|
||||
import type {
|
||||
AssembleSegmentationForSEGParams,
|
||||
ISegmentationBackend,
|
||||
LabelmapAddClassification,
|
||||
} from './ISegmentationBackend';
|
||||
|
||||
const { Labelmap: LABELMAP } = csToolsEnums.SegmentationRepresentations;
|
||||
const {
|
||||
state: { updateLabelmapSegmentationImageReferences },
|
||||
} = cstSegmentation;
|
||||
|
||||
/**
|
||||
* Native GenericViewport ("next") segmentation backend. Selected when the target
|
||||
* viewport is a native generic viewport (raw PlanarViewport;
|
||||
* `isNextViewport(viewport)` is true).
|
||||
*
|
||||
* The keystone of the native migration: a native PlanarViewport renders a labelmap
|
||||
* IN PLACE — cornerstone's `resolveLabelmapRenderPlan` picks `legacy-stack-image`
|
||||
* for a stack labelmap (no volumeId, no VTK_VOLUME_SLICE precondition) and the
|
||||
* duck-typed image-reference resolver maps it onto the viewport's current image. So
|
||||
* this twin NEVER promotes the viewport to an ORTHOGRAPHIC volume viewport: the
|
||||
* legacy `convertStackToVolumeViewport` calls `getViewPresentation` /
|
||||
* `setViewPresentation`, which the raw PlanarViewport does not implement — it throws
|
||||
* (the observed `getViewPresentation is not a function`) and would recreate the
|
||||
* viewport, defeating `useNextViewports`.
|
||||
*
|
||||
* Needs nothing from the host service (it never converts / never touches
|
||||
* servicesManager), so unlike the legacy twin it takes no internals handle.
|
||||
*/
|
||||
export class NextSegmentationBackend implements ISegmentationBackend {
|
||||
async classifyAndPrepareLabelmapAdd(
|
||||
_csViewport: csTypes.IViewport,
|
||||
_segmentation: cstTypes.Segmentation,
|
||||
viewportId: string,
|
||||
segmentationId: string,
|
||||
representationType: csToolsEnums.SegmentationRepresentations
|
||||
): Promise<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;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
export type { ISegmentationBackend, LabelmapAddClassification } from './ISegmentationBackend';
|
||||
export type { ISegmentationServiceInternals } from './ISegmentationServiceInternals';
|
||||
export { LegacySegmentationBackend } from './LegacySegmentationBackend';
|
||||
export { NextSegmentationBackend } from './NextSegmentationBackend';
|
||||
@ -35,13 +35,19 @@ import { usePositionPresentationStore } from '../../stores/usePositionPresentati
|
||||
import { useSynchronizersStore } from '../../stores/useSynchronizersStore';
|
||||
import { useSegmentationPresentationStore } from '../../stores/useSegmentationPresentationStore';
|
||||
import getClosestOrientationFromIOP from '../../utils/isReferenceViewable';
|
||||
import { getViewportAdapter } from './adapter';
|
||||
import { viewportOperations } from './backends/viewportOperations';
|
||||
import {
|
||||
getLegacyViewportType,
|
||||
isStackViewportType,
|
||||
isVolume3DViewportType,
|
||||
isVolumeViewportType,
|
||||
} from '../../utils/getLegacyViewportType';
|
||||
import { BlendModes } from '@cornerstonejs/core/enums';
|
||||
import { isNextViewportsEnabled } from '../../utils/nextViewports';
|
||||
import type { IViewportBackend } from './backends/IViewportBackend';
|
||||
import type { IViewportServiceInternals } from './backends/IViewportServiceInternals';
|
||||
import { LegacyViewportBackend } from './backends/LegacyViewportBackend';
|
||||
import { NextViewportBackend } from './backends/NextViewportBackend';
|
||||
|
||||
const EVENTS = {
|
||||
VIEWPORT_DATA_CHANGED: 'event::cornerstoneViewportService:viewportDataChanged',
|
||||
@ -108,7 +114,10 @@ export const WITH_ORIENTATION = { withNavigation: true, withOrientation: true };
|
||||
* Handles cornerstone viewport logic including enabling, disabling, and
|
||||
* updating the viewport.
|
||||
*/
|
||||
class CornerstoneViewportService extends PubSubService implements IViewportService {
|
||||
class CornerstoneViewportService
|
||||
extends PubSubService
|
||||
implements IViewportService, IViewportServiceInternals
|
||||
{
|
||||
static REGISTRATION = {
|
||||
name: 'cornerstoneViewportService',
|
||||
altName: 'CornerstoneViewportService',
|
||||
@ -133,12 +142,32 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
gridResizeDelay = 50;
|
||||
gridResizeTimeOut = null;
|
||||
|
||||
// Resolved once, lazily, on first use. Forked viewport concerns (mount dispatch +
|
||||
// native dataId lifecycle) route through it.
|
||||
private _backend: IViewportBackend | null = null;
|
||||
|
||||
constructor(servicesManager: AppTypes.ServicesManager) {
|
||||
super(EVENTS);
|
||||
this.renderingEngine = null;
|
||||
this.viewportGridResizeObserver = null;
|
||||
this.servicesManager = servicesManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanctioned flag read (the exhaustive list lives in backends/README.md): pick
|
||||
* the viewport backend once, on first use. Resolved lazily (not in the
|
||||
* constructor) because the service singleton is constructed during extension
|
||||
* registration, BEFORE init.tsx runs setNextViewportsEnabled — the first mount
|
||||
* (when this is first read) always happens after init, so the flag is settled.
|
||||
*/
|
||||
private get backend(): IViewportBackend {
|
||||
if (!this._backend) {
|
||||
this._backend = isNextViewportsEnabled()
|
||||
? new NextViewportBackend(this)
|
||||
: new LegacyViewportBackend(this);
|
||||
}
|
||||
return this._backend;
|
||||
}
|
||||
hangingProtocolService: unknown;
|
||||
viewportsInfo: unknown;
|
||||
sceneVolumeInputs: unknown;
|
||||
@ -236,6 +265,8 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
public destroy() {
|
||||
this._removeResizeObserver();
|
||||
this.viewportGridResizeObserver = null;
|
||||
// Flush any native dataId registrations the backend owns (§4.7); no-op for legacy.
|
||||
this.backend.destroy();
|
||||
try {
|
||||
this.renderingEngine?.destroy?.();
|
||||
} catch (e) {
|
||||
@ -257,6 +288,10 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
* @param viewportId - The viewportId to disable
|
||||
*/
|
||||
public disableElement(viewportId: string): void {
|
||||
// Release native dataId registrations BEFORE the viewport bookkeeping is
|
||||
// deleted (§4.7 ref-counted GC); no-op for the legacy backend.
|
||||
this.backend.onViewportDisabled(viewportId);
|
||||
|
||||
this.renderingEngine?.disableElement(viewportId);
|
||||
|
||||
// clean up
|
||||
@ -272,6 +307,12 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
* @param presentations - The presentations to apply to the viewport.
|
||||
* @param viewportInfo - Contains a view reference for immediate application
|
||||
*/
|
||||
// Public so the viewport backends (IViewportServiceInternals) can record which
|
||||
// display sets a viewport shows from their mount bodies.
|
||||
_trackViewportDisplaySets(viewportId: string, displaySetInstanceUIDs: string[]): void {
|
||||
this.viewportsDisplaySets.set(viewportId, displaySetInstanceUIDs);
|
||||
}
|
||||
|
||||
public setPresentations(viewportId: string, presentations: Presentations): void {
|
||||
const viewport = this.getCornerstoneViewport(viewportId) as
|
||||
| Types.IStackViewport
|
||||
@ -379,12 +420,9 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
|
||||
const viewportInfo = this.viewportsById.get(viewportId);
|
||||
|
||||
return {
|
||||
viewportType: viewportInfo.getViewportType(),
|
||||
viewReference: isVolume3DViewportType(csViewport) ? null : csViewport.getViewReference(),
|
||||
viewPresentation: csViewport.getViewPresentation({ pan: true, zoom: true }),
|
||||
viewportId,
|
||||
};
|
||||
// Forked per backend (§4.3 presentation read): legacy reads getViewPresentation
|
||||
// (pan/zoom); native omits it (a PLANAR_NEXT viewport has no getViewPresentation).
|
||||
return this.backend.getPositionPresentation(csViewport, viewportInfo, viewportId);
|
||||
}
|
||||
|
||||
private _getLutPresentation(viewportId: string): LutPresentation {
|
||||
@ -411,7 +449,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
|
||||
const properties = isVolumeViewportType(csViewport)
|
||||
? new Map()
|
||||
: cleanProperties(csViewport.getProperties());
|
||||
: cleanProperties(getViewportAdapter(csViewport).getPresentation());
|
||||
|
||||
if (properties instanceof Map) {
|
||||
const volumeIds = (csViewport as Types.IBaseVolumeViewport).getAllVolumeIds();
|
||||
@ -661,7 +699,8 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
|
||||
for (const id of this.viewportsById.keys()) {
|
||||
const viewport = this.getCornerstoneViewport(id);
|
||||
const { viewPlaneNormal } = viewport.getCamera();
|
||||
// Lane-appropriate view-plane normal (legacy getCamera vs native getViewReference).
|
||||
const viewPlaneNormal = viewportOperations.getViewPlaneNormal(viewport);
|
||||
|
||||
if (!viewPlaneNormal) {
|
||||
continue;
|
||||
@ -830,7 +869,8 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
/**
|
||||
* Sets the image data for the given viewport.
|
||||
*/
|
||||
private async _setEcgViewport(
|
||||
// Public so the viewport backends (IViewportServiceInternals) can dispatch to it.
|
||||
async _setEcgViewport(
|
||||
viewport: Types.IECGViewport,
|
||||
viewportData: StackViewportData
|
||||
): Promise<void> {
|
||||
@ -840,38 +880,29 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
console.error('[CornerstoneViewportService] ECG display set has no imageId');
|
||||
return;
|
||||
}
|
||||
return viewport.setEcg(imageId);
|
||||
|
||||
return this.backend.mountEcg(viewport, displaySet, imageId);
|
||||
}
|
||||
|
||||
private async _setOtherViewport(
|
||||
// Public so the viewport backends (IViewportServiceInternals) can dispatch to it.
|
||||
async _setOtherViewport(
|
||||
viewport: Types.IStackViewport,
|
||||
viewportData: StackViewportData,
|
||||
viewportInfo: ViewportInfo,
|
||||
_presentations: Presentations = {}
|
||||
): Promise<void> {
|
||||
const [displaySet] = viewportData.data;
|
||||
const displaySetId = displaySet.imageIds[0];
|
||||
// Register the WSI dataset so the viewport can resolve its imageIds +
|
||||
// webClient by display-set id, then mount via setDisplaySets. The webClient
|
||||
// was registered under the WADO_WEB_CLIENT module (keyed by imageIds[0]) by
|
||||
// the SM SOP class handler. CS3D's "redo viewports" reads this same registry
|
||||
// (genericViewportDisplaySetMetadataProvider) from its WSI data provider;
|
||||
// without this entry setDisplaySets throws "No registered WSI dataset" and
|
||||
// the viewport renders gray.
|
||||
const webClient = metaData.get(csEnums.MetadataModules.WADO_WEB_CLIENT, displaySetId);
|
||||
csUtils.genericViewportDisplaySetMetadataProvider.add(displaySetId, {
|
||||
imageIds: displaySet.imageIds,
|
||||
kind: 'wsi',
|
||||
options: { webClient },
|
||||
});
|
||||
await viewport.setDisplaySets({ displaySetId });
|
||||
|
||||
await this.backend.mountOther(viewport, displaySet);
|
||||
|
||||
const viewReference = viewportInfo.getViewReference();
|
||||
if (viewReference) {
|
||||
viewport.setViewReference(viewReference);
|
||||
}
|
||||
}
|
||||
|
||||
private async _setStackViewport(
|
||||
// Public so the viewport backends (IViewportServiceInternals) can dispatch to it.
|
||||
async _setStackViewport(
|
||||
viewport: Types.IStackViewport,
|
||||
viewportData: StackViewportData,
|
||||
viewportInfo: ViewportInfo,
|
||||
@ -924,7 +955,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
const overlayProcessingResults = this._processExtraDisplaySetsForViewport(viewport);
|
||||
|
||||
const referencedImageId = presentations?.positionPresentation?.viewReference?.referencedImageId;
|
||||
if (referencedImageId) {
|
||||
if (referencedImageId && imageIds) {
|
||||
initialImageIndexToUse = imageIds.indexOf(referencedImageId);
|
||||
}
|
||||
|
||||
@ -936,21 +967,20 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
initialImageIndexToUse = this._getInitialImageIndexForViewport(viewportInfo, imageIds) || 0;
|
||||
}
|
||||
|
||||
await viewport.setStack(imageIds, initialImageIndexToUse);
|
||||
viewport.setProperties({ ...properties });
|
||||
this.setPresentations(viewport.id, presentations, viewportInfo);
|
||||
|
||||
await this._addOverlayRepresentations(overlayProcessingResults);
|
||||
|
||||
if (displayArea) {
|
||||
viewport.setDisplayArea(displayArea);
|
||||
}
|
||||
if (rotation) {
|
||||
viewport.setProperties({ rotation });
|
||||
}
|
||||
if (flipHorizontal) {
|
||||
viewport.setCamera({ flipHorizontal: true });
|
||||
}
|
||||
// The lane-specific mount (legacy setStack/setProperties vs native
|
||||
// setDisplaySets/setDisplaySetPresentation/setViewState) lives in the backend.
|
||||
return this.backend.mountStack(viewport, {
|
||||
displaySetInstanceUIDs,
|
||||
imageIds,
|
||||
initialImageIndex: initialImageIndexToUse,
|
||||
properties,
|
||||
displayArea,
|
||||
rotation,
|
||||
flipHorizontal,
|
||||
presentations,
|
||||
viewportInfo,
|
||||
overlayProcessingResults,
|
||||
});
|
||||
}
|
||||
|
||||
private _getInitialImageIndexForViewport(
|
||||
@ -1149,6 +1179,24 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
|
||||
// For SEG and RT viewports
|
||||
const overlayProcessingResults = this._processExtraDisplaySetsForViewport(viewport) || [];
|
||||
|
||||
// Lane-specific volume mount: the native backend mounts registered dataIds via
|
||||
// setDisplaySets + per-binding presentations and reports the mount handled;
|
||||
// legacy reports unhandled and runs the shared setVolumes tail below (which a
|
||||
// native overlay-only mount also traverses — its legacy-surface steps are
|
||||
// lane-guarded via mountOverlayOnlyVolumes).
|
||||
const handledByBackend = await this.backend.mountVolumes(viewport, {
|
||||
filteredVolumeInputArray,
|
||||
volumesProperties,
|
||||
viewportInfo,
|
||||
overlayProcessingResults,
|
||||
presentations,
|
||||
});
|
||||
if (handledByBackend) {
|
||||
this._broadcastEvent(this.EVENTS.VIEWPORT_VOLUMES_CHANGED, { viewportInfo });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filteredVolumeInputArray.length && overlayProcessingResults?.length) {
|
||||
overlayProcessingResults.forEach(({ imageIds, addOverlayFn }) => {
|
||||
if (addOverlayFn) {
|
||||
@ -1213,7 +1261,10 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
await viewport.setVolumes(baseVolumeInputs);
|
||||
}
|
||||
} else if (volumeInputArray.length) {
|
||||
await viewport.setVolumes(volumeInputArray);
|
||||
// Every volume input is an overlay display set. Legacy still mounts them via
|
||||
// setVolumes; the native backend no-ops (its overlays are added via
|
||||
// _addOverlayRepresentations below).
|
||||
await this.backend.mountOverlayOnlyVolumes(viewport, volumeInputArray);
|
||||
}
|
||||
|
||||
await this._addOverlayRepresentations(overlayProcessingResults);
|
||||
@ -1412,7 +1463,9 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
return applyRepresentation();
|
||||
}
|
||||
|
||||
private async _addOverlayRepresentations(
|
||||
// Public so the viewport backends (IViewportServiceInternals) can run the
|
||||
// pending overlay adds from their mount bodies.
|
||||
async _addOverlayRepresentations(
|
||||
overlayProcessingResults?: Array<{ addOverlayFn?: () => Promise<void> }>
|
||||
): Promise<void> {
|
||||
if (!overlayProcessingResults?.length) {
|
||||
@ -1430,21 +1483,20 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
public updateViewport(viewportId: string, viewportData, keepCamera = false) {
|
||||
const viewportInfo = this.getViewportInfo(viewportId);
|
||||
const viewport = this.getCornerstoneViewport(viewportId);
|
||||
const viewportCamera = viewport.getCamera();
|
||||
|
||||
let displaySetPromise;
|
||||
// The camera snapshot/restore surface is forked per lane (legacy
|
||||
// getCamera/setCamera vs native view state), so the backend owns the re-mount.
|
||||
const displaySetPromise = this.backend.remount(
|
||||
viewport,
|
||||
viewportData,
|
||||
viewportInfo,
|
||||
keepCamera
|
||||
);
|
||||
|
||||
if (isVolumeViewportType(viewport)) {
|
||||
displaySetPromise = this._setVolumeViewport(viewport, viewportData, viewportInfo).then(() => {
|
||||
if (keepCamera) {
|
||||
viewport.setCamera(viewportCamera);
|
||||
viewport.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isStackViewportType(viewport)) {
|
||||
displaySetPromise = this._setStackViewport(viewport, viewportData, viewportInfo);
|
||||
// remount() returns undefined for viewport families with no re-mount path
|
||||
// (matching legacy behavior); nothing changed, so skip the event broadcast.
|
||||
if (!displaySetPromise) {
|
||||
return;
|
||||
}
|
||||
|
||||
displaySetPromise.then(() => {
|
||||
@ -1461,37 +1513,11 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
viewportInfo: ViewportInfo,
|
||||
presentations: Presentations = {}
|
||||
): Promise<void> {
|
||||
if (isStackViewportType(viewport)) {
|
||||
return this._setStackViewport(
|
||||
viewport,
|
||||
viewportData as StackViewportData,
|
||||
viewportInfo,
|
||||
presentations
|
||||
);
|
||||
}
|
||||
|
||||
if (isVolumeViewportType(viewport)) {
|
||||
return this._setVolumeViewport(
|
||||
viewport as Types.IVolumeViewport,
|
||||
viewportData as VolumeViewportData,
|
||||
viewportInfo,
|
||||
presentations
|
||||
);
|
||||
}
|
||||
|
||||
if (getLegacyViewportType(viewport) === csEnums.ViewportType.ECG) {
|
||||
return this._setEcgViewport(
|
||||
viewport as unknown as Types.IECGViewport,
|
||||
viewportData as StackViewportData
|
||||
);
|
||||
}
|
||||
|
||||
return this._setOtherViewport(
|
||||
viewport,
|
||||
viewportData as StackViewportData,
|
||||
viewportInfo,
|
||||
presentations
|
||||
);
|
||||
// The backend (legacy vs native, selected once in the constructor) owns the
|
||||
// per-family routing: legacy dispatches by the runtime cornerstone viewport
|
||||
// type; native dispatches by the bound data shape, because native stack and
|
||||
// volume content both report a single PLANAR_NEXT type (§4.4).
|
||||
return this.backend.dispatchMount(viewport, viewportData, viewportInfo, presentations);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1626,44 +1652,20 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi
|
||||
viewport: Types.IStackViewport | Types.IVolumeViewport,
|
||||
lutPresentation: LutPresentation
|
||||
): void {
|
||||
if (!lutPresentation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { properties } = lutPresentation;
|
||||
if (isVolumeViewportType(viewport)) {
|
||||
if (properties instanceof Map) {
|
||||
properties.forEach((propertiesEntry, volumeId) => {
|
||||
viewport.setProperties(propertiesEntry, volumeId);
|
||||
});
|
||||
} else {
|
||||
viewport.setProperties(properties);
|
||||
}
|
||||
} else {
|
||||
viewport.setProperties(properties);
|
||||
}
|
||||
// Forked per backend (§4.3 presentation write): legacy applies via setProperties;
|
||||
// native via setDisplaySetPresentation (a PLANAR_NEXT viewport has no setProperties),
|
||||
// so setPresentations no longer throws on the native path.
|
||||
this.backend.setLutPresentation(viewport, lutPresentation);
|
||||
}
|
||||
|
||||
private _setPositionPresentation(
|
||||
viewport: Types.IStackViewport | Types.IVolumeViewport,
|
||||
positionPresentation: PositionPresentation
|
||||
): void {
|
||||
const viewRef = positionPresentation?.viewReference;
|
||||
if (viewRef) {
|
||||
// The orientation can be updated here to navigate to the specified
|
||||
// measurement or previous item, but this will not switch to volume
|
||||
// or to stack from the other type
|
||||
if (viewport.isReferenceViewable(viewRef, WITH_ORIENTATION)) {
|
||||
viewport.setViewReference(viewRef);
|
||||
} else {
|
||||
console.warn('Unable to apply reference viewable', viewRef);
|
||||
}
|
||||
}
|
||||
|
||||
const viewPresentation = positionPresentation?.viewPresentation;
|
||||
if (viewPresentation) {
|
||||
viewport.setViewPresentation(viewPresentation);
|
||||
}
|
||||
// Forked per backend (§4.3 presentation write): both apply the view reference;
|
||||
// legacy then applies getViewPresentation pan/zoom via setViewPresentation, native
|
||||
// omits it for now (a PLANAR_NEXT viewport has no setViewPresentation).
|
||||
this.backend.setPositionPresentation(viewport, positionPresentation);
|
||||
}
|
||||
|
||||
private _setSegmentationPresentation(
|
||||
|
||||
@ -227,9 +227,16 @@ class ViewportInfo {
|
||||
// via cornerstoneViewportService
|
||||
let viewportData = this.getViewportData();
|
||||
|
||||
// Branch on the persisted data shape, not viewportType: native ("next") volume
|
||||
// viewports carry viewportType === PLANAR_NEXT while their data is still a volume
|
||||
// array, so keying off viewportType alone would treat them as a stack object and
|
||||
// miss the display set — skipping invalidateViewportData() on metadata invalidation.
|
||||
// Falls back to viewportType for legacy viewportData with no dataShapeType.
|
||||
const dataShapeType = viewportData.dataShapeType ?? viewportData.viewportType;
|
||||
|
||||
if (
|
||||
viewportData.viewportType === Enums.ViewportType.ORTHOGRAPHIC ||
|
||||
viewportData.viewportType === Enums.ViewportType.VOLUME_3D
|
||||
dataShapeType === Enums.ViewportType.ORTHOGRAPHIC ||
|
||||
dataShapeType === Enums.ViewportType.VOLUME_3D
|
||||
) {
|
||||
viewportData = viewportData as VolumeViewportData;
|
||||
return viewportData.data.some(
|
||||
|
||||
@ -0,0 +1,443 @@
|
||||
import { Enums, cache } from '@cornerstonejs/core';
|
||||
import {
|
||||
getViewportAdapter,
|
||||
isNextViewport,
|
||||
isVolumeRenderingViewport,
|
||||
} from './getViewportAdapter';
|
||||
import { LegacyViewportAdapter, LEGACY_OPACITY_GAMMA } from './LegacyViewportAdapter';
|
||||
import { NextViewportAdapter } from './NextViewportAdapter';
|
||||
|
||||
/**
|
||||
* Contract tests for IViewportAdapter: every behavioral guarantee the UI layer
|
||||
* relies on is asserted against BOTH lane implementations over mock viewports.
|
||||
* If a legacy/native divergence is intentional (e.g. opacity gamma), the
|
||||
* divergent expectations are asserted side by side so the difference is
|
||||
* documented here rather than rediscovered in a viewer session.
|
||||
*/
|
||||
|
||||
const { ViewportType, OrientationAxis } = Enums;
|
||||
|
||||
/** Minimal native ("next") viewport: satisfies csUtils.isGenericViewport. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function makeNextViewport(overrides: Record<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 });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,179 @@
|
||||
import type { Types as CoreTypes } from '@cornerstonejs/core';
|
||||
|
||||
/**
|
||||
* Content shape of a viewport, independent of the runtime cornerstone viewport
|
||||
* type. Native ("next") viewports collapse stack/volume/MPR onto a single
|
||||
* PLANAR_NEXT runtime type, so `viewport.type` checks cannot classify them;
|
||||
* this is the lane-agnostic answer to "what is this viewport showing".
|
||||
*/
|
||||
export type ViewportShape = 'stack' | 'volume' | 'volume3d' | 'unknown';
|
||||
|
||||
export type VOIRange = { lower: number; upper: number };
|
||||
|
||||
export interface ViewportColormap {
|
||||
name?: string;
|
||||
opacity?: number | Array<{ value: number; opacity: number }> | number[];
|
||||
threshold?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-display-set appearance (VOI/colormap/invert). On legacy this is the
|
||||
* getProperties()/setProperties() surface; on native it is the per-binding
|
||||
* display-set presentation keyed by dataId.
|
||||
*/
|
||||
export interface ViewportPresentation {
|
||||
voiRange?: VOIRange;
|
||||
colormap?: ViewportColormap;
|
||||
invert?: boolean;
|
||||
isComputedVOI?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* View-level state (rotation, flip, orientation, pan/zoom). On legacy this is
|
||||
* the getCamera()/setCamera() surface; on native it is the semantic
|
||||
* getViewState()/setViewState() surface. Field names follow the native shape
|
||||
* where the two overlap (rotation, flipHorizontal, flipVertical).
|
||||
*/
|
||||
export type ViewportViewState = Record<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>;
|
||||
}
|
||||
@ -0,0 +1,240 @@
|
||||
import { Enums, Types as CoreTypes } from '@cornerstonejs/core';
|
||||
import {
|
||||
getLegacyViewportType,
|
||||
isOrthographicViewportType,
|
||||
isStackViewportType,
|
||||
isVolumeViewportType,
|
||||
} from '../../../utils/getLegacyViewportType';
|
||||
import type {
|
||||
IViewportAdapter,
|
||||
ViewportColormap,
|
||||
ViewportPresentation,
|
||||
ViewportShape,
|
||||
ViewportViewState,
|
||||
VOIRange,
|
||||
} from './IViewportAdapter';
|
||||
|
||||
/**
|
||||
* Structural view of the legacy StackViewport/VolumeViewport surface used by
|
||||
* the adapter. Optional-chained because different legacy families expose
|
||||
* different subsets (e.g. only volume viewports have getAllVolumeIds).
|
||||
* Deliberately NOT an intersection with CoreTypes.IViewport: the adapter
|
||||
* contract types these members with the next-shaped signatures (e.g. getCamera
|
||||
* as a plain record), and IViewport's own declarations would win otherwise.
|
||||
*/
|
||||
type LegacyViewport = {
|
||||
getProperties?: (dataId?: string) => ViewportPresentation | undefined;
|
||||
setProperties?: (props: ViewportPresentation, dataId?: string) => void;
|
||||
getCamera?: () => Record<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,233 @@
|
||||
import { Enums, cache, Types as CoreTypes } from '@cornerstonejs/core';
|
||||
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
|
||||
import type {
|
||||
IViewportAdapter,
|
||||
ViewportColormap,
|
||||
ViewportPresentation,
|
||||
ViewportShape,
|
||||
ViewportViewState,
|
||||
VOIRange,
|
||||
} from './IViewportAdapter';
|
||||
|
||||
/**
|
||||
* Structural view of the native ("next") viewport surface used by the adapter.
|
||||
* These accessors live on IGenericViewport (not IViewport), so we cast at this
|
||||
* boundary rather than import core-internal PlanarViewport types.
|
||||
*/
|
||||
type NativeViewport = CoreTypes.IViewport & {
|
||||
getSourceDataId?: () => string | undefined;
|
||||
getDisplaySetPresentation?: (dataId: string) => ViewportPresentation | undefined;
|
||||
getDefaultVOIRange?: (dataId?: string) => VOIRange | undefined;
|
||||
setDisplaySetPresentation?: {
|
||||
(props: ViewportPresentation): void;
|
||||
(dataId: string, props: ViewportPresentation): void;
|
||||
};
|
||||
getViewState?: () => ViewportViewState | undefined;
|
||||
setViewState?: (patch: ViewportViewState) => void;
|
||||
getViewReference?: () => CoreTypes.ViewReference | undefined;
|
||||
setViewReference?: (ref: CoreTypes.ViewReference) => void;
|
||||
getCurrentMode?: () => string;
|
||||
setDisplaySets?: (args: {
|
||||
displaySetId: string;
|
||||
options?: Record<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());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
import { utilities as csUtils, Types as CoreTypes } from '@cornerstonejs/core';
|
||||
import type { IViewportAdapter } from './IViewportAdapter';
|
||||
import { LegacyViewportAdapter } from './LegacyViewportAdapter';
|
||||
import { NextViewportAdapter } from './NextViewportAdapter';
|
||||
|
||||
const adapterCache = new WeakMap<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;
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
export type {
|
||||
IViewportAdapter,
|
||||
ViewportColormap,
|
||||
ViewportPresentation,
|
||||
ViewportShape,
|
||||
ViewportViewState,
|
||||
VOIRange,
|
||||
} from './IViewportAdapter';
|
||||
export {
|
||||
getViewportAdapter,
|
||||
getViewportFocalPoint,
|
||||
isNextViewport,
|
||||
isVolumeRenderingViewport,
|
||||
} from './getViewportAdapter';
|
||||
export { LegacyViewportAdapter, LEGACY_OPACITY_GAMMA } from './LegacyViewportAdapter';
|
||||
export { NextViewportAdapter } from './NextViewportAdapter';
|
||||
@ -0,0 +1,195 @@
|
||||
import type { Types } from '@cornerstonejs/core';
|
||||
import type ViewportInfo from '../Viewport';
|
||||
import type {
|
||||
Presentations,
|
||||
PositionPresentation,
|
||||
LutPresentation,
|
||||
} from '../../../types/Presentation';
|
||||
import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService';
|
||||
import type { DataIdPayload } from './dataIdRegistry';
|
||||
|
||||
/** A pending overlay (SEG/RTSTRUCT) add produced by the service's prelude. */
|
||||
export type OverlayMountTask = {
|
||||
imageIds?: string[];
|
||||
addOverlayFn?: () => Promise<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;
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
import type { Types as CoreTypes } from '@cornerstonejs/core';
|
||||
|
||||
export type FlipValue = 'toggle' | boolean;
|
||||
export type RotationMode = 'apply' | 'set';
|
||||
|
||||
export interface VolumeLightingOptions {
|
||||
shade?: boolean;
|
||||
ambient?: number;
|
||||
diffuse?: number;
|
||||
specular?: number;
|
||||
}
|
||||
|
||||
export interface WindowLevelParams {
|
||||
windowWidth: number;
|
||||
windowCenter: number;
|
||||
/** Legacy volume target; resolved by the caller. Ignored on native (active binding). */
|
||||
volumeId?: string;
|
||||
/** Native per-binding target (e.g. the PT overlay in a fusion); ignored on legacy. */
|
||||
displaySetInstanceUID?: string;
|
||||
}
|
||||
|
||||
export interface ColormapParams {
|
||||
colormap: Record<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;
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import type { Types } from '@cornerstonejs/core';
|
||||
import type ViewportInfo from '../Viewport';
|
||||
import type { Presentations } from '../../../types/Presentation';
|
||||
import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService';
|
||||
import type { OverlayMountTask } from './IViewportBackend';
|
||||
|
||||
/**
|
||||
* The narrow slice of CornerstoneViewportService that a viewport backend is
|
||||
* allowed to reach (migration plan §4.3 access pattern). The service `implements`
|
||||
* this and passes `this` to each backend, so a backend can dispatch the per-family
|
||||
* mount work back to the shared service methods without reaching into unrelated
|
||||
* internals. Keeping this interface narrow is what stops the off (legacy) path
|
||||
* from drifting as the next backend grows.
|
||||
*/
|
||||
export interface IViewportServiceInternals {
|
||||
_setStackViewport(
|
||||
viewport: Types.IStackViewport,
|
||||
viewportData: StackViewportData,
|
||||
viewportInfo: ViewportInfo,
|
||||
presentations?: Presentations
|
||||
): Promise<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;
|
||||
}
|
||||
@ -0,0 +1,264 @@
|
||||
import { Enums as csEnums, Types, metaData } from '@cornerstonejs/core';
|
||||
import {
|
||||
getLegacyViewportType,
|
||||
isStackViewportType,
|
||||
isVolume3DViewportType,
|
||||
isVolumeViewportType,
|
||||
} from '../../../utils/getLegacyViewportType';
|
||||
import type ViewportInfo from '../Viewport';
|
||||
import type {
|
||||
Presentations,
|
||||
PositionPresentation,
|
||||
LutPresentation,
|
||||
} from '../../../types/Presentation';
|
||||
import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService';
|
||||
import type { IViewportBackend, StackMountContext, VolumeMountContext } from './IViewportBackend';
|
||||
import type { IViewportServiceInternals } from './IViewportServiceInternals';
|
||||
import { DataIdRegistry } from './dataIdRegistry';
|
||||
|
||||
// Mirrors WITH_ORIENTATION in CornerstoneViewportService (inlined to avoid a
|
||||
// value import that would create a backend -> service circular dependency).
|
||||
const WITH_ORIENTATION = { withNavigation: true, withOrientation: true };
|
||||
|
||||
/**
|
||||
* Legacy (default) viewport backend. Selected when `appConfig.useNextViewports`
|
||||
* is off. Routing mirrors today's CornerstoneViewportService._setDisplaySets legacy
|
||||
* branch exactly (dispatch by runtime cornerstone viewport type) and delegates the
|
||||
* per-family mount to the unchanged service methods, so the off path stays
|
||||
* byte-identical. The one legacy family that touches the GenericViewport metadata
|
||||
* provider is WSI (mountOther); those registrations go through the same
|
||||
* ref-counted registry as the native backend so they are released on viewport
|
||||
* disable/destroy instead of leaking across viewport reuse.
|
||||
*/
|
||||
export class LegacyViewportBackend implements IViewportBackend {
|
||||
private readonly registry = new DataIdRegistry();
|
||||
|
||||
constructor(private readonly service: IViewportServiceInternals) {}
|
||||
|
||||
dispatchMount(
|
||||
viewport: Types.IViewport,
|
||||
viewportData: StackViewportData | VolumeViewportData,
|
||||
viewportInfo: ViewportInfo,
|
||||
presentations: Presentations = {}
|
||||
): Promise<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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,272 @@
|
||||
import { utilities as csUtils, Types as CoreTypes } from '@cornerstonejs/core';
|
||||
import { mat4, vec3 } from 'gl-matrix';
|
||||
import {
|
||||
isStackViewportType,
|
||||
isVolumeViewportType,
|
||||
isOrthographicViewportType,
|
||||
} from '../../../utils/getLegacyViewportType';
|
||||
import { getCenterExtent } from '../../../utils/getCenterExtent';
|
||||
import { isMeasurementWithinViewport } from '../../../utils/isMeasurementWithinViewport';
|
||||
import type {
|
||||
IViewportOperations,
|
||||
FlipValue,
|
||||
RotationMode,
|
||||
VolumeLightingOptions,
|
||||
WindowLevelParams,
|
||||
ColormapParams,
|
||||
} from './IViewportOperations';
|
||||
|
||||
// Loose view of the VTK actor/mapper/property chain used by the 3D VR ops. These
|
||||
// live on vtk.js objects, not cornerstone types, so they are accessed structurally
|
||||
// (mirrors the previously-untyped commandsModule bodies).
|
||||
type VtkActorChain = {
|
||||
actor: {
|
||||
getMapper: () => Record<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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,671 @@
|
||||
import {
|
||||
Enums as csEnums,
|
||||
Types,
|
||||
metaData,
|
||||
utilities as csUtils,
|
||||
CONSTANTS as csConstants,
|
||||
isRegisteredRenderBackend,
|
||||
} from '@cornerstonejs/core';
|
||||
import type { RenderBackendValue } from '@cornerstonejs/core';
|
||||
import { utilities as csToolsUtils } from '@cornerstonejs/tools';
|
||||
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
|
||||
import { getViewportRenderingOverride } from '../../../utils/nextViewports';
|
||||
import type ViewportInfo from '../Viewport';
|
||||
import type {
|
||||
Presentations,
|
||||
PositionPresentation,
|
||||
LutPresentation,
|
||||
} from '../../../types/Presentation';
|
||||
import type { StackViewportData, VolumeViewportData } from '../../../types/CornerstoneCacheService';
|
||||
import type { IViewportBackend, StackMountContext, VolumeMountContext } from './IViewportBackend';
|
||||
import type { IViewportServiceInternals } from './IViewportServiceInternals';
|
||||
import { DataIdRegistry, type DataIdPayload } from './dataIdRegistry';
|
||||
|
||||
// Mirrors WITH_ORIENTATION in CornerstoneViewportService (inlined to avoid a
|
||||
// value import that would create a backend -> service circular dependency).
|
||||
const WITH_ORIENTATION = { withNavigation: true, withOrientation: true };
|
||||
|
||||
/**
|
||||
* Per-mount render backend override for a planar mount, resolved from the
|
||||
* `<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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,169 @@
|
||||
import {
|
||||
utilities as csUtils,
|
||||
CONSTANTS as csConstants,
|
||||
Types as CoreTypes,
|
||||
} from '@cornerstonejs/core';
|
||||
import { getViewportAdapter } from '../adapter';
|
||||
import { legacyViewportOperations } from './LegacyViewportOperations';
|
||||
import type {
|
||||
IViewportOperations,
|
||||
FlipValue,
|
||||
RotationMode,
|
||||
VolumeLightingOptions,
|
||||
WindowLevelParams,
|
||||
ColormapParams,
|
||||
} from './IViewportOperations';
|
||||
|
||||
// Native PLANAR_NEXT semantic accessors not covered by the viewport adapter.
|
||||
// They live on IGenericViewport, so cast structurally at the boundary.
|
||||
type NativePlanarViewport = CoreTypes.IViewport & {
|
||||
resetViewState?: () => void;
|
||||
resetDisplaySetPresentation?: (dataId?: string) => void;
|
||||
getZoom?: () => number;
|
||||
setZoom?: (zoom: number) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Native ("next") lane of IViewportOperations for direct PLANAR_NEXT viewports.
|
||||
* Appearance and camera/view-state ops go through the viewport adapter (which
|
||||
* encapsulates the native getViewState/setViewState and
|
||||
* getDisplaySetPresentation/setDisplaySetPresentation primitives, including the
|
||||
* active-binding dataId default); the remaining ops use the native semantic API
|
||||
* directly. The dispatcher only routes generic viewports here.
|
||||
*
|
||||
* No method calls viewport.render() — the command renders.
|
||||
*/
|
||||
export const nextViewportOperations: IViewportOperations = {
|
||||
flipHorizontal(viewport: CoreTypes.IViewport, newValue: FlipValue = 'toggle'): void {
|
||||
const adapter = getViewportAdapter(viewport);
|
||||
const flipHorizontal =
|
||||
newValue === 'toggle' ? !adapter.getViewState().flipHorizontal : newValue;
|
||||
adapter.setViewState({ flipHorizontal });
|
||||
},
|
||||
|
||||
flipVertical(viewport: CoreTypes.IViewport, newValue: FlipValue = 'toggle'): void {
|
||||
const adapter = getViewportAdapter(viewport);
|
||||
const flipVertical = newValue === 'toggle' ? !adapter.getViewState().flipVertical : newValue;
|
||||
adapter.setViewState({ flipVertical });
|
||||
},
|
||||
|
||||
invert(viewport: CoreTypes.IViewport): void {
|
||||
const adapter = getViewportAdapter(viewport);
|
||||
const { invert } = adapter.getPresentation();
|
||||
adapter.setPresentation({ invert: !invert });
|
||||
},
|
||||
|
||||
rotate(viewport: CoreTypes.IViewport, rotation: number, mode: RotationMode = 'apply'): void {
|
||||
// rotation/flip live in the semantic view state; getViewPresentation is absent.
|
||||
const adapter = getViewportAdapter(viewport);
|
||||
const state = adapter.getViewState();
|
||||
const currentRotation = (state.rotation as number) ?? 0;
|
||||
const newRotation =
|
||||
mode === 'apply'
|
||||
? (currentRotation + rotation + 360) % 360
|
||||
: (() => {
|
||||
const flipsParity = (state.flipHorizontal ? 1 : 0) + (state.flipVertical ? 1 : 0);
|
||||
const effectiveRotation = flipsParity % 2 === 1 ? -rotation : rotation;
|
||||
return (effectiveRotation + 360) % 360;
|
||||
})();
|
||||
adapter.setViewState({ rotation: newRotation });
|
||||
},
|
||||
|
||||
reset(viewport: CoreTypes.IViewport): void {
|
||||
const vp = viewport as NativePlanarViewport;
|
||||
// Reset the per-display-set presentation (VOI/colormap/invert) to defaults.
|
||||
vp.resetDisplaySetPresentation?.();
|
||||
// No resetCamera on PLANAR_NEXT; resetViewState resets pan/zoom/rotation/orientation/flip.
|
||||
vp.resetViewState?.();
|
||||
},
|
||||
|
||||
scaleBy(viewport: CoreTypes.IViewport, direction: number): void {
|
||||
// parallelScale and zoom are inversely related (smaller parallelScale = more
|
||||
// zoomed in = larger zoom), so divide by scaleFactor to match the legacy direction.
|
||||
const scaleFactor = direction > 0 ? 0.9 : 1.1;
|
||||
const vp = viewport as unknown as {
|
||||
getZoom?: () => number;
|
||||
setZoom?: (zoom: number) => void;
|
||||
resetViewState?: (options?: { resetOrientation?: boolean }) => void;
|
||||
};
|
||||
if (direction) {
|
||||
// Zoom is only meaningful on planar (stack / volume-slice / MPR) native viewports.
|
||||
// VolumeViewport3D is also a generic viewport but exposes no getZoom/setZoom, so
|
||||
// guard before calling — a no-op there matches the legacy lane, which only zoomed
|
||||
// stack viewports (otherwise the zoom hotkey would throw on a native 3D viewport).
|
||||
if (vp.getZoom && vp.setZoom) {
|
||||
vp.setZoom(vp.getZoom() / scaleFactor);
|
||||
}
|
||||
} else {
|
||||
// direction === 0 is the fitViewportToWindow command. Legacy resetCamera()
|
||||
// resets pan/zoom/rotation/flip but never the viewing orientation, while a
|
||||
// full native resetViewState() would also snap an MPR back to its requested
|
||||
// axis — so keep the orientation.
|
||||
vp.resetViewState?.({ resetOrientation: false });
|
||||
}
|
||||
},
|
||||
|
||||
getViewPlaneNormal(viewport: CoreTypes.IViewport): CoreTypes.Point3 | undefined {
|
||||
return getViewportAdapter(viewport).getViewPlaneNormal();
|
||||
},
|
||||
|
||||
centerOnMeasurement(): boolean {
|
||||
// CS-14: native PLANAR_NEXT has no getCamera/setCamera for in-plane pan; the
|
||||
// caller's setViewReference already navigated to the measurement's slice.
|
||||
// TODO(next): port in-plane centering via the camera bridge + setViewState pan.
|
||||
return false;
|
||||
},
|
||||
|
||||
setWindowLevel(viewport: CoreTypes.IViewport, params: WindowLevelParams): void {
|
||||
const { lower, upper } = csUtils.windowLevel.toLowHighRange(
|
||||
params.windowWidth,
|
||||
params.windowCenter
|
||||
);
|
||||
// Target the binding for params.displaySetInstanceUID so a PT/CT *fusion* W/L lands
|
||||
// on the intended layer (e.g. the PT overlay) instead of always the source (CT) —
|
||||
// mirroring setColormap. When no id is given (single stack/volume) the adapter falls
|
||||
// back to the source binding.
|
||||
getViewportAdapter(viewport).setPresentation(
|
||||
{ voiRange: { upper, lower } },
|
||||
params.displaySetInstanceUID
|
||||
);
|
||||
},
|
||||
|
||||
setColormap(viewport: CoreTypes.IViewport, params: ColormapParams): void {
|
||||
// Target the binding for params.displaySetInstanceUID. OHIF's dataId scheme maps a
|
||||
// display set 1:1 onto its native dataId (bare UID), so a PT/CT *fusion* colormap lands
|
||||
// on the overlay (PT) binding instead of defaulting to the source (CT). When no id is
|
||||
// given (single-volume / plain stack colormap) the adapter falls back to the source.
|
||||
getViewportAdapter(viewport).setPresentation(
|
||||
{ colormap: params.colormap },
|
||||
params.displaySetInstanceUID
|
||||
);
|
||||
},
|
||||
|
||||
setPreset(viewport: CoreTypes.IViewport, preset: string): void {
|
||||
// The native VolumeViewport3D has no setProperties; apply the volume-rendering
|
||||
// preset (RGBA transfer function) to the volume actor directly.
|
||||
const presetObj = csConstants.VIEWPORT_PRESETS?.find(p => p.name === preset);
|
||||
const actor = (
|
||||
viewport as unknown as { getDefaultActor?: () => { actor?: unknown } | undefined }
|
||||
).getDefaultActor?.()?.actor;
|
||||
if (presetObj && actor) {
|
||||
csUtils.applyPreset(actor as Parameters<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);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,62 @@
|
||||
# Next-viewport dispatch: how legacy-vs-native is decided
|
||||
|
||||
The `useNextViewports` opt-in routes OHIF onto cornerstone3D's native
|
||||
("next" / GenericViewport) API. This document is the single description of
|
||||
where and how the two lanes are selected. If you are adding a new legacy/native
|
||||
divergence, one of the homes below is the right place — never an inline
|
||||
`isGenericViewport` / flag check at the call site (enforced by
|
||||
`scripts/check-next-viewport-boundaries.sh`).
|
||||
|
||||
## The three homes for divergence
|
||||
|
||||
| Home | What belongs there |
|
||||
|---|---|
|
||||
| `../adapter/` (`IViewportAdapter`) | API-surface bridging on a live viewport: reads/writes that exist on both lanes with different spellings (camera vs view state, properties vs display-set presentation, volumeId vs dataId, classification). The contract is next-shaped; the legacy adapter does the adapting. |
|
||||
| `./` (`IViewportBackend`, `IViewportOperations`, and the segmentation twins in `../../SegmentationService/backends/`) | Lifecycle and interaction bodies: mount/re-mount, presentation capture/restore, dataId registration, per-command operations (flip/rotate/W-L/...), labelmap add/assembly. |
|
||||
| `../../../utils/nextViewportPolicies.ts` | Behavioral policy: appearance defaults and workflow rules that differ by choice, not by API (fusion opacity flattening, overlay opacity, RTSTRUCT hydrate stack-pin). |
|
||||
|
||||
Pre-mount classification of `viewportData` (no live viewport yet) lives in
|
||||
`../../../utils/viewportDataShape.ts`; the persisted `dataShapeType` field set
|
||||
by `CornerstoneCacheService` is the sanctioned way to survive the native
|
||||
type collapse (stack/volume/MPR all report `PLANAR_NEXT` at runtime).
|
||||
|
||||
## Two dispatch strategies (deliberately different)
|
||||
|
||||
- **Session flag, selected once** — `isNextViewportsEnabled()`. Used where no
|
||||
viewport exists yet, or for per-session lifecycle:
|
||||
`IViewportBackend` (the `get backend()` getter on CornerstoneViewportService),
|
||||
`getCornerstoneViewportType` (viewport-type resolution), the SEG assembly
|
||||
path, and the policies module.
|
||||
- **Per-viewport predicate** — `isNextViewport(viewport)` (the adapter module's
|
||||
wrapper around `csUtils.isGenericViewport`). Used where a self-describing
|
||||
viewport is in hand, because a flag-on session can hold BOTH native and
|
||||
legacy viewports: `getViewportAdapter`, `viewportOperations`, and the
|
||||
segmentation backend twins.
|
||||
|
||||
Do not "unify" these: lifecycle genuinely is per-session; everything else
|
||||
genuinely is per-viewport.
|
||||
|
||||
## Sanctioned flag reads (`isNextViewportsEnabled`) — the exhaustive list
|
||||
|
||||
1. `utils/getCornerstoneViewportType.ts` — maps requested OHIF viewport types
|
||||
to native types.
|
||||
2. `services/ViewportService/CornerstoneViewportService.ts` — the lazy
|
||||
`get backend()` selection.
|
||||
3. `services/SegmentationService/SegmentationService.ts` —
|
||||
`assembleSegmentationDataForSEG` (dispatched at SEG-load time, before any
|
||||
target viewport exists).
|
||||
4. `utils/nextViewportPolicies.ts` — policy rules that apply before viewports
|
||||
exist (e.g. the RTSTRUCT hydrate stack-pin).
|
||||
5. `extensions/tmtv/src/getHangingProtocolModule.ts` — applies the
|
||||
`NEXT_FUSION_PT_OPACITY` policy when the HP module is gathered (the flag is
|
||||
settled by then; the legacy ramp in `hpViewports` stays untouched).
|
||||
|
||||
Adding a sixth read requires updating this list AND the whitelist in
|
||||
`scripts/check-next-viewport-boundaries.sh` — if you can express the change as
|
||||
an adapter capability, a backend method, or a policy entry instead, do that.
|
||||
|
||||
## Sanctioned `csUtils.isGenericViewport` calls
|
||||
|
||||
Only `../adapter/getViewportAdapter.ts` (which also exports the
|
||||
`isNextViewport` predicate for the dispatchers above). Everyone else consumes
|
||||
`IViewportAdapter` methods.
|
||||
@ -0,0 +1,121 @@
|
||||
import { utilities as csUtils } from '@cornerstonejs/core';
|
||||
|
||||
/**
|
||||
* Payload registered with cornerstone's global GenericViewport dataset metadata
|
||||
* provider. The shape is family-specific and mirrors what each native viewport's
|
||||
* data provider reads (see the cornerstone genericViewport examples):
|
||||
* - planar : imageIds (+ optional volumeId) — stack / volume-slice / MPR / 3D
|
||||
* - video : sourceDataId (the video imageId)
|
||||
* - ecg : sourceDataId (the waveform imageId)
|
||||
* - wsi : imageIds + a DICOMweb webClient used to fetch tiles
|
||||
*/
|
||||
export type DataIdPayload =
|
||||
| { kind: 'planar'; imageIds: string[]; volumeId?: string; initialImageIdIndex?: number }
|
||||
| { kind: 'video' | 'ecg'; sourceDataId: string }
|
||||
| { kind: 'wsi'; imageIds: string[]; options: { webClient: unknown } };
|
||||
|
||||
/**
|
||||
* Owns the lifecycle of OHIF's `dataId` registrations against cornerstone's
|
||||
* process-global `genericViewportDisplaySetMetadataProvider` (see migration plan §4.7).
|
||||
*
|
||||
* Why this exists: cornerstone's `removeData`/`setDisplaySets` do NOT garbage-collect
|
||||
* the global registration store (upstream blocker CS-18), so OHIF must own add/remove.
|
||||
* The MPR triptych mounts the SAME `dataId` (the displaySetInstanceUID-derived volume)
|
||||
* from N panes, so a naive add-on-every-mount / never-remove both over-registers and
|
||||
* leaks. This registry ref-counts per `dataId` and keeps a per-viewport ledger so:
|
||||
* - `provider.add` fires only on the 0 -> 1 transition,
|
||||
* - `provider.remove` fires only on the 1 -> 0 transition,
|
||||
* - unmounting one pane of a shared-volume triptych does not unregister data the
|
||||
* other panes still need.
|
||||
*
|
||||
* Used by the native ("next") backend for all families, and by the legacy
|
||||
* backend for its one provider-backed family (WSI mounts via mountOther).
|
||||
*/
|
||||
export class DataIdRegistry {
|
||||
// Global ref-count keyed by dataId (the provider store is a single global namespace).
|
||||
private readonly refCounts = new Map<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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
import { Types as CoreTypes } from '@cornerstonejs/core';
|
||||
import { isNextViewport } from '../adapter';
|
||||
import type {
|
||||
IViewportOperations,
|
||||
FlipValue,
|
||||
RotationMode,
|
||||
VolumeLightingOptions,
|
||||
WindowLevelParams,
|
||||
ColormapParams,
|
||||
} from './IViewportOperations';
|
||||
import { legacyViewportOperations } from './LegacyViewportOperations';
|
||||
import { nextViewportOperations } from './NextViewportOperations';
|
||||
|
||||
/**
|
||||
* Picks the operations lane for a SPECIFIC viewport. Unlike the IViewportBackend
|
||||
* lifecycle backend (selected once by the appConfig flag because it owns the
|
||||
* per-session mount), operations route per viewport: the viewport is already created
|
||||
* and self-describing, and a session can hold both legacy and native viewports.
|
||||
*/
|
||||
function backendFor(viewport: CoreTypes.IViewport): IViewportOperations {
|
||||
return isNextViewport(viewport) ? nextViewportOperations : legacyViewportOperations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton facade over the legacy/next operations backends. commandsModule (and any
|
||||
* other caller) imports this and calls e.g. `viewportOperations.flipHorizontal(viewport)`,
|
||||
* keeping native-vs-legacy interaction logic out of the command bodies (migration §4.3).
|
||||
* Stateless and dependency-free (each op takes an already-resolved viewport), so it is a
|
||||
* plain singleton rather than a registered service.
|
||||
*/
|
||||
export const viewportOperations: IViewportOperations = {
|
||||
flipHorizontal: (viewport: CoreTypes.IViewport, newValue?: FlipValue) =>
|
||||
backendFor(viewport).flipHorizontal(viewport, newValue),
|
||||
|
||||
flipVertical: (viewport: CoreTypes.IViewport, newValue?: FlipValue) =>
|
||||
backendFor(viewport).flipVertical(viewport, newValue),
|
||||
|
||||
invert: (viewport: CoreTypes.IViewport) => backendFor(viewport).invert(viewport),
|
||||
|
||||
rotate: (viewport: CoreTypes.IViewport, rotation: number, mode?: RotationMode) =>
|
||||
backendFor(viewport).rotate(viewport, rotation, mode),
|
||||
|
||||
reset: (viewport: CoreTypes.IViewport) => backendFor(viewport).reset(viewport),
|
||||
|
||||
scaleBy: (viewport: CoreTypes.IViewport, direction: number) =>
|
||||
backendFor(viewport).scaleBy(viewport, direction),
|
||||
|
||||
getViewPlaneNormal: (viewport: CoreTypes.IViewport) =>
|
||||
backendFor(viewport).getViewPlaneNormal(viewport),
|
||||
|
||||
centerOnMeasurement: (viewport: CoreTypes.IViewport, measurement: Record<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),
|
||||
};
|
||||
@ -54,8 +54,17 @@ class ImageOverlayViewerTool extends AnnotationDisplayTool {
|
||||
return;
|
||||
}
|
||||
|
||||
// A direct Generic ("next") viewport returns a falsy view-reference id until
|
||||
// it has data bound (e.g. while it is being enabled, before setDisplaySets).
|
||||
// getTargetId() would throw in that case and break the whole render pass, so
|
||||
// skip overlay rendering until a reference is resolvable. Legacy viewports
|
||||
// always return a string here, so this leaves their behavior unchanged.
|
||||
if (!viewport.getViewReferenceId?.()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = this.getTargetId(viewport);
|
||||
return targetId.split('imageId:')[1];
|
||||
return targetId?.split('imageId:')[1];
|
||||
}
|
||||
|
||||
renderAnnotation = (enabledElement, svgDrawingHelper) => {
|
||||
|
||||
@ -21,11 +21,19 @@ type VolumeData = {
|
||||
|
||||
type StackViewportData = {
|
||||
viewportType: Enums.ViewportType;
|
||||
// The legacy stack/volume data-shape decision (STACK vs ORTHOGRAPHIC/VOLUME_3D),
|
||||
// preserved even when `viewportType` is a native Generic type (PLANAR_NEXT) that
|
||||
// collapses both. Consumers that must distinguish stack from volume content
|
||||
// (data re-build on invalidation, orientation markers) read this instead of
|
||||
// `viewportType`, which is ambiguous on the native path. See migration plan §4.7.
|
||||
dataShapeType?: Enums.ViewportType;
|
||||
data: StackData[];
|
||||
};
|
||||
|
||||
type VolumeViewportData = {
|
||||
viewportType: Enums.ViewportType;
|
||||
/** See StackViewportData.dataShapeType. */
|
||||
dataShapeType?: Enums.ViewportType;
|
||||
data: VolumeData[];
|
||||
};
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ import html2canvas from 'html2canvas';
|
||||
import { getEnabledElement } from '@cornerstonejs/core';
|
||||
import { ToolGroupManager, segmentation, Enums } from '@cornerstonejs/tools';
|
||||
import { getEnabledElement as OHIFgetEnabledElement } from '../state';
|
||||
import { isStackViewportType, isVolumeViewportType } from './getLegacyViewportType';
|
||||
import { getViewportAdapter } from '../services/ViewportService/adapter';
|
||||
import { useSystem } from '@ohif/core/src';
|
||||
|
||||
const { downloadUrl } = utils;
|
||||
@ -119,35 +119,12 @@ const CornerstoneViewportDownloadForm = ({
|
||||
const downloadViewport = renderingEngine.getViewport(VIEWPORT_ID);
|
||||
|
||||
try {
|
||||
// Capture current viewport state
|
||||
// - properties: VOI, colormap, interpolation, etc.
|
||||
// - viewPresentation: flip/rotate/zoom presentation state added for
|
||||
// saving flip and rotation for capture
|
||||
// - viewReference: image/volume reference
|
||||
const properties = viewport.getProperties();
|
||||
const viewPresentation = viewport.getViewPresentation?.();
|
||||
const viewRef = viewport.getViewReference?.();
|
||||
|
||||
if (isStackViewportType(downloadViewport)) {
|
||||
const imageId = viewport.getCurrentImageId();
|
||||
await downloadViewport.setStack([imageId]);
|
||||
} else if (isVolumeViewportType(downloadViewport)) {
|
||||
const volumeIds = viewport.getAllVolumeIds();
|
||||
await downloadViewport.setVolumes([{ volumeId: volumeIds[0] }]);
|
||||
}
|
||||
|
||||
// Apply presentation state so captured image preserves flip/rotate
|
||||
if (viewPresentation && downloadViewport.setViewPresentation) {
|
||||
downloadViewport.setViewPresentation(viewPresentation);
|
||||
}
|
||||
|
||||
// Apply viewport display properties
|
||||
downloadViewport.setProperties(properties);
|
||||
|
||||
// Ensure correct image/volume reference
|
||||
if (viewRef && downloadViewport.setViewReference) {
|
||||
downloadViewport.setViewReference(viewRef);
|
||||
}
|
||||
// Capture current viewport state. The download (capture) viewport is created
|
||||
// with the SAME type as the source (see handleEnableViewport), so source and
|
||||
// capture are both legacy or both native, and the source's adapter can mount
|
||||
// its displayed content (data + appearance + view state) onto the capture
|
||||
// viewport directly.
|
||||
await getViewportAdapter(viewport).copyDisplayedContentTo(downloadViewport);
|
||||
|
||||
downloadViewport.render();
|
||||
|
||||
|
||||
@ -11,6 +11,11 @@ jest.mock('@cornerstonejs/core', () => ({
|
||||
ORTHOGRAPHIC: 'orthographic',
|
||||
VOLUME_3D: 'volume3d',
|
||||
ECG: 'ecg',
|
||||
PLANAR_NEXT: 'planarNext',
|
||||
VOLUME_3D_NEXT: 'volume3dNext',
|
||||
VIDEO_NEXT: 'videoNext',
|
||||
WHOLE_SLIDE_NEXT: 'wholeSlideNext',
|
||||
ECG_NEXT: 'ecgNext',
|
||||
},
|
||||
},
|
||||
}));
|
||||
@ -58,7 +63,7 @@ describe('getCornerstoneViewportType', () => {
|
||||
|
||||
it('should throw error for invalid viewport type', () => {
|
||||
expect(() => getCornerstoneViewportType('invalid')).toThrow(
|
||||
'Invalid viewport type: invalid. Valid types are: stack, volume, video, wholeslide, ecg'
|
||||
'Invalid viewport type: invalid. Valid types are: stack, volume, orthographic, volume3d, video, wholeslide, ecg'
|
||||
);
|
||||
});
|
||||
|
||||
@ -94,4 +99,71 @@ describe('getCornerstoneViewportType', () => {
|
||||
const result = getCornerstoneViewportType('wholeslide', undefined);
|
||||
expect(result).toBe(Enums.ViewportType.WHOLE_SLIDE);
|
||||
});
|
||||
|
||||
describe('useNextViewports (native Generic Viewport types)', () => {
|
||||
it('maps stack to PLANAR_NEXT', () => {
|
||||
expect(getCornerstoneViewportType('stack', undefined, true)).toBe(
|
||||
Enums.ViewportType.PLANAR_NEXT
|
||||
);
|
||||
});
|
||||
|
||||
it('maps volume and orthographic to PLANAR_NEXT', () => {
|
||||
expect(getCornerstoneViewportType('volume', undefined, true)).toBe(
|
||||
Enums.ViewportType.PLANAR_NEXT
|
||||
);
|
||||
expect(getCornerstoneViewportType('orthographic', undefined, true)).toBe(
|
||||
Enums.ViewportType.PLANAR_NEXT
|
||||
);
|
||||
});
|
||||
|
||||
it('maps volume3d / video / wholeslide / ecg to their *_NEXT types', () => {
|
||||
expect(getCornerstoneViewportType('volume3d', undefined, true)).toBe(
|
||||
Enums.ViewportType.VOLUME_3D_NEXT
|
||||
);
|
||||
expect(getCornerstoneViewportType('video', undefined, true)).toBe(
|
||||
Enums.ViewportType.VIDEO_NEXT
|
||||
);
|
||||
expect(getCornerstoneViewportType('wholeslide', undefined, true)).toBe(
|
||||
Enums.ViewportType.WHOLE_SLIDE_NEXT
|
||||
);
|
||||
expect(getCornerstoneViewportType('ecg', undefined, true)).toBe(
|
||||
Enums.ViewportType.ECG_NEXT
|
||||
);
|
||||
});
|
||||
|
||||
it('honors the displaySet viewportType override under the flag', () => {
|
||||
const displaySets = [{ viewportType: 'volume' }] as Types.DisplaySet[];
|
||||
expect(getCornerstoneViewportType('stack', displaySets, true)).toBe(
|
||||
Enums.ViewportType.PLANAR_NEXT
|
||||
);
|
||||
});
|
||||
|
||||
it('throws for an invalid viewport type under the flag', () => {
|
||||
expect(() =>
|
||||
getCornerstoneViewportType('invalid', undefined, true)
|
||||
).toThrow('Invalid viewport type: invalid');
|
||||
});
|
||||
|
||||
it('leaves the legacy mapping unchanged when the flag is off', () => {
|
||||
expect(getCornerstoneViewportType('stack', undefined, false)).toBe(
|
||||
Enums.ViewportType.STACK
|
||||
);
|
||||
expect(getCornerstoneViewportType('volume', undefined, false)).toBe(
|
||||
Enums.ViewportType.ORTHOGRAPHIC
|
||||
);
|
||||
});
|
||||
|
||||
it('is idempotent for already-native types regardless of the flag', () => {
|
||||
// A viewport's stored cs type can be re-fed into the mapper.
|
||||
expect(getCornerstoneViewportType('planarNext', undefined, false)).toBe(
|
||||
Enums.ViewportType.PLANAR_NEXT
|
||||
);
|
||||
expect(getCornerstoneViewportType('planarNext', undefined, true)).toBe(
|
||||
Enums.ViewportType.PLANAR_NEXT
|
||||
);
|
||||
expect(getCornerstoneViewportType('volume3dNext', undefined, true)).toBe(
|
||||
Enums.ViewportType.VOLUME_3D_NEXT
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Types } from '@ohif/core';
|
||||
import { Enums } from '@cornerstonejs/core';
|
||||
import { isNextViewportsEnabled } from './nextViewports';
|
||||
|
||||
const STACK = 'stack';
|
||||
const VOLUME = 'volume';
|
||||
@ -11,10 +12,53 @@ const ECG = 'ecg';
|
||||
|
||||
export default function getCornerstoneViewportType(
|
||||
viewportType: string,
|
||||
displaySets?: Types.DisplaySet[]
|
||||
displaySets?: Types.DisplaySet[],
|
||||
useNextViewports = isNextViewportsEnabled()
|
||||
): Enums.ViewportType {
|
||||
const lowerViewportType =
|
||||
displaySets?.[0]?.viewportType?.toLowerCase() || viewportType.toLowerCase();
|
||||
|
||||
// Already a native Generic ("next") type — e.g. re-derived from a viewport's
|
||||
// stored cornerstone type (ViewportInfo.viewportType). Pass through
|
||||
// idempotently, exactly as the legacy types below map to themselves; this must
|
||||
// hold regardless of the flag so re-entrant callers don't throw.
|
||||
switch (lowerViewportType) {
|
||||
case 'planarnext':
|
||||
return Enums.ViewportType.PLANAR_NEXT;
|
||||
case 'volume3dnext':
|
||||
return Enums.ViewportType.VOLUME_3D_NEXT;
|
||||
case 'videonext':
|
||||
return Enums.ViewportType.VIDEO_NEXT;
|
||||
case 'wholeslidenext':
|
||||
return Enums.ViewportType.WHOLE_SLIDE_NEXT;
|
||||
case 'ecgnext':
|
||||
return Enums.ViewportType.ECG_NEXT;
|
||||
}
|
||||
|
||||
// Native Generic Viewport ("next") path (appConfig.useNextViewports). Stack and
|
||||
// volume/orthographic both collapse to PLANAR_NEXT — the render path (image vs
|
||||
// volume slice) is inferred from the data shape, not from the viewport type.
|
||||
if (useNextViewports) {
|
||||
switch (lowerViewportType) {
|
||||
case STACK:
|
||||
case VOLUME:
|
||||
case ORTHOGRAPHIC:
|
||||
return Enums.ViewportType.PLANAR_NEXT;
|
||||
case VOLUME_3D:
|
||||
return Enums.ViewportType.VOLUME_3D_NEXT;
|
||||
case VIDEO:
|
||||
return Enums.ViewportType.VIDEO_NEXT;
|
||||
case WHOLESLIDE:
|
||||
return Enums.ViewportType.WHOLE_SLIDE_NEXT;
|
||||
case ECG:
|
||||
return Enums.ViewportType.ECG_NEXT;
|
||||
default:
|
||||
throw new Error(
|
||||
`Invalid viewport type: ${viewportType}. Valid types are: stack, volume, orthographic, volume3d, video, wholeslide, ecg`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (lowerViewportType === STACK) {
|
||||
return Enums.ViewportType.STACK;
|
||||
}
|
||||
@ -39,6 +83,6 @@ export default function getCornerstoneViewportType(
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Invalid viewport type: ${viewportType}. Valid types are: stack, volume, video, wholeslide, ecg`
|
||||
`Invalid viewport type: ${viewportType}. Valid types are: stack, volume, orthographic, volume3d, video, wholeslide, ecg`
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,22 +0,0 @@
|
||||
/**
|
||||
* Resolves the data ID (e.g. volumeId) for a viewport and display set.
|
||||
* For viewports with multiple volumes/actors, returns the id that matches the display set; otherwise undefined.
|
||||
* Use this to call viewport.getProperties(dataId) in a viewport-type-agnostic way.
|
||||
*
|
||||
* @param viewport - Viewport instance (stack, volume, or future types with optional getAllVolumeIds)
|
||||
* @param displaySetInstanceUID - Display set instance UID to match
|
||||
* @returns volumeId (or equivalent) for multi-actor viewports, undefined for single-actor
|
||||
*/
|
||||
export function getDataIdForViewport(
|
||||
viewport: unknown,
|
||||
displaySetInstanceUID: string
|
||||
): string | undefined {
|
||||
const vp = viewport as { getAllVolumeIds?: () => string[] };
|
||||
if (typeof vp.getAllVolumeIds !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
const volumeIds = vp.getAllVolumeIds() || [];
|
||||
return volumeIds.length > 0
|
||||
? volumeIds.find(id => id.includes(displaySetInstanceUID)) ?? undefined
|
||||
: undefined;
|
||||
}
|
||||
@ -23,44 +23,42 @@ type ViewportLike = {
|
||||
* type. For "does this viewport support operation X" questions, prefer the
|
||||
* cornerstone capability guards (`utilities.viewportSupports*`) instead.
|
||||
*/
|
||||
export function getLegacyViewportType(
|
||||
viewport: unknown
|
||||
): csEnums.ViewportType | undefined {
|
||||
export function getLegacyViewportType(viewport: unknown): csEnums.ViewportType | undefined {
|
||||
const vp = viewport as ViewportLike | null | undefined;
|
||||
return vp?.requestedType ?? vp?.type;
|
||||
}
|
||||
|
||||
/** Legacy STACK viewport (image stack). Replaces `instanceof StackViewport`. */
|
||||
export function isStackViewportType(
|
||||
viewport: unknown
|
||||
): viewport is csTypes.IStackViewport {
|
||||
export function isStackViewportType(viewport: unknown): viewport is csTypes.IStackViewport {
|
||||
return getLegacyViewportType(viewport) === ViewportType.STACK;
|
||||
}
|
||||
|
||||
/** Legacy ORTHOGRAPHIC (MPR) viewport. Replaces `instanceof VolumeViewport`. */
|
||||
export function isOrthographicViewportType(
|
||||
viewport: unknown
|
||||
): viewport is csTypes.IVolumeViewport {
|
||||
export function isOrthographicViewportType(viewport: unknown): viewport is csTypes.IVolumeViewport {
|
||||
return getLegacyViewportType(viewport) === ViewportType.ORTHOGRAPHIC;
|
||||
}
|
||||
|
||||
/** Legacy VOLUME_3D viewport. Replaces `instanceof VolumeViewport3D`. */
|
||||
export function isVolume3DViewportType(
|
||||
viewport: unknown
|
||||
): viewport is csTypes.IVolumeViewport {
|
||||
return getLegacyViewportType(viewport) === ViewportType.VOLUME_3D;
|
||||
/**
|
||||
* 3D volume viewport. Replaces `instanceof VolumeViewport3D`.
|
||||
*
|
||||
* Matches both the legacy `VOLUME_3D` type and the native ("next") `VOLUME_3D_NEXT`.
|
||||
* Under `useNextViewports`, OHIF requests `VOLUME_3D_NEXT` directly, so cornerstone
|
||||
* leaves `requestedType` unset and `getLegacyViewportType` returns the native type
|
||||
* (cornerstone only rewrites `requestedType` back to the legacy type for its own
|
||||
* compat remap of a legacy `VOLUME_3D` request). Checking only `VOLUME_3D` would miss
|
||||
* native 3D viewports and misroute them through the planar (getViewReference/getViewState)
|
||||
* branch, and would leave 3D gates such as `is3DVolume` false.
|
||||
*/
|
||||
export function isVolume3DViewportType(viewport: unknown): viewport is csTypes.IVolumeViewport {
|
||||
const legacyType = getLegacyViewportType(viewport);
|
||||
return legacyType === ViewportType.VOLUME_3D || legacyType === ViewportType.VOLUME_3D_NEXT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy ORTHOGRAPHIC or VOLUME_3D viewport (i.e. a `BaseVolumeViewport`).
|
||||
* Replaces `instanceof BaseVolumeViewport`.
|
||||
*/
|
||||
export function isVolumeViewportType(
|
||||
viewport: unknown
|
||||
): viewport is csTypes.IVolumeViewport {
|
||||
export function isVolumeViewportType(viewport: unknown): viewport is csTypes.IVolumeViewport {
|
||||
const legacyType = getLegacyViewportType(viewport);
|
||||
return (
|
||||
legacyType === ViewportType.ORTHOGRAPHIC ||
|
||||
legacyType === ViewportType.VOLUME_3D
|
||||
);
|
||||
return legacyType === ViewportType.ORTHOGRAPHIC || legacyType === ViewportType.VOLUME_3D;
|
||||
}
|
||||
|
||||
41
extensions/cornerstone/src/utils/nextViewportPolicies.ts
Normal file
41
extensions/cornerstone/src/utils/nextViewportPolicies.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { isNextViewportsEnabled } from './nextViewports';
|
||||
|
||||
/**
|
||||
* The named home for every BEHAVIORAL POLICY difference between the legacy and
|
||||
* native ("next") viewport paths — appearance defaults and workflow rules that
|
||||
* are not API bridging (that's `services/ViewportService/adapter/`) and not
|
||||
* mount lifecycle (that's `services/ViewportService/backends/`). Keeping them
|
||||
* in one greppable file is the point: a policy divergence that lives inline in
|
||||
* a mode or hanging protocol is invisible to the next reader.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initial PT opacity for TMTV fusion viewports on the native path. Legacy (in
|
||||
* tmtv's hpViewports) uses a per-value opacity ramp; native applies a ramp
|
||||
* literally through its flat 2D blend (which would keep the background
|
||||
* transparent), so the native path replaces the ramp with this single flat,
|
||||
* more CT-weighted starting blend.
|
||||
*/
|
||||
export const NEXT_FUSION_PT_OPACITY = 0.4;
|
||||
|
||||
/**
|
||||
* Initial opacity for data overlays (e.g. colormapped foreground layers) on
|
||||
* the native path. Native viewports composite an overlay as a 2D image slice
|
||||
* with a flat alpha blend and no volume ray-cast opacity attenuation: the
|
||||
* legacy nominal 0.9 renders at ~40% effective through the ray-cast path but
|
||||
* reads ~80-90% on native, so native starts at the legacy-equivalent
|
||||
* effective value.
|
||||
*/
|
||||
export const NEXT_OVERLAY_OPACITY = 0.4;
|
||||
|
||||
/**
|
||||
* Viewport type to pin when hydrating a segmentation's referenced display set.
|
||||
* RTSTRUCT contours render correctly on a native stack/vtkImage viewport and
|
||||
* scroll fast, so the referenced image stays in stack mode on hydrate rather
|
||||
* than being promoted to a volume slice (which the perf acceptance criteria
|
||||
* forbid). Scoped to RTSTRUCT + the next path; SEG and legacy keep the default
|
||||
* (undefined = no pin).
|
||||
*/
|
||||
export function getHydrationViewportTypeForModality(modality: string): 'stack' | undefined {
|
||||
return modality === 'RTSTRUCT' && isNextViewportsEnabled() ? 'stack' : undefined;
|
||||
}
|
||||
157
extensions/cornerstone/src/utils/nextViewports.ts
Normal file
157
extensions/cornerstone/src/utils/nextViewports.ts
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Module-level accessor for the `appConfig.genericViewports.enabled` opt-in flag.
|
||||
*
|
||||
* The flag is captured once at extension init (from the `useNextViewports` URL
|
||||
* query param, else appConfig) and read by the
|
||||
* two viewport-type chokepoints (`getCornerstoneViewportType` and the
|
||||
* `CornerstoneViewportService` backend split) without threading appConfig
|
||||
* through every service/viewport constructor. Defaults to `false` so the legacy
|
||||
* path is unchanged until an app opts in.
|
||||
*/
|
||||
let nextViewportsEnabled = false;
|
||||
|
||||
export function setNextViewportsEnabled(value: boolean): void {
|
||||
nextViewportsEnabled = Boolean(value);
|
||||
}
|
||||
|
||||
export function isNextViewportsEnabled(): boolean {
|
||||
return nextViewportsEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the effective flag at init. A `useNextViewports` URL query parameter
|
||||
* takes precedence over the appConfig value, so the native backend can be opted
|
||||
* into per-session via the URL (e.g. `?useNextViewports=true`) without editing
|
||||
* the deployed config. `?useNextViewports` (no value), `=true`, or `=1` enable
|
||||
* it; any other value disables it. When the param is absent, appConfig wins.
|
||||
*/
|
||||
export function resolveNextViewportsEnabled(appConfigValue: unknown): boolean {
|
||||
return resolveBooleanUrlOptIn('useNextViewports', appConfigValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aliases accepted by the `viewportRendering` param/config, mapped to
|
||||
* cornerstone render backend wire ids ('gpu' is the VTK/WebGL backend).
|
||||
* Values not listed here pass through untouched so backends registered via
|
||||
* cornerstone's `registerRenderBackend()` (e.g. a webgpu backend) can be
|
||||
* selected by their wire id.
|
||||
*/
|
||||
const RENDER_BACKEND_ALIASES: Record<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);
|
||||
}
|
||||
85
extensions/cornerstone/src/utils/viewportDataShape.ts
Normal file
85
extensions/cornerstone/src/utils/viewportDataShape.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import { Enums } from '@cornerstonejs/core';
|
||||
|
||||
/**
|
||||
* Pre-mount classification of a viewport's bound data (viewportData from
|
||||
* CornerstoneCacheService), for code that runs before — or independently of —
|
||||
* a live viewport instance (overlays, scrollbars). Native ("next") viewports
|
||||
* collapse stack/volume onto a single PLANAR_NEXT viewportType, so these
|
||||
* helpers classify by the persisted dataShapeType and the data shape itself
|
||||
* (imageIds = stack, volume/volumeId = volume) instead of the runtime type.
|
||||
*
|
||||
* For classification of a LIVE viewport instance, use
|
||||
* `getViewportAdapter(viewport).getShape()` instead.
|
||||
*/
|
||||
|
||||
type ViewportDatum = {
|
||||
imageIds?: string[];
|
||||
volume?: unknown;
|
||||
volumeId?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ViewportDataLike = {
|
||||
viewportType?: Enums.ViewportType;
|
||||
dataShapeType?: Enums.ViewportType;
|
||||
data?: ViewportDatum | ViewportDatum[];
|
||||
};
|
||||
|
||||
/** The primary (non-overlay) datum of a viewportData. */
|
||||
export function getPrimaryViewportDatum(viewportData: ViewportDataLike): ViewportDatum | undefined {
|
||||
return Array.isArray(viewportData?.data) ? viewportData.data[0] : viewportData?.data;
|
||||
}
|
||||
|
||||
/** True when the primary datum is volume-shaped (volume/volumeId present). */
|
||||
export function isVolumeViewportData(viewportData: ViewportDataLike): boolean {
|
||||
const firstData = getPrimaryViewportDatum(viewportData);
|
||||
return !!(firstData && (firstData.volume || firstData.volumeId));
|
||||
}
|
||||
|
||||
/**
|
||||
* The stack/volume shape a viewportData was built for, transparent across the
|
||||
* native type collapse: the persisted dataShapeType when present (set by
|
||||
* CornerstoneCacheService on the native path), else the legacy viewportType.
|
||||
*/
|
||||
export function getViewportDataShapeType(
|
||||
viewportData: ViewportDataLike
|
||||
): Enums.ViewportType | undefined {
|
||||
return viewportData?.dataShapeType ?? viewportData?.viewportType;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice-navigation event for this viewport's content. Resolved from the
|
||||
* legacy viewportType when it is meaningful, else from the bound data shape —
|
||||
* which is known immediately, unlike a runtime content-mode check that may not
|
||||
* be ready while a native viewport is still binding. Native viewports emit the
|
||||
* same STACK_NEW_IMAGE / VOLUME_NEW_IMAGE events as legacy.
|
||||
*/
|
||||
export function getSliceEventName(viewportData: ViewportDataLike): string {
|
||||
const { viewportType } = viewportData;
|
||||
const firstData = getPrimaryViewportDatum(viewportData);
|
||||
return (
|
||||
(viewportType === Enums.ViewportType.STACK && Enums.Events.STACK_NEW_IMAGE) ||
|
||||
(viewportType === Enums.ViewportType.ORTHOGRAPHIC && Enums.Events.VOLUME_NEW_IMAGE) ||
|
||||
(isVolumeViewportData(viewportData) && Enums.Events.VOLUME_NEW_IMAGE) ||
|
||||
(firstData?.imageIds && Enums.Events.STACK_NEW_IMAGE) ||
|
||||
Enums.Events.IMAGE_RENDERED
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice count for a viewport. `viewport.getNumberOfSlices()` can be
|
||||
* premature while a native viewport is still binding its data (it returns 1
|
||||
* until then). For an image stack the count is known from the bound data, so
|
||||
* prefer that and only fall back to the viewport for volume/MPR (where the
|
||||
* count depends on orientation).
|
||||
*/
|
||||
export function getViewportSliceCount(
|
||||
viewportData: ViewportDataLike,
|
||||
viewport: { getNumberOfSlices: () => number }
|
||||
): number {
|
||||
const firstData = getPrimaryViewportDatum(viewportData);
|
||||
return (
|
||||
(!isVolumeViewportData(viewportData) && firstData?.imageIds?.length) ||
|
||||
viewport.getNumberOfSlices()
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-default",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "Common/default features and functionality for basic image viewing",
|
||||
"author": "OHIF Core Team",
|
||||
"license": "MIT",
|
||||
|
||||
@ -25,6 +25,7 @@ import {
|
||||
} from '../utils/dicomWriter';
|
||||
import { getGetThumbnailSrc, ThumbnailContext } from './retrieveThumbnail';
|
||||
import { getRenderedURL } from './retrieveRendered';
|
||||
import retrieveBulkData from './retrieveBulkData';
|
||||
|
||||
const { DicomMetaDictionary, DicomDict } = dcmjs.data;
|
||||
|
||||
@ -147,6 +148,54 @@ 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') {
|
||||
@ -501,8 +550,16 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
||||
dicomWebConfig
|
||||
);
|
||||
|
||||
// first naturalize the data
|
||||
const naturalizedInstancesMetadata = data.map(naturalizeDataset);
|
||||
// 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);
|
||||
|
||||
const seriesSummaryMetadata = {};
|
||||
const instancesPerSeries = {};
|
||||
@ -576,57 +633,19 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
||||
dicomWebConfig
|
||||
);
|
||||
|
||||
/**
|
||||
* 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];
|
||||
|
||||
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) {
|
||||
async function storeInstances(instances) {
|
||||
const naturalizedInstances = instances.map(addRetrieveBulkData);
|
||||
|
||||
// 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);
|
||||
|
||||
// Adding instanceMetadata to OHIF MetadataProvider
|
||||
naturalizedInstances.forEach(instance => {
|
||||
setNonEnumerableInstanceProperty(instance, 'wadoRoot', dicomWebConfig.wadoRoot);
|
||||
@ -678,20 +697,44 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
|
||||
|
||||
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
|
||||
|
||||
let completedSeriesCount = 0;
|
||||
const seriesDeliveredPromises = seriesPromises.map(promise => {
|
||||
if (!returnPromises) {
|
||||
promise?.start();
|
||||
}
|
||||
return promise.then(instances => {
|
||||
storeInstances(instances);
|
||||
});
|
||||
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.all(seriesDeliveredPromises).then(() => setSuccessFlag());
|
||||
return seriesPromises;
|
||||
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;
|
||||
} else {
|
||||
await Promise.all(seriesDeliveredPromises);
|
||||
await Promise.all(seriesDeliveredPromises.map(promise => promise.start()));
|
||||
setSuccessFlag();
|
||||
}
|
||||
|
||||
@ -761,34 +804,4 @@ 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 };
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
@ -3,7 +3,7 @@ import classnames from 'classnames';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAppConfig } from '@state';
|
||||
|
||||
import { Button, ButtonEnums } from '@ohif/ui';
|
||||
import { Button } from '@ohif/ui-next';
|
||||
|
||||
function DataSourceSelector() {
|
||||
const [appConfig] = useAppConfig();
|
||||
@ -31,8 +31,8 @@ function DataSourceSelector() {
|
||||
{ds.configuration?.friendlyName || ds.friendlyName}
|
||||
</h1>
|
||||
<Button
|
||||
type={ButtonEnums.type.primary}
|
||||
className={classnames('ml-2')}
|
||||
variant="secondary"
|
||||
className={classnames('ml-2', 'mt-1')}
|
||||
onClick={() => {
|
||||
navigate({
|
||||
pathname: '/',
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
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;
|
||||
@ -1,5 +1,5 @@
|
||||
import { ContextMenu } from '@ohif/ui';
|
||||
import { ContextMenuViewport } from '@ohif/ui-next';
|
||||
|
||||
export default {
|
||||
'ui.contextMenu': ContextMenu,
|
||||
'ui.contextMenu': ContextMenuViewport,
|
||||
};
|
||||
|
||||
102
extensions/default/src/getPTImageIdInstanceMetadata.test.ts
Normal file
102
extensions/default/src/getPTImageIdInstanceMetadata.test.ts
Normal file
@ -0,0 +1,102 @@
|
||||
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
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,6 @@
|
||||
import OHIF from '@ohif/core';
|
||||
import OHIF, { utils } from '@ohif/core';
|
||||
|
||||
import type { InstanceMetadata, PhilipsPETPrivateGroup } from '@cornerstonejs/calculate-suv/src/types';
|
||||
import type { InstanceMetadata, PhilipsPETPrivateGroup } from '@cornerstonejs/calculate-suv';
|
||||
|
||||
const metadataProvider = OHIF.classes.MetadataProvider;
|
||||
|
||||
@ -11,21 +11,25 @@ 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 ||
|
||||
!dicomMetaData.RadiopharmaceuticalInformationSequence ||
|
||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife === undefined ||
|
||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose === undefined ||
|
||||
!radiopharmaceuticalInfo ||
|
||||
radionuclideHalfLife === undefined ||
|
||||
radionuclideTotalDose === undefined ||
|
||||
dicomMetaData.DecayCorrection === undefined ||
|
||||
dicomMetaData.AcquisitionDate === undefined ||
|
||||
dicomMetaData.AcquisitionTime === undefined ||
|
||||
(dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartDateTime ===
|
||||
undefined &&
|
||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime ===
|
||||
undefined)
|
||||
(radiopharmaceuticalInfo.RadiopharmaceuticalStartDateTime === undefined &&
|
||||
radiopharmaceuticalInfo.RadiopharmaceuticalStartTime === undefined)
|
||||
) {
|
||||
throw new Error('required metadata are missing');
|
||||
}
|
||||
@ -37,73 +41,84 @@ export default function getPTImageIdInstanceMetadata(imageId: string): InstanceM
|
||||
const instanceMetadata: InstanceMetadata = {
|
||||
CorrectedImage: dicomMetaData.CorrectedImage,
|
||||
Units: dicomMetaData.Units,
|
||||
RadionuclideHalfLife: dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife,
|
||||
RadionuclideTotalDose:
|
||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose,
|
||||
RadiopharmaceuticalStartDateTime:
|
||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartDateTime,
|
||||
RadiopharmaceuticalStartTime:
|
||||
dicomMetaData.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime,
|
||||
RadionuclideHalfLife: radionuclideHalfLife,
|
||||
RadionuclideTotalDose: radionuclideTotalDose,
|
||||
RadiopharmaceuticalStartDateTime: radiopharmaceuticalInfo.RadiopharmaceuticalStartDateTime,
|
||||
RadiopharmaceuticalStartTime: radiopharmaceuticalInfo.RadiopharmaceuticalStartTime,
|
||||
DecayCorrection: dicomMetaData.DecayCorrection,
|
||||
PatientWeight: dicomMetaData.PatientWeight,
|
||||
PatientWeight: coerceNumber(dicomMetaData.PatientWeight),
|
||||
SeriesDate: dicomMetaData.SeriesDate,
|
||||
SeriesTime: dicomMetaData.SeriesTime,
|
||||
AcquisitionDate: dicomMetaData.AcquisitionDate,
|
||||
AcquisitionTime: dicomMetaData.AcquisitionTime,
|
||||
};
|
||||
|
||||
if (
|
||||
dicomMetaData['70531000'] ||
|
||||
dicomMetaData['70531000'] !== undefined ||
|
||||
dicomMetaData['70531009'] ||
|
||||
dicomMetaData['70531009'] !== undefined
|
||||
) {
|
||||
// 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) {
|
||||
const philipsPETPrivateGroup: PhilipsPETPrivateGroup = {
|
||||
SUVScaleFactor: dicomMetaData['70531000'],
|
||||
ActivityConcentrationScaleFactor: dicomMetaData['70531009'],
|
||||
SUVScaleFactor: suvScaleFactor,
|
||||
ActivityConcentrationScaleFactor: activityConcentrationScaleFactor,
|
||||
};
|
||||
instanceMetadata.PhilipsPETPrivateGroup = philipsPETPrivateGroup;
|
||||
}
|
||||
|
||||
if (dicomMetaData['0009100d'] && dicomMetaData['0009100d'] !== undefined) {
|
||||
if (dicomMetaData['0009100d'] !== undefined) {
|
||||
instanceMetadata.GEPrivatePostInjectionDateTime = dicomMetaData['0009100d'];
|
||||
}
|
||||
|
||||
if (dicomMetaData.FrameReferenceTime && dicomMetaData.FrameReferenceTime !== undefined) {
|
||||
instanceMetadata.FrameReferenceTime = dicomMetaData.FrameReferenceTime;
|
||||
const frameReferenceTime = coerceNumber(dicomMetaData.FrameReferenceTime);
|
||||
if (frameReferenceTime !== undefined) {
|
||||
instanceMetadata.FrameReferenceTime = frameReferenceTime;
|
||||
}
|
||||
|
||||
if (dicomMetaData.ActualFrameDuration && dicomMetaData.ActualFrameDuration !== undefined) {
|
||||
instanceMetadata.ActualFrameDuration = dicomMetaData.ActualFrameDuration;
|
||||
const actualFrameDuration = coerceNumber(dicomMetaData.ActualFrameDuration);
|
||||
if (actualFrameDuration !== undefined) {
|
||||
instanceMetadata.ActualFrameDuration = actualFrameDuration;
|
||||
}
|
||||
|
||||
if (dicomMetaData.PatientSex && dicomMetaData.PatientSex !== undefined) {
|
||||
if (dicomMetaData.PatientSex !== undefined) {
|
||||
instanceMetadata.PatientSex = dicomMetaData.PatientSex;
|
||||
}
|
||||
|
||||
if (dicomMetaData.PatientSize && dicomMetaData.PatientSize !== undefined) {
|
||||
instanceMetadata.PatientSize = dicomMetaData.PatientSize;
|
||||
const patientSize = coerceNumber(dicomMetaData.PatientSize);
|
||||
if (patientSize !== undefined) {
|
||||
instanceMetadata.PatientSize = 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;
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { isNextViewport, NEXT_OVERLAY_OPACITY } from '@ohif/extension-cornerstone';
|
||||
|
||||
export const DERIVED_OVERLAY_MODALITIES = ['SEG', 'RTSTRUCT'];
|
||||
export const DEFAULT_COLORMAP = 'hsv';
|
||||
export const DEFAULT_OPACITY = 0.9;
|
||||
@ -93,6 +95,11 @@ export function configureViewportForLayerAddition(params: {
|
||||
}
|
||||
}
|
||||
|
||||
// Native ("next") viewports need the legacy-equivalent initial overlay opacity
|
||||
// (see the NEXT_OVERLAY_OPACITY policy in @ohif/extension-cornerstone).
|
||||
const liveCsViewport = cornerstoneViewportService.getCornerstoneViewport(viewportId);
|
||||
const isLiveViewportNext = !!liveCsViewport && isNextViewport(liveCsViewport);
|
||||
|
||||
// create same amount of display set options as the number of display set UIDs
|
||||
const displaySetOptions = allDisplaySetInstanceUIDs.map((uid, index) => {
|
||||
// There is already a display set option for this display set, so return it.
|
||||
@ -106,7 +113,17 @@ export function configureViewportForLayerAddition(params: {
|
||||
}
|
||||
|
||||
const displaySet = displaySetService.getDisplaySetByUID(uid);
|
||||
return createColormapOverlayDisplaySetOptions(displaySet, 90, customizationService);
|
||||
const overlayOptions = createColormapOverlayDisplaySetOptions(
|
||||
displaySet,
|
||||
90,
|
||||
customizationService
|
||||
);
|
||||
|
||||
if (isLiveViewportNext && typeof overlayOptions.colormap?.opacity === 'number') {
|
||||
overlayOptions.colormap.opacity = NEXT_OVERLAY_OPACITY;
|
||||
}
|
||||
|
||||
return overlayOptions;
|
||||
});
|
||||
|
||||
viewport.displaySetOptions = displaySetOptions;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-dicom-microscopy",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "OHIF extension for DICOM microscopy",
|
||||
"author": "Bill Wallace, md-prog",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-dicom-pdf",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "OHIF extension for PDF display",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-dicom-video",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "OHIF extension for video display",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-measurement-tracking",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"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.13",
|
||||
"@cornerstonejs/tools": "5.4.13",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@cornerstonejs/tools": "5.4.17",
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/extension-cornerstone-dicom-sr": "workspace:*",
|
||||
"@ohif/extension-default": "workspace:*",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-test",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "OHIF extension used inside e2e testing",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-tmtv",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "OHIF extension for Total Metabolic Tumor Volume",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@ -10,6 +10,7 @@ import dicomRTAnnotationExport from './utils/dicomRTAnnotationExport/RTStructure
|
||||
|
||||
import { Enums } from '@cornerstonejs/tools';
|
||||
import { utils } from '@ohif/core';
|
||||
import { getViewportFocalPoint } from '@ohif/extension-cornerstone';
|
||||
|
||||
const { SegmentationRepresentations } = Enums;
|
||||
const { formatPN } = utils;
|
||||
@ -261,7 +262,15 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }:
|
||||
|
||||
setStartSliceForROIThresholdTool: () => {
|
||||
const { viewport } = _getActiveViewportsEnabledElement();
|
||||
const { focalPoint } = viewport.getCamera();
|
||||
// Native ("next") viewports have no getCamera; the slice-center focal point
|
||||
// comes from the view reference. Bridged so both lanes work.
|
||||
const focalPoint = getViewportFocalPoint(viewport);
|
||||
|
||||
// Native viewports may not resolve a focal point; writing undefined into the
|
||||
// annotation would invalidate its ROI-threshold coordinates.
|
||||
if (!focalPoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedAnnotationUIDs = _getAnnotationsSelectedByToolNames(
|
||||
ROI_THRESHOLD_MANUAL_TOOL_IDS
|
||||
@ -289,8 +298,11 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }:
|
||||
|
||||
const annotation = csTools.annotation.state.getAnnotation(annotationUID);
|
||||
|
||||
// get the current focal point
|
||||
const focalPointToEnd = viewport.getCamera().focalPoint;
|
||||
// get the current focal point (bridged: native viewports use the view reference)
|
||||
const focalPointToEnd = getViewportFocalPoint(viewport);
|
||||
if (!focalPointToEnd) {
|
||||
return;
|
||||
}
|
||||
annotation.data.endCoordinate = focalPointToEnd;
|
||||
|
||||
// IMPORTANT: invalidate the toolData for the cached stat to get updated
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { isNextViewportsEnabled, NEXT_FUSION_PT_OPACITY } from '@ohif/extension-cornerstone';
|
||||
import {
|
||||
ctAXIAL,
|
||||
ctCORONAL,
|
||||
@ -339,6 +340,20 @@ const ptCT: AppTypes.HangingProtocol.Protocol = {
|
||||
};
|
||||
|
||||
function getHangingProtocolModule() {
|
||||
// Replace the fusion PT opacity ramp with a flat scalar for the native ("next")
|
||||
// path only, leaving the legacy ramp in hpViewports untouched. Done here (not at
|
||||
// module load) because the useNextViewports flag is set during cornerstone
|
||||
// preRegistration, which runs before this module is gathered.
|
||||
if (isNextViewportsEnabled()) {
|
||||
[fusionAXIAL, fusionSAGITTAL, fusionCORONAL].forEach(viewport => {
|
||||
const ptDisplaySet = viewport.displaySets?.find(ds => ds.id === 'ptDisplaySet');
|
||||
const colormap = ptDisplaySet?.options?.colormap as { opacity?: unknown } | undefined;
|
||||
if (colormap) {
|
||||
colormap.opacity = NEXT_FUSION_PT_OPACITY;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
name: ptCT.id,
|
||||
|
||||
@ -286,6 +286,13 @@ const fusionAXIAL: AppTypes.HangingProtocol.Viewport = {
|
||||
options: {
|
||||
colormap: {
|
||||
name: 'hsv',
|
||||
// Legacy fusion PT opacity ramp (unchanged): low PT values stay mostly
|
||||
// transparent so the CT shows through. Do NOT flatten this to a scalar —
|
||||
// the legacy viewport renders this ramp and depends on it. The native
|
||||
// ("next") viewport would apply the ramp literally (keeping the
|
||||
// background transparent and preventing the 100% opacity slider from
|
||||
// fully covering the CT), so the next path replaces this ramp with a
|
||||
// flat scalar in getHangingProtocolModule instead of changing it here.
|
||||
opacity: [
|
||||
{ value: 0, opacity: 0 },
|
||||
{ value: 0.1, opacity: 0.8 },
|
||||
@ -349,6 +356,13 @@ const fusionSAGITTAL = {
|
||||
options: {
|
||||
colormap: {
|
||||
name: 'hsv',
|
||||
// Legacy fusion PT opacity ramp (unchanged): low PT values stay mostly
|
||||
// transparent so the CT shows through. Do NOT flatten this to a scalar —
|
||||
// the legacy viewport renders this ramp and depends on it. The native
|
||||
// ("next") viewport would apply the ramp literally (keeping the
|
||||
// background transparent and preventing the 100% opacity slider from
|
||||
// fully covering the CT), so the next path replaces this ramp with a
|
||||
// flat scalar in getHangingProtocolModule instead of changing it here.
|
||||
opacity: [
|
||||
{ value: 0, opacity: 0 },
|
||||
{ value: 0.1, opacity: 0.8 },
|
||||
@ -412,6 +426,13 @@ const fusionCORONAL = {
|
||||
options: {
|
||||
colormap: {
|
||||
name: 'hsv',
|
||||
// Legacy fusion PT opacity ramp (unchanged): low PT values stay mostly
|
||||
// transparent so the CT shows through. Do NOT flatten this to a scalar —
|
||||
// the legacy viewport renders this ramp and depends on it. The native
|
||||
// ("next") viewport would apply the ramp literally (keeping the
|
||||
// background transparent and preventing the 100% opacity slider from
|
||||
// fully covering the CT), so the next path replaces this ramp with a
|
||||
// flat scalar in getHangingProtocolModule instead of changing it here.
|
||||
opacity: [
|
||||
{ value: 0, opacity: 0 },
|
||||
{ value: 0.1, opacity: 0.8 },
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/extension-ultrasound-pleura-bline",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "",
|
||||
"author": "Rodrigo Basilio",
|
||||
"license": "MIT",
|
||||
@ -36,8 +36,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@cornerstonejs/core": "5.4.13",
|
||||
"@cornerstonejs/tools": "5.4.13",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@cornerstonejs/tools": "5.4.17",
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/extension-cornerstone": "workspace:*",
|
||||
"@ohif/extension-default": "workspace:*",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/mode-basic-dev-mode",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "Basic OHIF Viewer Using Cornerstone",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/mode-test",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "Basic mode for testing",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/mode-basic",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "A basic mode used to build other modes on top of",
|
||||
"author": "OHIF Contributors",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/mode-longitudinal",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "Longitudinal Workflow",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/mode-microscopy",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "OHIF mode for DICOM microscopy",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/mode-preclinical-4d",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "4D Workflow",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/mode-segmentation",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "OHIF segmentation mode which enables labelmap segmentation read/edit/export",
|
||||
"author": "@ohif",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/mode-tmtv",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "Total Metabolic Tumor Volume Workflow",
|
||||
"author": "OHIF",
|
||||
"license": "MIT",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/mode-ultrasound-pleura-bline",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"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.13",
|
||||
"@cornerstonejs/tools": "5.4.13",
|
||||
"@cornerstonejs/core": "5.4.17",
|
||||
"@cornerstonejs/tools": "5.4.17",
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/extension-cornerstone-dicom-sr": "workspace:*",
|
||||
"@ohif/extension-ultrasound-pleura-bline": "workspace:*",
|
||||
|
||||
@ -124,5 +124,5 @@
|
||||
"git add"
|
||||
]
|
||||
},
|
||||
"version": "3.13.0-beta.121"
|
||||
"version": "3.13.0-beta.125"
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/app",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"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.13",
|
||||
"@cornerstonejs/dicom-image-loader": "5.4.17",
|
||||
"@emotion/serialize": "1.3.3",
|
||||
"@ohif/core": "workspace:*",
|
||||
"@ohif/i18n": "workspace:*",
|
||||
|
||||
@ -37,6 +37,22 @@ window.config = {
|
||||
// '/remote/': 'https://cdn.example.com/ohif-custom/', // ?customization=/remote/siteA
|
||||
// },
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
// --- Native ("next") Generic Viewport --------------------------------------
|
||||
// OFF by default. Set `enabled: true` (or pass ?useNextViewports=true in the
|
||||
// URL) to drive viewports through cornerstone's native GenericViewport
|
||||
// ("next") API instead of the legacy Stack/Volume viewport classes.
|
||||
genericViewports: {
|
||||
enabled: false,
|
||||
// Render backend selection: 'cpu' | 'webgl' | 'auto' | a backend id
|
||||
// registered via cornerstone's registerRenderBackend (e.g. a webgpu
|
||||
// backend), or a map with per-viewport-type overrides, e.g.
|
||||
// { default: 'webgl', orthographic: 'cpu' }. The matching URL params take
|
||||
// precedence per-session: ?viewportRendering=cpu and
|
||||
// ?orthographic.viewportRendering=cpu.
|
||||
// viewportRendering: 'auto',
|
||||
},
|
||||
// ----------------------------------------------------------------------------
|
||||
showStudyList: true,
|
||||
// some windows systems have issues with more than 3 web workers
|
||||
maxNumberOfWebWorkers: 3,
|
||||
|
||||
@ -13,6 +13,8 @@ export const preserveKeys = [
|
||||
'hangingProtocolId',
|
||||
'customization',
|
||||
'theme',
|
||||
'debug',
|
||||
'useNextViewports',
|
||||
];
|
||||
|
||||
function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@ohif/cli",
|
||||
"version": "3.13.0-beta.121",
|
||||
"version": "3.13.0-beta.125",
|
||||
"description": "A CLI to bootstrap new OHIF extension or mode",
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
|
||||
@ -2,7 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { execa } from 'execa';
|
||||
import { keywords } from './enums/index.js';
|
||||
import { validateYarn, addExtensionToConfig, addModeToConfig } from './utils/index.js';
|
||||
import { validatePnpm, addExtensionToConfig, addModeToConfig } from './utils/index.js';
|
||||
|
||||
async function linkPackage(packageDir, options, addToConfig, keyword) {
|
||||
const { viewerDirectory } = options;
|
||||
@ -22,19 +22,18 @@ async function linkPackage(packageDir, options, addToConfig, keyword) {
|
||||
|
||||
const version = packageJSON.version;
|
||||
|
||||
// make sure yarn is installed
|
||||
await validateYarn();
|
||||
// make sure pnpm is installed
|
||||
await validatePnpm();
|
||||
|
||||
// change directory to packageDir and execute yarn link
|
||||
process.chdir(packageDir);
|
||||
// resolve the package directory before changing the working directory
|
||||
const resolvedPackageDir = path.resolve(packageDir);
|
||||
|
||||
let results;
|
||||
results = await execa(`yarn`, ['link']);
|
||||
|
||||
// change directory to OHIF Platform root and execute yarn link
|
||||
// change directory to the OHIF Platform root and link the local package there.
|
||||
// Linking mutates the lockfile, so disable the workspace's frozen-lockfile
|
||||
// default for this call (pnpm link rejects --no-frozen-lockfile).
|
||||
process.chdir(`${viewerDirectory}/../..`);
|
||||
|
||||
results = await execa(`yarn`, ['link', packageName]);
|
||||
let results = await execa('pnpm', ['link', resolvedPackageDir, '--config.frozen-lockfile=false']);
|
||||
console.log(results.stdout);
|
||||
|
||||
// Add the node_modules of the linked package so that webpack
|
||||
@ -63,7 +62,7 @@ async function linkPackage(packageDir, options, addToConfig, keyword) {
|
||||
});
|
||||
|
||||
// run prettier on the webpack config
|
||||
results = await execa(`yarn`, ['prettier', '--write', webpackPwaPath]);
|
||||
results = await execa('pnpm', ['exec', 'prettier', '--write', webpackPwaPath]);
|
||||
}
|
||||
|
||||
function linkExtension(packageDir, options) {
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
import { execa } from 'execa';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { validateYarn, removeExtensionFromConfig, removeModeFromConfig } from './utils/index.js';
|
||||
import { validatePnpm, removeExtensionFromConfig, removeModeFromConfig } from './utils/index.js';
|
||||
|
||||
const linkPackage = async (packageName, options, removeFromConfig) => {
|
||||
const { viewerDirectory } = options;
|
||||
|
||||
// make sure yarn is installed
|
||||
await validateYarn();
|
||||
// make sure pnpm is installed
|
||||
await validatePnpm();
|
||||
|
||||
// change directory to OHIF Platform root and execute yarn link
|
||||
process.chdir(viewerDirectory);
|
||||
// change directory to the OHIF Platform root, where the package was linked.
|
||||
// Unlinking mutates the lockfile, so disable the workspace's frozen-lockfile
|
||||
// default for this call (pnpm unlink rejects --no-frozen-lockfile).
|
||||
process.chdir(`${viewerDirectory}/../..`);
|
||||
|
||||
const results = await execa(`yarn`, ['unlink', packageName]);
|
||||
const results = await execa('pnpm', ['unlink', packageName, '--config.frozen-lockfile=false']);
|
||||
console.log(results.stdout);
|
||||
|
||||
const webpackPwaPath = path.join(viewerDirectory, '.webpack', 'webpack.pwa.js');
|
||||
@ -23,7 +25,7 @@ const linkPackage = async (packageName, options, removeFromConfig) => {
|
||||
removeFromConfig(packageName);
|
||||
|
||||
// run prettier on the webpack config
|
||||
await execa(`yarn`, ['prettier', '--write', webpackPwaPath]);
|
||||
await execa('pnpm', ['exec', 'prettier', '--write', webpackPwaPath]);
|
||||
};
|
||||
|
||||
async function removePathFromWebpackConfig(webpackConfigPath, packageName) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user