Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
81 changes: 79 additions & 2 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.options?.aspectRatio || [1, 1];

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,54 @@ 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 {
const { aspectRatio } = this.getCamera();
return aspectRatio ?? this.options?.aspectRatio ?? [1, 1];
}

/**
* Sets the aspect ratio of the viewport (canvas) 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 @@ -1489,6 +1544,7 @@ class Viewport {
viewAngle: vtkCamera.getViewAngle(),
flipHorizontal: this.flipHorizontal,
flipVertical: this.flipVertical,
aspectRatio: vtkCamera.getAspectRatio(),
};
}

Expand Down Expand Up @@ -1528,6 +1584,7 @@ class Viewport {
flipHorizontal,
flipVertical,
clippingRange,
aspectRatio,
} = cameraInterface;

// Note: Flip camera should be two separate calls since
Expand Down Expand Up @@ -1589,6 +1646,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 +2044,7 @@ class Viewport {
displayArea: true,
zoom: true,
pan: true,
aspectRatio: true,
flipHorizontal: true,
flipVertical: true,
}
Expand All @@ -2002,9 +2064,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 +2110,7 @@ class Viewport {
displayArea,
zoom = this.getZoom(),
pan,
aspectRatio = this.getAspectRatio(),
rotation,
flipHorizontal = this.flipHorizontal,
flipVertical = this.flipVertical,
Expand All @@ -2048,6 +2123,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
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { mat4, vec3 } from 'gl-matrix';

const EPSILON = 1e-6;

/**
* Computes a projection scaling matrix with rotation-invariant scaling in patient
* coordinate space. The stretch follows patient anatomical directions (AP or SI)
* rather than screen axes, maintaining consistent scaling after view rotations.
*
* @param viewUp - Camera viewUp vector in patient space
* @param viewPlaneNormal - Camera viewPlaneNormal vector in patient space
* @param aspectRatio - [scaleX, scaleY]. scaleY applies to dominant anatomical axis
* @returns Projection scaling matrix
*/
export function getProjectionScaleMatrix(
viewUp: vec3,
viewPlaneNormal: vec3,
aspectRatio: Array<number>
): mat4 {
// Normalize inputs
const up = vec3.normalize(vec3.create(), viewUp);
const vpn = vec3.normalize(vec3.create(), viewPlaneNormal);

// Screen Right axis (RH system: Up × Normal)
const viewRight = vec3.create();
vec3.cross(viewRight, up, vpn);

// Fallback if Up and Normal are nearly parallel
if (vec3.length(viewRight) < EPSILON) {
// Select a non-parallel basis vector
const tmp =
Math.abs(up[0]) < 1 / Math.sqrt(2)
? vec3.fromValues(1, 0, 0)
: vec3.fromValues(0, 1, 0);
vec3.cross(viewRight, tmp, up);
}
vec3.normalize(viewRight, viewRight);
const [scaleX, scaleY] = aspectRatio;

// Project patient anatomical axes onto view plane to determine stretch direction
const projectToPlane = (axis: vec3) => {
const dot = vec3.dot(axis, vpn);
const projection = vec3.create();
vec3.scaleAndAdd(projection, axis, vpn, -dot);
return projection;
};

const projY = projectToPlane(vec3.fromValues(0, 1, 0));
const projZ = projectToPlane(vec3.fromValues(0, 0, 1));

// Use most visible anatomical axis as stretch direction
const stretchAxis = vec3.length(projY) > vec3.length(projZ) ? projY : projZ;
vec3.normalize(stretchAxis, stretchAxis);

// Scale based on alignment: aligned -> scaleY, perpendicular -> scaleX
function getScaleFactor(screenVec: vec3): number {
const alignment = Math.abs(vec3.dot(screenVec, stretchAxis));
return alignment * scaleY + (1 - alignment) * scaleX;
}

const out = mat4.create();
return mat4.fromScaling(out, [
getScaleFactor(viewRight),
getScaleFactor(up),
1.0,
]);
}
2 changes: 2 additions & 0 deletions packages/core/src/RenderingEngine/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import setVolumesForViewports from './setVolumesForViewports';
import addVolumesToViewports from './addVolumesToViewports';
import volumeNewImageEventDispatcher from './volumeNewImageEventDispatcher';
import addImageSlicesToViewports from './addImageSlicesToViewports';
import { getProjectionScaleMatrix } from './getProjectionScaleMatrix';

export {
createVolumeActor,
Expand All @@ -13,4 +14,5 @@ export {
addVolumesToViewports,
addImageSlicesToViewports,
volumeNewImageEventDispatcher,
getProjectionScaleMatrix,
};
Loading
Loading