Compare commits

...

15 Commits

Author SHA1 Message Date
bbe37ff388 feat: annotation persistence (local storage)
Some checks failed
Build and Deploy Docs / build-and-deploy-docs (push) Has been cancelled
CodeQL Advanced / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL Advanced / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
2026-07-16 10:38:36 +01:00
ohif-bot
f7612cdd0a chore(version): Update package versions to 3.13.0-beta.125 [skip ci] 2026-07-14 23:14:16 +00:00
Alireza
125a174298
fix(cli): link and unlink extensions/modes with pnpm instead of yarn (#6144) 2026-07-14 19:10:49 -04:00
ohif-bot
b880d2acf9 chore(version): Update package versions to 3.13.0-beta.124 [skip ci] 2026-07-13 16:59:27 +00:00
Dan Rukas
03e17d764a
fix(ui): clean up remaining legacy ui usage (#6140)
* Update button on DataSourceSelector

* Removed dead code ToolbarButtonNestedMenu, button spacing
2026-07-13 12:56:17 -04:00
Dan Rukas
3d70db2d3b
ui(ContextMenuViewport): move to ui-next with theme support (#6008) 2026-07-13 12:56:03 -04:00
ohif-bot
110b293aa0 chore(version): Update package versions to 3.13.0-beta.123 [skip ci] 2026-07-11 16:19:35 +00:00
Alireza
70421225d7
fix(pt): resolve Philips PET private SUV bulkdata before scaling (#6096)
* fix(pt): resolve Philips PET private SUV bulkdata before scaling

When a DICOMweb server delivers the Philips PET private tags SUVScaleFactor
(7053,1000) / ActivityConcentrationScaleFactor (7053,1009) as bulkdata, dcmjs
naturalization leaves them as { BulkDataURI } objects. These were fed verbatim
to calculate-suv, which treats the object as a valid value and silently
corrupts the SUV scaling factors.

Resolve these scalar private tags to numbers during ingestion - in both the
lazy (async) and non-lazy (sync) DICOMweb metadata paths, before INSTANCES_ADDED
fires - by decoding the bulkdata (VR-aware: DS/IS text or little-endian FL/FD).
Harden getPTImageIdInstanceMetadata to coerce values to finite numbers (reusing
@ohif/core utils.toNumber) and reject unresolved bulkdata objects so they can
never reach calculate-suv. Share the bulkdata-attach helper between both
metadata paths. Adds unit tests for the bulkdata decoder/resolver and for
getPTImageIdInstanceMetadata.

* refactor(bulkdata): move PET bulkdata resolution to a generic core tag registry

Generalize resolvePETPrivateScalarBulkData into a datasource-agnostic
utils.resolveBulkDataTags in @ohif/core, backed by a static tag registry
seeded with the Philips PET SUV/activity-concentration scalar tags and
extensible via registerResolvedBulkDataTags.

* fix(bulkdata): strip NUL padding and refresh qido auth before resolution

Address review feedback:
- decodeText now strips NUL (0x00) padding, which String.trim() leaves
  intact, so NUL-padded DS/IS values no longer decode as NaN. Adds a
  regression test.
- refresh qidoDicomWebClient.headers before resolveBulkDataTags in both
  series-metadata paths; retrieveBulkData is bound to qidoDicomWebClient,
  matching every other qido op in this file.

* fix(dicomweb): await deferred metadata storage

* fix(dicomweb): support single-part bulkdata responses
2026-07-11 12:16:32 -04:00
Alireza
3c0e245398
feat(next): native Generic Viewport migration (useNextViewports) (#6101)
* feat(core): add appConfig.useNextViewports flag (Generic Viewport M0 step 1)

Opt-in flag to drive viewports through the DIRECT native cornerstone3D
GenericViewport ("next") API surface (PLANAR_NEXT / VOLUME_3D_NEXT, setDisplaySets,
setDisplaySetPresentation, setViewState, view references) instead of the legacy
stack/volume methods.

Distinct from (and overrides) useGenericViewport, which only routes legacy
viewport types through cornerstone compatibility adapters. This flag does NOT set
cornerstone rendering.useGenericViewport; it is read by getCornerstoneViewportType
and the CornerstoneViewportService backend split (subsequent M0 steps). Defaults
false; the legacy path stays byte-identical. Opt-in only.

* feat(cornerstone): map viewport types to native *_NEXT under useNextViewports (M0 step 2)

getCornerstoneViewportType gains an optional useNextViewports param. When set,
stack/volume/orthographic collapse to PLANAR_NEXT (render path inferred from data
shape), and volume3d/video/wholeslide/ecg map to their VOLUME_3D_NEXT / VIDEO_NEXT /
WHOLE_SLIDE_NEXT / ECG_NEXT types. Defaults false → legacy mapping byte-identical;
no caller passes true yet (wired via appConfig in the service split, step 3).

Tests: +6 cases (21 total) covering the *_NEXT mapping, displaySet override, the
invalid-type throw, and that the legacy mapping is unchanged when the flag is off.

* feat(cornerstone): native-next stack mount behind useNextViewports (M0 step 3 foundation)

Wires the useNextViewports flag through and mounts stack viewports natively:
- nextViewports.ts: module accessor; init.tsx captures appConfig.useNextViewports.
- getCornerstoneViewportType: defaults the flag from the accessor, and now passes
  native (*_NEXT) types through idempotently (a viewport's stored cs type is
  re-fed into the mapper; legacy types were already idempotent, native were not).
- CornerstoneViewportService._setDisplaySets: route native generic viewports by
  data shape (StackData vs VolumeData), since PLANAR_NEXT is one type for both.
- _setStackViewport: native branch mounts via genericViewportDataSetMetadataProvider.add
  + setDisplaySets, applies VOI/colormap via setDisplaySetPresentation and
  displayArea/rotation/flip via setViewState (no legacy setStack/setProperties/setCamera).

Validated in a running OHIF (linked cornerstone 5.0.8): with the flag on, the
viewer creates a native PlanarViewport (window.cornerstone...getViewports()[0] ->
'PlanarViewport :: type=planarNext'); the prior 'Invalid viewport type: planarNext'
is resolved. Flag OFF is byte-identical (native branches gated by isGenericViewport).

KNOWN WIP (next): flag-ON full render is blocked by the presentation-read seam —
peripheral consumers (useViewportRendering, overlays, colorbar, resize) still call
legacy getProperties/getViewPresentation/getCamera on the viewport. Stack render
completes once those are routed through getDisplaySetPresentation/viewportProjection.

* feat(cornerstone): render native Generic (next) stack viewport behind useNextViewports

Makes the flag-on native PLANAR_NEXT stack render end-to-end in OHIF:

- CornerstoneCacheService: resolve the stack-vs-volume data-builder from the
  legacy mapping, since native types collapse that distinction into PLANAR_NEXT.
  Without this the stack fell through to the 'other' builder and imageIds were
  never populated (PlanarViewport threw 'No registered planar dataset metadata').
- ImageOverlayViewerTool: skip overlay rendering when the viewport has no
  resolvable view reference yet (native returns falsy until data is bound),
  instead of letting getTargetId() throw and kill the route during enable.
- Add getViewportPresentation helpers (getViewportProperties / getViewportCameraState)
  bridging legacy getProperties/getCamera and native getDisplaySetPresentation/
  getViewState; apply at the toolbar property evaluator, VOI-range init, and the
  position/LUT presentation snapshots so reads no longer throw on native viewports.

Validated in a running OHIF (flag on): PlanarViewport(planarNext), 295 slices,
image renders, zero console errors, scroll + setImageIdIndex navigate correctly.
Legacy (flag off) behavior is unchanged. Native volume/MPR is a later increment.

* feat(cornerstone): render native Generic (next) volume/MPR behind useNextViewports

Mounts volumes on a direct PLANAR_NEXT viewport for volume/MPR rendering:

- CornerstoneViewportService.setVolumesForViewport: native branch -> new
  _setNativeVolumeDisplaySets. Each base volume is registered with its
  already-cached volumeId and bound via setDisplaySets at the viewport's
  orientation (first = source, others = overlay); VOI/colormap/invert applied
  per-binding via setDisplaySetPresentation(dataId, props). Skips the legacy
  setVolumes/setProperties/setPresentations surface a PLANAR_NEXT viewport does
  not expose. Cornerstone reuses the OHIF-cached volume (getVolumeId returns the
  passed volumeId) and selects the image vs reformatted-volume render path from
  the requested orientation.
- useViewportRendering colormap resolver: read via getViewportProperties for
  native viewports (getDisplaySetPresentation) instead of getProperties/getActors,
  which threw 'Error getting viewport colormap' on native volume.

Validated in a running OHIF (flag on): axial/sagittal/coronal all render in
volume mode, scroll navigates the volume, round-trip volume<->stack switches
getCurrentMode cleanly, zero console errors. Legacy (flag off) unchanged.

* feat(cornerstone): allow setViewportOrientation on native volume viewports

The setViewportOrientation command guarded on isOrthographicViewportType, which
is false for a native PLANAR_NEXT viewport (it reports type=planarNext even when
rendering MPR). Add the content-mode capability guard (csUtils.viewportIsInVolumeMode)
so the MPR orientation toolbar works on native viewports; PlanarViewport.setOrientation
already exists. Legacy behavior is unchanged (viewportIsInVolumeMode is false for
legacy viewports, so the existing isOrthographicViewportType branch still gates them).

Also flips useNextViewports on in the dev default config for local testing of the
native path (NOT for merge; see TODO_BEFORE_MERGE).

* feat: Add temporary support for native GenericViewport ("next") migration

- Introduced a dev-only configuration flag to toggle the native viewport backend.
- Added a toolbar button to switch between legacy and native viewports for debugging.
- Implemented logic to handle image slice data and viewport type detection for native viewports.
- Enhanced viewport service to derive default VOI window/level from DICOM metadata.
- Added utility functions for managing localStorage overrides for viewport settings.
- Marked all temporary changes with comments for easy identification and removal before merging.

* Add plan viewer HTML page for migration master plan display

* feat(cornerstone): fork viewport presentation read/write into the backend (§4.3)

Extends the legacyBackend/nextBackend seam so presentation read/write is forked,
not inline-guarded in the service:

- IViewportBackend gains getPositionPresentation / setPositionPresentation /
  setLutPresentation. LegacyViewportBackend keeps the exact legacy logic
  (getViewPresentation / setProperties / setViewPresentation) byte-identical;
  NextViewportBackend uses the native surface (getViewReference + setViewReference;
  setDisplaySetPresentation for VOI/colormap/invert). WITH_ORIENTATION is inlined in
  the backends to avoid a backend->service value-import cycle.
- CornerstoneViewportService._getPositionPresentation / _setLutPresentation /
  _setPositionPresentation now delegate to this.backend. _getLutPresentation stays
  shared (already native-aware via the getViewportProperties bridge).

Effect: setPresentations is now native-safe — it previously threw on a PLANAR_NEXT
viewport (setProperties / setViewPresentation), so the native mount skipped it; the
native path now round-trips presentation cleanly. Native pan/zoom persistence
(viewPresentation is still undefined on native) is a later increment.

Validated both lanes: native PlanarViewport renders + storePresentation/getPresentations/
setPresentations round-trip with no crash; legacy StackViewport byte-identical
round-trip; zero console errors each.

* feat(cornerstone): persist native viewport pan/zoom/rotation/flip via the backend

A PLANAR_NEXT viewport has no getViewPresentation/setViewPresentation, so pan/zoom
previously did not survive navigation/resize/layout on the native path. NextViewportBackend
now snapshots the pan/zoom subset of the semantic view state and restores it:

- getPositionPresentation: snapshots the PlanarViewState pan/zoom fields (displayArea,
  anchorWorld, anchorCanvas, scale, scaleMode, rotation, flipHorizontal, flipVertical) via
  getViewState() (already deep-cloned + serializable), stored in viewPresentation. Slice and
  orientation are intentionally excluded (they ride on the view reference).
- setPositionPresentation: applies the view reference first (slice/orientation), then a partial
  setViewState patch with only the pan/zoom subset — the merge preserves slice/orientation, so
  the view reference is never clobbered. Stale displayArea is cleared when live anchor/scale
  pan/zoom is restored. anchorCanvas is canvas-fractional, so it survives resize without drift.
- _setStackViewport native branch now restores the persisted positionPresentation on mount
  (position-only; LUT already applied inline), so a returning stack recovers its camera.

Validated on native: zoom -> snapshot (scale captured) -> reset -> restore (scale back),
slice unchanged, zero errors. Legacy path unchanged. Volume/MPR mount pan/zoom restore is a
follow-up (its native mount helper does not yet thread presentations).

* feat(cornerstone): restore native volume/MPR pan/zoom on mount

Extends native pan/zoom persistence to the volume/MPR mount: the native branch of
setVolumesForViewport now restores the persisted positionPresentation (view reference
+ pan/zoom via the backend) after _setNativeVolumeDisplaySets, mirroring the stack
mount. Position-only (LUT applied per-binding above), native-safe.

Validated on a native sagittal MPR: zoom -> snapshot (scale 1.7) -> reset -> restore
(scale back to 1.7) with orientation (sagittal) and slice (256) preserved; zero errors.

* fix(next): bridge invert/flip/window-level commands for native viewports

Add setViewportProperties/setViewportCameraState write bridges alongside the
existing read bridges, and route invertViewport, flipViewportHorizontal,
flipViewportVertical and setViewportWindowLevel through them.

Direct PLANAR_NEXT viewports have no getCamera/setCamera/getProperties/
setProperties, so these four commands threw on native. The bridges dispatch on
isGenericViewport: native reads/writes via getViewState/setViewState (flip) and
getDisplaySetPresentation/setDisplaySetPresentation (invert/voiRange) on the
active binding; legacy falls through to the identical getCamera/setCamera/
getProperties/setProperties calls, so flag-off stays byte-identical.

Verified live on a native stack viewport: all four now apply (invert
undefined->true, flipH/flipV false->true, voiRange retargets) instead of
throwing.

* fix(next): apply setViewportColormap on native viewports

setViewportColormap was fully guarded by isStackViewportType/
isOrthographicViewportType, both of which report false for native PLANAR_NEXT
viewports, so the command was a silent no-op on native (returned ok but applied
nothing). Add a native branch that applies the colormap via the
setViewportProperties bridge (setDisplaySetPresentation) on the active binding,
honoring the immediate render flag, before the legacy guards.

Verified live: HSV colormap now applies and renders on a native stack viewport.

* fix(next): make ColorbarService native-safe

ColorbarService called getActors/getProperties/setProperties directly, all of
which throw on direct PLANAR_NEXT (next) viewports, so toggling a colorbar threw
on native. Replace the getActors content gate with a viewportHasContent helper
(getCurrentMode for native, getActors for legacy), and route the property
read/write through the getViewportProperties/setViewportProperties bridges.
Legacy keeps the identical getActors/getProperties/setProperties calls.

Verified live on native: addColorbar no longer throws, hasColorbar becomes true,
and the colormap applies via setDisplaySetPresentation.

* fix(next): guard per-volume histogram WL panel for native viewports

getWindowLevelsData drives the per-volume histogram WL panel via
getAllVolumeIds/getProperties, which direct PLANAR_NEXT (next) viewports do not
expose (they throw). Add an early guard that returns no rows when getAllVolumeIds
is absent, so the panel degrades to 'No window level data available' instead of
erroring on the interval/event refresh. Legacy stack viewports also lack
getAllVolumeIds and were never passed here, so this is a no-op for them; the
native stack/volume WL path is driven by setViewportWindowLevel.

* fix(next): make resetViewport/scaleViewport/rotate commands native-safe

These three command paths assumed legacy camera APIs that direct PLANAR_NEXT
(next) viewports do not expose:
- resetViewport called viewport.resetCamera() (absent on native -> threw). Native
  branch uses resetViewState() (resets pan/zoom/rotation/orientation/flip;
  navigation/slice preserved). resetProperties stays optional-chained.
- scaleViewport (scaleUpViewport/scaleDownViewport) was guarded by
  isStackViewportType, which is false for native, so the zoom buttons were a
  silent no-op. Native branch uses getZoom/setZoom; parallelScale and zoom are
  inversely related so it divides by scaleFactor to match legacy direction.
- _rotateViewport (rotateViewportCW/CCW/CWSet) used getViewPresentation/
  setViewPresentation (absent on native). Native branch reads rotation/flip via
  the getViewportCameraState bridge (getViewState) and writes the new rotation
  via setViewportCameraState (setViewState), preserving the flip-parity logic of
  the 'set' mode.

Verified live on native stack: reset no longer throws and resets zoom; zoom in
1->1.111; CW/CCW/Set rotation applies (90/180/90/90). Legacy unchanged.

* fix(next): guard jumpToMeasurement camera-centering for native viewports

jumpToMeasurement re-centers the camera when a measurement is off-screen via
isMeasurementWithinViewport (calls viewport.getCamera()) + getCamera/setCamera.
Native PLANAR_NEXT viewports have neither, so jumping to a measurement threw
'viewport.getCamera is not a function' at the gate before the centering block.

Short-circuit the centering on native (isGenericViewport) so it is skipped;
setViewReference above already navigated to the measurement's slice, so the
measurement is still reached - only in-plane re-centering is deferred.
TODO(next): port in-plane centering via the camera bridge + setViewState pan.

Verified live: native viewport has no getCamera (getCamera() throws) and
isGenericViewport is true, so the throwing branch is now skipped; setViewReference
remains available for slice navigation. Legacy unchanged.

* fix(next): make getViewportAlignmentData + updateViewport native-safe

Two CornerstoneViewportService methods called getCamera()/setCamera(), which
direct PLANAR_NEXT (next) viewports lack:
- getViewportAlignmentData looped every viewport reading getCamera().viewPlaneNormal
  (reached from findNavigationCompatibleViewportId on a cross-orientation
  jumpToMeasurement). Native reads viewPlaneNormal from getViewReference() instead
  (both backends populate it); legacy keeps getCamera (byte-identical).
- updateViewport (metadata-invalidation re-mount, keepCamera) read getCamera()
  unconditionally and had no native branch in its stack/volume if/else, so it threw
  and would not re-mount native data. Add a native branch that snapshots/restores the
  camera via the view-state bridges and routes the re-mount through _setDisplaySets
  (backend.dispatchMount, which dispatches by data shape). Legacy path unchanged.

Verified live on native: getViewportAlignmentData returns data (no throw);
updateViewport(keepCamera) re-mounts, restores zoom (1.4/1.5), keeps the image actor
and renders, with zero errors.

* refactor(next): move viewport interaction ops into a Legacy/Next operations backend

The commandsModule carried inline native-vs-legacy branches for every viewport
interaction/appearance command. Extract them into a dedicated operations backend
that mirrors the existing IViewportBackend twin pattern:
- IViewportOperations: the interface (flip/invert/rotate/reset/scaleBy/
  setWindowLevel/setColormap/getViewPlaneNormal/centerOnMeasurement + 3D VR ops)
- LegacyViewportOperations: legacy lane via direct legacy APIs (getCamera/
  setProperties/getViewPresentation/resetCamera/actors), lifted verbatim
- NextViewportOperations: native lane via the presentation/camera-state bridges +
  native semantic API (getViewState/resetViewState/getViewReference/setZoom); the
  3D VR ops warn-once and no-op behind a CS-14 gate (native VR not supported yet)
- viewportOperations: per-viewport dispatcher (isGenericViewport ? next : legacy)

commandsModule's 13 interaction commands become one-line delegations (the file
shrinks ~239 lines) and CornerstoneViewportService.getViewportAlignmentData uses
viewportOperations.getViewPlaneNormal.

Dispatch is per-viewport (not flag-selected like the lifecycle IViewportBackend)
because operations run on already-created, self-describing viewports and a session
can mix lanes; this preserves the previous inline isGenericViewport branching
exactly. Render() stays in the command (per-command render timing preserved).

Validated live both lanes: native (flag on) applies all ops (invert/flip/rotate/
zoom/WL/colormap; reset is camera-only) and legacy (flag off) is byte-identical
(parallelScale*0.9 zoom, getViewPresentation rotation, resetProperties+resetCamera),
both with a clean console. Adversarial review found no byte-identity/runtime defects.
Segmentation untouched.

* feat(next): render native 3D volume rendering + enable its VR operations

Make native VOLUME_3D_NEXT viewports actually render volume rendering and wire up
the 3D VR operations:

- CornerstoneViewportService._setNativeVolumeDisplaySets: a 3D viewport (cornerstone
  type VOLUME_3D_NEXT) now mounts with setDisplaySets({ options: { renderMode:
  'vtkVolume3d' } }) instead of the planar { orientation, role }, and applies the
  display-set's volume-rendering preset to the volume actor via csUtils.applyPreset
  (the bare native VolumeViewport3D has no setProperties). colormap (a planar LUT
  concept) is skipped for 3D. The dataId registration is unchanged (the volume3d data
  provider reads imageIds/volumeId and ignores the stored kind).

- NextViewportOperations: the four VR ops are no longer CS-14 no-ops. setPreset
  applies the preset to the volume actor via applyPreset; setVolumeRenderingQuality/
  shiftVolumeOpacityPoints/setVolumeLighting operate on the vtk volume actor through
  getActors (which native VolumeViewport3D exposes), so they reuse the legacy
  actor-based implementations. Pairs with the cornerstone fix that keeps the 3D
  viewport's canvas visible.

Verified live: native 3D VR renders (CT-Bone/CT-Cardiac presets) and all four VR
commands apply on a native VOLUME_3D_NEXT viewport; legacy VOLUME_3D unchanged.

* docs(next): refresh migration plan to HEAD (2026-06-19 audit)

* fix(next): target fusion colormap at the overlay binding

NextViewportOperations.setColormap dropped params.displaySetInstanceUID and
always wrote to the active source binding via getSourceDataId(), so a PT/CT
fusion colormap landed on the CT source instead of the PT overlay. Thread the
displaySetInstanceUID through to setViewportProperties so it targets the right
native binding (OHIF maps each display set 1:1 onto its bare dataId); falls back
to the source when no id is given (single-volume / plain stack colormap).

Also document DataIdRegistry.dataIdFor's 'overlay' suffix as reserved for the
same-UID source/overlay case (derived labelmap overlays, M4) rather than fusion,
whose distinct-UID overlays are already collision-free under the bare id.

* fix(next): make residual native-unsafe viewport sites safe

Sweeps the OHIF-side sites a native PLANAR_NEXT viewport reaches that still
called legacy-only APIs (getProperties/getCamera) or branched on the collapsed
viewportType:

- CornerstoneCacheService: persist the legacy stack/volume decision as
  viewportData.dataShapeType (createViewportData) and branch on it in
  invalidateViewportData instead of viewportType. Native collapses stack+volume
  onto PLANAR_NEXT, so a native stack previously fell through to the VOLUME
  rebuild and re-mounted as volume data on metadata invalidation. Falls back to
  viewportType for legacy/older data (byte-identical off-path).
- ViewportOrientationMarkers: gate the synthetic-IOP default-cosine check on
  dataShapeType, not viewportType==='stack' (dead on native -> guard was skipped).
- CornerstoneViewportDownloadForm: the capture viewport is the source's type, so a
  native source threw on getProperties/setStack/setProperties. Add a native capture
  path that re-mounts the source's already-registered dataId via setDisplaySets and
  copies presentation + view state through the bridges (legacy path byte-identical).
- tmtv ROI-threshold: read the slice focal point via a new getViewportFocalPoint
  bridge (native getViewReference().cameraFocalPoint vs legacy getCamera().focalPoint)
  instead of getCamera(), which is absent on native.

New bridge getViewportFocalPoint added to getViewportPresentation.ts and exported
from @ohif/extension-cornerstone. Validated live: native stack renders, console
clean, viewportData.dataShapeType='stack' while viewportType='planarNext',
orientation markers render.

* feat(next): mount native video/WSI/ECG viewports

Under useNextViewports, NextViewportBackend.dispatchMount routed ALL viewports by
data shape (volume vs stack), so VIDEO_NEXT/WHOLE_SLIDE_NEXT/ECG_NEXT constructed
as native classes but mis-ran _setStackViewport's stack-specific prefetch/VOI/
kind:'planar' logic and never reached their dedicated mounts; ECG additionally
called the absent setEcg.

- NextViewportBackend.dispatchMount: route the non-planar families by viewport
  type to _setEcgViewport / _setOtherViewport (mirrors the legacy backend's type
  dispatch); planar stack/volume still routes by data shape.
- _setEcgViewport: native branch registers {kind:'ecg', sourceDataId} and mounts
  via the generic setDisplaySets API (native ECG has no setEcg).
- _setOtherViewport: native branch registers {kind:'video', sourceDataId} or, for
  WSI, {kind:'wsi', imageIds, options:{webClient}} with the client resolved from
  WADO_WEB_CLIENT metadata exactly as the legacy WSI adapter does, then mounts via
  setDisplaySets + setViewReference.
- DataIdPayload widened to a family-specific union (planar/video/ecg/wsi).

All registration goes through the ref-counted DataIdRegistry (§4.7). OHIF-only —
the cornerstone native classes already support setDisplaySets (per the
genericVideo/genericEcg/genericWsi examples). Validated: native planar render
unaffected by the dispatch change (console clean); video/WSI/ECG mounts follow the
canonical cornerstone examples but are not yet live-validated (no such study on the
dev dicomweb).

* chore(next): guard the useNextViewports flag-read allowlist (M7 prep)

Adds .scripts/check-next-viewports-flag-reads.mjs (wired as `yarn
next:check-flag-reads`) enforcing migration plan §4.2: the flag may be read only
in the sanctioned seam — getCornerstoneViewportType (type selection),
CornerstoneViewportService (backend selection), nextViewports.ts (the accessor),
init.tsx (the one appConfig.useNextViewports read), and the TEMP dev toggle in
getToolbarModule.tsx. Any other isNextViewportsEnabled()/appConfig.useNextViewports
read under extensions/cornerstone/src fails the check, so the legacy off-path
cannot drift. (Comment-only mentions are ignored; tests are exempt.)

The earlier audit framed the '2 sanctioned reads' contract as already violated by a
'backend trio', but those files only MENTION the flag in doc comments — the actual
runtime read surface is the sanctioned set above, so the rule is enforceable as
written. TODO_BEFORE_MERGE.md updated: the guard is permanent (not a dev revert),
and removing the dev toggle must also drop its allowlist entry.

Does NOT perform the destructive M7 reverts (config default flip, toggle button):
those are premature while segmentation/M4 is unmigrated and would disable the
in-browser test loop.

* docs(next): mark CS-12 native calibration done; refresh CS-20/M6 status

* docs(next): re-verify migration status at HEAD; correct stale prose

Re-audited every milestone (M0-M7) and CS blocker against HEAD source via a
multi-agent audit + adversarial verification pass. Five commits landed after the
last full prose refresh (05e0df0ca) and only d5d03d888 touched the doc, so the
Implementation status section was materially stale. Corrections:

- M2: fusion colormap keying is FIXED (7b61e08ee), not 'unsound'
- M3: four 'native-unsafe throws' are FIXED (a19bd7826); the per-volume WL panel
  is guarded (d28202610), not throwing - feature port, not a crash
- M6: video/WSI/ECG ARE mounted natively (ca746e2f0)
- M7: flag-read allowlist IS built (b5784ca80), just not wired into CI
- CS-21: stated trigger is unreachable; narrowed to single-point SCOORD3D, and
  the open code is PlanarViewReferenceController.ts (not planarViewReference.ts)

Adds a consolidated, verified remaining-work punch-list and a corrections
subsection. The one real native crash that remains is the M4 segmentation OHIF
half (convertStackToVolumeViewport throws AND promotes to legacy ORTHOGRAPHIC).

* Refactor SegmentationService to support dual backends for segmentation handling

- Introduced ISegmentationBackend interface to define methods for segmentation backends.
- Implemented LegacySegmentationBackend for existing behavior with stack/volume promotion.
- Implemented NextSegmentationBackend for native GenericViewport handling without promotion.
- Updated SegmentationService to utilize the appropriate backend based on viewport type.
- Removed legacy viewport handling logic from SegmentationService and delegated to backends.
- Enhanced segmentation data assembly to support overlapping segments in the Next backend.
- Updated CornerstoneViewportService to ensure proper restoration of segmentation presentations.
- Added support for preserving additional query parameters in the application.

* temp

* Refactor CornerstoneViewportService to optimize setDisplaySets handling for 3D volumes

* d

* Refactor viewport handling and opacity management for native volume rendering

* fix(WindowLevel): re-sync fusion tab to foreground default after async resolve

The effect only adopted the foreground (PT) default when activeDisplaySetUID was
falsy, so if foregroundDisplaySets was empty at mount the tab seeded to the CT
fallback and stayed pinned to CT once PT resolved. Track explicit user selection
and re-sync to the foreground default until the user picks a tab.

* chore(next): remove WIP migration plan artifacts from the branch

Delete the planning docs and plan-viewer pages that were committed during
development (migration plans, blueprint, TODO_BEFORE_MERGE, plan-viewer.html).
They are not part of the shipped viewer and only attract review noise.

* fix(next): address review findings (guards + correctness)

Apply CodeRabbit review comments on the migration code:
- commandsModule: guard missing viewport before setWindowLevel/render
- WindowLevel: drop stale activeDisplaySetUID when the viewport's display sets change
- SegmentationService: hard guard when segmentation lookup fails before backend classify
- LegacyViewportOperations: guard getActors()[0] before actor-chain calls
- CornerstoneViewportService: guard empty native stack imageIds; don't route generic
  overlay-only mounts to legacy setVolumes; stop overwriting tracked display sets with
  base-volume-only ids (drops SEG/RT/fusion overlay UIDs)
- getCornerstoneViewportType: list orthographic/volume3d in the invalid-type error
- nextViewports: skip reload (warn) when the toggle can't be persisted
- tmtv: guard missing focal point before mutating ROI annotation coordinates
- check-next-viewports-flag-reads: also catch bracket/destructured flag reads

* fix(overlay): show instance number on next viewports

The viewport overlay's getInstanceNumber switched on viewportData.viewportType,
which for next viewports is the native PLANAR_NEXT type (the stack/volume shape
is persisted separately as dataShapeType). The switch matched no case, so the
instance number was null and the overlay showed only the slice index/count.

Switch on (dataShapeType ?? viewportType) so next stack/volume viewports take
the correct branch (legacy is unaffected). Also make _getInstanceNumberFromVolume
read the view-plane normal via getViewReference for native viewports, which
expose no getCamera, so routing next volume viewports through it cannot throw.

* fix(overlay): refresh window level on series change for next viewports

The overlay's WW/WL comes from useViewportRendering, whose init effect reads the
VOI via getViewportProperties. On series change the effect re-runs, but native
(next) viewports expose only explicit VOI overrides through
getDisplaySetPresentation; a freshly shown series has none, so properties.voiRange
was undefined, setVoiRange was skipped, and the overlay kept showing the previous
series' window level. Legacy getProperties always returns the applied VOI, so only
native viewports were affected.

Fall back to the viewport's computed default VOI (getDefaultVOIRange) for generic
viewports when no override is stored, matching the LivewireContourTool and
WindowLevelTool bridges. Legacy behavior is unchanged.

* fix(scrollbar): seed slice state on orientation change for next viewports

The progress scrollbar seeded its slice state (imageIndex/numberOfSlices) only
when viewportData changed. Native (next) viewports keep the same viewportData
across a stack->volume transition or an orientation change, and the slice-
navigation event does not fire until the first scroll, so the scrollbar was
missing on the initial slice (or stale with a wrong slice count) until the user
scrolled once.

Re-seed the slice state from the live viewport on CAMERA_MODIFIED (which native
viewports emit on orientation/geometry changes, as the sibling full-mode hook
already relies on). A guard skips redundant state updates so pure pan/zoom does
not churn React state.

* refactor(next): call resetDisplaySetPresentation on reset

Follow the cs3D rename of the native viewport's presentation-reset method from
resetProperties to resetDisplaySetPresentation (the next viewport API uses
get/set DisplaySetPresentation, not get/set Properties). Behavior unchanged.

* fix(segmentation): make border/outline thickness slider integer-only

The Border (outline width) slider for labelmap and RTSTRUCT used step=0.1,
allowing fractional outline widths. Outline thickness is a pixel width and
should be a whole number, so use step=1 and round the committed value. The
fill/opacity sliders keep their fractional step.

* fix(crosshairs): guard resetCrosshairs against unregistered Crosshairs tool

resetCrosshairs (run by Reset Viewport) called toolGroup.getToolInstance('Crosshairs')
for every tool group; getToolInstance logs 'Crosshairs is not registered with this
toolGroup' when the tool is absent, and a next viewport's default tool group does not
include Crosshairs, so Reset Viewport logged a spurious warning. Guard the lookup with
toolGroup.hasTool('Crosshairs') and skip tool groups that lack it; also guard against a
missing tool group for the viewport.

* fix(next-fusion): promote source to volume slice when a data overlay is added

A data overlay (fusion) on a next (PLANAR_NEXT) viewport rendered the source as a
vtkImage stack while the overlay was a vtkVolumeSlice, producing a broken/unstable
fusion (geometry mismatch, intermittent across slices/scroll). Two causes:

1. CornerstoneCacheService.createViewportData built stack data when the fusion's
   primary display set resolved to a stack shape, so the source never got a
   volumeId. Force a volume (orthographic) shape when there are 2+ reconstructable
   image display sets (a data fusion must be volume; legacy already did this, and
   non-reconstructable SEG/RT overlays are excluded).

2. dataIdRegistry.register used first-writer-wins, so re-registering the source
   (originally a vtkImage stack, no volumeId) with its fusion volumeId was dropped,
   leaving its dataset volumeId-less -> the render-path decision kept it vtkImage.
   Update the provider when a payload promotes a dataId to volume-backed.

Validated via agent-browser: adding a PT overlay onto a CT source now mounts both
as vtkVolumeSlice (mode=volume), the fusion is anatomically coherent and stable
across scroll, with no console errors.

* fix(next-rtss): keep referenced CT in stack mode on RTSTRUCT hydrate

RTSTRUCT (contour) hydration on a native PLANAR_NEXT viewport re-mounted the
referenced CT as a volume slice, which is the slow path the perf AC forbids.

Spike proved cs3d already renders contour segmentations on a stack/vtkImage
PLANAR_NEXT viewport (via the annotation + isReferenceViewable path), and that
stack-mode contour scroll is fast (~0.15ms/scroll, no metadata storm) once the
canvas-dimension layout-thrash fix is in place. The only remaining issue was the
hydrate re-mount promoting the CT to volume.

Pin the referenced viewport to 'stack' for RTSTRUCT hydration on next viewports:
hydrateSecondaryDisplaySet passes viewportType:'stack' (scoped to RTSTRUCT +
isNextViewportsEnabled), and loadSegmentationDisplaySetsForViewport applies it as
a per-mount viewportOptions override. SEG and legacy keep their current behavior.

Verified at runtime: after hydrate the viewport stays vtkImage/stack, contours
render across slices, scroll is 0.16ms with 0 metaData.get calls, CT VOI intact.

* fix(next-fusion): match legacy initial data-overlay opacity (~40%) on next viewports

The data-overlay add path passes a nominal colormap opacity of 0.9. Legacy
volume rendering attenuates that to ~40% effective via ray-cast opacity-unit-
distance correction, but native PLANAR_NEXT viewports composite the overlay as
a flat 2D image-slice blend with no such attenuation, so the same 0.9 rendered
at ~80-90%. Override the initial overlay opacity to 0.4 for next viewports
(mirrors the TMTV fusion NEXT_FUSION_PT_OPACITY), gated on isGenericViewport and
a numeric opacity so SEG/RTSTRUCT overlays and the legacy path are unaffected.

* fix(next-fusion): preserve fusion on orientation change in next viewports

The orientation corner menu branched on viewportType === ORTHOGRAPHIC to decide
in-place reorient vs viewport recreation. Native next viewports always report
planarNext, so a fusion already in volume mode wrongly took the recreation path,
which passes empty displaySetOptions and drops the PET overlay colormap/opacity
(rendering PET only). Branch instead on whether the live viewport is already in
volume mode (isOrthographicViewportType || utilities.viewportIsInVolumeMode):
volume-mode viewports reorient in place (setViewportOrientation, which preserves
all bindings and their presentation); only a genuine stack->volume conversion
recreates. Legacy ortho/stack behavior is unchanged.

* fix(next-seg): preserve base image window level through SEG hydration

Hydrating a SEG re-mounted the referenced image and restored a stale, computed
VOI from the LUT presentation store, brightening the base image (e.g. an MR went
from its DICOM WC/WW default to a volume min/max default). During the SEG-load
intermediate mount the base image briefly carries a computed default VOI; the
native read bridge returned it with no isComputedVOI marker, so cleanProperties
never stripped it and it was persisted then replayed over the correct default.
Legacy StackViewport tags computed VOIs (isComputedVOI) so they are stripped.
Mirror that: in getViewportProperties, stamp isComputedVOI on a native binding's
VOI when it matches the binding's getDefaultVOIRange, so the LUT capture strips
it. Harmless when a genuine user VOI equals the default (stripping falls back to
the same value).

* fix(next-mpr): re-seed slice scrollbar after post-mount camera carry

On a stack->volume/MPR transition the slice carry (e.g. layout-selector MPR HP
restoring the prior slice) moves the camera and fires its slice events
synchronously during the mount, before the scrollbar effect attaches its
listeners and around its initial seed -- so the scrollbar latched the mount-time
index. Re-seed once on the next frame after mount+carry settle; the pushSliceData
guard makes it a no-op when nothing changed (no churn).

* chore(deps): bump @cornerstonejs/* to 5.1.2

* chore: empty commit

* fix(next): use published genericViewportDisplaySetMetadataProvider export

The next viewport backend imported genericViewportDataSetMetadataProvider
from @cornerstonejs/core, a symbol that only existed in the local custom
cornerstone worktree (linked via symlink). Published cornerstone exports it
as genericViewportDisplaySetMetadataProvider (same add/remove/get/clear API).

CI always builds against published packages, so the production rspack build
failed (ESModulesLinkingError -> Netlify red) and the cypress dev-server
build showed a full-screen error overlay that blocked all clicks (PR_CHECKS
red). Renaming to the published symbol fixes both.

* test(e2e): extend Scoord3dProbe jump screenshot retry window

The jump-to-measurement screenshot was the only failing assertion (pre/post
hydration pass). It uses waitVolumeLoad:false, so on slower CI the dynamic
tfl_dyn_fast_tra series can still be progressively loading when the shot is
taken, giving a partial probe value (52.0 vs baseline 78.0) and shifted VOI.
Raise checkForScreenshot attempts 10->20 and delay 1250->2000ms (~11s -> ~40s)
to let the volume settle before failing.

* fix(next): address review findings (scaleBy 3D crash, fusion W/L target, off-path gate, seg export perf)

- NextViewportOperations.scaleBy: guard getZoom/setZoom so the zoom hotkey
  no-ops on a native VolumeViewport3D instead of throwing (matches legacy).
- Native setWindowLevel: forward displaySetInstanceUID so PT/CT fusion W/L
  targets the intended binding (mirrors setColormap) instead of always source.
- CornerstoneCacheService: scope the reconstructable-fusion STACK->ORTHOGRAPHIC
  promotion to PLANAR_NEXT so the legacy (flag-off) path stays byte-identical.
- dicom-seg buildLabelmap3D: precompute a referencedImageId->index Map to drop
  the multi-layer export from O(slices^2) to O(slices).

* fix(next): address CodeRabbit review (dispatch discriminator, seg return contract, error msg)

- NextViewportBackend.dispatchMount: route stack/volume on the persisted
  dataShapeType contract instead of the lazily-populated 'volume' field probe.
- SegmentationService.attemptStackToVolumeConversion: return false explicitly on
  the frame-of-reference-mismatch path to honor the Promise<boolean> contract.
- getCornerstoneViewportType: list orthographic/volume3d in the legacy-path
  invalid-type error (both are valid and handled above the throw).

* test(next): update getCornerstoneViewportType invalid-type assertion

Match the legacy-path error message now listing orthographic/volume3d (b16129ece).

* chore(next): replace dev toggle + flag-read guard with URL opt-in

- Remove the .scripts/check-next-viewports-flag-reads.mjs CI guard and its
  package.json script entry.
- Drop the TEMP ToggleNextViewport toolbar button across all modes plus the
  toggleNextViewports command and getToolbarModule evaluator (revert mode files
  to their master state; keep the real native-path toolbar fixes).
- Remove the localStorage override / toggleNextViewportsAndReload from
  nextViewports.ts; resolveNextViewportsEnabled now honors a ?useNextViewports
  URL query param (true/1/empty enable) over appConfig.
- Stop forcing useNextViewports:true in default.js (opt-in via URL/appConfig).
- Preserve useNextViewports across navigation (preserveQueryParameters).

* fix(tmtv): keep legacy fusion PT opacity ramp; flatten only on next path

Flattening the fusion PT opacity to a scalar 0.9 in hpViewports changed the
legacy TMTV fusion rendering (the ramp keeps low PT values transparent so the CT
shows through). Restore the legacy ramp in hpViewports and instead replace it
with the flat native scalar (0.4) inside getHangingProtocolModule only when
useNextViewports is on, so legacy is unchanged and native still gets a flat blend.

* redo

* fix(next): avoid top-level dicomWebUtils destructure crash on boot

getSopClassHandlerModule destructured transferDenaturalizedDataset /
fixMultiValueKeys from dicomWebUtils at module-eval time. This module and
@ohif/extension-default form a circular import, so dicomWebUtils can be
undefined at eval time depending on bundler module order; the top-level
destructure then throws (Cannot destructure property ... of dicomWebUtils
as it is undefined) and crashes app boot before the Layout renders, which
surfaced as the Playwright globalSetup warmup timing out on [data-cy=Layout].

Access the utils lazily at call time inside getDICOMwebMetadata instead.

* fix(next): guard remount no-op path and defer legacy camera snapshot

- CornerstoneViewportService.updateViewport: backend.remount() is typed
  Promise<void> | undefined and returns undefined for viewport families with
  no re-mount path; guard before .then() so those families no longer throw.
- LegacyViewportBackend.remount: take the camera snapshot inside the volume
  branch (the only consumer) instead of before the family checks, so families
  without a camera surface no-op safely and the stack path skips a dead call.

Addresses CodeRabbit review findings on PR #6101.

* feat(next): add ?cpu=true URL opt-in to force the CPU render path

Mirrors the ?useNextViewports opt-in: a cpu URL query param overrides
appConfig.useCPURendering per-session (?cpu, =true, or =1 enable it).
Wired through cornerstone.setUseCPURendering in init, whose global flag is
consulted by the GenericViewport PlanarRenderPathDecisionService for both
the image (CPU_IMAGE) and volume (CPU_VOLUME) paths, so under
useNextViewports a single ?cpu=true forces the next viewport onto CPU.

Extracted the shared query-param parsing into resolveBooleanUrlOptIn.

* feat(cornerstone): select render backends via viewportRendering param

Replaces the boolean cpu URL param with viewportRendering=cpu|webgl|auto
(or any backend registered via cornerstone registerRenderBackend, e.g. a
webgpu backend), mapped to the cornerstone render-backend registry. A
per-viewport-type override (e.g. orthographic.viewportRendering=cpu) is
passed as the per-mount renderBackend option on native planar mounts.
The global value also drives the legacy useCPURendering flag so legacy
viewports follow the same selection, letting a session force GPU when
the deployed config defaults to CPU.

* fix(dicom-seg): store overlapping segmentations as binary SEG

A LABELMAP SEG frame stores a single label per voxel, so the labelmap
encoder cannot represent overlapping segments (the later layer wins).
When the export produces multiple overlapping layers and the resolved
store mode is labelmap, switch that store to the binary SEG encoding,
which writes overlapping segments as separate frames referencing the
same source slice.

* fix(cornerstone): apply review fixes to viewport backends

- Anchor the cached-volume lookup in NextViewportAdapter to the
  loaderSchema:displaySetInstanceUID id shape instead of a substring
  match, so a derived volume id embedding the same UID cannot resolve.
- Keep the viewing orientation on fitViewportToWindow (scaleBy 0) for
  native planar viewports, matching legacy resetCamera semantics.
- Release legacy WSI metadata-provider registrations through the same
  ref-counted DataIdRegistry the native backend uses, so entries are
  removed on viewport disable and service destroy.

* test(cornerstone): use real volumeId shape in adapter contract test

The anchored cached-volume lookup rejects ids that merely embed the
display set UID, so the mock now uses the real
volumeLoaderSchema:displaySetInstanceUID shape and asserts an embedded
UID id does not match.

* docs(config): document useNextViewports and viewportRendering in default config

Gives deployments an obvious place to opt into the native Generic
Viewport path and configure its render backend. Both stay off/auto by
default; the explicit false preserves the opt-in contract.

* refactor(config): group next viewport settings under genericViewports

Replaces the two top-level app config keys (useNextViewports,
viewportRendering) with one genericViewports object:
{ enabled, viewportRendering }. The URL params are unchanged.

* chore(deps): bump cornerstone packages to 5.4.15

Picks up the planar initial-slice remap fix (cornerstonejs/cornerstone3D#2799):
opening a SEG display set now lands on the first segmented slice instead of
its mirror when the cached volume reverses the imageId ordering.

* test(segmentation): verify overlapping SEG rendering

* fix(sr): make SCOORD3D hydration deterministic

---------

Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
2026-07-11 12:13:54 -04:00
ohif-bot
00bb77d940 chore(version): Update package versions to 3.13.0-beta.122 [skip ci] 2026-07-11 06:24:30 +00:00
Ghadeer Albattarni
01939e2236
test: add E2E test for contour combine intersect and subtract operations (#6131) 2026-07-11 02:21:39 -04:00
ohif-bot
f0e2f64397 chore(version): Update package versions to 3.13.0-beta.121 [skip ci] 2026-07-10 22:54:59 +00:00
Alireza
43226d9191
fix(app): appearance modal provider scope, worklist preview persistence, and tag browser label overflow (#6136)
- Insert ServiceProvidersManager providers ahead of the dialog/modal
  providers in App.tsx: modal content renders as a sibling of the
  provider's children, so contexts registered via the manager (e.g.
  ActiveThemeProvider) were out of scope and the appearance modal
  crashed with 'useActiveTheme must be used within an ActiveThemeProvider'.
- Persist the worklist preview panel open/closed state in
  sessionStorage so it survives navigating into a study and back.
- Keep the DICOM tag browser instance number label on one line:
  the words truncate, the (n of total) digits never clip.
2026-07-10 18:52:06 -04:00
ohif-bot
dc9df56be4 chore(version): Update package versions to 3.13.0-beta.120 [skip ci] 2026-07-10 21:08:34 +00:00
Alireza
9c76afa075
feat(segmentation): replace One Click Segment with ClickSegmentTool (#6135)
* feat(segmentation): replace One Click Segment with ClickSegmentTool

Wire OHIF to ClickSegmentTool from Cornerstone3D (#2780), rename the
toolbox button to Click to Segment, and enable it only on PET (PT)
viewports.

* chore(deps): bump @cornerstonejs packages to 5.4.13

Pick up ClickSegmentTool from the published Cornerstone3D release so
OHIF installs the tool from npm without a local CS3D link.
2026-07-10 17:05:26 -04:00
152 changed files with 7454 additions and 1306 deletions

View File

@ -17,7 +17,7 @@ This package follows the agentskills.io SKILL.md convention. `SKILL.md` is the e
2. **Read the seed spec.** Consult [references/patterns-by-feature.md](references/patterns-by-feature.md) to find the canonical existing spec for that area. Read it end-to-end before writing. This is the single most important step — OHIF specs follow consistent idioms that are easier to mimic than to reconstruct from first principles. (This mirrors Playwright's own agent guidance: use seed tests as the example for generated tests.)
3. **Scaffold from the template.** Start from [assets/spec-template.ts](assets/spec-template.ts) — or copy the seed spec and adapt.
4. **Look up specifics in the source, not from memory.** The reference files [page-objects.md](references/page-objects.md) and [utilities.md](references/utilities.md) capture the **stable rules** — fixture keys, import conventions, access idioms, the reasons certain things trip people up. They deliberately do not enumerate methods. For the current method surface or a utility's exact signature, open the relevant file under `tests/pages/` or `tests/utils/` — the source evolves, and the source is always right. The seed spec you picked in step 2 is usually the fastest second source, because it co-evolves with the API.
5. **Run the test when execution is available.** `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.

View File

@ -6,9 +6,9 @@ Before debugging, classify. Most OHIF test failures are timing or hydration —
|----------|---------|-----|
| Timing | Element not visible, action timeout | Add / increase the `delay` param of `visitStudy`; for actions that re-render viewports, use `waitForViewportRenderCycle(page)` (started before the action) instead of `waitForTimeout`; wrap the assertion in `expect.toPass({ timeout })` |
| Selector | Element not found | Verify `data-cy` on the target; confirm the panel is open (`toggle()` / `select()` before interacting); check for capital `D` in `DOMOverlayPageObject` when destructuring |
| Hydration | Segmentation/RT 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.

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-pmap",
"version": "3.13.0-beta.119",
"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.12",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/adapters": "5.4.17",
"@cornerstonejs/core": "5.4.17",
"@kitware/vtk.js": "35.5.3"
},
"devDependencies": {

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-seg",
"version": "3.13.0-beta.119",
"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.12",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/adapters": "5.4.17",
"@cornerstonejs/core": "5.4.17",
"@kitware/vtk.js": "35.5.3"
},
"devDependencies": {

View File

@ -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,

View File

@ -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">

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dicom-sr",
"version": "3.13.0-beta.119",
"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.12",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"@cornerstonejs/adapters": "5.4.17",
"@cornerstonejs/core": "5.4.17",
"@cornerstonejs/tools": "5.4.17",
"classnames": "2.5.1"
},
"devDependencies": {

View File

@ -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 = {

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone-dynamic-volume",
"version": "3.13.0-beta.119",
"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.12",
"@cornerstonejs/tools": "5.4.12",
"@cornerstonejs/core": "5.4.17",
"@cornerstonejs/tools": "5.4.17",
"classnames": "2.5.1"
},
"devDependencies": {

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-cornerstone",
"version": "3.13.0-beta.119",
"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.12",
"@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.12",
"@cornerstonejs/ai": "5.4.12",
"@cornerstonejs/core": "5.4.12",
"@cornerstonejs/labelmap-interpolation": "5.4.12",
"@cornerstonejs/metadata": "5.4.12",
"@cornerstonejs/polymorphic-segmentation": "5.4.12",
"@cornerstonejs/tools": "5.4.12",
"@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",

View File

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { vec3 } from 'gl-matrix';
import PropTypes from 'prop-types';
import { metaData, Enums, 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);

View File

@ -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);

View File

@ -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];

View File

@ -1,6 +1,6 @@
import { Enums } from '@cornerstonejs/core';
import { ViewportData } from './types';
import { isVolume3DViewportType } from '../../../utils/getLegacyViewportType';
import { getViewportAdapter } from '../../../services/ViewportService/adapter';
export function getImageIndexFromEvent(event): number | undefined {
const { imageIndex, newImageIdIndex = imageIndex, imageIdIndex } = event.detail;
@ -24,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;

View File

@ -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]);
}

View File

@ -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();

View File

@ -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;
});

View File

@ -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,

View File

@ -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
? {

View File

@ -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);
}}
>

View File

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

View File

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

View File

@ -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)))
);
}

View File

@ -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];

View File

@ -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 (

View File

@ -8,7 +8,7 @@ import {
} from '@cornerstonejs/core';
import { Enums as cs3DToolsEnums } from '@cornerstonejs/tools';
import { utilities as csMetadataUtilities } from '@cornerstonejs/metadata';
import { Types } 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,

View File

@ -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) {

View File

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

View File

@ -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) {

View File

@ -1,7 +1,7 @@
import { PubSubService, Types as OhifTypes } from '@ohif/core';
import { RENDERING_ENGINE_ID } from '../ViewportService/constants';
import { getRenderingEngine } from '@cornerstonejs/core';
import { 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();

View File

@ -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;
}

View File

@ -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;

View File

@ -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) {

View File

@ -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;
}

View File

@ -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>;
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

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

View File

@ -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(

View File

@ -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(

View File

@ -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 });
});
});

View File

@ -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>;
}

View File

@ -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);
}
}
}

View File

@ -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());
}
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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);
}
},
};

View File

@ -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();
}
}

View File

@ -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);
},
};

View File

@ -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.

View File

@ -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();
}
}

View File

@ -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),
};

View File

@ -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) => {

View File

@ -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[];
};

View File

@ -4,7 +4,7 @@ import html2canvas from 'html2canvas';
import { getEnabledElement } from '@cornerstonejs/core';
import { ToolGroupManager, segmentation, Enums } from '@cornerstonejs/tools';
import { getEnabledElement as OHIFgetEnabledElement } from '../state';
import { 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();

View File

@ -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
);
});
});
});

View File

@ -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`
);
}

View File

@ -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;
}

View File

@ -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;
}

View 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;
}

View 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);
}

View 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()
);
}

View File

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

View File

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

View File

@ -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 };

View File

@ -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);
});
});

View File

@ -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;
});
}

View File

@ -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: '/',

View File

@ -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;

View File

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

View 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
);
});
});

View File

@ -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;
}

View File

@ -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;

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-measurement-tracking",
"version": "3.13.0-beta.119",
"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.12",
"@cornerstonejs/tools": "5.4.12",
"@cornerstonejs/core": "5.4.17",
"@cornerstonejs/tools": "5.4.17",
"@ohif/core": "workspace:*",
"@ohif/extension-cornerstone-dicom-sr": "workspace:*",
"@ohif/extension-default": "workspace:*",

View File

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

View File

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

View File

@ -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

View File

@ -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,

View File

@ -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 },

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/extension-ultrasound-pleura-bline",
"version": "3.13.0-beta.119",
"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.12",
"@cornerstonejs/tools": "5.4.12",
"@cornerstonejs/core": "5.4.17",
"@cornerstonejs/tools": "5.4.17",
"@ohif/core": "workspace:*",
"@ohif/extension-cornerstone": "workspace:*",
"@ohif/extension-default": "workspace:*",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/mode-ultrasound-pleura-bline",
"version": "3.13.0-beta.119",
"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.12",
"@cornerstonejs/tools": "5.4.12",
"@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:*",

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@ohif/app",
"version": "3.13.0-beta.119",
"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.12",
"@cornerstonejs/dicom-image-loader": "5.4.17",
"@emotion/serialize": "1.3.3",
"@ohif/core": "workspace:*",
"@ohif/i18n": "workspace:*",

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