Skip to content

fix: more generic viewport push for parity with legacy - #2766

Merged
sedghi merged 78 commits into
mainfrom
cornerstoneohifnextapi
Jun 30, 2026
Merged

fix: more generic viewport push for parity with legacy#2766
sedghi merged 78 commits into
mainfrom
cornerstoneohifnextapi

Conversation

@sedghi

@sedghi sedghi commented Jun 20, 2026

Copy link
Copy Markdown
Member

OHIF_REF: ohifohifnextapi

Linked downstream test: the OHIF Downstream Validation workflow runs the OHIF branch ohifohifnextapi (OHIF/Viewers#6101) against this cornerstone PR.

Context

This PR teaches the legacy tools package and parts of core to operate against the new native GenericViewport ("next") rendering lane, so existing tools and synchronizers work across both the legacy and native viewport families.

Changes & Results

Core

  • New viewport-capability predicates (isGenericViewport, viewportIsInVolumeMode, getCurrentMode) plus a viewportCapabilities utility, replacing instanceof checks.
  • voiLUTFunction plumbed through the planar render paths and presentation props.
  • Per-mount forceCPU option for planar render-path selection.
  • Native spacing calibration (CS-12) and a CPU image pre-scaling flag.
  • Slice-count and presentation-modified events for native viewports; planar orientation predicate and segmentation-preserving setDisplaySets.
  • RenderModesPanel / StatsOverlay now include viewportType for debugging context.
  • getImageActor added to PlanarViewport; keep the on-screen canvas visible for VolumeViewport3D.

Core — VOI / slice-index parity (recent)

  • Emit VOI_MODIFIED when a display set is mounted (its default VOI), mirroring legacy stack/volume viewports so OHIF's window-level readout, colorbar, and VOI synchronizers update on a display-set change.
  • Prescaled PT (SUV) images default to a 0–5 VOI on the native image path, matching legacy StackViewport — previously the raw DICOM window center/width was used, rendering PET far too wide.
  • Slice-index is derived from the camera (single source of truth, like legacy VolumeViewport.getSliceIndex); the scrollbar index is reconciled with the rendered slice on volume mount; and the acquisition orientation centers the volume on MPR mount instead of pinning to the first slice.

Tools

  • Lane-agnostic camera helpers (getViewportICamera / setViewportCamera / resetViewportCamera) and isGenericViewport-gated branches so the following support native (next) viewports: Crosshairs, Magnify, AdvancedMagnify, ReferenceLines, ReferenceCursors, cine playClip, 3D VR tools (Trackball/VolumeRotate/VolumeCropping), overlay/region 2D tools, and contour segmentation rendering (CS-8).
  • VOI and image-slice synchronizers, camera-position sync, and stack-prefetch updated to the capability-based checks.

Examples

  • New genericViewportShowcase example demonstrating the native viewport with interactive crosshairs.

Testing

  • viewportContentMode jest coverage added.
  • Open the genericViewportShowcase example and exercise the supported tools (crosshairs jump, magnify, reference lines, cine) on native viewports.

Checklist

PR

  • My Pull Request title is descriptive, accurate and follows the
    semantic-release format and guidelines.

Code

  • My code has been well-documented (function documentation, inline comments,
    etc.)

Public Documentation Updates

  • The documentation page has been updated as necessary for any public API
    additions or removals.

Tested Environment

  • "OS:
  • "Node version:
  • "Browser:

Summary by CodeRabbit

  • New Features
    • Added VOI LUT configuration for planar rendering, including sampled sigmoid.
    • Added planar dataset option to force CPU rendering (forceCPU).
    • Expanded Generic (“next”) viewport support across rendering, camera/view-state sync, VOI updates, cine playback, and core tools.
  • Improvements
    • Improved planar CPU rendering: pre-scaled image handling, user calibration overrides, VOI/LUT propagation, and more accurate new-image signaling.
    • Updated planar colormap opacity to better respect absolute threshold; refined render-mode decisions and stats UI (viewport type).
  • Bug Fixes
    • Improved generic viewport resize sizing and avoided redundant legacy camera reset behavior.
    • Fixed PT/RTDOSE scaling identity short-circuits and improved tool geometry using shared camera helpers.

sedghi added 22 commits June 16, 2026 17:35
…lice-count

Generic Viewport ("next") migration - Waves 0 and 1 of the OHIF native-next
enablement (see GENERIC_VIEWPORT_MIGRATION_PLAN).

Wave 0 - foundation:
- CS-5: GenericViewport.getCurrentMode() + PlanarViewport content-true
  stack/volume/empty classification from the active source binding; add
  protected getSourceBinding().
- CS-17: isGenericViewport() narrowing guard plus getViewportContentMode,
  viewportIsInVolumeMode and viewportIsInStackMode capability utilities,
  exported from utilities.
- CS-16: new IGenericViewport and ViewportContentMode types. The
  IEnabledElement.viewport union widening is intentionally deferred to the
  tools-camera wave (CS-8/CS-10) to avoid cascading narrowing changes across
  the tools annotation/render path; documented inline.

Wave 1 - planar stack:
- CS-1: setDisplaySetPresentation now emits VOI_MODIFIED / COLORMAP_MODIFIED via
  a mergeDataPresentation -> notifyDataPresentationModified hook on the native
  path only, so the legacy compatibility adapter (which applies colormap through
  setDataPresentationState directly) does not double-fire.
- CS-6: volume-backed slice changes now also emit VOLUME_NEW_IMAGE so volume
  scroll indicators, MPR slice sync and segmentation slice tracking react.
- CS-7: native PlanarViewport.getNumberOfSlices() so csUtils.jumpToSlice and
  image-slice synchronizers work on PLANAR_NEXT (which is not a StackViewport).

CS-4 (data-inferred stack/volume render path) already holds via
PlanarRenderPathDecisionService/DefaultPlanarDataProvider, no change needed.

Verified: core tsc clean, oxlint clean, 106 jest unit tests pass (97 existing
planar/compat + 9 new viewportContentMode), genericViewport Playwright suite
124 passed / 2 skipped / 0 failed on chromium.
…tDisplaySets

Generic Viewport ("next") migration - Wave 2 (planar volume / MPR),
cornerstone-core part.

- CS-15: add PlanarRenderPathDecisionService.canRender(), a non-throwing
  companion to select(), and PlanarViewport.canRenderOrientation(orientation,
  dataId?) so callers (e.g. OHIF MPR/orientation controls) can pre-check whether
  a reformatted orientation is renderable for the bound data instead of relying
  on select() throwing on an unsupported request.
- CS-3: PlanarViewport.setDisplaySets() now preserves externally-managed
  segmentation overlays when the source is re-set to the same display set
  (removeReplaceableData replaces the blanket removeAllData). A hydrated labelmap
  overlay, mounted out-of-band by the segmentation display tool, no longer blanks
  when the source is re-applied (e.g. when an app re-runs its viewport-data
  sync). A source change still clears everything so a stale segmentation cannot
  leak across datasets.

Verified: core tsc clean, oxlint clean, 89 planar/compat jest tests pass,
genericViewport Playwright suite 124 passed / 2 skipped / 0 failed on chromium.
…hronizers

Generic Viewport ("next") migration - Wave 2 (CS-11).

- voiSyncCallback: apply VOI / invert / colormap to direct Generic ("next")
  viewports via setDisplaySetPresentation (guarded by utilities.isGenericViewport)
  instead of throwing "Viewport type not supported". Reacts to the VOI_MODIFIED /
  COLORMAP_MODIFIED events now emitted by the native presentation path.
- imageSliceSyncCallback: detect volume-backed slice content on a native
  PlanarViewport via utilities.viewportIsInVolumeMode (it is not a VolumeViewport
  instance), so the volume slice-index reversal is applied correctly.

Verified: core tsc clean, tools tsc clean, oxlint clean, genericViewport
Playwright suite 124 passed / 2 skipped / 0 failed on chromium.
Generic Viewport ("next") migration - Wave 3 (fusion), CS-19.

- Add PlanarSetDataOptions.forceCPU so an individual display set / overlay can be
  pinned to the CPU render path regardless of global rendering configuration or
  byte-size thresholds (forwarded as webGLAvailable:false to the render-path
  decision). Left undefined by default, so global config + thresholds continue to
  decide.

CS-4 (multi-volume fusion mount via setDisplaySets source/overlay roles) and
CS-22 (per-overlay colormap with a multi-point opacity transfer function via
setDisplaySetPresentation, ColormapPublic.opacity: OpacityMapping[] | number) are
already implemented in the native architecture and exercised by the
genericMultiVolumeAPI example + Playwright snapshot, so no change was needed there.

Verified: core tsc clean, oxlint clean, genericViewport Playwright suite 124
passed / 2 skipped / 0 failed on chromium.
…CS-8)

Generic Viewport ("next") migration - contour sub-track of segmentation (Wave 4),
part of CS-8 (tools must not assume a legacy ICamera).

Contour rendering and contour-segmentation hydration read
viewport.getCamera().viewPlaneNormal, which is absent on a direct PLANAR_NEXT
viewport (TypeError), so RTSTRUCT / contour overlays could not render natively.

Route these reads through the existing getViewportICamera bridge, which derives
the camera from getResolvedView().toICamera() and falls back to the legacy
getCamera() on classic viewports. This is backward-compatible (identical camera
on legacy viewports) and enables contour rendering on native generic viewports,
matching how the annotation base tools already resolve their camera.

Native viewports intentionally do not expose getCamera/setCamera (enforced by
the "does not expose view-presentation methods on Planar Next" test), so the
bridge - not a promoted native getCamera - is the correct path.

Verified: tools tsc clean, oxlint clean (only pre-existing warnings),
genericViewport (124) + contourRendering + contourRenderingTiled Playwright
specs: 126 passed / 2 skipped / 0 failed on chromium.
ReferenceLinesTool reads the source/target viewport camera (viewUp,
viewPlaneNormal, focalPoint) to draw the lines. On a direct PLANAR_NEXT viewport
getCamera() is absent (TypeError), so reference lines could not render natively.

Route the three reads through the getViewportICamera bridge (resolved-view
derived camera with a legacy getCamera() fallback). ReferenceLinesTool is
read-only, so this fully enables it on native generic viewports while remaining
byte-identical on legacy viewports.

Verified: tools tsc clean, oxlint clean (only pre-existing warnings),
genericViewport (124) + MPRReformat Playwright specs: 126 passed / 2 skipped /
0 failed on chromium.
cameraSyncCallback wrote tViewport.setCamera(camera), which throws on a direct
PLANAR_NEXT viewport. When the target is a Generic ("next") viewport, copy the
source viewport's spatial view reference (setViewReference) instead - the native
analog for cross-viewport camera-position synchronization. Legacy viewports keep
the setCamera path unchanged (guarded by utilities.isGenericViewport).

Verified: tools tsc clean, oxlint clean, genericViewport 124 passed / 2 skipped /
0 failed on chromium.
The legacy Viewport exposes getImageActor(volumeId), which VOI/colorbar tooling
(e.g. tools/utilities/voi/colorbar/ViewportColorbar) depends on to read an
actor's image/VOI range. PlanarViewport (PLANAR_NEXT) extends GenericViewport,
which lacks it, so constructing a ViewportColorbar against a native viewport
threw 'viewport.getImageActor is not a function'.

Add a getImageActor mirroring the legacy implementation: resolve the actor entry
from getActors() (by volumeId or the first entry) and return it when it is an
image actor, otherwise null. CPU render paths with no VTK actor return null, and
existing callers already handle null by falling back to a default range.
playClip's _createCinePlayContext routed by instanceof StackViewport/
VolumeViewport/VideoViewport and threw 'Unknown viewport type' for anything else.
Direct PLANAR_NEXT (next) viewports match none of those, so starting cine on a
native viewport threw.

Add a capability branch (utilities.isGenericViewport) before the throw that builds
a generic cine play context: numScrollSteps/currentStepIndex from the semantic
getNumberOfSlices/getSliceIndex, frameTimeVector enabled only in stack mode
(acquired multi-frame; volume reformat cine uses a fixed rate), and scroll via
csUtils.scroll with the same waitForRendered gate as the stack context (guarded so
a missing viewportStatus does not stall). The legacy instanceof branches are
unchanged, and stopClip already no-ops safely for native (it only clears the
interval), so the stop path needed no change.

Verified live in OHIF on a 295-slice native stack: playClip no longer throws,
cine auto-advances (sliceIndex 0->24 at 10fps) via both the raw utility and the
OHIF cineService, and stopClip halts it; legacy (flag off) cine is byte-identical.
A native VolumeViewport3D renders its VTK volume directly to the shared on-screen
canvas (.cornerstone-canvas) and, unlike PlanarViewport, has no separate CPU
canvas. When the same element previously hosted a CPU PlanarViewport, that
viewport set the shared canvas to display:none (showing its cpuCanvas instead).
On switching the element to a 3D viewport, the canvas stayed hidden, so the
volume rendering was produced (the engine composited it) but never shown — a
black viewport.

Ensure the 3D viewport's canvas is visible (display:'') in the constructor.
Legacy 3D viewports never hid the canvas, so they are unaffected.

Verified live in OHIF: native VOLUME_3D_NEXT volume rendering now displays
(previously black); legacy VOLUME_3D unchanged.
CrosshairsTool and ReferenceCursors drove cross-viewport MPR navigation through
getCamera/setCamera/resetCamera and slab APIs that direct PLANAR_NEXT (next)
viewports do not expose, so crosshairs threw on native MPR.

Reads: every viewport.getCamera() read (22 in CrosshairsTool, 3 in ReferenceCursors)
now goes through getViewportICamera(viewport) - byte-identical for legacy (it falls
back to getCamera) and resolved-view-derived for native. Mirrors ReferenceLinesTool.

Writes (guarded by isGenericViewport, legacy path unchanged):
- setToolCenter / applyDeltaShift: pure along-normal scroll -> setViewReference
  ({cameraFocalPoint}) on native (snaps to slice); ReferenceCursors plane move too.
- resetCrosshairs: resetViewState (pan/zoom/orientation/flip) on native (no slab /
  slice-recenter); legacy keeps resetCamera + resetSlabThickness.
- ROTATE, SLAB, auto-pan: gated off on native (no validated free-oblique orientation
  write, no slab API, no world-space pan); handles are inert rather than throwing.

Native-safe reads: getSlabThickness -> MINIMUM_SLAB_THICKNESS, _checkIfViewports-
RenderingSameScene compares view-reference dataId (no getAllVolumeIds), and a
getDisplayedCanvasSize helper uses the element size for native (viewport.canvas is the
hidden cornerstone-canvas with clientWidth/Height 0, which collapsed the reference
lines).

Verified live in OHIF MPR 3-up, both lanes: reference lines render with correct
geometry, crosshairs jump scrolls the other viewports, console clean; rotate/slab/
auto-pan no-op on native; legacy MPR crosshairs unchanged.
The 3D volume-rendering interaction tools (TrackballRotate, VolumeRotate,
OrientationController, VolumeCropping, VolumeCroppingControl) drove the VR camera
through getCamera/setCamera/resetCamera, which native VolumeViewport3D does not
expose by design (it uses getViewState/setViewState/resetViewState, asserted by
viewportProjectionService.jest). So native 3D VR rendered but could not be rotated,
cropped, or re-oriented.

Add a setViewportCamera write bridge (+ resetViewportCamera) - the write
counterpart of getViewportICamera: legacy uses setCamera/resetCamera; native applies
the same camera fields (position/focalPoint/viewUp/viewPlaneNormal/parallelScale) via
setViewState -> applyVolume3DCamera on the vtk camera, and resets via resetViewState.

Migrate all five tools: getCamera() -> getViewportICamera(viewport) (byte-identical
for legacy), setCamera(...) -> setViewportCamera(viewport, ...), resetCamera(opts) ->
resetViewportCamera(viewport, opts). getVtkActiveCamera (already native-safe) is
untouched. No viewport API change, so the jest assertion that VolumeViewport3D lacks
resetCamera/getViewPresentation still holds.

Verified live in OHIF: TrackballRotate rotates a native VOLUME_3D_NEXT viewport (the
volume visibly reorients, camera position updates), 0 errors; legacy 3D camera writes
unchanged (guarded).
WindowLevelRegionTool, ScaleOverlayTool, ETDRSGridTool, and OverlayGridTool read
viewport.getCamera() (viewPlaneNormal/viewUp/focalPoint) to place their overlays/
annotations, which throws on direct PLANAR_NEXT (next) viewports. These are
read-only (no setCamera), so swap each getCamera() to getViewportICamera(viewport):
byte-identical for legacy (it falls back to getCamera) and resolved-view-derived for
native. Mirrors the ReferenceLinesTool/CrosshairsTool read migration.
MagnifyTool hard-gated to StackViewport and read several source-viewport APIs that
direct PLANAR_NEXT (next) viewports do not expose (getProperties, getViewPresentation,
getCamera.parallelScale), so magnify threw on a native source.

- Relax the StackViewport gate to also accept csUtils.isGenericViewport viewports.
- _getReferencedImageId: native stack-mode viewport returns getCurrentImageId().
- Source appearance for the loupe: native VOI/LUT via getDisplaySetPresentation
  (getSourceDataId), and rotation/flip via the projection-aware getViewportPresentation
  helper (legacy still uses getProperties/getViewPresentation).
- Magnification base: native has no parallelScale, so fall back to the loupe viewport's
  own (fit) parallelScale.

The loupe is always a legacy StackViewport (its setStack/getCamera/setProperties are
unchanged), so only the SOURCE reads needed native handling; the legacy source path is
byte-identical (guarded by isGenericViewport). Typecheck clean; native methods verified
(PlanarViewport.getCurrentImageId/getDisplaySetPresentation/getSourceDataId).
AdvancedMagnifyTool needs deeper native source-type dispatch and is a follow-up.
A single example that exercises everything migrated to the native next
API: planar acquisition rendering, volume MPR (axial/sagittal/coronal
PlanarViewport), native 3D volume rendering with presets, plus the
Crosshairs, Cine, Reference Cursors, Magnify, Scale Overlay and Window
Level tools - all running on GenericViewports.
getReferenceLineDraggableRotatable was returning false, which gates the
crosshairs reference-line grab, the center gap, and the drag-to-translate
reslice in the tool's hit detection (_pointNearTool) and drag handler.
The result was static lines with no gap that could not reslice the other
MPR viewports. Return true so the crosshairs translate/jump works on the
native PLANAR_NEXT viewports (rotation drag remains a no-op on native by
design - the tool gates OPERATION.ROTATE off for GenericViewports).
…ports

Native PLANAR_NEXT sources lack getCamera/getProperties/getViewPresentation
and, crucially, setStack/addVolumes are absent on the native class, so the
existing _isStackViewport/_isVolumeViewport detection misses native entirely
and the loupe was mounted with no data.

- Add a third dispatch lane: when the source is a generic (next) viewport,
  back the loupe with a legacy STACK viewport (mirrors MagnifyTool) and feed
  it via the native getImageIds()/getCurrentImageIdIndex().
- Bridge the source reads: orientation via getViewportICamera, VOI/LUT via
  getDisplaySetPresentation(getSourceDataId()), flip via getViewportPresentation,
  referenced image id via getCurrentImageId (the base getReferencedImageId
  would parse the frameOfReference UID as a bogus image id on native).
- Reconstruct the source parallelScale geometrically in
  _convertZoomFactorToParallelScale, since native has no getCamera and its
  on-screen canvas is hidden (offsetWidth === 0).

Legacy viewports take the original code paths unchanged (isGenericViewport is
false for them), so flag-off behavior is byte-identical. Validated end-to-end
in the browser on both native and legacy: loupe creates, clones the stack,
transfers VOI, renders magnified content, and the glass/circle/cursor coincide.
Native PlanarViewport had no calibrateSpacing, so viewportSupportsStackCalibration
was false and the tools calibrateImageSpacing utility skipped native viewports
entirely (silent no-op) — CalibrationLine never took effect on the next path. Two
gaps, both fixed:

1. PlanarViewport.calibrateSpacing(imageId): the native counterpart of
   StackViewport.calibrateSpacing. Re-renders and emits IMAGE_SPACING_CALIBRATED so
   annotation tools invalidate cached stats and recompute lengths. Defining it makes
   viewportSupportsStackCalibration true, so calibrateImageSpacing routes here.

2. buildPlanarImageData merged the USER calibration (calibratedPixelSpacing provider)
   on top of the DICOM calibration module, mirroring legacy StackViewport.getImageData
   ({ ...csImage.calibration, ...this.calibration }). getImageDataMetadata reads only
   the DICOM 'calibrationModule', so without this native getImageData().calibration
   ignored CalibrationLine and getCalibratedLengthUnitsAndScale never picked up the
   scale.

Validated live in OHIF on a native PLANAR_NEXT CT: calibrateImageSpacing(imageId, re,
2.5) -> getImageData().calibration {} -> {type:'User',scale:2.5}; the length tool's
getCalibratedLengthUnitsAndScale scale 1.848 -> 2.5; IMAGE_SPACING_CALIBRATED fires;
console clean. Additive/conditional (no user calibration => behavior unchanged), so
the legacy path and existing genericViewport behavior are byte-identical.
…dering

feat(PlanarViewport): establish stacking context for viewport element
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Introduces IGenericViewport and ViewportContentMode as public API contracts for the "next" viewport architecture, implements PlanarViewport with new lifecycle and navigation APIs, adds lane-agnostic camera helpers (setViewportCamera, resetViewportCamera, viewport state extraction), and adapts all synchronizers, tools, render paths, and the rendering engine resize flow to operate on both legacy and generic viewports. Refactors labelmap layer discriminator from type to storageKind across models and tests.

Changes

Generic Viewport Integration

Layer / File(s) Summary
IGenericViewport public contract and type exports
packages/core/src/types/IGenericViewport.ts, packages/core/src/types/index.ts, packages/core/src/types/IEnabledElement.ts
Defines ViewportContentMode union and IGenericViewport interface extending IViewport with display-set and view-state APIs; re-exports from the types barrel; documents the narrow union guidance in IEnabledElement.
Viewport capability guards and content-mode utilities
packages/core/src/utilities/viewportCapabilities.ts, packages/core/src/utilities/index.ts, packages/core/test/viewportContentMode.jest.js
Adds isGenericViewport, getViewportContentMode, viewportIsInVolumeMode, viewportIsInStackMode with barrel re-exports and full Jest coverage.
Lane-agnostic camera and viewport helpers
packages/tools/src/utilities/setViewportCamera.ts, packages/tools/src/utilities/genericViewportToolHelpers.ts, packages/tools/src/utilities/index.ts
New setViewportCamera and resetViewportCamera helpers dispatch to setViewState/resetViewState for generic viewports and setCamera/resetCamera for legacy viewports. Adds genericViewportToolHelpers module with getSlabThicknessOrDefault, jumpToFocalPoint, and getNativeSourceProperties to extract native viewport state and properties.
GenericViewport base class additions
packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts
Adds getCurrentMode() returning 'empty'/'unknown', getSourceBinding() fallback logic, and notifyDataPresentationModified hook with mounted-conditional invocation in mergeDataPresentation.
PlanarViewport types and render-path decision service
packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts, packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts, packages/core/src/init.ts
Extends PlanarSetDataOptions with forceCPU and PlanarPresentationProps with voiLUTFunction; adds canRender() boolean method; changes CPU/GPU image-path selection to use global getShouldUseCPURendering(); sets volume CPU threshold to Infinity.
PlanarViewport lifecycle and public API surface
packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts
Constructor sets element CSS isolation; setDisplaySets uses selective removeReplaceableData preserving segmentation overlays; adds getImageActor, getCurrentMode, getNumberOfSlices, calibrateSpacing, canRenderOrientation; loadPlanarData routes forceCPU to render-path decision.
Planar render path updates
packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts, VtkImageMapperRenderPath.ts, VtkVolumeSliceRenderPath.ts, CpuVolumeSliceRenderPath.ts, planarImageEvents.ts, planarVolumePresentation.ts, packages/core/src/RenderingEngine/helpers/planarImageRendering.ts
CPU image path sets isPreScaled from preScale.scaled and merges calibratedPixelSpacing into calibration; VTK mapper forwards voiLUTFunction to presentation and reuses imageData when compatible; createPlanarRGBTransferFunction branches on sigmoid/linear; volume events gain required maxImageIdIndex and emit VOLUME_NEW_IMAGE; colormap opacity handles absolute thresholds correctly.
Rendering engine resize, canvas visibility, and 3D view-state
packages/core/src/RenderingEngine/ContextPoolRenderingEngine.ts, packages/core/src/RenderingEngine/GenericViewport/Volume3D/viewport3D.ts, packages/core/src/utilities/isPTPrescaledWithSUV.ts
_getDisplayedSize measures from vp.element for generic viewports to avoid zero-canvas reads; skips legacy camera snapshot/restore for generic viewports on resize; VolumeViewport3D restores canvas visibility and extends resetViewState with options for selective zoom/pan/center preservation; isPTPrescaledWithSUV uses optional chaining.
Debug stats and render-modes panel typing
packages/core/src/RenderingEngine/helpers/stats/RenderModesPanel.ts, packages/core/src/RenderingEngine/helpers/stats/StatsOverlay.ts
RenderModesPanel extends entries to include viewportType and refactors header styling; StatsOverlay populates viewport type and handles legacy viewports via fallback to getActors().
Synchronizer callbacks adapted for generic viewports
packages/tools/src/synchronizers/callbacks/cameraSyncCallback.ts, imageSliceSyncCallback.ts, voiSyncCallback.ts, packages/tools/src/utilities/stackPrefetch/stackPrefetchUtils.ts, packages/tools/test/imageSliceSyncCallback.jest.js
Camera sync uses setViewReference for generic viewports; image-slice sync detects volume-mode generic viewports; VOI sync applies setDisplaySetPresentation; stack prefetch uses capability guard instead of instanceof StackViewport; test mock adds viewportIsInVolumeMode stub.
Routine getViewportICamera migration across tools
packages/tools/src/tools/OrientationControllerTool.ts, OverlayGridTool.ts, ReferenceLinesTool.ts, ScaleOverlayTool.ts, TrackballRotateTool.ts, VolumeCroppingControlTool.ts, VolumeCroppingTool.ts, VolumeRotateTool.ts, WindowLevelRegionTool.ts, annotation/ETDRSGridTool.ts, displayTools/Contour/*
Replaces viewport.getCamera() / setCamera / resetCamera with getViewportICamera / setViewportCamera / resetViewportCamera across all listed tools and contour display helpers.
Complex tool adaptations for generic viewports
packages/tools/src/tools/CrosshairsTool.ts, AdvancedMagnifyTool.ts, MagnifyTool.ts, ReferenceCursors.ts, OrientationMarkerTool.ts, WindowLevelTool.ts, displayTools/Labelmap/labelmapRenderPlan.ts, packages/tools/src/utilities/cine/playClip.ts, packages/tools/src/utilities/voi/colorbar/ViewportColorbar.ts
CrosshairsTool adds getDisplayedCanvasSize helper, guards slab/rotation operations on generic viewports, compares scenes via getViewReference metadata; AdvancedMagnifyTool reconstructs parallel scale and reads properties via generic APIs; MagnifyTool broadens viewport guard; ReferenceCursors adds generic-viewport branch in updateViewportImage; OrientationMarkerTool skips unsupported viewports; WindowLevelTool detects modality via getImageData(); cine adds native viewport context; colorbar handles generic viewports and voxelManager fallback.
Generic viewport showcase demo
packages/tools/examples/genericViewportShowcase/index.ts
Complete new demo wiring acquisition, MPR, and 3D native viewports with toolgroups, toolbar controls, preset application, cine, scale overlay, and stack prefetch.
Modality detection and image scaling
packages/core/src/utilities/getViewportModality.ts, packages/dicomImageLoader/src/imageLoader/getScalingParameters.ts, packages/tools/src/utilities/viewport/isViewportPreScaled.ts
getViewportModality adds generic viewport branch to resolve modality from actors and imageData metadata; getScalingParameters detects PT suvbw and RTDOSE dose-grid scaling and preserves non-identity scaling; isViewportPreScaled adds generic viewport branch using actor matching.
Generic viewport reference compatibility memoization
packages/core/src/RenderingEngine/GenericViewport/genericViewportReferenceCompatibility.ts
Adds module-level caches for imageId→compatible URIs and imageIds array→URI entries with fast path caching for reference resolution.

Labelmap Layer Storage Kind Refactor

Layer / File(s) Summary
Labelmap layer storageKind rename and helpers
packages/tools/src/types/LabelmapTypes.ts, packages/tools/src/stateManagement/segmentation/labelmapModel/*, packages/tools/src/stateManagement/segmentation/helpers/labelmapImageMapperSupport.ts, packages/tools/src/tools/displayTools/Labelmap/*
Renames labelmap discriminator from type to storageKind for semantic clarity across types, models, and tests. Adds transfer-function style preservation in syncStackLabelmapActors when rebuilding overlay actors. Guards cache lookups and dimension checks in legacyVolumePlan.

Sequence Diagram(s)

sequenceDiagram
  participant App
  participant PlanarViewport
  participant PlanarRenderPathDecisionService
  participant CpuImageSliceRenderPath
  participant planarImageRendering

  App->>PlanarViewport: setDisplaySets(entries, options)
  PlanarViewport->>PlanarViewport: removeReplaceableData(entries)
  PlanarViewport->>PlanarViewport: addDisplaySetInternal(entry, options)
  PlanarViewport->>PlanarRenderPathDecisionService: select(dataSet, {webGLAvailable, forceCPU})
  PlanarRenderPathDecisionService-->>PlanarViewport: renderPath (cpu/gpu)
  PlanarViewport->>CpuImageSliceRenderPath: addData(payload)
  CpuImageSliceRenderPath->>CpuImageSliceRenderPath: markCpuImagePreScaled(image)
  CpuImageSliceRenderPath->>planarImageRendering: applyPlanarImagePresentation({voiLUTFunction})
  CpuImageSliceRenderPath-->>PlanarViewport: rendered
  PlanarViewport->>PlanarViewport: triggerCameraModifiedEvent()
Loading
sequenceDiagram
  participant Tool
  participant getViewportICamera
  participant setViewportCamera
  participant GenericViewport
  participant LegacyViewport

  Tool->>getViewportICamera: getViewportICamera(viewport)
  getViewportICamera-->>Tool: {focalPoint, viewPlaneNormal, viewUp, ...}
  Tool->>setViewportCamera: setViewportCamera(viewport, camera)
  setViewportCamera->>setViewportCamera: isGenericViewport(viewport)?
  alt generic viewport
    setViewportCamera->>GenericViewport: setViewState(camera)
  else legacy viewport
    setViewportCamera->>LegacyViewport: setCamera(camera)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Suggested reviewers

  • jbocce
  • wayfarer3130

Poem

🐇 A new viewport hops into view,
Generic and nimble, fresh like dew.
isGenericViewport sniffs out the lane,
Camera helpers abstract the refrain.
Legacy and "next" now dance as one pair—
Labelmap storage kinds everywhere! 📦✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.82% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The PR mostly follows the template, but the checklist is incomplete and the tested environment/public docs items are not filled out. Complete the missing checklist entries with OS, Node, and browser details, and state whether public docs need updates or not.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'fix: more generic viewport push for parity with legacy' is concise, follows semantic-release format with 'fix:' prefix, and accurately summarizes the main objective of extending GenericViewport support across the ecosystem for parity with legacy viewports.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cornerstoneohifnextapi

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sedghi sedghi changed the title cornerstoneohifnextapi fix: more generic viewport push for parity with legacy Jun 20, 2026
@sedghi
sedghi requested a review from wayfarer3130 June 20, 2026 19:38

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts (1)

247-254: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Trigger new-image event on max-slice updates in VTK path too.

At Line 247, the guard ignores maxImageIdIndexChanged. That can leave numberOfSlices stale for consumers when only the max index changes.

Suggested fix
-    if (triggerImageEvent && imageIdIndexChanged) {
+    if (triggerImageEvent && (imageIdIndexChanged || maxImageIdIndexChanged)) {
       triggerPlanarVolumeNewImage(ctx, {
         camera,
         acquisitionOrientation: rendering.acquisitionOrientation,
         imageIds: rendering.imageIds,
         imageIdIndex: rendering.currentImageIdIndex,
         maxImageIdIndex: rendering.maxImageIdIndex,
       });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts`
around lines 247 - 254, The guard condition for triggering the planar volume new
image event only checks imageIdIndexChanged but ignores maxImageIdIndexChanged,
which causes the numberOfSlices to become stale when only the maximum index
changes. Modify the if condition at line 247 to include maxImageIdIndexChanged
in the guard, so the triggerPlanarVolumeNewImage call is executed whenever
either imageIdIndexChanged or maxImageIdIndexChanged is true, ensuring consumers
receive the updated slice information in both cases.
packages/core/src/RenderingEngine/GenericViewport/Planar/CpuVolumeSliceRenderPath.ts (1)

536-543: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Emit VOLUME_NEW_IMAGE when max slice count changes.

At Line 536, event emission is gated only by imageIdIndexChanged. If maxImageIdIndexChanged occurs alone, listeners miss the updated numberOfSlices even though the payload now carries it.

Suggested fix
-    if (imageIdIndexChanged) {
+    if (imageIdIndexChanged || maxImageIdIndexChanged) {
       triggerPlanarVolumeNewImage(ctx, {
         camera,
         acquisitionOrientation: rendering.acquisitionOrientation,
         imageIds: rendering.imageIds,
         imageIdIndex: rendering.currentImageIdIndex,
         maxImageIdIndex: rendering.maxImageIdIndex,
       });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/core/src/RenderingEngine/GenericViewport/Planar/CpuVolumeSliceRenderPath.ts`
around lines 536 - 543, The triggerPlanarVolumeNewImage event emission is gated
only by the imageIdIndexChanged condition, which means listeners will miss
updates when maxImageIdIndexChanged occurs independently. Update the if
condition to emit the event when either imageIdIndexChanged OR
maxImageIdIndexChanged is true, ensuring that listeners receive the updated
numberOfSlices payload whenever the max slice count changes.
packages/tools/src/tools/CrosshairsTool.ts (1)

369-423: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Suppress camera-event reentrancy during generic reset.

The generic reset path calls resetViewState without an event-suppression guard, while the legacy path uses suppressEvents: true. During onResetCamera, this can trigger onCameraModified mid-reset and cause redundant recompute/render work.

Proposed patch
   resetCrosshairs = () => {
+    const previousIgnoreFiredEvents = this._ignoreFiredEvents;
+    this._ignoreFiredEvents = true;
+    try {
       const viewportsInfo = this._getViewportsInfo();
       for (const viewportInfo of viewportsInfo) {
         const { viewportId, renderingEngineId } = viewportInfo;
@@
       }
 
       this._computeToolCenter(viewportsInfo);
+    } finally {
+      this._ignoreFiredEvents = previousIgnoreFiredEvents;
+    }
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/tools/src/tools/CrosshairsTool.ts` around lines 369 - 423, The
resetCrosshairs method has inconsistent event suppression between the generic
and legacy viewport paths. The generic viewport branch (inside the `if
(csUtils.isGenericViewport(viewport))` block) calls `resetViewState` without
suppressing events, while the legacy path passes `suppressEvents: true` to
`resetCamera`. Add the suppressEvents option to the `resetViewState` call in the
generic viewport path to prevent reentrancy issues where onResetCamera triggers
onCameraModified during reset.
packages/tools/src/tools/displayTools/Contour/contourHandler/handleContourSegmentation.ts (1)

58-67: ⚠️ Potential issue | 🔴 Critical

Replace unsafe StackViewport cast with viewport-agnostic image reference resolution.

Line 63 uses getClosestImageIdForStackViewport(viewport as StackViewport, ...) despite the function parameter accepting StackViewport | Types.IVolumeViewport. When handleContourSegmentation is called with an IVolumeViewport (e.g., from PLANAR_NEXT rendering), this cast bypasses the viewport contract and produces missing or incorrect image references.

Use the viewport type-checking pattern from annotationHydration.ts (lines 59–74): conditionally call getClosestImageIdForStackViewport for StackViewport or utilities.getClosestImageId for volume viewports.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/tools/src/tools/displayTools/Contour/contourHandler/handleContourSegmentation.ts`
around lines 58 - 67, The unsafe StackViewport cast in the
getClosestImageIdForStackViewport call on line 63 bypasses type safety and
causes incorrect behavior when called with IVolumeViewport instances. Replace
the unsafe cast with a viewport type-checking pattern that conditionally calls
getClosestImageIdForStackViewport when the viewport is a StackViewport, or calls
utilities.getClosestImageId when the viewport is an IVolumeViewport. Check the
viewport type before the forEach loop using instanceof or a similar type-guard
pattern, then use the appropriate function based on the viewport type to resolve
the image reference correctly.
packages/tools/src/tools/MagnifyTool.ts (1)

50-63: ⚠️ Potential issue | 🟠 Major

Widen _getReferencedImageId to the admitted viewport contract.

preMouseDownCallback now permits generic viewports via the guard at lines 75-78, but _getReferencedImageId still declares a narrower parameter type and returns only string despite the generic lookup being optional. The method must accept Types.IViewport and return string | undefined to align the TypeScript signature with the implementation's actual behavior and the caller's expectations at line 84.

Proposed fix
   _getReferencedImageId(
-    viewport: Types.IStackViewport | Types.IVolumeViewport
-  ): string {
+    viewport: Types.IViewport
+  ): string | undefined {
     const targetId = this.getTargetId(viewport);
 
-    let referencedImageId;
+    let referencedImageId: string | undefined;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/tools/src/tools/MagnifyTool.ts` around lines 50 - 63, The
`_getReferencedImageId` method has a parameter type that is too narrow and a
return type that does not match its actual behavior. Change the parameter type
from `Types.IStackViewport | Types.IVolumeViewport` to the broader
`Types.IViewport` to accept generic viewports (aligning with the guard check at
lines 75-78 in preMouseDownCallback). Additionally, change the return type from
`string` to `string | undefined` because the method uses optional chaining with
`getCurrentImageId?.()` for generic viewports, which can return undefined, and
this must match the caller's expectations at line 84.
🧹 Nitpick comments (1)
packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts (1)

76-83: ⚡ Quick win

Align the forceCPU docs with the new image-path decision.

shouldUseCPUForImage now ignores image byte-size thresholds and only uses webGLAvailable === false or the global CPU setting, so this comment still over-promises that the “CPU thresholds above” participate when forceCPU is omitted.

Suggested wording
-   * configuration, WebGL availability, and the CPU thresholds above.
+   * configuration and WebGL availability. Size-based CPU thresholds apply only
+   * to volume-backed render paths.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts`
around lines 76 - 83, Update the JSDoc comment for the forceCPU property to
accurately reflect the current image-path decision logic. Remove the reference
to "CPU thresholds above" from the comment since shouldUseCPUForImage no longer
considers image byte-size thresholds. The updated documentation should clarify
that when forceCPU is omitted, the render path is decided only by global
rendering configuration and WebGL availability, not by CPU thresholds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@packages/core/src/RenderingEngine/GenericViewport/Planar/planarImageEvents.ts`:
- Around line 66-68: The imageIndex property in the VOLUME_NEW_IMAGE event
triggered in the triggerEvent call is using a null coalescing operator to
default to 0 when params.imageIdIndex is unknown, which incorrectly snaps
volume-aware listeners to the first slice. Fix this by removing the default
fallback so that imageIndex is only set to a defined value when
params.imageIdIndex actually has a known value, allowing listeners to properly
handle unknown indices instead of treating them as the first slice.

In `@packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts`:
- Around line 763-765: The getNumberOfSlices() method currently returns 1 for an
empty viewport when it should return 0. This happens because when getImageIds()
is empty (length is 0), getMaxImageIdIndex() falls back to 0, and Math.max(0, 1)
returns 1. Fix this by adding an early return statement that checks if
getImageIds().length is 0 and returns 0 immediately, before executing the
Math.max calculation. This ensures empty viewports correctly report zero slices
for navigation and prefetch operations.
- Around line 692-714: The code retrieves displaySetId and its presentation at
the beginning but ignores the displaySetId when emitting VOI_MODIFIED and
COLORMAP_MODIFIED events. Instead of using the global volumeId from
this.getVolumeId(), replace all volumeId references in both event triggerEvent
calls with the volume ID specific to the displaySetId being modified. Determine
the correct method to get the display-set-specific volume ID (likely
this.getVolumeId(displaySetId) or extracting it from the presentation object)
and use that in place of the current this.getVolumeId() calls to ensure the
events carry the correct actor reference for the modified display set.

In `@packages/core/src/utilities/isPTPrescaledWithSUV.ts`:
- Line 4: The return statement in the isPTPrescaledWithSUV function currently
returns a truthy/falsy value from the && operator instead of an explicit
boolean. Modify the return statement to explicitly coerce the result to a strict
boolean value using the !! operator or the Boolean() function, ensuring the
predicate returns true or false rather than potentially returning the suvbw
value or false when the conditions are evaluated.

In `@packages/tools/examples/genericViewportShowcase/index.ts`:
- Around line 252-260: The `onSelectedValueChange` callback handler and similar
toolbar handlers at lines 279, 317, 339, and 357 assume that
ToolGroupManager.getToolGroup(acqToolGroupId) returns a valid tool group
instance. If a user clicks before the `run()` function completes initialization,
the toolGroup could be null or undefined, causing errors when calling methods
like setToolPassive() and setToolActive(). Add guard checks at the beginning of
each affected handler callback to verify that the toolGroup exists and is
properly initialized before attempting to call methods on it. If the toolGroup
is not yet available, return early from the handler to prevent null reference
errors.
- Line 549: The `run()` function call on line 549 is not handling promise
rejections, which causes initialization and network failures to be silently
ignored. Add a `.catch()` handler to the `run()` call to properly catch any
rejected promises and log the error details. This ensures that bootstrap
failures are visible and can be diagnosed instead of being silently dropped.
- Around line 96-103: The code retrieves the content element using
document.getElementById but does not check if it exists before calling
appendChild on it. Add a defensive null check after the getElementById call to
verify that the content element was found in the DOM before attempting to append
the grid to it, and either skip the grid creation or provide an appropriate
fallback behavior if the content container is missing.

In `@packages/tools/src/tools/MagnifyTool.ts`:
- Around line 140-144: The viewportProperties obtained from
getDisplaySetPresentation() contain the property voiLUTFunction (lowercase), but
the legacy StackViewport's setProperties() method expects VOILUTFunction
(uppercase). Before using viewportProperties with setProperties() in the
MagnifyTool.ts magnify viewport, add a mapping function that transforms the
generic property name voiLUTFunction to the legacy property name VOILUTFunction.
Apply the same mapping transformation in AdvancedMagnifyTool.ts where similar
property handling occurs around lines 1763-1785.

In `@packages/tools/src/tools/ReferenceCursors.ts`:
- Around line 523-528: The updateViewportImage method signature declares a
parameter type of IStackViewport or IVolumeViewport, but the implementation
includes a branch for generic viewports (the isGenericViewport check) that
forces an unnecessary cast at line 528. Update the updateViewportImage method
signature to accept Types.IViewport as the viewport parameter type instead of
the union of IStackViewport and IVolumeViewport. This broader type accurately
reflects the method's actual contract since all viewport types derive from
IViewport, and it will eliminate the workaround cast currently needed to handle
the generic viewport case.

In `@packages/tools/src/tools/VolumeCroppingControlTool.ts`:
- Around line 685-688: The condition checking enabledElement.viewport.getCamera
is a legacy gate preventing orientation computation on generic/native viewports,
leaving orientation null and causing annotation filtering failures. Remove the
getCamera check from the conditional at line 685 by deleting the &&
enabledElement.viewport.getCamera clause, keeping only the
enabledElement.viewport check so that the orientation assignment with
getOrientationFromNormal and getViewportICamera executes for all viewport types.

---

Outside diff comments:
In
`@packages/core/src/RenderingEngine/GenericViewport/Planar/CpuVolumeSliceRenderPath.ts`:
- Around line 536-543: The triggerPlanarVolumeNewImage event emission is gated
only by the imageIdIndexChanged condition, which means listeners will miss
updates when maxImageIdIndexChanged occurs independently. Update the if
condition to emit the event when either imageIdIndexChanged OR
maxImageIdIndexChanged is true, ensuring that listeners receive the updated
numberOfSlices payload whenever the max slice count changes.

In
`@packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts`:
- Around line 247-254: The guard condition for triggering the planar volume new
image event only checks imageIdIndexChanged but ignores maxImageIdIndexChanged,
which causes the numberOfSlices to become stale when only the maximum index
changes. Modify the if condition at line 247 to include maxImageIdIndexChanged
in the guard, so the triggerPlanarVolumeNewImage call is executed whenever
either imageIdIndexChanged or maxImageIdIndexChanged is true, ensuring consumers
receive the updated slice information in both cases.

In `@packages/tools/src/tools/CrosshairsTool.ts`:
- Around line 369-423: The resetCrosshairs method has inconsistent event
suppression between the generic and legacy viewport paths. The generic viewport
branch (inside the `if (csUtils.isGenericViewport(viewport))` block) calls
`resetViewState` without suppressing events, while the legacy path passes
`suppressEvents: true` to `resetCamera`. Add the suppressEvents option to the
`resetViewState` call in the generic viewport path to prevent reentrancy issues
where onResetCamera triggers onCameraModified during reset.

In
`@packages/tools/src/tools/displayTools/Contour/contourHandler/handleContourSegmentation.ts`:
- Around line 58-67: The unsafe StackViewport cast in the
getClosestImageIdForStackViewport call on line 63 bypasses type safety and
causes incorrect behavior when called with IVolumeViewport instances. Replace
the unsafe cast with a viewport type-checking pattern that conditionally calls
getClosestImageIdForStackViewport when the viewport is a StackViewport, or calls
utilities.getClosestImageId when the viewport is an IVolumeViewport. Check the
viewport type before the forEach loop using instanceof or a similar type-guard
pattern, then use the appropriate function based on the viewport type to resolve
the image reference correctly.

In `@packages/tools/src/tools/MagnifyTool.ts`:
- Around line 50-63: The `_getReferencedImageId` method has a parameter type
that is too narrow and a return type that does not match its actual behavior.
Change the parameter type from `Types.IStackViewport | Types.IVolumeViewport` to
the broader `Types.IViewport` to accept generic viewports (aligning with the
guard check at lines 75-78 in preMouseDownCallback). Additionally, change the
return type from `string` to `string | undefined` because the method uses
optional chaining with `getCurrentImageId?.()` for generic viewports, which can
return undefined, and this must match the caller's expectations at line 84.

---

Nitpick comments:
In
`@packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts`:
- Around line 76-83: Update the JSDoc comment for the forceCPU property to
accurately reflect the current image-path decision logic. Remove the reference
to "CPU thresholds above" from the comment since shouldUseCPUForImage no longer
considers image byte-size thresholds. The updated documentation should clarify
that when forceCPU is omitted, the render path is decided only by global
rendering configuration and WebGL availability, not by CPU thresholds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 88ca1336-12a4-4a7d-a37b-0132a6a8bbc3

📥 Commits

Reviewing files that changed from the base of the PR and between 7b60dbf and 401e257.

📒 Files selected for processing (44)
  • packages/core/src/RenderingEngine/ContextPoolRenderingEngine.ts
  • packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts
  • packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts
  • packages/core/src/RenderingEngine/GenericViewport/Planar/CpuVolumeSliceRenderPath.ts
  • packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarRenderPathDecisionService.ts
  • packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts
  • packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewportTypes.ts
  • packages/core/src/RenderingEngine/GenericViewport/Planar/VtkImageMapperRenderPath.ts
  • packages/core/src/RenderingEngine/GenericViewport/Planar/VtkVolumeSliceRenderPath.ts
  • packages/core/src/RenderingEngine/GenericViewport/Planar/planarImageEvents.ts
  • packages/core/src/RenderingEngine/GenericViewport/Volume3D/viewport3D.ts
  • packages/core/src/RenderingEngine/helpers/planarImageRendering.ts
  • packages/core/src/RenderingEngine/helpers/stats/RenderModesPanel.ts
  • packages/core/src/RenderingEngine/helpers/stats/StatsOverlay.ts
  • packages/core/src/types/IEnabledElement.ts
  • packages/core/src/types/IGenericViewport.ts
  • packages/core/src/types/index.ts
  • packages/core/src/utilities/index.ts
  • packages/core/src/utilities/isPTPrescaledWithSUV.ts
  • packages/core/src/utilities/viewportCapabilities.ts
  • packages/core/test/viewportContentMode.jest.js
  • packages/tools/examples/genericViewportShowcase/index.ts
  • packages/tools/src/synchronizers/callbacks/cameraSyncCallback.ts
  • packages/tools/src/synchronizers/callbacks/imageSliceSyncCallback.ts
  • packages/tools/src/synchronizers/callbacks/voiSyncCallback.ts
  • packages/tools/src/tools/AdvancedMagnifyTool.ts
  • packages/tools/src/tools/CrosshairsTool.ts
  • packages/tools/src/tools/MagnifyTool.ts
  • packages/tools/src/tools/OrientationControllerTool.ts
  • packages/tools/src/tools/OverlayGridTool.ts
  • packages/tools/src/tools/ReferenceCursors.ts
  • packages/tools/src/tools/ReferenceLinesTool.ts
  • packages/tools/src/tools/ScaleOverlayTool.ts
  • packages/tools/src/tools/TrackballRotateTool.ts
  • packages/tools/src/tools/VolumeCroppingControlTool.ts
  • packages/tools/src/tools/VolumeCroppingTool.ts
  • packages/tools/src/tools/VolumeRotateTool.ts
  • packages/tools/src/tools/WindowLevelRegionTool.ts
  • packages/tools/src/tools/annotation/ETDRSGridTool.ts
  • packages/tools/src/tools/displayTools/Contour/contourDisplay.ts
  • packages/tools/src/tools/displayTools/Contour/contourHandler/handleContourSegmentation.ts
  • packages/tools/src/utilities/cine/playClip.ts
  • packages/tools/src/utilities/setViewportCamera.ts
  • packages/tools/src/utilities/stackPrefetch/stackPrefetchUtils.ts

Comment thread packages/core/src/RenderingEngine/GenericViewport/Planar/planarImageEvents.ts Outdated
Comment thread packages/core/src/utilities/isPTPrescaledWithSUV.ts Outdated
Comment thread packages/tools/examples/genericViewportShowcase/index.ts
Comment thread packages/tools/examples/genericViewportShowcase/index.ts
Comment thread packages/tools/examples/genericViewportShowcase/index.ts
Comment thread packages/tools/src/tools/MagnifyTool.ts Outdated
Comment thread packages/tools/src/tools/ReferenceCursors.ts Outdated
Comment thread packages/tools/src/tools/VolumeCroppingControlTool.ts Outdated
sedghi added 2 commits June 20, 2026 16:07
…support

- planarImageEvents: only emit VOLUME_NEW_IMAGE when the slice index is known
  instead of synthesizing index 0
- PlanarViewport: emit VOI/COLORMAP events with the modified display set's own
  volumeId; getNumberOfSlices returns 0 for an empty viewport
- isPTPrescaledWithSUV: return a strict boolean
- tools: extract shared generic-viewport helpers (slab thickness,
  jumpToFocalPoint, native source properties) and bridge voiLUTFunction casing
  so the magnify loupe honors a sigmoid VOI
- viewport3D: honor reset options in resetViewState
- ReferenceCursors: widen updateViewportImage to IViewport and drop the cast
- VolumeCroppingControlTool: resolve orientation on native viewports
- sync/cine/prefetch: capability-based guards for generic viewports
The callback now consults utilities.viewportIsInVolumeMode for native generic
viewports; the test's @cornerstonejs/core mock lacked it, throwing
"viewportIsInVolumeMode is not a function". The fixtures are plain stack/volume
mocks (no getCurrentMode), so it reports non-volume mode and detection falls
through to the existing renderMode check.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/tools/src/tools/MagnifyTool.ts (1)

74-79: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Error message is now misleading.

The guard now accepts both StackViewport and generic viewports, but the error message still says "MagnifyTool only works on StackViewports". Consider updating to reflect the expanded support.

Proposed fix
     if (
       !(viewport instanceof StackViewport) &&
       !csUtils.isGenericViewport(viewport)
     ) {
-      throw new Error('MagnifyTool only works on StackViewports');
+      throw new Error('MagnifyTool only works on Stack or native planar viewports');
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/tools/src/tools/MagnifyTool.ts` around lines 74 - 79, The error
message in the viewport validation guard in MagnifyTool is now inaccurate
because the condition has been expanded to accept both StackViewport instances
and generic viewports (checked via csUtils.isGenericViewport). Update the error
message string to reflect that MagnifyTool now works on both StackViewports and
generic viewports, ensuring the message accurately describes the actual viewport
requirements enforced by the guard condition.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@packages/tools/src/tools/MagnifyTool.ts`:
- Around line 74-79: The error message in the viewport validation guard in
MagnifyTool is now inaccurate because the condition has been expanded to accept
both StackViewport instances and generic viewports (checked via
csUtils.isGenericViewport). Update the error message string to reflect that
MagnifyTool now works on both StackViewports and generic viewports, ensuring the
message accurately describes the actual viewport requirements enforced by the
guard condition.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4510b371-296b-4848-9462-11fa735ad824

📥 Commits

Reviewing files that changed from the base of the PR and between a459977 and a5492df.

📒 Files selected for processing (14)
  • packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts
  • packages/core/src/RenderingEngine/GenericViewport/Planar/planarImageEvents.ts
  • packages/core/src/RenderingEngine/GenericViewport/Volume3D/viewport3D.ts
  • packages/core/src/utilities/isPTPrescaledWithSUV.ts
  • packages/tools/src/synchronizers/callbacks/cameraSyncCallback.ts
  • packages/tools/src/tools/AdvancedMagnifyTool.ts
  • packages/tools/src/tools/CrosshairsTool.ts
  • packages/tools/src/tools/MagnifyTool.ts
  • packages/tools/src/tools/ReferenceCursors.ts
  • packages/tools/src/tools/VolumeCroppingControlTool.ts
  • packages/tools/src/utilities/cine/playClip.ts
  • packages/tools/src/utilities/genericViewportToolHelpers.ts
  • packages/tools/src/utilities/index.ts
  • packages/tools/src/utilities/stackPrefetch/stackPrefetchUtils.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • packages/core/src/utilities/isPTPrescaledWithSUV.ts
  • packages/tools/src/synchronizers/callbacks/cameraSyncCallback.ts
  • packages/tools/src/utilities/stackPrefetch/stackPrefetchUtils.ts
  • packages/tools/src/utilities/cine/playClip.ts
  • packages/tools/src/tools/VolumeCroppingControlTool.ts
  • packages/tools/src/tools/CrosshairsTool.ts
  • packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts

sedghi added 11 commits June 24, 2026 12:50
…action

The prior next-viewport fix bridged the VOI read/write via the display-set
presentation, but extractWindowLevelRegionToolData still threw 'Viewport not
supported' for native PLANAR_NEXT viewports (neither StackViewport nor
VolumeViewport), so the region tool failed before reaching that bridge.

Add a generic-viewport branch that reads the displayed slice from the
cornerstone cache via getCurrentImageId(), returning the same shape the stack
extractor produces (scalarData, width/height, min/max, rows/columns, color).
Also add getCurrentImageId() to IGenericViewport so the new branch narrows via
isGenericViewport instead of an inline cast.
…ray log

The storeScalarData parameter was documented as caching the expanded
scalar data but only console.logged a debug string and discarded it.
Implement the documented behavior (retain on this.scalarData) and remove
the stray log. No current caller passes true, so the default path is
unchanged.
…icViewport narrowing

Eliminates the recurring inline intersection-cast anti-pattern on viewports
across the next-viewport accessor cluster by fixing the root cause: the
methods the call sites probed are now declared on IGenericViewport (or a
focused capability guard) so sites narrow via isGenericViewport instead of
casting and degrading silently on rename.

Interface/guards:
- IGenericViewport: add setViewReference + getResolvedView (both universal via
  base GenericViewport) and the 2-arg setDisplaySetPresentation overload.
- viewportCapabilities: add viewportSupportsDisplaySetPresentation guard +
  DisplaySetPresentationViewport type for the PlanarViewport-only getSourceDataId
  / getDefaultVOIRange (focused guard, not weakening IGenericViewport).

Sites converted (no behavior change, tsc + runtime verified):
- getSubPixelSpacingAndXYDirections -> getViewportContentMode util
- CrosshairsTool resetViewState -> direct call after isGenericViewport
- ViewportColorbar -> setDisplaySetPresentation/render after narrowing
- genericViewportToolHelpers (jumpToFocalPoint, getNativeSourceProperties)
- getViewportICamera (the getCamera bridge) -> getResolvedView via narrowing
- WindowLevelRegionTool / LivewireContourTool -> viewportSupportsDisplaySetPresentation

Remaining cast sites (rendering-engine lifecycle hooks, actor-accessor bundles,
setViewportCamera's multi-method zoom path, debug fields) deferred as a focused
follow-up; documented in the task.
…arity

Native PLANAR_NEXT viewports applied a freshly-mounted display set's default
VOI to the actor but emitted no VOI_MODIFIED event: addDisplaySetInternal goes
through setDefaultDataPresentation -> setDataPresentationState, which (unlike
the public mergeDataPresentation path) does not notify. Legacy Stack/Volume
viewports emit VOI_MODIFIED at load, so OHIF consumers that key off that event
(colorbar, VOI synchronizers, window-level sliders) went stale on a display-set
change for next viewports.

Emit the effective VOI (explicit override or computed default) per binding after
the data is mounted, mirroring the existing resetDisplaySetPresentation
precedent. Fires once per binding (source and each overlay) with that binding's
own volumeId resolved internally; guarded so nothing is emitted when no range is
available, and suppressed under suppressEvents.
…gacy

The native image render path (getDefaultImageVOIRange) used only the image
DICOM window center/width, so a prescaled PT (SUV) series rendered as a stack
got a far wider default window than legacy. Legacy StackViewport overrides the
WC/WW with a 0-5 range for prescaled PT (_getPTPreScaledRange); the volume path
(handlePreScaledVolume) already did the same. Only the native image path was
missing it, so e.g. a PET AC series showed W:11 L:5 instead of legacy W:6 L:3.

Return {lower:0, upper:5} when isPTPrescaledWithSUV(image), reusing the existing
helper (keyed off the loader-set preScale.scaled/suvbw fields, not image.isPreScaled
which the native path never sets). Covers GPU and CPU image paths, mount and scroll.
… volume mount

On a stack->MPR (volume) mount the scrollbar source (rendering.currentImageIdIndex)
was seeded from the raw initialImageIdIndex (0) and emitted via VOLUME_NEW_IMAGE
before the camera projection ran, while the camera renders the centered/reference
slice. The scrollbar reported the first slice while a different slice was shown,
and scroll math relative to the stale index returned the old carried slice
(perceived as a prepended first slice). Reconcile currentImageIdIndex/maxImageIdIndex
with the camera-projected slice (resolvePlanarRenderPathProjection) before emitting
the first VOLUME_NEW_IMAGE, so the scrollbar matches the rendered slice at mount —
a single source of truth, mirroring legacy getSliceIndex (camera-derived).
…arity)

getActiveImageIdIndex returned the stored rendering.currentImageIdIndex scalar,
which tracks the camera on render but can lag a post-mount camera carry (e.g. the
layout-selector MPR protocol restoring the prior stack slice via setViewReference).
That decoupled the scrollbar/scroll index from the rendered slice: the camera
showed the carried slice while the index stayed at the mount value, so scrolling
moved relative to the wrong base. Derive the index from the resolved view (the
current camera/projection) for volume-slice content, mirroring legacy
VolumeViewport.getSliceIndex (camera focal point as the single source of truth);
fall back to the stored scalar otherwise. Validated: normal scroll unchanged,
camera carry now reflected in the index.
createInitialVolumeSliceState pinned the ACQUISITION orientation to
planarData.initialImageIdIndex, which defaults to 0 when no slice is carried
(e.g. the MPR hanging protocol supplies none). So a stack->MPR mounted the
acquisition (axial) viewport at slice 0 with the scrollbar at the top, while the
reformatted (sagittal/coronal) orientations and the legacy VolumeViewport both
center the volume. Map a defaulted 0 to 'center' (|| undefined) while still
honoring a real carried slice (> 0). Validated on 3001: mpr-axial now mounts at
the center (147/295, matching legacy) with index/viewReference/resolved view all
consistent, and scroll tracks correctly.
# Conflicts:
#	packages/dicomImageLoader/src/imageLoader/getScalingParameters.ts
… unit tests

The mount-time resolvePlanarRenderPathProjection call in the volume render paths'
addData invoked ctx.viewport.isCurrentDataId before the viewport is fully wired
(failing planarCpuVolumeRenderPath.jest.js: 'isCurrentDataId is not a function').
It was redundant: the acquisition-centering fix sets the initial viewState and
getActiveImageIdIndex derives the index from the resolved view, so the scrollbar
already tracks the rendered slice without seeding the index at addData. Remove it
from both VtkVolumeSliceRenderPath and CpuVolumeSliceRenderPath.
}

triggerPlanarNewImage(ctx);
// A volume-backed slice change must also emit VOLUME_NEW_IMAGE so that

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably we should deprecate both volume and stack new image in favour of viewport new image, but keep on firing the old ones for a while.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, that's the right long-term direction: a single VIEWPORT_NEW_IMAGE that supersedes STACK_NEW_IMAGE / VOLUME_NEW_IMAGE, with both legacy events kept firing through a deprecation window so existing consumers (the OHIF scrollbar, synchronizers, stack prefetch, etc.) keep working. I'd rather land that as its own focused PR than fold it into this one since it touches event consumers across core, tools, and downstream. Tracking it as a follow-up.

@wayfarer3130 wayfarer3130 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review pass (correctness-focused). These are findings not already covered by the existing CodeRabbit review/a5492df fixes. Each was verified against the current head. Cleanup/duplication nits (parallelScale reconstruction across the magnify tools, dataId→volumeId actor resolution shared between getViewportModality/isViewportPreScaled) are omitted here to keep signal high.

Comment thread packages/tools/src/tools/WindowLevelRegionTool.ts
Comment on lines +471 to +473
const incomingComponents = image.color ? 3 : 1;

return scalars.getNumberOfComponents() === incomingComponents;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness (perf) — canReuseImageDataForImage assumes 3 components for every color image, so RGBA images never reuse their texture.

image.color ? 3 : 1 yields 3 for a 4-component RGBA image, while the existing vtkImageData reports 4 components, so this equality always fails and a fresh vtkImageData/texture is allocated on every scroll — reintroducing the one-frame black flash this function exists to prevent for RGBA ultrasound / secondary-capture stacks.

Derive the component count from the image (e.g. image.rgba ? 4 : image.color ? 3 : 1, or from the actual pixel-data layout) instead of hard-coding 3 for all color.

(The getDataType() vs constructor.name compare a few lines up is fine — VTK's data-type strings match the TypedArray constructor names.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this as image.color ? 3 : 1 (comment clarified in c939187). In this render path a color image's vtkImageData is always built with 3 components: getImageDataMetadata derives numberOfComponents from the photometric interpretation (RGB/YBR -> 3), and updateVTKImageDataWithCornerstoneImage down-converts an RGBA (4-component) source to RGB in place. So the post-transform component count is 3 for color and 1 otherwise, and color ? 3 : 1 matches what the imageData is actually built with.

Using image.rgba (or a literal 4) would mis-report a freshly loaded RGBA frame (rgba still true, numberOfComponents unset) as 4, never match the 3-component imageData, and force a fresh vtkImageData on every scroll - reintroducing the one-frame black flash this reuse path exists to prevent. Verified on a 94-frame RGB ultrasound: the scalars are 3-component and reuse stays on the fast path.

Comment thread packages/core/src/utilities/VoxelManager.ts
Comment thread packages/core/src/RenderingEngine/GenericViewport/Planar/PlanarViewport.ts Outdated
Comment on lines +441 to +451
if (voiRange) {
this.notifyDataPresentationModified(dataId, { voiRange });
}

if (role === 'source') {
// Emit an initial CAMERA_MODIFIED for the source binding only, mirroring
// legacy StackViewport's first-image triggerCameraEvent. Overlays and
// orientation markers paint their first frame off this event. Trigger the
// event directly rather than routing through modified() to avoid a
// redundant render (addLoadedData already rendered).
this.triggerCameraModifiedEvent(this.getCameraForEvent());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness — mount-time VOI_MODIFIED/CAMERA_MODIFIED can reset or echo across synchronizer groups.

addLoadedData now fires notifyDataPresentationModified (→ VOI_MODIFIED) per binding and triggerCameraModifiedEvent for the source on every mount. For a PLANAR_NEXT viewport in a camera or VOI sync group, mounting/replacing one display set drives the sync callback, which can copy this freshly-centered camera / default VOI onto the other viewports in the group (overwriting a slice/zoom the user already set), or feed a VOI_MODIFIED echo back into the synchronizer.

The per-display-set volumeId scoping here is already correct (fixed in a5492df); the remaining concern is the unconditional mount-time emission itself. Consider suppressing these during the mount/replace path, or guarding the synchronizer against self-originated mount events.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this matches legacy behavior rather than introducing a new reset/echo: legacy StackViewport.setStack emits VOI_MODIFIED on mount (via setVOI) and the first-image CAMERA_MODIFIED, and the native emission goes through notifyDataPresentationModified, which honors suppressEvents the same way. The VOI/camera synchronizers already have echo-suppression, and the per-binding volumeId scoping was fixed in a5492df.

So I'd prefer not to blanket-suppress the mount emission, since OHIF relies on it to refresh the W/L readout, colorbar, and VOI/camera sync on a display-set change. I'll verify the specific synced-group scenario (load a series into one pane of a synced group and confirm it doesn't reset the others' slice/VOI any differently than legacy) and narrow the emission only if there's a real divergence. Happy to take a concrete repro if you have one.

@wayfarer3130

Copy link
Copy Markdown
Collaborator

Generally I'm happy with the PR, but some repeated code bothered me, and also some of hte prescaling handling. I worked with claude to generate an analysis of that which is below:

How much of the PR is prescaling
It's a small fraction by line count but a recurring, disproportionately fiddly motif. The directly prescale-of-pixel-values cluster is roughly ~150 lines across ~7 files (~5% of the +3122):

CpuImageSliceRenderPath.ts — markCpuImagePreScaled (sets image.isPreScaled = preScale?.scaled) called at two render sites, so the CPUFallback skips the modality LUT.
planarImageRendering.ts getDefaultImageVOIRange — isPTPrescaledWithSUV(image) → hardcode {lower:0, upper:5}.
isPTPrescaledWithSUV.ts — null-safety hardening of the predicate.
getScalingParameters.ts — normalize rescale slope/intercept defaults before the identity short-circuit.
WindowLevelTool.ts — re-detect "prescaled PT SUV" (two different ways, because native viewports lack .modality/.preScale.scalingParameters).
isViewportPreScaled.ts and getViewportModality.ts — per-family branches to recover prescale/modality state.
The tell is that the same question — "is this a prescaled PT/SUV image, and what's its modality?" — is re-derived in five places with different code, because the native PlanarViewport doesn't expose the StackViewport surface (.modality, .preScale.scalingParameters, .isPreScaled) the legacy consumers were written against. That's the friction you're sensing, and it's the right thing to want to remove.

Would a single Uint16 raw→display LUT simplify it?
Partly yes for rendering, but it can't be the universal mechanism, and it doesn't delete the modality concept — it relocates it. Three grounded points:

  1. The CPU path already does this when not prescaled. generateLut.ts:42 builds exactly a composite lut[stored] = vlutfn(mlutfn(stored)) (modality ∘ VOI) over the integer pixel range when isPreScaled is false. So your proposal is essentially "make the non-prescaled composite-LUT path the only path." Doing that would let you drop markCpuImagePreScaled, the Float32 storage selection, and the float-fallback toggling of isPreScaled in BaseStreamingImageVolume.ts:369 — a real loader/volume-type simplification.

  2. It actually fixes the PT float problem rather than creating it. Today prescaling bakes SUV into a Float32Array, and floats can't index a LUT, so renderGrayscaleImage.ts:71 needs a special per-pixel function VOI for PT. If pixels stay raw integer counts, a 65536-entry composite LUT renders PT fine and that special case disappears. This is the strongest argument for your instinct.

  3. But it breaks on two hard edges and one semantic one:

GPU path isn't an indexed LUT. Volumes and the new PlanarViewport VTK image mapper use a vtkColorTransferFunction over a continuous scalar range, not a 64K-entry index array. You'd fold the affine modality transform into the TF domain instead — fine for affine, but it's a different mechanism, so "one Uint16 LUT everywhere" isn't achievable; you'd have two composing strategies.
Genuinely-float source data (RTDOSE, parametric maps, float rescale slopes) still can't index an integer LUT. Rare, but needs the function path to survive.
Tools need modality units, not display units. ROI mean/stddev, ProbeTool, and threshold segmentation read voxel values expecting HU/SUV (ProbeTool.ts:657, getStatistics.ts:62). A pixel→display LUT throws that domain away. Prescaling exists partly so tools read modality values straight off the voxels. Remove it and every such tool must apply slope/intercept/SUV itself.
The verdict / what would actually simplify
A raw-pixels + composite-LUT model is genuinely cleaner for the render path and removes the float-baking machinery — keep that idea. But the modality transform can't be deleted, only moved: the cheap affine (slope·x + intercept, ·suvbw) has to stay first-class and queryable for the GPU TF and for tools. So you don't get to "delete prescaling and the concept goes away."

The bigger win — the one that kills most of this PR's special-casing regardless of bake-vs-LUT — is a single scaling descriptor on the image/viewport: { rescaleSlope, rescaleIntercept, suvbw, modality, units } exposed uniformly by both StackViewport and PlanarViewport. Rendering reads it to build the composite LUT or TF; tools read it to convert raw→modality. That alone collapses markCpuImagePreScaled, the WindowLevelTool dual-detection, the isViewportPreScaled/getViewportModality per-family branches, and the scattered isPTPrescaledWithSUV checks into one source of truth — which is ~80% of the prescale pain in this PR. Whether you then also drop baking in favor of always-LUT is a separable, smaller decision.

If it's useful I can sketch what that descriptor + the two render-side consumers (CPU composite LUT, GPU TF folding) would look like, scoped to where this PR already touches.

sedghi added 10 commits June 26, 2026 10:58
getScalarData(true) caches the expanded scalar data (e.g. an RLE decode) so
later reads skip re-expansion. A subsequent voxel write went to the backing
store but not the cache, so getScalarData() could return stale data. Track
the cached expansion with a flag and drop it on setAtIJK/setAtIndex writes;
a real backing store (constructor / setScalarData / _updateScalarData) is left
untouched, so this is a one-boolean-check no-op on the hot path.

Addresses review feedback on VoxelManager.ts.
…nd on reformatted planes

- WindowLevelRegionTool: when a presentation (native) viewport has no resolvable
  source data id, apply the computed VOI via the single-arg setDisplaySetPresentation
  (targets the current binding) instead of silently dropping it.
- extractWindowLevelRegionToolData: a volume-mode native viewport on a reformatted
  (sagittal/coronal/oblique) plane has no single backing image; degrade gracefully
  (warn + return undefined, with a caller guard) instead of throwing and aborting
  the tool interaction. Full reformatted-slice support is a follow-up.

Addresses review feedback on WindowLevelRegionTool.ts and extractWindowLevelRegionToolData.ts.
…eometry

- Stop defaulting initialImageIdIndex to 0 in DefaultPlanarDataProvider; carry
  'no slice requested' as undefined through PlanarPayload so the volume acquisition
  view centers, while an explicit index (including 0) is honored instead of being
  collapsed to center. Render-path seeds default the placeholder index with ?? 0.
- Refresh a reused vtkImageData's origin/direction/spacing on the scroll path
  (updateVTKImageDataGeometryFromImage). Multi-frame stacks (e.g. ultrasound cine)
  place each frame at a distinct world Z and the camera follows that plane on
  scroll; without this the reused actor stayed on the first frame's plane while
  the camera moved away, leaving the viewport black from the second frame onward.
- Clarify canReuseImageDataForImage: a color image's vtkImageData is always
  3-component here (metadata + RGBA->RGB down-conversion), so color?3:1 is the
  correct post-transform count; document why not to use image.rgba / 4.

Addresses review feedback on PlanarViewport.ts (slice 0) and VtkImageMapperRenderPath.ts.
…port guard message

Accept any IViewport in _getReferencedImageId (called with native generic
viewports) and return string | undefined to match its actual behavior.
Update the guard error to reflect that the tool now supports native planar
viewports in addition to StackViewports.
The notifyDataPresentationModified hook referenced the old DataId type name,
which was renamed to DisplaySetId during the DataSet to DisplaySet
terminology change merged from main. This broke the ESM build (TS2304),
failing the docusaurus-build and OHIF downstream CI jobs.
… stop stack cache corruption on scroll

createVTKImageDataFromImage wrapped the source image's voxelManager scalar
buffer by reference. The reuse-in-place scroll path (updateVTKImageData-
WithCornerstoneImage -> scalarData.set) then overwrote that buffer with the
next frame's pixels, corrupting the first image's cached pixel data. Scrolling
back to a previously-viewed stack slice rendered the wrong (previous) slice's
pixels.

Invisible in grayscale (adjacent slices look near-identical) but surfaced as a
~52% pixel diff under a high-contrast colormap (HSV), failing the compat
Playwright stackProperties previous-image snapshots.

Copy the scalars so the actor's vtkImageData owns a private buffer (mirroring
legacy StackViewport, whose actor buffer is independent of the image cache).
The no-flash texture-reuse optimization is preserved - only the private
buffer's contents are updated in place.
…isters on the self-hosted runner

The legacy Playwright lengthTool snapshot captured a half-created measurement
(~0.552mm instead of ~138mm): on the loaded self-hosted runner the stepped
mousemoves were dropped before the active handle left the start point, so the
annotation was finalized with a near-zero length.

Increase the post-mousedown handler-attach wait (50->150ms) and the per-step
settle (10->30ms) so each move registers under runner load. End point and
local behavior unchanged (length tests still pass locally); validated on the
runner via CI.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants