From 6abe023c18129d123fa92706e6527199d818e57e Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 17 Jun 2026 09:52:08 -0400 Subject: [PATCH 01/60] 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. --- platform/core/src/types/AppTypes.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/platform/core/src/types/AppTypes.ts b/platform/core/src/types/AppTypes.ts index dca0919ff36..d943191794b 100644 --- a/platform/core/src/types/AppTypes.ts +++ b/platform/core/src/types/AppTypes.ts @@ -103,6 +103,26 @@ declare global { * Requires @cornerstonejs/core >= 5.0.0-beta (GenericViewport architecture). */ useGenericViewport?: boolean; + /** + * Opt-in: drive viewports through the DIRECT native GenericViewport ("next") + * API surface — `PLANAR_NEXT` / `VOLUME_3D_NEXT` / etc. created natively and + * driven with `setDisplaySets`, `setDisplaySetPresentation`, `setViewState` + * and view references — rather than the legacy `setStack` / `setVolumes` / + * `setProperties` / `getCamera` / `setCamera` methods. + * + * This is distinct from (and overrides) `useGenericViewport`: that flag only + * routes legacy viewport types through cornerstone's compatibility adapters + * (`rendering.useGenericViewport`), keeping the legacy method surface. This + * flag instead selects native viewport types in `getCornerstoneViewportType` + * and the native data/presentation/view-state APIs inside + * `CornerstoneViewportService` (the `nextBackend`). It does NOT set + * cornerstone's `rendering.useGenericViewport`. + * + * Defaults to false (the legacy path, byte-identical to today). Opt-in only; + * the community is not force-migrated. + * Requires @cornerstonejs/core >= 5.0.x with the GenericViewport "next" APIs. + */ + useNextViewports?: boolean; useCursors?: boolean; maxCacheSize?: number; max3DTextureSize?: number; From 6c1aad1e56ae9a42710c119722e99e12f1476f8f Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 17 Jun 2026 09:54:41 -0400 Subject: [PATCH 02/60] feat(cornerstone): map viewport types to native *_NEXT under useNextViewports (M0 step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../utils/getCornerstoneViewportType.test.ts | 59 +++++++++++++++++++ .../src/utils/getCornerstoneViewportType.ts | 28 ++++++++- 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts index b6d02f02b52..6ef7b5e9eb1 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts @@ -11,6 +11,11 @@ jest.mock('@cornerstonejs/core', () => ({ ORTHOGRAPHIC: 'orthographic', VOLUME_3D: 'volume3d', ECG: 'ecg', + PLANAR_NEXT: 'planarNext', + VOLUME_3D_NEXT: 'volume3dNext', + VIDEO_NEXT: 'videoNext', + WHOLE_SLIDE_NEXT: 'wholeSlideNext', + ECG_NEXT: 'ecgNext', }, }, })); @@ -94,4 +99,58 @@ 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 + ); + }); + }); }); diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts index fd89f0193cf..3b1c97e4b93 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts @@ -11,10 +11,36 @@ const ECG = 'ecg'; export default function getCornerstoneViewportType( viewportType: string, - displaySets?: Types.DisplaySet[] + displaySets?: Types.DisplaySet[], + useNextViewports = false ): Enums.ViewportType { const lowerViewportType = displaySets?.[0]?.viewportType?.toLowerCase() || viewportType.toLowerCase(); + + // 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, video, wholeslide, ecg` + ); + } + } + if (lowerViewportType === STACK) { return Enums.ViewportType.STACK; } From ef25b550a4896a3e4795f688fd649672c573e946 Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 17 Jun 2026 12:07:59 -0400 Subject: [PATCH 03/60] feat(cornerstone): native-next stack mount behind useNextViewports (M0 step 3 foundation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- extensions/cornerstone/src/init.tsx | 7 ++ .../CornerstoneViewportService.ts | 80 +++++++++++++++++++ .../utils/getCornerstoneViewportType.test.ts | 13 +++ .../src/utils/getCornerstoneViewportType.ts | 20 ++++- .../cornerstone/src/utils/nextViewports.ts | 18 +++++ 5 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 extensions/cornerstone/src/utils/nextViewports.ts diff --git a/extensions/cornerstone/src/init.tsx b/extensions/cornerstone/src/init.tsx index cc870af9acc..f0dedfaf516 100644 --- a/extensions/cornerstone/src/init.tsx +++ b/extensions/cornerstone/src/init.tsx @@ -27,6 +27,7 @@ import initCornerstoneTools from './initCornerstoneTools'; import { connectToolsToMeasurementService } from './initMeasurementService'; import initCineService from './initCineService'; import initStudyPrefetcherService from './initStudyPrefetcherService'; +import { setNextViewportsEnabled } from './utils/nextViewports'; import interleaveCenterLoader from './utils/interleaveCenterLoader'; import nthLoader from './utils/nthLoader'; import interleaveTopToBottom from './utils/interleaveTopToBottom'; @@ -81,6 +82,12 @@ 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). + setNextViewportsEnabled(Boolean(appConfig.useNextViewports)); + // For debugging large datasets, otherwise prefer the defaults const { maxCacheSize } = appConfig; if (maxCacheSize) { diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index e833fdd8a0d..5d9803ac6ba 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -924,6 +924,62 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi initialImageIndexToUse = this._getInitialImageIndexForViewport(viewportInfo, imageIds) || 0; } + // Native Generic Viewport ("next") 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. + if (csUtils.isGenericViewport(viewport)) { + const dataId = displaySetInstanceUIDs[0]; + + csUtils.genericViewportDataSetMetadataProvider.add(dataId, { + kind: 'planar', + imageIds, + initialImageIdIndex: initialImageIndexToUse, + }); + + await viewport.setDisplaySets({ + displaySetId: dataId, + options: { + orientation: csEnums.OrientationAxis.ACQUISITION, + role: 'source', + }, + }); + + const presentationProps: Record = {}; + if (properties.voiRange) { + presentationProps.voiRange = properties.voiRange; + } + if (properties.invert !== undefined) { + presentationProps.invert = properties.invert; + } + if (properties.colormap) { + presentationProps.colormap = properties.colormap; + } + if (Object.keys(presentationProps).length > 0) { + viewport.setDisplaySetPresentation(presentationProps); + } + + const viewStatePatch: Record = {}; + if (displayArea) { + viewStatePatch.displayArea = displayArea; + } + if (rotation) { + viewStatePatch.rotation = rotation; + } + if (flipHorizontal) { + viewStatePatch.flipHorizontal = true; + } + if (Object.keys(viewStatePatch).length > 0) { + viewport.setViewState(viewStatePatch); + } + + // Note: legacy presentation restore (setPresentations) and stack-context + // prefetch are handled on the native path in a later increment. + await this._addOverlayRepresentations(overlayProcessingResults); + return; + } + await viewport.setStack(imageIds, initialImageIndexToUse); viewport.setProperties({ ...properties }); this.setPresentations(viewport.id, presentations, viewportInfo); @@ -1351,6 +1407,30 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi viewportInfo: ViewportInfo, presentations: Presentations = {} ): Promise { + // Native Generic ("next") viewports report a single PLANAR_NEXT type for both + // stack and volume content, so the legacy isStack/isVolume type checks below + // do not classify them. Route by the data shape instead (VolumeData carries a + // `volume` field; StackData does not). + if (csUtils.isGenericViewport(viewport)) { + const firstData = (viewportData?.data?.[0] ?? {}) as Record; + + if ('volume' in firstData) { + return this._setVolumeViewport( + viewport as unknown as Types.IVolumeViewport, + viewportData as VolumeViewportData, + viewportInfo, + presentations + ); + } + + return this._setStackViewport( + viewport as unknown as Types.IStackViewport, + viewportData as StackViewportData, + viewportInfo, + presentations + ); + } + if (isStackViewportType(viewport)) { return this._setStackViewport( viewport, diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts index 6ef7b5e9eb1..c9cbcfa98b4 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.test.ts @@ -152,5 +152,18 @@ describe('getCornerstoneViewportType', () => { Enums.ViewportType.ORTHOGRAPHIC ); }); + + it('is idempotent for already-native types regardless of the flag', () => { + // A viewport's stored cs type can be re-fed into the mapper. + expect(getCornerstoneViewportType('planarNext', undefined, false)).toBe( + Enums.ViewportType.PLANAR_NEXT + ); + expect(getCornerstoneViewportType('planarNext', undefined, true)).toBe( + Enums.ViewportType.PLANAR_NEXT + ); + expect(getCornerstoneViewportType('volume3dNext', undefined, true)).toBe( + Enums.ViewportType.VOLUME_3D_NEXT + ); + }); }); }); diff --git a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts index 3b1c97e4b93..aa2d300326a 100644 --- a/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts +++ b/extensions/cornerstone/src/utils/getCornerstoneViewportType.ts @@ -1,5 +1,6 @@ import type { Types } from '@ohif/core'; import { Enums } from '@cornerstonejs/core'; +import { isNextViewportsEnabled } from './nextViewports'; const STACK = 'stack'; const VOLUME = 'volume'; @@ -12,11 +13,28 @@ const ECG = 'ecg'; export default function getCornerstoneViewportType( viewportType: string, displaySets?: Types.DisplaySet[], - useNextViewports = false + 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. diff --git a/extensions/cornerstone/src/utils/nextViewports.ts b/extensions/cornerstone/src/utils/nextViewports.ts new file mode 100644 index 00000000000..1d789f77c68 --- /dev/null +++ b/extensions/cornerstone/src/utils/nextViewports.ts @@ -0,0 +1,18 @@ +/** + * Module-level accessor for the `appConfig.useNextViewports` opt-in flag. + * + * The flag is captured once at extension init (from 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; +} From a3fa6adfa84786e35470108c6ae874d24bf23bcb Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 17 Jun 2026 14:46:16 -0400 Subject: [PATCH 04/60] 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. --- .../cornerstone/src/getToolbarModule.tsx | 8 ++- .../src/hooks/useViewportRendering.tsx | 9 ++-- .../CornerstoneCacheService.ts | 13 +++-- .../CornerstoneViewportService.ts | 13 ++++- .../src/tools/ImageOverlayViewerTool.tsx | 11 +++- .../src/utils/getViewportPresentation.ts | 52 +++++++++++++++++++ 6 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 extensions/cornerstone/src/utils/getViewportPresentation.ts diff --git a/extensions/cornerstone/src/getToolbarModule.tsx b/extensions/cornerstone/src/getToolbarModule.tsx index 5f96730a16f..c039f30fd9d 100644 --- a/extensions/cornerstone/src/getToolbarModule.tsx +++ b/extensions/cornerstone/src/getToolbarModule.tsx @@ -1,5 +1,9 @@ import { Enums } from '@cornerstonejs/tools'; import i18n from '@ohif/i18n'; +import { + getViewportProperties, + getViewportCameraState, +} from './utils/getViewportPresentation'; import { utils } from '@ohif/ui-next'; import { ViewportDataOverlayMenuWrapper } from './components/ViewportDataOverlaySettingMenu/ViewportDataOverlayMenuWrapper'; import { ViewportOrientationMenuWrapper } from './components/ViewportOrientationMenu/ViewportOrientationMenuWrapper'; @@ -543,8 +547,8 @@ export default function getToolbarModule({ servicesManager, extensionManager }: const propId = button.id; - const properties = viewport.getProperties(); - const camera = viewport.getCamera(); + const properties = getViewportProperties(viewport); + const camera = getViewportCameraState(viewport); const prop = camera?.[propId] || properties?.[propId]; diff --git a/extensions/cornerstone/src/hooks/useViewportRendering.tsx b/extensions/cornerstone/src/hooks/useViewportRendering.tsx index c134657f916..4223e804950 100644 --- a/extensions/cornerstone/src/hooks/useViewportRendering.tsx +++ b/extensions/cornerstone/src/hooks/useViewportRendering.tsx @@ -3,6 +3,7 @@ import { useSystem } from '@ohif/core'; import { useViewportDisplaySets } from './useViewportDisplaySets'; import { Types, utilities, Enums, cache } from '@cornerstonejs/core'; import { getDataIdForViewport } from '../utils/getDataIdForViewport'; +import { getViewportProperties } from '../utils/getViewportPresentation'; import { isStackViewportType, isVolumeViewportType, @@ -269,10 +270,10 @@ export function useViewportRendering( try { const dataId = getDataIdForViewport(viewport as unknown, activeDisplaySetInstanceUID); - const properties = - dataId != null - ? (viewport as Types.IBaseVolumeViewport).getProperties(dataId) - : viewport.getProperties(); + // Native Generic ("next") viewports expose per-display-set appearance via + // getDisplaySetPresentation rather than getProperties; the helper bridges + // both so this VOI/colormap initialization works regardless of backend. + const properties = getViewportProperties(viewport, dataId ?? activeDisplaySetInstanceUID); if (!properties) { return; diff --git a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts index 8940a17cd82..49311845a73 100644 --- a/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts +++ b/extensions/cornerstone/src/services/CornerstoneCacheService/CornerstoneCacheService.ts @@ -41,12 +41,19 @@ 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. + const dataShapeType = getCornerstoneViewportType(viewportType, displaySets, false); + if ( - cs3DViewportType === Enums.ViewportType.ORTHOGRAPHIC || - cs3DViewportType === Enums.ViewportType.VOLUME_3D + 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, diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 5d9803ac6ba..5deeb2b3188 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -34,6 +34,7 @@ import { usePositionPresentationStore } from '../../stores/usePositionPresentati import { useSynchronizersStore } from '../../stores/useSynchronizersStore'; import { useSegmentationPresentationStore } from '../../stores/useSegmentationPresentationStore'; import getClosestOrientationFromIOP from '../../utils/isReferenceViewable'; +import { getViewportProperties } from '../../utils/getViewportPresentation'; import { getLegacyViewportType, isStackViewportType, @@ -378,10 +379,18 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi const viewportInfo = this.viewportsById.get(viewportId); + // Direct Generic ("next") viewports do not expose the legacy getViewPresentation + // (pan/zoom) surface; their pan/zoom lives in the semantic view state and is not + // restored through the legacy setPresentations path on the native mount. Omit it + // here so position snapshots (e.g. resize save/restore) do not throw. + const viewPresentation = csUtils.isGenericViewport(csViewport) + ? undefined + : csViewport.getViewPresentation({ pan: true, zoom: true }); + return { viewportType: viewportInfo.getViewportType(), viewReference: isVolume3DViewportType(csViewport) ? null : csViewport.getViewReference(), - viewPresentation: csViewport.getViewPresentation({ pan: true, zoom: true }), + viewPresentation, viewportId, }; } @@ -410,7 +419,7 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi const properties = isVolumeViewportType(csViewport) ? new Map() - : cleanProperties(csViewport.getProperties()); + : cleanProperties(getViewportProperties(csViewport)); if (properties instanceof Map) { const volumeIds = (csViewport as Types.IBaseVolumeViewport).getAllVolumeIds(); diff --git a/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx b/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx index 84958713c18..f4fbd96b357 100644 --- a/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx +++ b/extensions/cornerstone/src/tools/ImageOverlayViewerTool.tsx @@ -54,8 +54,17 @@ class ImageOverlayViewerTool extends AnnotationDisplayTool { return; } + // A direct Generic ("next") viewport returns a falsy view-reference id until + // it has data bound (e.g. while it is being enabled, before setDisplaySets). + // getTargetId() would throw in that case and break the whole render pass, so + // skip overlay rendering until a reference is resolvable. Legacy viewports + // always return a string here, so this leaves their behavior unchanged. + if (!viewport.getViewReferenceId?.()) { + return; + } + const targetId = this.getTargetId(viewport); - return targetId.split('imageId:')[1]; + return targetId?.split('imageId:')[1]; } renderAnnotation = (enabledElement, svgDrawingHelper) => { diff --git a/extensions/cornerstone/src/utils/getViewportPresentation.ts b/extensions/cornerstone/src/utils/getViewportPresentation.ts new file mode 100644 index 00000000000..5a3d1916399 --- /dev/null +++ b/extensions/cornerstone/src/utils/getViewportPresentation.ts @@ -0,0 +1,52 @@ +import { utilities as csUtils } from '@cornerstonejs/core'; + +type GenericNextViewport = { + getSourceDataId?: () => string | undefined; + getDisplaySetPresentation?: (dataId: string) => Record | undefined; + getViewState?: () => Record | undefined; +}; + +type LegacyViewport = { + getProperties?: (dataId?: string) => Record | undefined; + getCamera?: () => Record | undefined; +}; + +/** + * Reads per-display-set appearance (voiRange, colormap, invert, ...) in a way + * that works for both legacy and direct Generic ("next") viewports. + * + * Legacy viewports expose `getProperties()`; direct PLANAR_NEXT viewports expose + * the per-binding `getDisplaySetPresentation(dataId)` instead (legacy property + * methods are absent and throw). This bridges the two so presentation readers + * (overlays, toolbar evaluators, colorbar, window-level) do not need to branch. + * + * @param viewport - any cornerstone viewport + * @param dataId - display set id to read; defaults to the active source binding + */ +export function getViewportProperties( + viewport: unknown, + dataId?: string +): Record { + if (csUtils.isGenericViewport(viewport)) { + const vp = viewport as unknown as GenericNextViewport; + const id = dataId ?? vp.getSourceDataId?.(); + return (id ? vp.getDisplaySetPresentation?.(id) : undefined) ?? {}; + } + + const legacy = viewport as LegacyViewport; + return (dataId ? legacy.getProperties?.(dataId) : legacy.getProperties?.()) ?? {}; +} + +/** + * Reads view-level state (rotation, flipHorizontal/Vertical, ...) for both + * legacy and Generic ("next") viewports. Legacy viewports expose this through + * `getCamera()`; direct Generic viewports expose semantic `getViewState()` + * (no `getCamera`). Returns an empty object when neither is available. + */ +export function getViewportCameraState(viewport: unknown): Record { + if (csUtils.isGenericViewport(viewport)) { + return (viewport as unknown as GenericNextViewport).getViewState?.() ?? {}; + } + + return (viewport as LegacyViewport).getCamera?.() ?? {}; +} From 13af1cd77f80b2bcfe51e9e2218ac9d5c5510c9b Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 17 Jun 2026 14:57:34 -0400 Subject: [PATCH 05/60] 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. --- .../src/hooks/useViewportRendering.tsx | 12 +++ .../CornerstoneViewportService.ts | 88 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/extensions/cornerstone/src/hooks/useViewportRendering.tsx b/extensions/cornerstone/src/hooks/useViewportRendering.tsx index 4223e804950..04db29f7bc1 100644 --- a/extensions/cornerstone/src/hooks/useViewportRendering.tsx +++ b/extensions/cornerstone/src/hooks/useViewportRendering.tsx @@ -663,6 +663,18 @@ export function useViewportRendering( return null; } + // Native Generic ("next") viewports (stack or volume) expose colormap via + // getDisplaySetPresentation rather than getProperties/getActors; the helper + // reads it for both backends. + if (utilities.isGenericViewport(viewport)) { + const { colormap } = getViewportProperties(viewport, activeDisplaySetInstanceUID); + return ( + colormap || + colorbarProperties?.colormaps?.find(c => c.Name === 'Grayscale') || + colorbarProperties?.colormaps?.[0] + ); + } + if (isStackViewportType(viewport)) { const { colormap } = viewport.getProperties(); if (!colormap) { diff --git a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts index 5deeb2b3188..84213687378 100644 --- a/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts +++ b/extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts @@ -1202,6 +1202,25 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi // For SEG and RT viewports const overlayProcessingResults = this._processExtraDisplaySetsForViewport(viewport) || []; + + // Native Generic ("next") 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. This replaces the legacy setVolumes/setProperties/ + // setPresentations surface, which a direct PLANAR_NEXT viewport does not expose. + if (csUtils.isGenericViewport(viewport) && filteredVolumeInputArray.length) { + await this._setNativeVolumeDisplaySets( + viewport as unknown as Types.IViewport, + filteredVolumeInputArray, + volumesProperties, + viewportInfo, + overlayProcessingResults + ); + this._broadcastEvent(this.EVENTS.VIEWPORT_VOLUMES_CHANGED, { viewportInfo }); + return; + } + if (!filteredVolumeInputArray.length && overlayProcessingResults?.length) { overlayProcessingResults.forEach(({ imageIds, addOverlayFn }) => { if (addOverlayFn) { @@ -1299,6 +1318,75 @@ class CornerstoneViewportService extends PubSubService implements IViewportServi }); } + /** + * Mounts one or more volumes on a native Generic ("next") 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. + * + * Legacy presentation restore (setPresentations) and jumpToSlice are intentionally + * not applied on the native path yet (a later increment), as they call the legacy + * camera/properties surface a direct PLANAR_NEXT viewport does not expose. + */ + private async _setNativeVolumeDisplaySets( + viewport: Types.IViewport, + filteredVolumeInputArray: Array<{ volumeInput; displaySetOptions }>, + volumesProperties: Array<{ properties: ViewportProperties; volumeId: string }>, + viewportInfo: ViewportInfo, + overlayProcessingResults + ): Promise { + const orientation = viewportInfo.getOrientation(); + const nativeViewport = viewport as unknown as { + setDisplaySets: (args: unknown) => Promise; + setDisplaySetPresentation: (dataId: string, props: Record) => void; + render: () => void; + }; + const displaySetInstanceUIDs: string[] = []; + + for (const [index, { volumeInput }] of filteredVolumeInputArray.entries()) { + const { imageIds, volumeId, displaySetInstanceUID } = volumeInput; + const dataId = displaySetInstanceUID; + displaySetInstanceUIDs.push(dataId); + + csUtils.genericViewportDataSetMetadataProvider.add(dataId, { + kind: 'planar', + imageIds, + volumeId, + }); + + await nativeViewport.setDisplaySets({ + displaySetId: dataId, + options: { + orientation, + role: index === 0 ? 'source' : 'overlay', + }, + }); + + const props = volumesProperties[index]?.properties; + if (props) { + const presentationProps: Record = {}; + if (props.voiRange) { + presentationProps.voiRange = props.voiRange; + } + if (props.invert !== undefined) { + presentationProps.invert = props.invert; + } + if (props.colormap) { + presentationProps.colormap = props.colormap; + } + if (Object.keys(presentationProps).length > 0) { + nativeViewport.setDisplaySetPresentation(dataId, presentationProps); + } + } + } + + this.viewportsDisplaySets.set((viewport as { id: string }).id, displaySetInstanceUIDs); + + await this._addOverlayRepresentations(overlayProcessingResults); + nativeViewport.render(); + } + private _processExtraDisplaySetsForViewport( viewport: Types.IStackViewport | Types.IVolumeViewport ) { From f78d01e2d37c3d27ece155121e02ab98c21f4989 Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 17 Jun 2026 15:09:21 -0400 Subject: [PATCH 06/60] 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). --- extensions/cornerstone/src/commandsModule.ts | 9 ++++++++- platform/app/public/config/default.js | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 98e3d495b99..dbd9e4d7580 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -2151,7 +2151,14 @@ function commandsModule({ setViewportOrientation: ({ viewportId, orientation }) => { const viewport = cornerstoneViewportService.getCornerstoneViewport(viewportId); - if (!viewport || !isOrthographicViewportType(viewport)) { + // Accept legacy ORTHOGRAPHIC viewports and native Generic ("next") viewports + // currently in volume mode. A native PLANAR_NEXT viewport renders MPR but + // reports type=planarNext, so the legacy type guard alone misses it; the + // content-mode capability guard catches it. Both expose setOrientation(). + if ( + !viewport || + !(isOrthographicViewportType(viewport) || csUtils.viewportIsInVolumeMode(viewport)) + ) { console.warn('Orientation can only be set on volume viewports'); return; } diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index afca474b1e1..5c0182ddb2a 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -3,6 +3,7 @@ window.config = { name: 'config/default.js', routerBasename: null, + useNextViewports: true, // whiteLabeling: {}, extensions: [], modes: [], From e164f8c5e68835ab106c2323f77b4193b050e3b5 Mon Sep 17 00:00:00 2001 From: Alireza Date: Wed, 17 Jun 2026 16:57:11 -0400 Subject: [PATCH 07/60] 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. --- GENERIC_VIEWPORT_MIGRATION_PLAN.html | 271 ++++++++ GENERIC_VIEWPORT_MIGRATION_PLAN.md | 634 ++++++++++++++++++ TODO_BEFORE_MERGE.md | 41 ++ .../Overlays/ViewportImageScrollbar.tsx | 21 +- .../ViewportSliceProgressScrollbar/helpers.ts | 20 +- .../ViewportSliceProgressScrollbar/hooks.ts | 15 +- extensions/cornerstone/src/commandsModule.ts | 10 + .../cornerstone/src/getToolbarModule.tsx | 14 + extensions/cornerstone/src/init.tsx | 6 +- .../CornerstoneViewportService.ts | 45 +- .../cornerstone/src/utils/nextViewports.ts | 36 + modes/basic/src/index.tsx | 2 + modes/basic/src/toolbarButtons.ts | 14 + 13 files changed, 1121 insertions(+), 8 deletions(-) create mode 100644 GENERIC_VIEWPORT_MIGRATION_PLAN.html create mode 100644 GENERIC_VIEWPORT_MIGRATION_PLAN.md create mode 100644 TODO_BEFORE_MERGE.md diff --git a/GENERIC_VIEWPORT_MIGRATION_PLAN.html b/GENERIC_VIEWPORT_MIGRATION_PLAN.html new file mode 100644 index 00000000000..bc029620f02 --- /dev/null +++ b/GENERIC_VIEWPORT_MIGRATION_PLAN.html @@ -0,0 +1,271 @@ + + + + + +OHIF → Cornerstone Generic Viewport Migration — Status & Next Waves + + + +
+ + +
+
+

OHIF → Cornerstone Generic Viewport ("next") — Status & Next Waves

+
Living status of the native-next migration. The Playwright-validatable cornerstone + foundation is shipped (7 local commits); this document tracks only what remains and where the next + wave should go. The original full plan is in GENERIC_VIEWPORT_MIGRATION_PLAN.md.
+
+ Cornerstone: branch cornerstoneohifnextapi @ 5.0.8 + Shipped: 7 commits (local, unpushed) + OHIF M0: not started + Updated: 2026-06-16 +
+
+ + +

1Status at a glance

+
Cornerstone foundation: DONE & Playwright-green +

8 blockers fully implemented (CS-1, CS-3, CS-5, CS-7, CS-11, CS-15, CS-17, CS-19), + 4 already present in 5.0.x (CS-4, CS-13, CS-21, CS-22), and the read-only / sync parts of CS-8 + (contour, reference lines, camera-position sync). Every commit was gated on the tests/genericViewport + Playwright suite (chromium, 124–126 passing, 0 failed).

+
What's left can't be gated in cornerstone +

The remaining blockers — CS-8 writes (CrosshairsTool, ReferenceCursors, + TrackballRotate), CS-9 cine, CS-10 crosshairs-orthographic — have no native Playwright coverage + in cornerstone's tests/ (no crosshairs/cine/3D specs). They become honestly testable only when + driven through OHIF. That is why the next wave is the OHIF M0 seam (§6).

+ + +

2Shipped — 7 commits on cornerstoneohifnextapi

+
+ + + + + + + + + + +
CommitBlockersWhat it does
5635bdab4CS-5, CS-16(type), CS-17, CS-1, CS-6(slice), CS-7content-mode predicate + capability guards + IGenericViewport type; VOI_MODIFIED/COLORMAP_MODIFIED events; VOLUME_NEW_IMAGE; native getNumberOfSlices()
1b13aa55fCS-3, CS-15segmentation-preserving setDisplaySets; canRenderOrientation() + canRender()
b1c633a74CS-11VOI + image-slice synchronizers work on native viewports
5e80431c1CS-19per-mount PlanarSetDataOptions.forceCPU
06605d040CS-8 (contour)contour / RTSTRUCT rendering on native viewports via the getViewportICamera bridge
2a7ab294bCS-8 (ref-lines)ReferenceLinesTool on native viewports (read-only → fully works)
96e610d5cCS-8 (camera sync)cameraSyncCallback via setViewReference on native viewports
+

Already present in 5.0.x (no work needed): CS-4 data-inferred + render path & multi-volume mount · CS-13 labelmap-slice rendering · CS-21 view-reference resolution + (referencedImageId / planeRestriction; null-orientation edge cases may still need hardening) + · CS-22 fusion colormap + multi-point opacity TF (ColormapPublic.opacity: OpacityMapping[]).

+
Design rule that shaped CS-8 +

Native viewports must not expose getCamera/setCamera/resetCamera + — enforced by the jest test "does not expose view-presentation methods on Planar Next" (verified by reverting + the "cheap" approach). Tools read camera via the getViewportICamera bridge (resolved-view + legacy + fallback) and write via setViewReference/setViewState, guarded by utilities.isGenericViewport.

+ + +

3Remaining cornerstone work

+ +
CS-8 PARTIAL + — CrosshairsTool / ReferenceCursors / TrackballRotate camera writes +

CrosshairsTool.ts (26 raw getCamera/setCamera) · ReferenceCursors.ts (4) · TrackballRotateTool.ts (2)

+

Reads → getViewportICamera; writes (slice-jump navigation) → setViewReference/setViewState, guarded by isGenericViewport. Crosshairs is large and intricate (linked-viewport navigation). + Validation: no cornerstone native test — gate via OHIF crosshairs scenario.

+ +
CS-10 REMAINING + — Crosshairs assume ORTHOGRAPHIC volume viewports +

Internal instanceof VolumeViewport / type === ORTHOGRAPHIC assumptions; resolve geometry via capability + view references. Tied to the CS-8 crosshairs migration above.

+ +
CS-9 REMAINING + — cine playClip throws on PLANAR_NEXT +

utilities/cine/playClip.ts (instanceof Stack/Volume/Video branches; "Unknown viewport type")

+

Add a generic/PLANAR_NEXT branch using getCurrentMode()/getNumberOfSlices() instead of instanceof. No cornerstone cine spec — gate via OHIF cine.

+ +
CS-6 (part 2) PARTIAL + VOLUME_VIEWPORT_NEW_VOLUME on the native add path +

Part 1 (VOLUME_NEW_IMAGE) shipped. Part 2 still fires only from the legacy compatibility controllers. Emit it once-per-setDisplaySets from the native volume mount and remove the legacy duplicate (double-fire care). Needed for per-volume OHIF init (seg reps, colorbar).

+ +
CS-16 PARTIAL + — widen IEnabledElement.viewport union +

The IGenericViewport type ships; the union widening is intentionally deferred until tool consumers narrow (blocked on CS-8/CS-10, since ~114 tools files read enabledElement.viewport.getCamera()).

+ +
CS-12 / CS-18 / CS-20 MINOR + — calibration, registration-store GC, ECG/video/WSI id ergonomics +

CS-12 native calibrateSpacing() on PlanarViewport · CS-18 registration-store eviction/validation · CS-20 cleaner display-set id setup for ECG/video/WSI. All low-priority polish; can land any time.

+ + +

4Feature matrix (updated)

+
+ + + + + + + + + + + + + + + + + +
FeatureNative-next pathStatus
Stack loadadd+setDisplaySets (data-inferred)DONE
Volume / MPR loadsetDisplaySets({orientation})DONE
PT/CT fusionsource+overlay roles + per-overlay setDisplaySetPresentationDONE (pre-existing)
VOI / W-L, colormap, invertsetDisplaySetPresentation + VOI/COLORMAP eventsDONE (CS-1)
Content-mode discriminationgetCurrentMode() + isGenericViewport guardsDONE (CS-5/17)
Slice nav & jumpnative getNumberOfSlices(); jumpToSliceDONE (CS-7)
Orientation pre-checkcanRenderOrientation()DONE (CS-15)
Segmentation (labelmap)useSliceRendering; seg-preserving setDisplaySetsDONE (CS-3/13)
Sync — VOI / image-slice / zoom-pan / camera-posnative synchronizer callbacksDONE (CS-11, CS-8)
Contour / RTSTRUCTgetViewportICamera bridgeDONE (CS-8)
Reference linesgetViewportICamera bridgeDONE (CS-8)
Crosshairs (+ MPR navigation)bridge reads + setViewReference writesPARTIAL (CS-8/10)
Cinegeneric branch in playClipREMAINING (CS-9)
3D / MIP VRnative VR preset API / TrackballRotateREMAINING (CS-14)
+ + +

5SR / RT / PMAP / tmtv

+
    +
  • SR — thin layer; jump already uses setViewReference. Rides OHIF M1 (stack). View-reference resolution (CS-21) is largely present.
  • +
  • RTSTRUCT — contour rendering now works on native viewports DONE (CS-8 contour). The OHIF-side rep wiring rides M4.
  • +
  • PMAP — a color-fusion second volume; rides M2 (mount) + M3 (presentation). Per-overlay colormap+opacity-TF already supported (CS-22).
  • +
  • tmtv — heaviest consumer; fusion colormap (M3) + ROI-threshold labelmap (M4) + crosshairs (CS-8 writes) + MIP (CS-14). Integration/acceptance vehicle; depends on the crosshairs/3D remainder.
  • +
+ + +

6Next wave — decision: OHIF M0 seam

+
Decision +

Do the OHIF M0 seam next, not more isolated cornerstone work. The + Playwright-validatable cornerstone foundation is done; the remaining cornerstone blockers (crosshairs writes, + cine, 3D) cannot be honestly gated where they live. OHIF M0 is the harness that turns them into observable, + gateable behavior — so pull them in demand-driven as real OHIF scenarios break.

+ +

Why now

+
    +
  • Cornerstone foundation (planar stack/volume/fusion, presentation events, synchronizers, contour, reference lines) is shipped and green.
  • +
  • Remaining cornerstone blockers have zero native Playwright coverage — adding more there is unfalsifiable progress.
  • +
  • OHIF M0 touches exactly two chokepoints (small, well-understood) and has useGenericViewport as a flag-threading precedent.
  • +
+ +

Ordered steps

+
0Link OHIF to the local 5.0.8 cornerstone worktree
+
The 7 commits are local & unpushed, so npm 5.0.8 does not contain them. Use the repo's + cs3d:install/cs3d:build/cs3d:link scripts against + /Users/alireza/open-source/clean/cornerstone3d.worktrees/cornerstoneohifnextapi (this OHIF worktree has no node_modules yet). A plain version bump alone gets base 5.0.8 without these changes.
+
1Add appConfig.useNextViewports
+
Type it in platform/core/src/types/AppTypes.ts next to useGenericViewport; read it in exactly two places. It does not set cornerstone's useGenericViewport (that's the forbidden legacy remap).
+
2*_NEXT branch in getCornerstoneViewportType.ts
+
When the flag is on: stack/volume/orthographicPLANAR_NEXT; others → their *_NEXT. (44-line pure mapper; no HP/mode edits.)
+
3Backend split in CornerstoneViewportService
+
Move-only legacyBackend (byte-identical) vs nextBackend (setDisplaySets/setDisplaySetPresentation/setViewState/view references), selected by the flag in the constructor. The line-855 setDisplaySets is a legacy-adapter shim — build the real native path.
+
4Fix getLegacyViewportType misclassification
+
It reads requestedType ?? type; native PLANAR_NEXT carries no legacy requestedType, so route its ~67 consumers through ViewportInfo / capability guards before enabling the flag.
+
5Validate native stack + MPR in OHIF behind the flag
+
This is where CS-8 crosshairs/CS-9 cine/CS-14 3D become real, gateable bugs. Fix each in the 5.0.8 worktree as it breaks an OHIF scenario, re-link, and prove it with an OHIF-level Playwright test.
+ + +

7How OHIF consumes the cornerstone changes

+
Dependency reality +

OHIF pins @cornerstonejs/* to 5.0.0. The native-next commits live only + on the local cornerstoneohifnextapi worktree (5.0.8, unpushed). So:

+
    +
  • Right path: cs3d:link OHIF → the local 5.0.8 worktree (fast local iteration on the fixes discovered during M0).
  • +
  • Not sufficient: bumping the npm pin to 5.0.8 — published 5.0.8 lacks these commits.
  • +
  • Durable path: push/publish the cornerstone commits, then bump the OHIF pin.
  • +
+ + +

8Validation gap & risks

+
    +
  • The core gap: cornerstone has no native crosshairs/cine/3D Playwright specs, so those blockers can only be validated through OHIF (M0) — the reason the next wave is OHIF, not more cornerstone.
  • +
  • Backward-compat: the bridge migrations (contour/reference-lines/cameraSync) fall back to getCamera() on legacy viewports, so legacy behavior is byte-identical (validated by genericViewport + contour/MPR legacy specs).
  • +
  • Highest residual risk: crosshairs camera writes (linked-viewport slice navigation) and cine playback on volume-backed planar — intricate; validate with dedicated OHIF MPR/cine scenarios.
  • +
  • Don't force-break the community: M0 keeps the legacy path default and byte-identical; useNextViewports is opt-in.
  • +
+ +
+ Status v2 · 2026-06-16 · cornerstone branch cornerstoneohifnextapi (7 local commits) · + next wave = OHIF M0 seam (link to local 5.0.8). Source of record: + GENERIC_VIEWPORT_MIGRATION_PLAN.md. +
+
+
+ + diff --git a/GENERIC_VIEWPORT_MIGRATION_PLAN.md b/GENERIC_VIEWPORT_MIGRATION_PLAN.md new file mode 100644 index 00000000000..a059439f16d --- /dev/null +++ b/GENERIC_VIEWPORT_MIGRATION_PLAN.md @@ -0,0 +1,634 @@ +# OHIF → Cornerstone3D Generic Viewport ("next") Migration Plan + +Status: draft plan, grounded in `@cornerstonejs/core` 5.0.x source at +`cornerstone3d.worktrees/cornerstoneohifnextapi` (core reports `5.0.8`) and the OHIF +`extensions/cornerstone` source in this worktree. Facts marked **[VERIFIED]** were read directly +from source for this plan; items marked **[from investigation]** come from the agent sweep and +should be re-pinned to exact line numbers before any PR cites them. + +--- + +## 1. Goal and constraints + +- OHIF must run on the cornerstone3D **native Generic Viewport / "next" API directly** — + `ViewportType.PLANAR_NEXT` / `VOLUME_3D_NEXT` / `VIDEO_NEXT` / `ECG_NEXT` / `WHOLE_SLIDE_NEXT`, + driven by `setDisplaySets` / `addDisplaySet` / `setDisplaySetPresentation` / `setViewState` / + `updateViewState` / `resetViewState` / `viewportProjection` / view references. +- OHIF must **not** use the legacy compatibility layer: not the + `init({ rendering: { useGenericViewport: true } })` remap, and not the legacy adapter methods + (`setStack` / `setVolumes` / `setProperties` / `getProperties` / `getCamera` / `setCamera` / + `getViewPresentation` / `setViewPresentation` / `resetCamera`). +- The migration is **opt-in** behind a new OHIF `appConfig` flag. The legacy OHIF code path stays + byte-identical until the community has migrated; no forced breaking changes. +- Where something is missing or broken in cornerstone for native-next OHIF to work, the fix + **belongs in cornerstone3D, not OHIF** (Section 5). + +--- + +## 2. The architectural inversion (why this is more than a method rename) + +In the legacy world, the **viewport type encodes the data shape**: `STACK` viewports take +`setStack(imageIds)`, `ORTHOGRAPHIC` viewports take `setVolumes([{ volumeId }])`. OHIF chooses the +type up front and the type dictates the data API. + +In cornerstone-next, **one `PLANAR_NEXT` viewport renders both**, and the **render path is inferred +from the data**: + +``` +displaySetId + -> genericViewportDataSetMetadataProvider.add(id, { kind:'planar', imageIds, initialImageIdIndex, volumeId? }) + -> viewport.setDisplaySets({ displaySetId, options:{ orientation, role } }) + -> render path inferred: no volumeId + ACQUISITION => image/stack path; volumeId or reformatted orientation => volume-slice path + -> view state (pan/zoom/rotate/flip/slice/displayArea) via setViewState / viewportProjection + -> per-binding appearance (VOI/colormap/invert/opacity/visible) via setDisplaySetPresentation(displaySetId, ...) +``` + +Two ownership splits follow: + +- **View presentation** (pan, zoom/scale, rotation, flips, displayArea) lives in `viewState`, + mutated only through `setViewState`/`updateViewState`/`resetViewState` (or + `viewportProjection.withPresentation(...) -> setViewState`). +- **Data presentation** (VOI, colormap, invert, opacity, blend mode, interpolation, visibility) is + **per mounted display set** via `setDisplaySetPresentation(displaySetId, props)`. This is what + lets a CT source and PET overlay share one view but keep independent windowing/color. + +### 2.1 The decisive fact: how to get a *native* viewport **[VERIFIED]** + +`packages/core/src/RenderingEngine/helpers/viewportTypeToViewportClass.ts` registers: + +```ts +registerViewportType({ + type: ViewportType.PLANAR_NEXT, + ViewportClass: PlanarViewport, // <- native class + resolveClass: ({ type, requestedType }) => + type === ViewportType.PLANAR_NEXT && + (requestedType === ViewportType.STACK || requestedType === ViewportType.ORTHOGRAPHIC) + ? PlanarViewportLegacyAdapter // <- legacy shim ONLY in this case + : undefined, +}); +``` + +Consequences that anchor the whole plan: + +1. If OHIF calls `enableElement({ type: PLANAR_NEXT })` **without** setting `requestedType` to a + legacy type, it gets the **pure `PlanarViewport`** — the legacy methods (`setStack`, + `setProperties`, `getCamera`, `setCamera`, …) are **genuinely absent** (calling them is a + `"… is not a function"` TypeError, not a deprecation warning). This is exactly the surface we + target, and it is why the tool/command blockers in Section 5 are real, not theoretical. +2. The `useGenericViewport` remap works precisely by requesting a legacy type (`STACK`) that + cornerstone re-routes to runtime `PLANAR_NEXT` while preserving `requestedType: STACK`, which + selects `PlanarViewportLegacyAdapter`. **That is the path we forbid.** Our flag must request + `PLANAR_NEXT` directly. +3. A natively-created `PLANAR_NEXT` viewport therefore carries **no legacy `requestedType`**. Any + OHIF helper that reads `viewport.requestedType ?? viewport.type` to decide "stack vs volume" + (the ~67-consumer `getLegacyViewportType` helper) will see `PLANAR_NEXT` for both — see §4.4 for + how OHIF sidesteps this with its own `ViewportInfo` type tracking. + +### 2.2 Native `PlanarViewport` surface that already works **[VERIFIED]** + +- Data: `setDisplaySets(...entries)`, `addDisplaySet(id, options)`, `removeData(dataId)` + (note: **no** `removeDisplaySet`/`getDisplaySets` enumerator — removal is by id, the only listing + accessor is `getSourceDataId()`). +- Presentation: `setDisplaySetPresentation(props | (displaySetId, props))`, + `getDisplaySetPresentation(displaySetId)`. +- View state: `getViewState()` / `setViewState(patch)` / `updateViewState(updater)` / + `resetViewState(opts)` — and `PlanarViewState` carries **`orientation`, `slice`, `anchorWorld`, + `anchorCanvas`, `scale`, `scaleMode`, `rotation`, `flipHorizontal`, `flipVertical`, + `displayArea`**. So pan (anchor), zoom (`scale`), rotation, flips and displayArea all round-trip + through view state. `getScale`/`setScale` are native; `getPan`/`setPan`/`getZoom`/`setZoom` exist + but are `@deprecated` shims. +- Navigation: `setImageIdIndex` (stores `stackIndex` for image data, resolves a `volumePoint` for + volume data), `getCurrentImageIdIndex`/`getSliceIndex`, `getImageIds`. +- Spatial: `getViewReference` / `setViewReference` / `getViewReferenceId` / `isReferenceViewable`, + `getResolvedView()` (exposes `toICamera()` — the native replacement for `getCamera()`), + `canvasToWorld` / `worldToCanvas`, `getFrameOfReferenceUID`. +- Events that fire natively **[VERIFIED]**: `CAMERA_MODIFIED` (on `setViewState`), `CAMERA_RESET` + (on `resetViewState`), plus `IMAGE_RENDERED` and `DISPLAY_AREA_MODIFIED` [from investigation]. + +--- + +## 3. OHIF surface area (what couples to the legacy viewport API) + +The good news: OHIF is already mid-cleanup. Raw `instanceof StackViewport/VolumeViewport` checks +are gone, `getStackViewports`/`getVolumeViewports` are gone, and type branching is already funneled +through two helpers (`getCornerstoneViewportType`, `getLegacyViewportType`). The imperative +cornerstone-viewport calls are concentrated in **one service**. + +- **`extensions/cornerstone/src/services/ViewportService/CornerstoneViewportService.ts`** (1610 + lines) — the only object that drives the imperative viewport API (`setStack`, `setVolumes`, + `setProperties`, `getCamera`/`setCamera`, `setDisplayArea`, presentation get/set). **This is the + seam.** +- **`extensions/cornerstone/src/utils/getCornerstoneViewportType.ts`** — the single chokepoint + mapping OHIF type strings (`stack`/`volume`/`orthographic`/`volume3d`/`video`/`wholeslide`/`ecg`) + to `Enums.ViewportType`. **[VERIFIED]** it already prefers `displaySets[0].viewportType`. This is + where the flag flips the requested type. +- **`extensions/cornerstone/src/utils/getLegacyViewportType.ts`** (+ `isStack*`/`isVolume*` + guards, ~67 consumers) — reads `requestedType ?? type`; misclassifies native `PLANAR_NEXT`. +- **`extensions/cornerstone/src/commandsModule.ts`** (~99 KB) — window-level/VOI, colormap, invert, + flip, rotate, zoom, reset, jump-to-slice, jump-to-measurement, 3D VR presets via raw vtk actors. +- **`extensions/cornerstone/src/hooks/useViewportRendering.tsx`** + overlays + (`CustomizableViewportOverlay`, `ViewportOrientationMarkers`, `ViewportImageScrollbar`, + `ViewportSliceProgressScrollbar`) — read live state (`getProperties().voiRange`, `getZoom()`, + `getCamera().viewPlaneNormal`) and subscribe to element events. +- **Presentation stores** (`extensions/cornerstone/src/stores`, `getPresentationIds`) + + **synchronizers** (`SyncGroupService`, `extensions/cornerstone/src/synchronizers`). +- **`SegmentationService`** — legacy single-viewport `addSegmentationRepresentations` + + `convertStackToVolumeViewport`. +- **`CornerstoneCacheService`** — builds the stack vs volume data the service mounts (the actual + data-build seam feeding `setDisplaySets`). + +Existing flag wiring **[VERIFIED]**: `appConfig.useGenericViewport` exists (`AppTypes.ts:105`, +`init.tsx:80`) and activates cornerstone's legacy remap. We add a sibling flag; we do **not** reuse +this one. + +--- + +## 4. OHIF architecture: the opt-in native-next seam + +### 4.1 Strangler-fig principle + +Additive from the community's perspective: do not rewrite the stack/volume paths — **move** them +byte-for-byte behind one boundary and grow a second implementation alongside. One flag selects which +runs. Flag off (default) = today's behavior exactly; nobody is force-broken. The native path can +ship incomplete (unmigrated families fall back to legacy) without regressing anyone. + +### 4.2 The flag + +Add **`appConfig.useNextViewports`** (boolean, default `false`) next to `useGenericViewport` in +`platform/core/src/types/AppTypes.ts`, read in `extensions/cornerstone/src/init.tsx`. + +- It does **not** set cornerstone's `rendering.useGenericViewport`. +- It tells OHIF to (a) request `*_NEXT` types natively and (b) drive them with the native API. +- The two flags are mutually exclusive in intent (`useGenericViewport` = legacy methods over + remapped viewports; `useNextViewports` = native methods over native `*_NEXT` viewports). + Document that `useNextViewports` overrides/ignores `useGenericViewport`. + +The flag is read in exactly **two** places: `getCornerstoneViewportType` (type selection) and the +`CornerstoneViewportService` constructor (backend selection). A lint/grep CI rule should forbid it +anywhere else so the off-path cannot drift. + +### 4.3 Backend split inside `CornerstoneViewportService` + +Extract two collaborators behind a small interface, selected once in the constructor: + +| Concern | Shared (stays in service) | Forked (`legacyBackend` vs `nextBackend`) | +|---|---|---| +| Element registration / resize / destroy / find / navigation helpers | yes | — | +| `setViewportData` front door, `_setDisplaySets` shell | yes | dispatch only | +| Stack mount (`_setStackViewport`) | — | yes | +| Volume / 3D mount (`_setVolumeViewport` / `setVolumesForViewport`) | — | yes | +| ECG mount (`_setEcgViewport`) | — | yes | +| Video / WSI mount (`_setOtherViewport`) | already native (`setDisplaySets`) | shared template | +| Presentation read (`_getPositionPresentation` / `_getLutPresentation`) | — | yes | +| Presentation write (`_setLutPresentation` / `_setPositionPresentation`) | — | yes | +| `updateViewport` keep-camera | — | yes | + +- **`legacyBackend`** is a *move-only* extraction (cut/paste of the exact current bodies, zero + behavioral change) — runs when the flag is off and must stay byte-identical. +- **`nextBackend`** is the new implementation using the §2.2 native surface. `_setOtherViewport` is + the proof-of-shape: it already does `await viewport.setDisplaySets({ displaySetId })` + + `setViewReference(...)` for VIDEO/WHOLE_SLIDE inside this very service. + +### 4.4 Viewport-type selection: `'stack'`/`'volume'` collapse onto `PLANAR_NEXT` + +Under `useNextViewports`, `getCornerstoneViewportType` maps: + +- `'stack'` → `PLANAR_NEXT` +- `'volume'` / `'orthographic'` → `PLANAR_NEXT` +- `'volume3d'` → `VOLUME_3D_NEXT`, `'video'` → `VIDEO_NEXT`, `'wholeslide'` → `WHOLE_SLIDE_NEXT`, + `'ecg'` → `ECG_NEXT` + +**No hanging protocol or mode edits are required** — they keep emitting `'stack'`/`'volume'` +strings through this one chokepoint. + +The stack-vs-volume decision moves to the **data layer**: keep building a cached volume (`volumeId`) +for reconstructable/MPR/3D display sets in `CornerstoneCacheService` exactly as today (that decision +is modality/`isReconstructable`-driven, not type-driven). Then register the dataset and mount it; +`ACQUISITION` orientation → image/stack path, `AXIAL/SAGITTAL/CORONAL` → volume-slice path. + +**Type discrimination after the switch.** A native `PLANAR_NEXT` viewport reports `type === +PLANAR_NEXT` with no legacy `requestedType`, so `getLegacyViewportType` (read off the cornerstone +viewport) would misclassify. The mitigation is that **OHIF already stores its own requested type on +`ViewportInfo`** (`getViewportType()` returns the OHIF string/legacy enum). The React overlays and +service branches read that OHIF-owned value, not the runtime cornerstone type — so they survive both +backends untouched **as long as we preserve that invariant**. Code that only holds a *cornerstone* +viewport handle (mostly tools/utils) still needs a content/capability predicate from cornerstone +(blocker CS-5 / CS-17). + +### 4.5 Presentation store/restore → viewState / DataPresentation / viewReference + +The store **keys** (`getPositionPresentationId`, `getLutPresentationId`) are viewport-agnostic and +**do not change**. Only the read/write channel forks: + +- **PositionPresentation** (camera/zoom/pan/slice): `getViewReference` / `setViewReference` / + `isReferenceViewable` are **already native — keep them**. The pan/zoom portion moves from + `getViewPresentation`/`setViewPresentation` to `getViewState`/`setViewState` (or + `viewportProjection.getPresentation`/`withPresentation` → `setViewState`). The + `PositionPresentation` type already stores a `ViewReference` + a presentation blob, so the schema + survives. +- **LutPresentation** (VOI/colormap/invert): from `getProperties([volumeId])` / + `getAllVolumeIds()` → `getDisplaySetPresentation(displaySetId)` / + `setDisplaySetPresentation(displaySetId, { voiRange, colormap, invert, … })`. **Re-key the store + by `displaySetInstanceUID`** instead of `volumeId`. +- **displayArea / rotation / flip**: today three separate legacy calls (`setDisplayArea`, + `setProperties({rotation})`, `setCamera({flipHorizontal})`); in the next path they fold into a + single `setViewState({ displayArea, rotation, flipHorizontal, flipVertical })` — **[VERIFIED]** + all four are `PlanarViewState` fields. + +`updateViewport` keep-camera becomes a `getViewState()`/`setViewState()` snapshot-restore. + +### 4.6 React layer + overlays + +The React layer is thin and stays shared: `OHIFCornerstoneViewport` only calls service-level +`enableViewport` + `setViewportData`. The risk concentrates in **overlays / `useViewportRendering`**, +which read live state and subscribe to element events: + +- **State reads** route through `viewportProjection.getPresentation()` / `getViewState()` / + `getDisplaySetPresentation()` (and `canvasToWorld`, already native). Centralize these in + `useViewportRendering` so overlays consume an OHIF-shaped object regardless of backend. +- **Type branches** keep working because they read OHIF's `ViewportInfo.viewportType`, not the + runtime type (preserve this invariant — §4.4). +- **Events** are the largest blocked-on-cornerstone risk — see CS-1 and CS-6. + +### 4.7 Data-build seam (`CornerstoneCacheService`) and `dataId` lifecycle + +The `nextBackend` owns a thin wrapper over `genericViewportDataSetMetadataProvider.add/remove`: + +- **`dataId` scheme**: deterministic and unique, derived from `displaySetInstanceUID` (+ an overlay + suffix for fusion / labelmap overlays). This makes cornerstone's `dataId` equal to OHIF's + `displaySetInstanceUID`, which is also the key the LUT presentation store now uses. +- **Cleanup**: call `.remove(dataId)` on unmount. Cornerstone's `removeData`/`setDisplaySets` do + **not** GC the global registration store (blocker CS-18), so OHIF must own this discipline. +- **Shared-volume case** (MPR triptych shares one `volumeId`): ref-count the registration, or scope + registration per viewport, so unmounting one pane does not unregister data another still uses. + (This is an OHIF-owned invariant; design it in M0.) + +--- + +## 5. Cornerstone-side work (fixes that belong in cornerstone3D, **not** OHIF) + +Consolidated and de-duplicated from the investigation. Severity reflects the **verified** native +path (OHIF requesting `PLANAR_NEXT` directly, per §2.1). Re-pin all cornerstone `file:line` +references against the pinned 5.0.x build before opening PRs. + +### Blockers (gate the core planar milestones) + +- **CS-1 — `setDisplaySetPresentation` emits no event. [VERIFIED]** + `GenericViewport.setDataPresentationState` (`GenericViewport.ts:567-582`) calls + `binding.updateDataPresentation(props)` and never `triggerEvent`. `COLORMAP_MODIFIED` fires only + from the legacy controller. OHIF's VOI sliders, colorbar, W/L text, invert/opacity/visibility + controls, and VOI sync groups are entirely event-driven, so they go stale after any programmatic + or tool-driven presentation change. **Fix:** after `updateDataPresentation`, diff and + `triggerEvent` `VOI_MODIFIED` (voiRange/invert), `COLORMAP_MODIFIED` (colormap), and a generic + `VIEWPORT_DATA_PRESENTATION_MODIFIED` carrying the full delta + `displaySetId` (planar bindings + are multi). **This is the single biggest blocker.** + +- **CS-6 — slice / new-volume events on `PLANAR_NEXT`.** Volume-backed slices emit + `STACK_NEW_IMAGE`, never `VOLUME_NEW_IMAGE`; `VOLUME_VIEWPORT_NEW_VOLUME` fires only from the + legacy controller. OHIF volume scroll indicators, MPR slice sync, segmentation slice tracking, and + per-volume init never run. **Fix:** emit `VOLUME_NEW_IMAGE` for volume-backed slices and + `VOLUME_VIEWPORT_NEW_VOLUME` on the native add path (or define documented native equivalents and a + stable one-event-set mapping). Confirm `STACK_VIEWPORT_SCROLL` and `ELEMENT_ENABLED` fire for + natively-created viewports. + +- **CS-7 — `getNumberOfSlices()` absent on native `PlanarViewport`.** `utilities/jumpToSlice.ts` + takes the volume branch for a non-`StackViewport` and calls `getNumberOfSlices()` → TypeError. + Breaks programmatic jumps and every image-slice synchronizer targeting a `PLANAR_NEXT` viewport. + **Fix:** add native `getNumberOfSlices()` (promote the adapter impl), and make + `jumpToSlice`/`_getImageSliceData` duck-type on `getNumberOfSlices`/`getSliceIndex` instead of + `instanceof StackViewport`. + +- **CS-8 — tools call `getCamera()`/`setCamera()`/`resetCamera()` unconditionally.** Crosshairs, + ReferenceLines, ReferenceCursors, contour display, and `cameraSyncCallback` throw immediately on + `PLANAR_NEXT`. **Fix (pick one repo-wide):** (a) promote native `getCamera`/`setCamera`/ + `resetCamera` onto `GenericViewport` delegating to `getResolvedView().toICamera()` (read) and + `setViewState`/`setOrientation` (write) — unblocks everything at once; or (b) migrate each tool to + `getResolvedView().toICamera()` + `setViewReference`/`setViewState` (the labelmap chain already + does this). `cameraSyncCallback` should mirror `zoomPanSyncCallback`'s projection fallback. + +- **CS-9 — cine `playClip` throws `'Unknown viewport type'` on `PLANAR_NEXT`.** + `_createCinePlayContext` only branches `instanceof Stack/Volume/Video`. **Fix:** add a + `PLANAR_NEXT` cine context (duck-type `scroll`/`getSliceIndex`/`getNumberOfSlices`; depends on + CS-7). Can stay legacy under the flag and ship in the tail. + +- **CS-10 — Crosshairs/ReferenceLines assume `ORTHOGRAPHIC` volume viewports.** Tool internals + branch on `instanceof VolumeViewport` / `type === ORTHOGRAPHIC`. **Fix:** detect viewport + capability (volume actor / orthographic slicing) via capability API and resolve source/target + geometry via view references. (Tool-internal counterpart to CS-8.) + +- **CS-3 — `setDisplaySets`/`addDisplaySet` must be idempotent & incremental.** `setDisplaySets` + calls `removeAllData()` first, so a naive re-set blanks sibling actors (MPR blanks during SEG + hydrate — today OHIF hand-rolls a `getActors()` class-name add-vs-set heuristic that native OHIF + cannot reproduce). **Fix:** re-setting the same source keeps existing overlay (segmentation) + actors; adding a display set does not rebuild existing ones. Lets OHIF delete the actor + introspection and the `setTimeout(cb,0)` actor-ready hack. + +- **CS-4 — data-inferred render path + OHIF's multi-input shapes.** Confirm/define that + `PLANAR_NEXT.setDisplaySets({ displaySetId, volumeId?, imageIds, orientation })` renders a stack + with no `volumeId` and an MPR slice with one, accepts OHIF's multi-input shapes (imageIds + + initialImageIndex; arrays of volume inputs for fusion), and accepts an OHIF + `displaySetInstanceUID` as the id. Make `setStack`/`setVolumes` fully replaceable. + +- **CS-5 — no content/mode predicate.** A native `PLANAR_NEXT` viewport passes **both** + `viewportSupportsImageSlices` and `viewportSupportsVolumeId` regardless of what it currently + displays (the guards test method presence, not content), and collapses STACK+ORTHOGRAPHIC into one + `.type`. **Fix:** add a runtime content predicate (`getCurrentMode(): 'stack'|'volume'|'empty'`, + `getVolumeId()`/`hasVolumeId()` reflecting bound content) and/or content-aware capability queries. + *OHIF mitigates the common case via `ViewportInfo` (§4.4); this is needed for cornerstone-handle- + only code.* + +### Major (gate specific milestones; OHIF can interim-cast or defer) + +- **CS-11 — synchronizers must drive native APIs.** `createImageSliceSynchronizer` (slice nav via + `setViewReference({ sliceIndex })` + native slice event; depends CS-6/CS-7) and + `createVOISynchronizer` (propagate via `setDisplaySetPresentation`, react to the new presentation + event; depends CS-1). `createZoomPanSynchronizer` already prefers `viewportProjection` and works + on `PLANAR_NEXT` today. +- **CS-13 — volume-labelmap slice rendering on `PLANAR_NEXT` without stack→volume promotion.** + Ensure `addLabelmapRepresentationToViewportMap` + `useSliceRendering` fully renders volume + labelmaps as slices in-place, plus a capability query for "needs promotion", so OHIF can drop + `convertStackToVolumeViewport`. (Render chain is already `PLANAR_NEXT`-aware; the add path and + `VOLUME_VIEWPORT_NEW_VOLUME` emission are the gaps.) +- **CS-14 — native VR API for `VOLUME_3D_NEXT`.** OHIF's 3D toolbar reaches into raw vtk via + `getActors()[0].actor.getProperty()/getMapper()` for preset/quality/lighting/opacity. **Fix:** + provide native methods (or `setDisplaySetPresentation` fields) for VR preset, sample distance, + shading, and scalar-opacity edits — or guarantee a stable `getActors()` contract. +- **CS-15 — generic MPR orientation change + graceful degrade.** `setViewportOrientation` is gated + on legacy volume types, and requesting an MPR orientation on a non-volumeable dataset **throws** + in `PlanarRenderPathDecisionService.select()`. **Fix:** support orientation via view + state/`setOrientation` on generic viewports; expose `canRenderOrientation(dataId, orientation)` or + degrade to ACQUISITION with a warning event instead of throwing. +- **CS-16 / CS-17 — types & narrowing.** Widen `IEnabledElement.viewport` to include the + generic-next interface (today `IStackViewport | IVolumeViewport` forces casts); newly-optional + `CameraModifiedEventDetail.element` / `CameraResetEventDetail.element` / `FrameOfReferenceUID` + need null-checks OHIF-side. Ship a single `isGenericViewport(vp)` narrowing guard + + content-capability utilities so OHIF replaces the `getLegacyViewportType` shim with capability + branching. +- **CS-12 — native spacing calibration.** `viewportSupportsStackCalibration` returns false for + native `PLANAR_NEXT` (`calibrateSpacing` is adapter-only); expose a native calibration entry + point. Non-core; can lag. +- **CS-21 — view-reference resolution on `*_NEXT` (SR/RT navigation).** SR and RTSTRUCT + jump-to-measurement hand `setViewReference` a reference that may carry **only** + `{ referencedImageId }` (2D SCOORD), a `planeRestriction` (oblique), or a 3D SCOORD3D reference + with a focal point but **null `viewPlaneNormal`/`viewUp`** (`hydrateStructuredReport.ts` + `chooseCameraView`). **Fix:** ensure native viewports' `setViewReference` resolves a + `referencedImageId` to the right slice (stack), honors `planeRestriction`, and deterministically + fills a default camera orientation when orientation is null — matching legacy. Without this, SR/RT + navigation silently fails to position. (Major.) +- **CS-22 — fusion data-presentation: colormap + multi-point opacity transfer function on a + non-segmentation overlay.** PT/CT fusion, **PMAP**, and tmtv apply a colormap *plus a multi-point + opacity transfer function* plus VOI to the **second (overlay) volume** only. The native + `setDisplaySetPresentation` API targets a binding by id (so per-display-set keying exists), but + whether it accepts a full multi-point opacity transfer function for a *non-segmentation* fusion + volume needs confirmation. **Fix:** guarantee `setDisplaySetPresentation(overlayId, { colormap + (with opacity points), voiRange })` drives a fusion overlay without `volumeId` resolution, and + (with CS-1) emits a presentation-modified event so the colorbar/VOI-sync update. (Major; gates + PMAP + tmtv fusion.) + +### Minor + +- **CS-18 — registration store**: no schema validation, single process-global namespace, no GC on + `removeData`. Add per-kind validation, viewport-scoped or ref-counted registration, and removal on + unmount. (OHIF owns discipline short-term — §4.7.) +- **CS-19 — per-mount force-CPU/GPU**: add a `renderTarget`/`forceCPU` field to + `PlanarSetDataOptions` (today CPU/GPU is global or threshold-based only). +- **CS-20 — `ECG_NEXT` / `VIDEO_NEXT` / `WHOLE_SLIDE_NEXT` load ergonomics**: ECG loads via bespoke + `setEcg()`; video/WSI pass `imageIds[0]` as the id. Have these accept a display-set identifier and + resolve image ids internally. + +### Cornerstone landing sequence (so OHIF can ship one milestone at a time) + +- **Wave 0 (foundation):** CS-5 content predicate, CS-17 narrowing guard, CS-16 widen + `IEnabledElement`. +- **Wave 1 (planar stack):** CS-4 data-inferred mount, CS-1 presentation events, CS-6 + CS-7 + slice/scroll events + `getNumberOfSlices`, CS-21 view-reference resolution (SR/RT nav), CS-12 + calibration (trailing). +- **Wave 2 (planar volume / MPR):** CS-3 idempotent mount, CS-15 orientation, CS-11 synchronizers. +- **Wave 3 (fusion):** validation gate on CS-4 multi-volume + CS-22 fusion-overlay presentation + (colormap + opacity TF) + CS-1 per-binding presentation; CS-19 optional. +- **Wave 4 (segmentation):** CS-13 labelmap (+ CS-6 `VOLUME_VIEWPORT_NEW_VOLUME`); **CS-8 contour + camera unblocks RTSTRUCT** (the leading contour sub-track of M4). +- **Wave 5 (crosshairs/reference lines):** CS-8, CS-10 (+ role/displaySet-addressable tool targeting + for tmtv crosshairs slab-thickness and fusion tool config). +- **Wave 6 (tail: 3D/video/WSI/ECG/cine):** CS-9, CS-14, CS-20, CS-18. + +--- + +## 6. Feature-area migration matrix + +"Blocked" = OHIF cannot complete it with today's cornerstone-next API. + +| # | Feature | Legacy OHIF call | Native-next replacement | Difficulty | Blocked on CS? | +|---|---|---|---|---|---| +| 1 | Stack load | `viewport.setStack(imageIds, idx)` (`CornerstoneViewportService`) | `provider.add(id,{imageIds,initialImageIdIndex})` → `setDisplaySets({displaySetId,options:{orientation:ACQUISITION,role:'source'}})` | Moderate | No (API exists); OHIF owns `dataId` GC (CS-18) | +| 2 | Volume-slice / MPR load | `setVolumes([...])` / `addVolumes` | `add(id,{imageIds,volumeId})` + `setDisplaySets({options:{orientation:AXIAL|SAG|COR}})` | Hard | Partial — CS-15 (orient throws), CS-19 (force GPU) | +| 3 | PT/CT fusion | `setVolumes([ct,pt])` + per-volume `setProperties(...,volumeId)` | `setDisplaySets({…ct,role:'source'},{…pt,role:'overlay'})` + `setDisplaySetPresentation(ptId,{colormap,voiRange})` | Hard | Validate CS-4 multi-volume + CS-1 per-binding (API supports per-id presentation) | +| 4 | VOI / window-level | `setProperties({voiRange},volumeId?)`; read `getProperties().voiRange` | `setDisplaySetPresentation(id,{voiRange})` / `getDisplaySetPresentation(id)` | Moderate | **Yes — CS-1** (no `VOI_MODIFIED`) | +| 5 | Colormap / invert | `setProperties({colormap|invert})` | `setDisplaySetPresentation(id,{colormap,invert})` | Moderate | **Yes — CS-1** (no `COLORMAP_MODIFIED`) | +| 6 | Pan/zoom/rotate/flip | `getCamera/setCamera`, `getViewPresentation/setViewPresentation`, `setZoom/getZoom` | `setViewState/updateViewState` (rotation/flip/displayArea), `setScale` (zoom), `viewportProjection.withPresentation` (pan/zoom) | Moderate | No — **[VERIFIED]** fields exist in `PlanarViewState`; only `getPan/setPan` deprecated | +| 7 | Slice nav & jump | `csUtils.jumpToSlice(el,{imageIndex})`; `getNumberOfSlices()` | `setImageIdIndex` / `setViewReference({sliceIndex})`; native `getCurrentImageIdIndex`/`getImageIds` | Moderate | **Yes — CS-7** (`getNumberOfSlices` absent → TypeError) | +| 8 | displayArea | `setDisplayArea(...)` | native `setDisplayArea`, fold into `setViewState({displayArea})` | Moderate | No (`DISPLAY_AREA_MODIFIED` fires) | +| 9 | Position presentation | `getViewReference`+`getViewPresentation` → store; restore `isReferenceViewable`+`setViewReference`+`setViewPresentation` | view-reference APIs native; pan/zoom via `getViewState`/`setViewState` | Moderate | No (verify pan/zoom independent of slice on resize) | +| 9b | LUT presentation | `getProperties([volumeId])`+`getAllVolumeIds()`; restore `setProperties` | `getDisplaySetPresentation`/`setDisplaySetPresentation` keyed by displaySetId | Moderate→Hard | **Yes — CS-1** (no event to refresh store) | +| 10 | Sync — image-slice | `createImageSliceSynchronizer`; `frameViewSynchronizer` uses `STACK_VIEWPORT_SCROLL`+`jumpToSlice` | same factory; nav via `setViewReference({sliceIndex})` | Moderate | **Yes — CS-7, CS-6** | +| 10b | Sync — VOI | `createVOISynchronizer` (legacy props + `VOI_MODIFIED`) | drive `setDisplaySetPresentation` + new event | Moderate | **Yes — CS-1, CS-11** | +| 10c | Sync — zoom/pan | `createZoomPanSynchronizer` | already `viewportProjection`+`setViewState` | Trivial | No (works today) | +| 11 | Measurements | annotation bridge (reads `annotation.metadata`); jump uses `setViewReference`+camera nudge | bridge already type-agnostic; jump keeps `setViewReference`, nudge → `updateViewState`, zoom → `setScale` | Moderate | Partial — needs #6 | +| 12 | Segmentation (labelmap) | `addSegmentationRepresentations(viewportId,[rep])`; `convertStackToVolumeViewport` | `addLabelmapRepresentationToViewportMap` + `useSliceRendering`, render in-place | Hard | **Yes — CS-13, CS-6** (render chain already next-aware) | +| 13 | Crosshairs / ref-lines | tools added to MPR toolgroups; `getCamera/setCamera` | read via `getResolvedView().toICamera()`, nav via `setViewReference/setViewState` | Hard | **Yes — CS-8, CS-10** (fix in tools) | +| 14 | MPR | `setVolumes`+`setOrientation` | `setDisplaySets({options:{orientation}})`, `setOrientation` on volume | Hard | **Yes — CS-15 + #13** | +| 15 | Cine | `utilities.cine.playClip(element)` | element-based, unchanged at OHIF layer | Trivial→Moderate | **Yes — CS-9** | +| 16 | 3D / MIP | `setVolumes`+`setProperties({preset})`; VR via raw `getActors()` | `VOLUME_3D_NEXT`; preset via native VR API | Hard | **Yes — CS-14**; TrackballRotate camera | +| 17 | Video (cornerstone) | `_setOtherViewport` → `setDisplaySets({displaySetId:imageIds[0]})` | already native; pass `displaySetInstanceUID` | Trivial | Minor — CS-20 | +| 18 | Whole-slide | `_setOtherViewport` → `setDisplaySets({displaySetId:imageIds[0]})` | already native; map to `WHOLE_SLIDE_NEXT` | Trivial | Minor — CS-20 | + +Note: OHIF DICOM **MP4 video** uses an HTML5 `