Skip to content

Commit 6fc0256

Browse files
authored
fix: more generic viewport push for parity with legacy (#2766)
1 parent f86b3b5 commit 6fc0256

101 files changed

Lines changed: 3541 additions & 347 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/core/src/RenderingEngine/ContextPoolRenderingEngine.ts

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import type {
2525
VtkOffscreenMultiRenderWindow,
2626
} from '../types';
2727
import { getViewportClassForInput } from './helpers/viewportTypeToViewportClass';
28+
import { isGenericViewport } from '../utilities/viewportCapabilities';
2829

2930
/**
3031
* ContextPoolRenderingEngine extends BaseRenderingEngine to provide parallel rendering
@@ -198,6 +199,31 @@ class ContextPoolRenderingEngine extends BaseRenderingEngine {
198199
}
199200
}
200201

202+
/**
203+
* Returns the displayed size (in device pixels) of a viewport's container.
204+
*
205+
* For native Generic ("next") viewports the on-screen canvas returned by
206+
* getOrCreateCanvas (the .cornerstone-canvas / VTK target) can report
207+
* clientWidth/Height === 0 when the CPU render path is active and that canvas
208+
* is hidden in favor of the CPU canvas. Measuring from the viewport element
209+
* instead always reflects the real container size, so native viewports are
210+
* detected on container resize and their resize() (which refits the active
211+
* CPU/VTK canvas) actually runs. Legacy viewports keep measuring their canvas.
212+
*/
213+
private _getDisplayedSize(vp: IViewport): {
214+
displayedWidth: number;
215+
displayedHeight: number;
216+
} {
217+
const devicePixelRatio = window.devicePixelRatio || 1;
218+
const sizeSource = isGenericViewport(vp)
219+
? vp.element
220+
: getOrCreateCanvas(vp.element);
221+
return {
222+
displayedWidth: Math.round(sizeSource.clientWidth * devicePixelRatio),
223+
displayedHeight: Math.round(sizeSource.clientHeight * devicePixelRatio),
224+
};
225+
}
226+
201227
/**
202228
* Resizes viewports that use VTK.js for rendering.
203229
* Only updates the VTK offscreen size when the displayed size (canvas client size in device pixels)
@@ -211,16 +237,11 @@ class ContextPoolRenderingEngine extends BaseRenderingEngine {
211237
keepCamera = true,
212238
immediate = true
213239
) {
214-
const devicePixelRatio = window.devicePixelRatio || 1;
215-
216240
// Compute target display size (pixels the canvas is actually displayed at) for each viewport
217241
const viewportsNeedingResize: (IStackViewport | IVolumeViewport)[] = [];
218242
for (const vp of vtkDrivenViewports) {
219243
const canvas = getOrCreateCanvas(vp.element);
220-
const displayedWidth = Math.round(canvas.clientWidth * devicePixelRatio);
221-
const displayedHeight = Math.round(
222-
canvas.clientHeight * devicePixelRatio
223-
);
244+
const { displayedWidth, displayedHeight } = this._getDisplayedSize(vp);
224245

225246
if (displayedWidth === 0 || displayedHeight === 0) {
226247
continue;
@@ -242,11 +263,7 @@ class ContextPoolRenderingEngine extends BaseRenderingEngine {
242263
}
243264

244265
for (const vp of viewportsNeedingResize) {
245-
const canvas = getOrCreateCanvas(vp.element);
246-
const displayedWidth = Math.round(canvas.clientWidth * devicePixelRatio);
247-
const displayedHeight = Math.round(
248-
canvas.clientHeight * devicePixelRatio
249-
);
266+
const { displayedWidth, displayedHeight } = this._getDisplayedSize(vp);
250267
const targetWidth = Math.max(VIEWPORT_MIN_SIZE, displayedWidth);
251268
const targetHeight = Math.max(VIEWPORT_MIN_SIZE, displayedHeight);
252269

@@ -265,6 +282,14 @@ class ContextPoolRenderingEngine extends BaseRenderingEngine {
265282
vtkDrivenViewports.forEach((vp) => {
266283
vp.resize?.();
267284

285+
if (isGenericViewport(vp)) {
286+
// Generic ("next") viewports (PlanarViewport / PLANAR_NEXT) refit
287+
// inside their own resize() (recompute fitScale + reapply presentation
288+
// with scaleMode 'fit') and do NOT expose the legacy getCamera/setCamera/
289+
// setViewPresentation surface. Skip the legacy snapshot/restore dance.
290+
return;
291+
}
292+
268293
const prevCamera = vp.getCamera();
269294
const rotation = vp.getRotation?.() ?? 0;
270295
const { flipHorizontal } = prevCamera;

packages/core/src/RenderingEngine/GenericViewport/GenericViewport.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type * as EventTypes from '../../types/EventTypes';
22
import type ICamera from '../../types/ICamera';
3-
import type { Point2, Point3 } from '../../types';
3+
import type { Point2, Point3, ViewportContentMode } from '../../types';
44
import Events from '../../enums/Events';
55
import ViewportStatus from '../../enums/ViewportStatus';
66
import triggerEvent from '../../utilities/triggerEvent';
7+
import renderingEngineCache from '../renderingEngineCache';
8+
import type { IRenderingEngine } from '../../types';
79
import type {
810
BaseViewportRenderContext,
911
BindingRole,
@@ -210,6 +212,22 @@ abstract class GenericViewport<
210212
return this.getDataPresentationState(displaySetId);
211213
}
212214

215+
/**
216+
* Content-true classification of the currently bound source data.
217+
*
218+
* The duck-typing capability guards (`viewportSupportsImageSlices`,
219+
* `viewportSupportsVolumeId`, ...) report which methods a viewport exposes,
220+
* not what it is showing, so a single generic viewport reports support for
221+
* both stack and volume operations regardless of its bound content. This
222+
* method answers the content question instead, derived from the mounted
223+
* source binding. The base implementation only distinguishes "has bound data"
224+
* from "empty"; concrete viewport families override it to report `stack`,
225+
* `volume`, `volume3d`, etc. See {@link ViewportContentMode}.
226+
*/
227+
getCurrentMode(): ViewportContentMode {
228+
return this.getSourceBinding() ? 'unknown' : 'empty';
229+
}
230+
213231
/**
214232
* Returns a spatial reference for the current viewport state.
215233
*/
@@ -260,6 +278,16 @@ abstract class GenericViewport<
260278
);
261279
}
262280

281+
/**
282+
* Returns the rendering engine that owns this viewport. Tools and utilities
283+
* rely on this method existing on every viewport (legacy Viewport provides
284+
* it); without it, calls like `viewport.getRenderingEngine()` threw on native
285+
* generic viewports.
286+
*/
287+
getRenderingEngine(): IRenderingEngine {
288+
return renderingEngineCache.get(this.renderingEngineId);
289+
}
290+
263291
// ====================================================================
264292
// Public API -- coordinate transforms
265293
// ====================================================================
@@ -647,9 +675,30 @@ abstract class GenericViewport<
647675

648676
this.setDataPresentationState(displaySetId, nextPresentation);
649677

678+
// Notify only when the change targets a mounted dataset so that listeners
679+
// (VOI/colormap UI, synchronizers) react to applied presentation changes.
680+
if (this.bindings.has(displaySetId)) {
681+
this.notifyDataPresentationModified(displaySetId, props);
682+
}
683+
650684
return nextPresentation;
651685
}
652686

687+
/**
688+
* Hook invoked after a per-display-set presentation update is applied through
689+
* the public `setDisplaySetPresentation` path and the target display set is
690+
* mounted. Concrete viewport families override this to emit their
691+
* presentation-modified events (e.g. `VOI_MODIFIED`, `COLORMAP_MODIFIED`) so
692+
* application UI and synchronizers can react to programmatic and tool-driven
693+
* presentation changes. The base implementation is intentionally a no-op.
694+
*/
695+
protected notifyDataPresentationModified(
696+
_displaySetId: DisplaySetId,
697+
_props: Partial<TDataPresentation>
698+
): void {
699+
// Subclasses emit presentation-modified events; base viewports do not.
700+
}
701+
653702
// ====================================================================
654703
// Protected -- view state
655704
// ====================================================================
@@ -765,6 +814,22 @@ abstract class GenericViewport<
765814
return this.bindings.values().next().value;
766815
}
767816

817+
/**
818+
* Returns the active source binding (the dataset that defines the view),
819+
* falling back to the first mounted binding when no explicit `source` role is
820+
* present. Used for content-mode classification and source-scoped queries.
821+
*/
822+
protected getSourceBinding():
823+
| ViewportDataBinding<TDataPresentation>
824+
| undefined {
825+
for (const binding of this.bindings.values()) {
826+
if (binding.role === 'source') {
827+
return binding;
828+
}
829+
}
830+
return this.getFirstBinding();
831+
}
832+
768833
/**
769834
* Returns the binding used for generic transform and frame-of-reference
770835
* queries when a viewport family does not override the selection logic.

packages/core/src/RenderingEngine/GenericViewport/Planar/CpuImageSliceRenderPath.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export class CpuImageSliceRenderPath
7878
ctx.display.activateRenderMode(ActorRenderMode.CPU_IMAGE);
7979

8080
let rendering: PlanarCpuImageRendering;
81+
markCpuImagePreScaled(payload.image);
8182
const compatibilityActor = new CanvasActor(
8283
{
8384
getImageData: () => this.getImageData(rendering),
@@ -107,7 +108,7 @@ export class CpuImageSliceRenderPath
107108
actorEntryUID: uuidv4(),
108109
enabledElement,
109110
compatibilityActor,
110-
currentImageIdIndex: payload.initialImageIdIndex,
111+
currentImageIdIndex: payload.initialImageIdIndex ?? 0,
111112
defaultVOIRange: getDefaultImageVOIRange(payload.image),
112113
dataPresentation: undefined,
113114
fitScale: getCPUFallbackScalarScale(enabledElement.viewport.scale),
@@ -117,7 +118,7 @@ export class CpuImageSliceRenderPath
117118

118119
triggerPlanarNewImage(ctx, {
119120
image: payload.image,
120-
imageIdIndex: payload.initialImageIdIndex,
121+
imageIdIndex: payload.initialImageIdIndex ?? 0,
121122
});
122123

123124
return {
@@ -576,13 +577,38 @@ function worldToCPUImageDrawPoint(
576577
];
577578
}
578579

580+
/**
581+
* Mirror legacy StackViewport._setCSImage: when the cached image's pixel data is
582+
* already pre-scaled (modality rescale baked in), flag it so the CPUFallback
583+
* renderer skips the modality LUT. Without this the renderer
584+
* (generateLut/getDefaultViewport/renderGrayscaleImage all branch on
585+
* image.isPreScaled) re-applies slope/intercept on top of already-scaled data,
586+
* shifting every pixel down by the rescale intercept — e.g. a CT renders far too
587+
* dark, with only the densest bone surviving the window.
588+
*/
589+
function markCpuImagePreScaled(image: IImage): void {
590+
image.isPreScaled = image.preScale?.scaled;
591+
}
592+
579593
export function buildPlanarImageData(
580594
image: IImage,
581595
frameOfReferenceUID?: string
582596
): CPUIImageData {
583597
const metadata = getImageDataMetadata(image);
584-
const { calibration, dimensions, direction, modality, origin, spacing } =
585-
metadata;
598+
const { dimensions, direction, modality, origin, spacing } = metadata;
599+
// getImageDataMetadata().calibration is the DICOM calibration module; the USER
600+
// calibration (CalibrationLine -> calibratedPixelSpacing provider) is stored
601+
// separately, so merge it on top — mirroring legacy StackViewport.getImageData()
602+
// (`calibration: { ...csImage.calibration, ...this.calibration }`). Without this,
603+
// native getImageData().calibration ignores user calibration and length tools never
604+
// rescale after calibrateImageSpacing().
605+
const userCalibration = metaData.get(
606+
'calibratedPixelSpacing',
607+
image.imageId
608+
) as Record<string, unknown> | undefined;
609+
const calibration = userCalibration
610+
? { ...metadata.calibration, ...userCalibration }
611+
: metadata.calibration;
586612
const rowVector = direction.slice(0, 3) as Point3;
587613
const columnVector = direction.slice(3, 6) as Point3;
588614
const scalarData =
@@ -681,6 +707,7 @@ async function updateRenderedImage(args: {
681707
}): Promise<void> {
682708
const { ctx, dataId, image, imageIdIndex, props, rendering } = args;
683709
const enabledElement = rendering.enabledElement;
710+
markCpuImagePreScaled(image);
684711
const camera = ctx.viewport.getViewState();
685712
const defaultViewport = getDefaultViewport(ctx.cpu.canvas, image);
686713
const previousViewport = enabledElement.viewport;

packages/core/src/RenderingEngine/GenericViewport/Planar/CpuVolumeSliceRenderPath.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export class CpuVolumeSliceRenderPath
8484
ctx.cpu.canvas.width,
8585
ctx.cpu.canvas.height
8686
),
87-
currentImageIdIndex: payload.initialImageIdIndex,
87+
currentImageIdIndex: payload.initialImageIdIndex ?? 0,
8888
maxImageIdIndex: payload.imageIds.length - 1,
8989
defaultVOIRange,
9090
renderingInvalidated: true,
@@ -219,6 +219,7 @@ export class CpuVolumeSliceRenderPath
219219
acquisitionOrientation: rendering.acquisitionOrientation,
220220
imageIds: rendering.imageIds,
221221
imageIdIndex: rendering.currentImageIdIndex,
222+
maxImageIdIndex: rendering.maxImageIdIndex,
222223
});
223224

224225
return {
@@ -538,6 +539,7 @@ export class CpuVolumeSliceRenderPath
538539
acquisitionOrientation: rendering.acquisitionOrientation,
539540
imageIds: rendering.imageIds,
540541
imageIdIndex: rendering.currentImageIdIndex,
542+
maxImageIdIndex: rendering.maxImageIdIndex,
541543
});
542544
}
543545
}

packages/core/src/RenderingEngine/GenericViewport/Planar/DefaultPlanarDataProvider.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,18 @@ export class DefaultPlanarDataProvider implements PlanarDataProvider {
3434
throw new Error('[PlanarViewport] Cannot load an empty planar dataset');
3535
}
3636

37+
// A concrete, clamped index is always needed to load a single image below.
3738
const clampedImageIdIndex = Math.min(
3839
Math.max(0, dataSet.initialImageIdIndex ?? 0),
3940
dataSet.imageIds.length - 1
4041
);
42+
// But preserve "no slice requested" (undefined) in the payload so the volume
43+
// acquisition view can center instead of pinning to slice 0; an explicit
44+
// index (including 0) is clamped and honored downstream.
45+
const initialImageIdIndex =
46+
dataSet.initialImageIdIndex === undefined
47+
? undefined
48+
: clampedImageIdIndex;
4149

4250
if (
4351
options.renderMode === ActorRenderMode.VTK_VOLUME_SLICE ||
@@ -56,7 +64,7 @@ export class DefaultPlanarDataProvider implements PlanarDataProvider {
5664
id: dataId,
5765
type: 'image',
5866
imageIds,
59-
initialImageIdIndex: clampedImageIdIndex,
67+
initialImageIdIndex,
6068
acquisitionOrientation: options.acquisitionOrientation,
6169
imageData: dataSet.imageData,
6270
imageVolume,
@@ -79,7 +87,7 @@ export class DefaultPlanarDataProvider implements PlanarDataProvider {
7987
imageIds: dataSet.imageIds,
8088
image,
8189
imageData: dataSet.imageData,
82-
initialImageIdIndex: clampedImageIdIndex,
90+
initialImageIdIndex,
8391
acquisitionOrientation: options.acquisitionOrientation,
8492
reference: dataSet.reference,
8593
renderMode: options.renderMode,

0 commit comments

Comments
 (0)