Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions packages/core/src/RenderingEngine/StackViewport.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray';
import type { vtkImageData as vtkImageDataType } from '@kitware/vtk.js/Common/DataModel/ImageData';
import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData';
import vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera';
import extendedVtkCamera from './vtkClasses/extendedVtkCamera';
import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction';
import vtkColorMaps from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction/ColorMaps';
import vtkImageMapper from '@kitware/vtk.js/Rendering/Core/ImageMapper';
Expand Down Expand Up @@ -42,6 +42,7 @@ import type {
ImagePixelModule,
ImagePlaneModule,
PixelDataTypedArray,
ResetCameraOptions,
} from '../types';
import { actorIsA, isImageActor } from '../utilities/actorCheck';
import * as colormapUtils from '../utilities/colormap';
Expand Down Expand Up @@ -285,7 +286,7 @@ class StackViewport extends Viewport {

private _resetGPUViewport() {
const renderer = this.getRenderer();
const camera = vtkCamera.newInstance();
const camera = extendedVtkCamera.newInstance();
renderer.setActiveCamera(camera);

const viewPlaneNormal = [0, 0, -1] as Point3;
Expand Down Expand Up @@ -350,6 +351,7 @@ class StackViewport extends Viewport {
resetZoom?: boolean;
resetToCenter?: boolean;
suppressEvents?: boolean;
resetAspectRatio?: boolean;
}) => boolean;

/**
Expand Down Expand Up @@ -844,6 +846,7 @@ class StackViewport extends Viewport {
resetPan: true,
resetZoom: true,
resetToCenter: true,
resetAspectRatio: true,
suppressEvents: true,
});
};
Expand Down Expand Up @@ -1041,8 +1044,14 @@ class StackViewport extends Viewport {
const { viewport, image } = this._cpuFallbackEnabledElement;
const previousCamera = this.getCameraCPU();

const { focalPoint, parallelScale, scale, flipHorizontal, flipVertical } =
cameraInterface;
const {
focalPoint,
parallelScale,
scale,
flipHorizontal,
flipVertical,
aspectRatio,
} = cameraInterface;

const { clientHeight } = this.element;

Expand Down Expand Up @@ -2679,7 +2688,7 @@ class StackViewport extends Viewport {
});
}

private resetCameraGPU({ resetPan, resetZoom }): boolean {
private resetCameraGPU(resetOptions: ResetCameraOptions): boolean {
// Todo: we need to make the rotation a camera properties so that
// we can reset it there, right now it is not possible to reset the rotation
// without this
Expand All @@ -2695,7 +2704,7 @@ class StackViewport extends Viewport {
// For stack Viewport we since we have only one slice
// it should be enough to reset the camera to the center of the image
const resetToCenter = true;
return super.resetCamera({ resetPan, resetZoom, resetToCenter });
return super.resetCamera({ resetToCenter, ...resetOptions });
}

/**
Expand Down Expand Up @@ -3604,10 +3613,18 @@ class StackViewport extends Viewport {
return true;
},
gpu: (
options: { resetPan?: boolean; resetZoom?: boolean } = {}
options: {
resetPan?: boolean;
resetZoom?: boolean;
resetAspectRatio?: boolean;
} = {}
): boolean => {
const { resetPan = true, resetZoom = true } = options;
this.resetCameraGPU({ resetPan, resetZoom });
const {
resetPan = true,
resetZoom = true,
resetAspectRatio = true,
} = options;
this.resetCameraGPU({ resetPan, resetZoom, resetAspectRatio });
return true;
},
},
Expand Down
89 changes: 85 additions & 4 deletions packages/core/src/RenderingEngine/Viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import type vtkMapper from '@kitware/vtk.js/Rendering/Core/Mapper';
import { deepClone } from '../utilities/deepClone';
import { updatePlaneRestriction } from '../utilities/updatePlaneRestriction';
import { getConfiguration } from '../init';
import type { extendedVtkCamera } from './vtkClasses/extendedVtkCamera';

/**
* An object representing a single viewport, which is a camera
Expand All @@ -71,6 +72,7 @@ class Viewport {
rotation: true,
pan: true,
zoom: true,
aspectRatio: true,
displayArea: true,
};

Expand Down Expand Up @@ -1057,12 +1059,14 @@ class Viewport {
resetZoom?: boolean;
resetToCenter?: boolean;
storeAsInitialCamera?: boolean;
resetAspectRatio?: boolean;
}): boolean {
const {
resetPan = true,
resetZoom = true,
resetToCenter = true,
storeAsInitialCamera = true,
resetAspectRatio = true,
} = options || {};

const renderer = this.getRenderer();
Expand Down Expand Up @@ -1201,13 +1205,16 @@ class Viewport {
-focalPointToSet[2]
);

const initialAspectRatio = this.getAspectRatio();

this.setCamera({
parallelScale: resetZoom ? parallelScale : previousCamera.parallelScale,
focalPoint: focalPointToSet,
position: positionToSet,
viewAngle: 90,
viewUp: viewUpToSet,
clippingRange: clippingRangeToUse,
aspectRatio: resetAspectRatio && initialAspectRatio,
});

const modifiedCamera = this.getCamera();
Expand Down Expand Up @@ -1391,6 +1398,58 @@ class Viewport {
);
}

/**
* Returns the current aspect ratio of the viewport as a 2D point `[widthRatio, heightRatio]`.
*
* @returns The current aspect ratio `[widthRatio, heightRatio]`
* based on the active camera settings.
*/
public getAspectRatio(): Point2 {
if (this.options?.aspectRatio) {
return this.options.aspectRatio;
}

const { aspectRatio } = this.getCamera();
return aspectRatio || [1, 1];
}

/**
* Sets the aspect ratio of the viewport using the provided 2D point
* `[widthRatio, heightRatio]`.
*
* This updates the VTK camera's **projection matrix** to apply axis-based
* stretching during rendering.
*
* As a result:
* - World-to-canvas mapping is updated via the camera projection.
* - Canvas-to-world mapping is also updated and remains consistent, as
* VTK utilizes the inverse of the same projection matrix for picking and
* coordinate transformations.
*
* Note:
* - The camera pose and orientation (position, focal point, and viewPlaneNormal)
* remain unchanged.
* - Image data, spacing, and world coordinates are not modified.
*
* @param value - The aspect ratio to set as `[widthRatio, heightRatio]`.
* @param storeAsInitialCamera - Whether to store the updated camera state as the initial camera.
* Defaults to `false`.
*/
public setAspectRatio(value: Point2, storeAsInitialCamera = false): void {
const camera = this.getCamera();
if (storeAsInitialCamera) {
this.options.aspectRatio = value;
}

this.setCamera(
{
...camera,
aspectRatio: value,
},
storeAsInitialCamera
);
}

/**
* Because the focalPoint is always in the centre of the viewport,
* we must do planar computations if the frame (image "slice") is to be preserved.
Expand Down Expand Up @@ -1454,7 +1513,7 @@ class Viewport {
}

protected getCameraNoRotation(): ICamera {
const vtkCamera = this.getVtkActiveCamera();
const vtkCamera = this.getVtkActiveCamera() as extendedVtkCamera;

// Helper function to replace NaN vectors with defaults
const sanitizeVector = (vector: Point3, defaultValue: Point3): Point3 => {
Expand Down Expand Up @@ -1489,6 +1548,7 @@ class Viewport {
viewAngle: vtkCamera.getViewAngle(),
flipHorizontal: this.flipHorizontal,
flipVertical: this.flipVertical,
aspectRatio: vtkCamera.getAspectRatio(),
};
}

Expand All @@ -1515,7 +1575,7 @@ class Viewport {
cameraInterface: ICamera,
storeAsInitialCamera = false
): void {
const vtkCamera = this.getVtkActiveCamera();
const vtkCamera = this.getVtkActiveCamera() as extendedVtkCamera;
const previousCamera = this.getCamera();
const updatedCamera = Object.assign({}, previousCamera, cameraInterface);
const {
Expand All @@ -1528,6 +1588,7 @@ class Viewport {
flipHorizontal,
flipVertical,
clippingRange,
aspectRatio,
} = cameraInterface;

// Note: Flip camera should be two separate calls since
Expand Down Expand Up @@ -1589,6 +1650,10 @@ class Viewport {
vtkCamera.setClippingRange(clippingRange);
}

if (aspectRatio) {
vtkCamera.setAspectRatio(aspectRatio);
}

// update clipping range only if focal point changed of a new actor is added
const prevFocalPoint = previousCamera.focalPoint;
const prevViewUp = previousCamera.viewUp;
Expand Down Expand Up @@ -1983,6 +2048,7 @@ class Viewport {
displayArea: true,
zoom: true,
pan: true,
aspectRatio: true,
flipHorizontal: true,
flipVertical: true,
}
Expand All @@ -2002,9 +2068,21 @@ class Viewport {
if (zoom) {
target.zoom = initZoom;
}
const currentAspectRatio = this.getAspectRatio();
target.aspectRatio = currentAspectRatio;
if (pan) {
target.pan = this.getPan();
vec2.scale(target.pan, target.pan, 1 / initZoom);
const currentPan = this.getPan();
const [aspectX, aspectY] = currentAspectRatio;

// Normalize pan to remove the effects of zoom and projection-based stretching.
// Since axis-based stretching is applied via the camera projection matrix,
// pan values are captured in post-projection space. Dividing by both the
// zoom and aspect ratio ensures the pan state remains consistent across
// viewports, regardless of their specific zoom levels or stretching factors.
const normalizedPanX = currentPan[0] / (initZoom * aspectX);
const normalizedPanY = currentPan[1] / (initZoom * aspectY);

target.pan = [normalizedPanX, normalizedPanY] as Point2;
}

if (flipHorizontal) {
Expand Down Expand Up @@ -2036,6 +2114,7 @@ class Viewport {
displayArea,
zoom = this.getZoom(),
pan,
aspectRatio = this.getAspectRatio(),
rotation,
flipHorizontal = this.flipHorizontal,
flipVertical = this.flipVertical,
Expand All @@ -2048,6 +2127,8 @@ class Viewport {
this.setPan(vec2.scale([0, 0], pan, zoom) as Point2);
}

this.setAspectRatio(aspectRatio);

// flip operation requires another re-render to take effect, so unfortunately
// right now if the view presentation requires a flip, it will flicker. The
// correct way to handle this is to wait for camera and flip together and then
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/RenderingEngine/VolumeViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,12 +382,13 @@ class VolumeViewport extends BaseVolumeViewport {
resetToCenter = true,
suppressEvents = false,
resetOrientation = true,
resetAspectRatio = true,
} = options || {};
const { orientation } = this.viewportProperties;
if (orientation && resetOrientation) {
this.applyViewOrientation(orientation, false);
}
super.resetCamera({ resetPan, resetZoom, resetToCenter });
super.resetCamera({ resetPan, resetZoom, resetToCenter, resetAspectRatio });

const activeCamera = this.getVtkActiveCamera();
const viewPlaneNormal = activeCamera.getViewPlaneNormal() as Point3;
Expand Down Expand Up @@ -822,11 +823,13 @@ class VolumeViewport extends BaseVolumeViewport {
const resetZoom = true;
const resetToCenter = true;
const resetCameraRotation = true;
const resetAspectRatio = true;
this.resetCamera({
resetPan,
resetZoom,
resetToCenter,
resetCameraRotation,
resetAspectRatio,
});

triggerEvent(this.element, Events.VOI_MODIFIED, eventDetails);
Expand Down
Copy link
Collaborator

Choose a reason for hiding this comment

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

Modify this as requested to return the scaling matrix so that it works generically for any orientation, not just axial/sagittal/coronal.

Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { vec3 } from 'gl-matrix';

const EPSILON = 1e-6;

/**
* Determine which projection-matrix indices to multiply for canvas X and Y scaling.
* For each anatomical axis stretch work as follows
* axial => stretch in anterior-posterior
* sagittal => stretch in superior-inferior
* coronal => stretch in superior-inferior
* @param {Array<number>} viewUp - [ux, uy, uz] camera viewUp (patient-space)
* @param {Array<number>} viewPlaneNormal - [dx, dy, dz] camera viewPlaneNormal (patient-space)
* @returns {{ idxX: number, idxY: number}}
*/
export function getProjectionScaleIndices(viewUp, viewPlaneNormal) {
const up = vec3.normalize(vec3.create(), viewUp);
const vpn = vec3.normalize(vec3.create(), viewPlaneNormal);

// Image axes in patient space
// Right-hand coordinate system: imageX = up × vpn
const imageX = vec3.create();
vec3.cross(imageX, up, vpn);
if (vec3.length(imageX) < EPSILON) {
// Fallback: if up and vpn are nearly parallel, create an orthogonal axis
const tmp =
Math.abs(up[0]) < 1 / Math.sqrt(2) // Use 45 degree to find the nearest isometric axis
? vec3.fromValues(1, 0, 0)
: vec3.fromValues(0, 1, 0);
vec3.cross(imageX, tmp, up);
}
vec3.normalize(imageX, imageX);
const imageY = up;

// Determine anatomical orientation (axial/sagittal/coronal)
const absVpn = [Math.abs(vpn[0]), Math.abs(vpn[1]), Math.abs(vpn[2])];
let orientation = 'axial';
if (absVpn[0] === Math.max(...absVpn)) {
orientation = 'sagittal';
} else if (absVpn[1] === Math.max(...absVpn)) {
orientation = 'coronal';
}

// Determine which anatomical axis to stretch
const AY = vec3.fromValues(0, 1, 0); // A-P
const AZ = vec3.fromValues(0, 0, 1); // S-I
const target = orientation === 'axial' ? AY : AZ;

// Which image axis (X or Y) aligns better with target?
const scoreX = Math.abs(vec3.dot(imageX, target));
const scoreY = Math.abs(vec3.dot(imageY, target));

// Choose projection-matrix diagonal indices
// 0 - scale X (horizontal)
// 5 - scale Y (vertical)
let [idxX, idxY] = [0, 5];
// If target aligns with horizontal, swap so vertical scaling follows rotation
if (scoreX > scoreY) {
[idxX, idxY] = [5, 0];
}

return { idxX, idxY };
}
Loading
Loading