diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index c35f7f2b8d..39124a351a 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -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'; @@ -42,6 +42,7 @@ import type { ImagePixelModule, ImagePlaneModule, PixelDataTypedArray, + ResetCameraOptions, } from '../types'; import { actorIsA, isImageActor } from '../utilities/actorCheck'; import * as colormapUtils from '../utilities/colormap'; @@ -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; @@ -350,6 +351,7 @@ class StackViewport extends Viewport { resetZoom?: boolean; resetToCenter?: boolean; suppressEvents?: boolean; + resetAspectRatio?: boolean; }) => boolean; /** @@ -844,6 +846,7 @@ class StackViewport extends Viewport { resetPan: true, resetZoom: true, resetToCenter: true, + resetAspectRatio: true, suppressEvents: true, }); }; @@ -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; @@ -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 @@ -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 }); } /** @@ -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; }, }, diff --git a/packages/core/src/RenderingEngine/Viewport.ts b/packages/core/src/RenderingEngine/Viewport.ts index 2a4ee95cf7..b4f9ff5547 100644 --- a/packages/core/src/RenderingEngine/Viewport.ts +++ b/packages/core/src/RenderingEngine/Viewport.ts @@ -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 @@ -71,6 +72,7 @@ class Viewport { rotation: true, pan: true, zoom: true, + aspectRatio: true, displayArea: true, }; @@ -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(); @@ -1201,6 +1205,8 @@ class Viewport { -focalPointToSet[2] ); + const initialAspectRatio = this.options?.aspectRatio || [1, 1]; + this.setCamera({ parallelScale: resetZoom ? parallelScale : previousCamera.parallelScale, focalPoint: focalPointToSet, @@ -1208,6 +1214,7 @@ class Viewport { viewAngle: 90, viewUp: viewUpToSet, clippingRange: clippingRangeToUse, + aspectRatio: resetAspectRatio && initialAspectRatio, }); const modifiedCamera = this.getCamera(); @@ -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. @@ -1489,6 +1544,7 @@ class Viewport { viewAngle: vtkCamera.getViewAngle(), flipHorizontal: this.flipHorizontal, flipVertical: this.flipVertical, + aspectRatio: vtkCamera.getAspectRatio(), }; } @@ -1528,6 +1584,7 @@ class Viewport { flipHorizontal, flipVertical, clippingRange, + aspectRatio, } = cameraInterface; // Note: Flip camera should be two separate calls since @@ -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; @@ -1983,6 +2044,7 @@ class Viewport { displayArea: true, zoom: true, pan: true, + aspectRatio: true, flipHorizontal: true, flipVertical: true, } @@ -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) { @@ -2036,6 +2110,7 @@ class Viewport { displayArea, zoom = this.getZoom(), pan, + aspectRatio = this.getAspectRatio(), rotation, flipHorizontal = this.flipHorizontal, flipVertical = this.flipVertical, @@ -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 diff --git a/packages/core/src/RenderingEngine/VolumeViewport.ts b/packages/core/src/RenderingEngine/VolumeViewport.ts index 49ba0ef339..93ca6a2a6b 100644 --- a/packages/core/src/RenderingEngine/VolumeViewport.ts +++ b/packages/core/src/RenderingEngine/VolumeViewport.ts @@ -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; @@ -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); diff --git a/packages/core/src/RenderingEngine/helpers/getProjectionScaleMatrix.ts b/packages/core/src/RenderingEngine/helpers/getProjectionScaleMatrix.ts new file mode 100644 index 0000000000..802c362bc6 --- /dev/null +++ b/packages/core/src/RenderingEngine/helpers/getProjectionScaleMatrix.ts @@ -0,0 +1,61 @@ +import { mat4, vec3 } from 'gl-matrix'; + +const EPSILON = 1e-6; + +/** + * Computes a projection scaling matrix that adaptively blends aspect ratios + * based on the alignment of screen axes with patient anatomical directions. + * + * @param viewUp - [ux, uy, uz] camera viewUp (patient-space) + * @param viewPlaneNormal - [dx, dy, dz] camera viewPlaneNormal (patient-space) + * @param aspectRatio - [scaleX, scaleY]. + * @returns Projection scaling matrix. + */ +export function getProjectionScaleMatrix( + viewUp: vec3, + viewPlaneNormal: vec3, + aspectRatio: Array +): 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; + + // Blend scale based on anatomical axis alignment + function getScaleFactor(screenVec: vec3): number { + // SI (Z) and AP (Y) alignment + const alignmentZ = Math.abs(vec3.dot(screenVec, vec3.fromValues(0, 0, 1))); + const alignmentY = Math.abs(vec3.dot(screenVec, vec3.fromValues(0, 1, 0))); + + // Axial view: use AP, otherwise SI + const absVpn = [Math.abs(vpn[0]), Math.abs(vpn[1]), Math.abs(vpn[2])]; + const isAxial = Math.abs(vpn[2]) === Math.max(...absVpn); + const alignFactor = isAxial ? alignmentY : alignmentZ; + + return alignFactor * scaleY + (1 - alignFactor) * scaleX; + } + + // Apply scaling to Right (X) and Up (Y) + const out = mat4.create(); + return mat4.fromScaling(out, [ + getScaleFactor(viewRight), + getScaleFactor(up), + 1.0, + ]); +} diff --git a/packages/core/src/RenderingEngine/helpers/index.ts b/packages/core/src/RenderingEngine/helpers/index.ts index 27415bb374..786387ef81 100644 --- a/packages/core/src/RenderingEngine/helpers/index.ts +++ b/packages/core/src/RenderingEngine/helpers/index.ts @@ -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, @@ -13,4 +14,5 @@ export { addVolumesToViewports, addImageSlicesToViewports, volumeNewImageEventDispatcher, + getProjectionScaleMatrix, }; diff --git a/packages/core/src/RenderingEngine/vtkClasses/extendedVtkCamera.ts b/packages/core/src/RenderingEngine/vtkClasses/extendedVtkCamera.ts new file mode 100644 index 0000000000..c213b07e6a --- /dev/null +++ b/packages/core/src/RenderingEngine/vtkClasses/extendedVtkCamera.ts @@ -0,0 +1,122 @@ +import macro from '@kitware/vtk.js/macros'; +import vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera'; +import { getProjectionScaleMatrix } from '../helpers/getProjectionScaleMatrix'; +import { getNormalizedAspectRatio } from '../../utilities/getNormalizedAspectRatio'; +import { mat4 } from 'gl-matrix'; + +interface ICameraInitialValues { + position?: number[]; + focalPoint?: number[]; + viewUp?: number[]; + directionOfProjection?: number[]; + parallelProjection?: boolean; + useHorizontalViewAngle?: boolean; + viewAngle?: number; + parallelScale?: number; + clippingRange?: number[]; + windowCenter?: number[]; + viewPlaneNormal?: number[]; + useOffAxisProjection?: boolean; + screenBottomLeft?: number[]; + screenBottomRight?: number[]; + screenTopRight?: number[]; + freezeFocalPoint?: boolean; + physicalTranslation?: number[]; + physicalScale?: number; + physicalViewUp?: number[]; + physicalViewNorth?: number[]; + aspectRatio?: number[]; +} + +declare module '@kitware/vtk.js/Rendering/Core/Camera' { + export interface vtkCamera { + /** + * Get the aspectRatio of the viewport + * @defaultValue [1, 1] + */ + getAspectRatio(): [x: number, y: number]; + + /** + * Set the aspectRatio of the viewport + * @param aspectRatio - aspectRatio of the viewport in x and y axis + */ + setAspectRatio(aspectRatio: [x: number, y: number]): boolean; + } +} + +export type extendedVtkCamera = vtkCamera; + +/** + * extendedVtkCamera - A derived class of the core vtkCamera class + * + * This customization is necessary because when need to handle stretched viewport + * + * @param {*} publicAPI The public API to extend + * @param {*} model The private model to extend. + */ +function extendedVtkCamera(publicAPI, model) { + model.classHierarchy.push('extendedVtkCamera'); + + // Keep original + const superGetProjectionMatrix = publicAPI.getProjectionMatrix; + + /** + * getProjectionMatrix - A fork of vtkCamera's getProjectionMatrix method. + * This fork performs most of the same actions, but added handling for stretched viewport. + */ + publicAPI.getProjectionMatrix = (aspect, nearZ, farZ) => { + const matrix = superGetProjectionMatrix(aspect, nearZ, farZ); + + const [sx, sy] = getNormalizedAspectRatio(model.aspectRatio); + + if (sx !== 1.0 || sy !== 1.0) { + const viewUp = publicAPI.getViewUp(); + const viewPlaneNormal = publicAPI.getViewPlaneNormal(); + const scaleMatrix = getProjectionScaleMatrix(viewUp, viewPlaneNormal, [ + sx, + sy, + ]); + mat4.multiply(matrix, scaleMatrix, matrix); + } + + return matrix; + }; + + publicAPI.getAspectRatio = () => { + return model.aspectRatio; + }; + + publicAPI.setAspectRatio = (aspectRatio) => { + model.aspectRatio = aspectRatio; + }; +} + +// ---------------------------------------------------------------------------- +// Object factory +// ---------------------------------------------------------------------------- + +// ---------------------------------------------------------------------------- + +const DEFAULT_VALUES = { + aspectRatio: [1, 1], +}; + +export function extend(publicAPI, model, initialValues = {}) { + Object.assign(model, DEFAULT_VALUES, initialValues); + + vtkCamera.extend(publicAPI, model, initialValues); + + macro.setGet(publicAPI, model, ['aspectRatio']); + + // Object methods + extendedVtkCamera(publicAPI, model); +} + +// ---------------------------------------------------------------------------- + +export const newInstance: ( + initialValues?: ICameraInitialValues +) => extendedVtkCamera = macro.newInstance(extend, 'extendedVtkCamera'); +// ---------------------------------------------------------------------------- + +export default { newInstance, extend }; diff --git a/packages/core/src/RenderingEngine/vtkClasses/vtkSlabCamera.ts b/packages/core/src/RenderingEngine/vtkClasses/vtkSlabCamera.ts index 05b4ce2d70..c440bce56b 100644 --- a/packages/core/src/RenderingEngine/vtkClasses/vtkSlabCamera.ts +++ b/packages/core/src/RenderingEngine/vtkClasses/vtkSlabCamera.ts @@ -1,9 +1,11 @@ import macro from '@kitware/vtk.js/macros'; -import vtkCamera from '@kitware/vtk.js/Rendering/Core/Camera'; +import extendedVtkCamera from './extendedVtkCamera'; import vtkMath from '@kitware/vtk.js/Common/Core/Math'; import { vec3, mat4 } from 'gl-matrix'; import type { vtkObject } from '@kitware/vtk.js/interfaces'; import type { Range } from '@kitware/vtk.js/types'; +import { getProjectionScaleMatrix } from '../helpers/getProjectionScaleMatrix'; +import { getNormalizedAspectRatio } from '../../utilities/getNormalizedAspectRatio'; /** * @@ -746,6 +748,17 @@ export interface vtkSlabCamera extends vtkObject { setIsPerformingCoordinateTransformation(status: boolean): void; computeCameraLightTransform(): void; + /** + * Get the aspectRatio of the viewport + * @defaultValue [1, 1] + */ + getAspectRatio(): [x: number, y: number]; + + /** + * Set the aspectRatio of the viewport + * @param aspectRatio - aspectRatio of the viewport in x and y axis + */ + setAspectRatio(aspectRatio: [x: number, y: number]): boolean; } const DEFAULT_VALUES = { @@ -768,7 +781,7 @@ function extend( ): void { Object.assign(model, DEFAULT_VALUES, initialValues); - vtkCamera.extend(publicAPI, model, initialValues); + extendedVtkCamera.extend(publicAPI, model, initialValues); macro.setGet(publicAPI, model, ['isPerformingCoordinateTransformation']); @@ -909,6 +922,18 @@ function vtkSlabCamera(publicAPI, model) { tmpMatrix[15] = 0.0; } + const [sx, sy] = getNormalizedAspectRatio(model.aspectRatio); + + if (sx !== 1.0 || sy !== 1.0) { + const viewUp = publicAPI.getViewUp(); + const viewPlaneNormal = publicAPI.getViewPlaneNormal(); + const scaleMatrix = getProjectionScaleMatrix(viewUp, viewPlaneNormal, [ + sx, + sy, + ]); + mat4.multiply(tmpMatrix, scaleMatrix, tmpMatrix); + } + mat4.copy(result, tmpMatrix); return result; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 33a5d6f039..0bfa177f2f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -87,6 +87,7 @@ import { setVolumesForViewports, addVolumesToViewports, addImageSlicesToViewports, + getProjectionScaleMatrix, } from './RenderingEngine/helpers'; export * from './loaders/decimatedVolumeLoader'; @@ -158,6 +159,7 @@ export { setVolumesForViewports, addVolumesToViewports, addImageSlicesToViewports, + getProjectionScaleMatrix, // imageLoadPoolManager as requestPoolManager, imageRetrievalPoolManager, diff --git a/packages/core/src/types/ICamera.ts b/packages/core/src/types/ICamera.ts index 1d749f2707..bcd24bcd58 100644 --- a/packages/core/src/types/ICamera.ts +++ b/packages/core/src/types/ICamera.ts @@ -33,6 +33,15 @@ interface ICamera { flipVertical?: boolean; /** clipping range */ clippingRange?: Point2; + /** Aspect Ratio */ + aspectRatio?: Point2; } -export type { ICamera as default }; +interface ResetCameraOptions { + resetPan?: boolean; + resetZoom?: boolean; + resetToCenter?: boolean; + resetAspectRatio?: boolean; +} + +export type { ICamera as default, ResetCameraOptions }; diff --git a/packages/core/src/types/IImage.ts b/packages/core/src/types/IImage.ts index 89a897e8b9..2ebde4a9f3 100644 --- a/packages/core/src/types/IImage.ts +++ b/packages/core/src/types/IImage.ts @@ -175,6 +175,7 @@ interface CPUFallbackEnabledElement { imagePixelModule?: ImagePixelModule; }; voxelManager?: IVoxelManager | IVoxelManager; + aspectRatio?: Point2; } export type { IImage as default }; diff --git a/packages/core/src/types/IViewport.ts b/packages/core/src/types/IViewport.ts index cf9a4069f3..0502ab0c16 100644 --- a/packages/core/src/types/IViewport.ts +++ b/packages/core/src/types/IViewport.ts @@ -322,6 +322,11 @@ export interface ViewPresentation { * The flip vertical value is true if the view is flipped vertically. */ flipVertical?: boolean; + + /** + * The aspect ratio is how the viewport image is stretched and the default is [1,1]. + */ + aspectRatio?: Point2; } /** @@ -351,6 +356,7 @@ export interface ViewPresentationSelector { displayArea?: boolean; zoom?: boolean; pan?: boolean; + aspectRatio?: boolean; flipHorizontal?: boolean; flipVertical?: boolean; // Transfer function relative parameters diff --git a/packages/core/src/types/ViewportInputOptions.ts b/packages/core/src/types/ViewportInputOptions.ts index 62e13fbe48..4373e36b4f 100644 --- a/packages/core/src/types/ViewportInputOptions.ts +++ b/packages/core/src/types/ViewportInputOptions.ts @@ -2,6 +2,7 @@ import type { OrientationAxis } from '../enums'; import type OrientationVectors from './OrientationVectors'; import type DisplayArea from './displayArea'; import type RGB from './RGB'; +import type Point2 from './Point2'; /** * This type defines the shape of viewport input options, so we can throw when it is incorrect. @@ -20,6 +21,8 @@ interface ViewportInputOptions { * parallel projection of a stack viewport or volume viewport using viewport input options. */ parallelProjection?: boolean; + /** aspect ratio as [width, height] */ + aspectRatio?: Point2; } export type { ViewportInputOptions as default }; diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index e939c15054..9d49a304c1 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -146,6 +146,7 @@ import type GeometryLoaderFn from './GeometryLoaderFn'; import type { RenderingEngineModeType } from './RenderingEngineMode'; import type { VtkOffscreenMultiRenderWindow } from './VtkOffscreenMultiRenderWindow'; +import type { ResetCameraOptions } from './ICamera'; export type * from './MetadataModuleTypes'; export type * from './InstanceTypes'; @@ -156,6 +157,7 @@ export type { // IBaseStreamingImageVolume, ICamera, + ResetCameraOptions, IStackViewport, IVideoViewport, IWSIViewport, diff --git a/packages/core/src/utilities/getNormalizedAspectRatio.ts b/packages/core/src/utilities/getNormalizedAspectRatio.ts new file mode 100644 index 0000000000..c653989dd9 --- /dev/null +++ b/packages/core/src/utilities/getNormalizedAspectRatio.ts @@ -0,0 +1,23 @@ +import type { Point2 } from '../types'; + +const EPSILON = 1e10; + +/** + * Normalizes a pair of dimensions into a standardized aspect ratio array. + * @param aspectRatio - An array containing [width, height]. + * @returns A normalized array [w, h] where at least one value is 1. + */ +export const getNormalizedAspectRatio = (aspectRatio: Point2): Point2 => { + const [width, height] = aspectRatio; + if (width === height) { + return [1, 1]; + } + + const min = Math.min(width, height); + + // Normalize by the minimum value and round to handle floating point errors + const normalizedW = Math.round((width / min) * EPSILON) / EPSILON; + const normalizedH = Math.round((height / min) * EPSILON) / EPSILON; + + return [normalizedW, normalizedH]; +}; diff --git a/packages/core/src/utilities/index.ts b/packages/core/src/utilities/index.ts index c41ef986c5..cd7ce73883 100644 --- a/packages/core/src/utilities/index.ts +++ b/packages/core/src/utilities/index.ts @@ -104,6 +104,7 @@ export * as logger from './logger'; import { calculateNeighborhoodStats } from './calculateNeighborhoodStats'; export * from './getPixelSpacingInformation'; import { asArray } from './asArray'; +import { getNormalizedAspectRatio } from './getNormalizedAspectRatio'; export { updatePlaneRestriction } from './updatePlaneRestriction'; const getViewportModality = (viewport: IViewport, volumeId?: string) => @@ -210,4 +211,5 @@ export { buildMetadata, calculateNeighborhoodStats, asArray, + getNormalizedAspectRatio, }; diff --git a/packages/core/test/utilities/getProjectionScaleMatrix.jest.js b/packages/core/test/utilities/getProjectionScaleMatrix.jest.js new file mode 100644 index 0000000000..9a82f67cb8 --- /dev/null +++ b/packages/core/test/utilities/getProjectionScaleMatrix.jest.js @@ -0,0 +1,51 @@ +import { vec3, mat4 } from 'gl-matrix'; +import { getProjectionScaleMatrix } from '../../src/RenderingEngine/helpers/getProjectionScaleMatrix'; +import { describe, it, expect } from '@jest/globals'; + +describe('getProjectionScaleMatrix', () => { + const aspect = [1.0, 2.0]; // scaleX=1, scaleY=2 + const EPSILON = 1e-6; + + it('axial view vpn [0, 0, 1] stretch in Anterior-Posterior', () => { + const viewUp = vec3.fromValues(0, -1, 0); // Anterior-Posterior + const vpn = vec3.fromValues(0, 0, 1); // Axial + const matrix = getProjectionScaleMatrix(viewUp, vpn, aspect); + + expect(matrix[0]).toBeCloseTo(1.0); // Right axis is Left-Right (align 0) + expect(matrix[5]).toBeCloseTo(2.0); // Up axis is AP (align 1) + }); + + it('sagittal view vpn [1, 0, 0] Superior-Inferior', () => { + const viewUp = vec3.fromValues(0, 0, 1); // Superior (SI axis) + const vpn = vec3.fromValues(1, 0, 0); // Sagittal + + const matrix = getProjectionScaleMatrix(viewUp, vpn, aspect); + + expect(matrix[0]).toBeCloseTo(1.0); // Right is AP (align 1) + expect(matrix[5]).toBeCloseTo(2.0); // Up is SI (align 1) + }); + + it('coronal view vpn [0, 1, 0] Superior-Inferior', () => { + const viewUp = vec3.fromValues(0, 0, 1); // Superior-Inferior + const vpn = vec3.fromValues(0, -1, 0); // Coronal + const matrix = getProjectionScaleMatrix(viewUp, vpn, aspect); + + expect(matrix[0]).toBeCloseTo(1.0); // Right axis is Left-Right (align 0) + expect(matrix[5]).toBeCloseTo(2.0); // Up axis is SI (align 1) + }); + + it('oblique vpn halfway between axial and sagittal', () => { + // VPN is Axial. + // We tilt viewUp 45 degrees between Left-Right (X) and Superior-Inferior (Z) + const viewUp = vec3.normalize(vec3.create(), vec3.fromValues(1, 0, 1)); + const vpn = vec3.fromValues(0, 1, 0); + + const matrix = getProjectionScaleMatrix(viewUp, vpn, aspect); + + // viewUp is 45 deg between X (0) and Z (1), alignment should be ~0.707 + // Interpolation: (0.707 * 2.0) + (0.293 * 1.0) = 1.707 + expect(matrix[5]).toBeGreaterThan(1.0); + expect(matrix[5]).toBeLessThan(2.0); + expect(matrix[5]).toBeCloseTo(1.7071, 4); + }); +}); diff --git a/packages/docs/docs/concepts/cornerstone-tools/tools.md b/packages/docs/docs/concepts/cornerstone-tools/tools.md index 368149d76c..c4a79058f4 100644 --- a/packages/docs/docs/concepts/cornerstone-tools/tools.md +++ b/packages/docs/docs/concepts/cornerstone-tools/tools.md @@ -159,3 +159,16 @@ interactions. + +### Annotation in stretched viewport + +The tool prioritizes physical distance in World Coordinates. +The **Circle ROI** is defined by a center point and a radius. If the viewport is stretched, the tool will automatically render as an ellipse. This ensures that if you draw a circle in stretched view, it still represents a true physical circle in patient's body. + +### Segmentation Circular Brush Cursor in stretched viewport + +When you move your mouse, the tool calculates the cursor shape in Canvas Coordinates. It draws a circle with a radius defined in pixels. The cursor remains a perfect circle even if the image behind it is stretched 2x vertically. + +### Segmentation Circular/Sphere Brush/Eraser/Scissor Tools in stretched viewport + +The tool maps the Canvas-space circle to the underlying image pixels. The brush should draw perfect circles, not ellipses, regardless of image stretching. When the image is stretched or shrunk, the drawn segments should stretch or shrink proportionally with the image. diff --git a/packages/tools/examples/axialBasedImageStretching/index.ts b/packages/tools/examples/axialBasedImageStretching/index.ts new file mode 100644 index 0000000000..cca9a30d12 --- /dev/null +++ b/packages/tools/examples/axialBasedImageStretching/index.ts @@ -0,0 +1,516 @@ +import type { Types } from '@cornerstonejs/core'; +import { + RenderingEngine, + Enums, + setVolumesForViewports, + volumeLoader, + ProgressiveRetrieveImages, + utilities, + getRenderingEngine, +} from '@cornerstonejs/core'; +import { + initDemo, + createImageIdsAndCacheMetaData, + setTitleAndDescription, + addDropdownToToolbar, + addSliderToToolbar, + setCtTransferFunctionForVolumeActor, + getLocalUrl, +} from '../../../../utils/demo/helpers'; +import * as cornerstoneTools from '@cornerstonejs/tools'; + +// This is for debugging purposes +console.warn( + 'Click on index.ts to open source code for this example --------->' +); + +const { + ToolGroupManager, + Enums: csToolsEnums, + segmentation, + LengthTool, + RectangleROITool, + EllipticalROITool, + CircleROITool, + BidirectionalTool, + RectangleScissorsTool, + SphereScissorsTool, + CircleScissorsTool, + BrushTool, + PaintFillTool, + PanTool, + ZoomTool, + StackScrollTool, + utilities: cstUtils, +} = cornerstoneTools; + +const { MouseBindings, KeyboardBindings } = csToolsEnums; +const { ViewportType } = Enums; +const { segmentation: segmentationUtils } = cstUtils; + +// Define a unique id for the volume +const volumeName = 'CT_VOLUME_ID'; // Id of the volume less loader prefix +const segmentationId = 'MY_SEGMENTATION_ID'; +const toolGroupId = 'MY_TOOLGROUP_ID'; +const volumeLoaderScheme = 'cornerstoneStreamingImageVolume'; // Loader id which defines which volume loader to use +const volumeId = `${volumeLoaderScheme}:${volumeName}`; +const renderingEngineId = 'myRenderingEngine'; +const viewportId1 = 'CT_AXIAL'; +const viewportId2 = 'CT_SAGITTAL'; +const viewportId3 = 'CT_CORONAL'; + +// ======== Set up page ======== // +setTitleAndDescription( + 'Axial-based Image Stretching', + 'Here we demonstrate axial based stretching with annotation and segmentation tools' +); + +const size = '500px'; +const content = document.getElementById('content'); +const viewportGrid = document.createElement('div'); + +viewportGrid.style.display = 'flex'; +viewportGrid.style.display = 'flex'; +viewportGrid.style.flexDirection = 'row'; + +const element1 = document.createElement('div'); +const element2 = document.createElement('div'); +const element3 = document.createElement('div'); +element1.style.width = size; +element1.style.height = size; +element2.style.width = size; +element2.style.height = size; +element3.style.width = size; +element3.style.height = size; + +// Disable right click context menu so we can have right click tools +element1.oncontextmenu = (e) => e.preventDefault(); +element2.oncontextmenu = (e) => e.preventDefault(); +element3.oncontextmenu = (e) => e.preventDefault(); + +viewportGrid.appendChild(element1); +viewportGrid.appendChild(element2); +viewportGrid.appendChild(element3); + +content.appendChild(viewportGrid); + +const instructions = document.createElement('p'); +instructions.innerText = ` + Left Click: Use selected Segmentation Tool. + Middle Click: Pan + Right Click: Zoom + Mouse wheel: Scroll Stack + `; + +content.append(instructions); + +const brushInstanceNames = { + CircularBrush: 'CircularBrush', + CircularEraser: 'CircularEraser', + SphereBrush: 'SphereBrush', + SphereEraser: 'SphereEraser', + ThresholdCircle: 'ThresholdCircle', + ScissorsEraser: 'ScissorsEraser', +}; + +const brushStrategies = { + [brushInstanceNames.CircularBrush]: 'FILL_INSIDE_CIRCLE', + [brushInstanceNames.CircularEraser]: 'ERASE_INSIDE_CIRCLE', + [brushInstanceNames.SphereBrush]: 'FILL_INSIDE_SPHERE', + [brushInstanceNames.SphereEraser]: 'ERASE_INSIDE_SPHERE', + [brushInstanceNames.ThresholdCircle]: 'THRESHOLD_INSIDE_CIRCLE', + [brushInstanceNames.ScissorsEraser]: 'ERASE_INSIDE', +}; + +const brushValues = [ + brushInstanceNames.CircularBrush, + brushInstanceNames.CircularEraser, + brushInstanceNames.SphereBrush, + brushInstanceNames.SphereEraser, + brushInstanceNames.ThresholdCircle, +]; + +const toolsNames = [ + LengthTool.toolName, + RectangleROITool.toolName, + EllipticalROITool.toolName, + CircleROITool.toolName, + BidirectionalTool.toolName, +]; + +const optionsValues = [ + ...brushValues, + RectangleScissorsTool.toolName, + CircleScissorsTool.toolName, + SphereScissorsTool.toolName, + brushInstanceNames.ScissorsEraser, + PaintFillTool.toolName, + ...toolsNames, +]; + +// ============================= // +addDropdownToToolbar({ + options: { values: optionsValues, defaultValue: BrushTool.toolName }, + onSelectedValueChange: (nameAsStringOrNumber) => { + const name = String(nameAsStringOrNumber); + const toolGroup = ToolGroupManager.getToolGroup(toolGroupId); + + // Set the currently active tool disabled + const toolName = toolGroup.getActivePrimaryMouseButtonTool(); + + if (toolName) { + toolGroup.setToolDisabled(toolName); + } + + if (brushValues.includes(name)) { + toolGroup.setToolActive(name, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + } else { + const toolName = name; + + toolGroup.setToolActive(toolName, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + } + }, +}); + +const thresholdOptions = new Map(); +thresholdOptions.set('CT Fat: (-150, -70)', { + threshold: [-150, -70], +}); +thresholdOptions.set('CT Bone: (200, 1000)', { + threshold: [200, 1000], +}); + +addDropdownToToolbar({ + options: { + values: Array.from(thresholdOptions.keys()), + defaultValue: thresholdOptions[0], + }, + onSelectedValueChange: (nameAsStringOrNumber) => { + const name = String(nameAsStringOrNumber); + + const thresholdArgs = thresholdOptions.get(name); + + segmentationUtils.setBrushThresholdForToolGroup(toolGroupId, { + range: thresholdArgs.threshold, + isDynamic: false, + dynamicRadius: null, + }); + }, +}); + +addSliderToToolbar({ + title: 'Brush Size', + range: [5, 50], + defaultValue: 25, + onSelectedValueChange: (valueAsStringOrNumber) => { + const value = Number(valueAsStringOrNumber); + segmentationUtils.setBrushSizeForToolGroup(toolGroupId, value); + }, +}); + +let stretchAxis = ['Stretch X', 'Stretch Y']; +let selectedAxis = stretchAxis[0]; + +const setStretch = (value) => { + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport = renderingEngine.getViewport(viewportId1); + const { aspectRatio } = viewport.getCamera(); + let [sx, sy] = aspectRatio; + if (selectedAxis === 'Stretch X') { + [sx, sy] = [value, aspectRatio[1]]; + } else { + [sx, sy] = [aspectRatio[0], value]; + } + [viewportId1, viewportId2, viewportId3].forEach((id) => { + const vp = renderingEngine.getViewport(id); + vp.setAspectRatio([sx, sy]); + vp.render(); + }); +}; + +const aspects = ['1:1', '1:2', '2:1', '0.5:1', '1:0.5', '3:17']; +addDropdownToToolbar({ + id: 'aspect', + options: { + values: aspects, + defaultValue: aspects[0], + }, + onSelectedValueChange: (value) => { + const renderingEngine = getRenderingEngine(renderingEngineId); + const aspect = (value as string) + .split(':') + .map((it) => Number(it)) as Types.Point2; + + [viewportId1, viewportId2, viewportId3].forEach((id) => { + const vp = renderingEngine.getViewport(id); + vp.setAspectRatio(aspect); + vp.render(); + }); + }, +}); + +addDropdownToToolbar({ + options: { + values: stretchAxis, + defaultValue: selectedAxis, + }, + onSelectedValueChange: (nameAsStringOrNumber) => { + const name = String(nameAsStringOrNumber); + document.getElementById('stretchSlider').value = 1; + setStretch(1); + selectedAxis = name; + }, +}); + +addSliderToToolbar({ + id: 'stretchSlider', + title: 'Stretch Value', + range: [1, 10], + defaultValue: 1, + onSelectedValueChange: (valueAsStringOrNumber) => { + const value = Number(valueAsStringOrNumber); + setStretch(value); + }, +}); + +// ============================= // + +async function addSegmentationsToState() { + // Create a segmentation of the same resolution as the source data + volumeLoader.createAndCacheDerivedLabelmapVolume(volumeId, { + volumeId: segmentationId, + }); + + // Add the segmentations to state + segmentation.addSegmentations([ + { + segmentationId, + representation: { + // The type of segmentation + type: csToolsEnums.SegmentationRepresentations.Labelmap, + // The actual segmentation data, in the case of labelmap this is a + // reference to the source volume of the segmentation. + data: { + volumeId: segmentationId, + }, + }, + }, + ]); +} + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + // This is not necessary, but makes the images appear faster + utilities.imageRetrieveMetadataProvider.add( + 'volume', + ProgressiveRetrieveImages.interleavedRetrieveStages + ); + + // Add tools to Cornerstone3D + cornerstoneTools.addTool(LengthTool); + cornerstoneTools.addTool(RectangleROITool); + cornerstoneTools.addTool(EllipticalROITool); + cornerstoneTools.addTool(CircleROITool); + cornerstoneTools.addTool(BidirectionalTool); + cornerstoneTools.addTool(PanTool); + cornerstoneTools.addTool(ZoomTool); + cornerstoneTools.addTool(StackScrollTool); + cornerstoneTools.addTool(RectangleScissorsTool); + cornerstoneTools.addTool(CircleScissorsTool); + cornerstoneTools.addTool(SphereScissorsTool); + cornerstoneTools.addTool(PaintFillTool); + cornerstoneTools.addTool(BrushTool); + + // Define tool groups to add the segmentation display tool to + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + + // Manipulation Tools + toolGroup.addTool(PanTool.toolName); + toolGroup.addTool(ZoomTool.toolName); + + // Annotation Tools + toolGroup.addTool(LengthTool.toolName); + toolGroup.addTool(RectangleROITool.toolName); + toolGroup.addTool(EllipticalROITool.toolName); + toolGroup.addTool(CircleROITool.toolName); + toolGroup.addTool(BidirectionalTool.toolName); + + // Segmentation Tools + toolGroup.addTool(RectangleScissorsTool.toolName); + toolGroup.addTool(CircleScissorsTool.toolName); + toolGroup.addTool(SphereScissorsTool.toolName); + toolGroup.addToolInstance( + brushInstanceNames.ScissorsEraser, + SphereScissorsTool.toolName, + { + activeStrategy: brushStrategies.ScissorsEraser, + } + ); + toolGroup.addTool(PaintFillTool.toolName); + toolGroup.addTool(StackScrollTool.toolName); + toolGroup.addToolInstance( + brushInstanceNames.CircularBrush, + BrushTool.toolName, + { + activeStrategy: brushStrategies.CircularBrush, + } + ); + toolGroup.addToolInstance( + brushInstanceNames.CircularEraser, + BrushTool.toolName, + { + activeStrategy: brushStrategies.CircularEraser, + } + ); + toolGroup.addToolInstance( + brushInstanceNames.SphereBrush, + BrushTool.toolName, + { + activeStrategy: brushStrategies.SphereBrush, + } + ); + toolGroup.addToolInstance( + brushInstanceNames.SphereEraser, + BrushTool.toolName, + { + activeStrategy: brushStrategies.SphereEraser, + } + ); + toolGroup.addToolInstance( + brushInstanceNames.ThresholdCircle, + BrushTool.toolName, + { + activeStrategy: brushStrategies.ThresholdCircle, + } + ); + + toolGroup.setToolActive(brushInstanceNames.CircularBrush, { + bindings: [{ mouseButton: MouseBindings.Primary }], + }); + + toolGroup.setToolActive(StackScrollTool.toolName, { + bindings: [{ mouseButton: MouseBindings.Wheel }], + }); + toolGroup.setToolActive(ZoomTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Primary, // Shift Left Click + modifierKey: KeyboardBindings.Shift, + }, + ], + }); + + toolGroup.setToolActive(PanTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Auxiliary, // Middle Click + }, + { + mouseButton: MouseBindings.Primary, + modifierKey: KeyboardBindings.Ctrl, + }, + ], + }); + toolGroup.setToolActive(ZoomTool.toolName, { + bindings: [ + { + mouseButton: MouseBindings.Secondary, // Right Click + }, + ], + }); + + // Get Cornerstone imageIds for the source data and fetch metadata into RAM + const imageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463', + SeriesInstanceUID: + '1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561', + wadoRsRoot: 'https://d14fa38qiwhyfd.cloudfront.net/dicomweb', + }); + + // Define a volume in memory + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds, + }); + + volume.load(); + + // Add some segmentations based on the source data volume + await addSegmentationsToState(); + + // Instantiate a rendering engine + const renderingEngine = new RenderingEngine(renderingEngineId); + + // Create the viewports + const viewportInputArray = [ + { + viewportId: viewportId1, + type: ViewportType.ORTHOGRAPHIC, + element: element1, + defaultOptions: { + orientation: Enums.OrientationAxis.AXIAL, + background: [0, 0, 0], + }, + }, + { + viewportId: viewportId2, + type: ViewportType.ORTHOGRAPHIC, + element: element2, + defaultOptions: { + orientation: Enums.OrientationAxis.SAGITTAL, + background: [0, 0, 0], + }, + }, + { + viewportId: viewportId3, + type: ViewportType.ORTHOGRAPHIC, + element: element3, + defaultOptions: { + orientation: Enums.OrientationAxis.CORONAL, + background: [0, 0, 0], + }, + }, + ]; + + renderingEngine.setViewports(viewportInputArray); + + toolGroup.addViewport(viewportId1, renderingEngineId); + toolGroup.addViewport(viewportId2, renderingEngineId); + toolGroup.addViewport(viewportId3, renderingEngineId); + + // Set the volume to load + // volume.load(); + + // Set volumes on the viewports + await setVolumesForViewports( + renderingEngine, + [{ volumeId, callback: setCtTransferFunctionForVolumeActor }], + [viewportId1, viewportId2, viewportId3] + ); + + // Add the segmentation representation to the viewports + const segmentationRepresentation = { + segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap, + }; + + await segmentation.addLabelmapRepresentationToViewportMap({ + [viewportId1]: [segmentationRepresentation], + [viewportId2]: [segmentationRepresentation], + [viewportId3]: [segmentationRepresentation], + }); + + // Render the image + renderingEngine.render(); +} + +run(); diff --git a/packages/tools/examples/calibrationTools/index.ts b/packages/tools/examples/calibrationTools/index.ts index 56022c2720..a1fc3f656a 100644 --- a/packages/tools/examples/calibrationTools/index.ts +++ b/packages/tools/examples/calibrationTools/index.ts @@ -191,14 +191,14 @@ const calibrations = [ }, }, { - value: 'Aspect 1:2 (breaks existing annotations)', + value: 'Size Aspect 1:2 (breaks existing annotations)', selected: 'applyMetadata', metadata: { '00280030': { Value: [0.5 * originalSpacing, originalSpacing] }, }, }, { - value: 'Aspect 1:1 (breaks existing annotations)', + value: 'Size Aspect 1:1 (breaks existing annotations)', selected: 'applyMetadata', metadata: { '00280030': { Value: [originalSpacing, originalSpacing] }, @@ -224,6 +224,70 @@ addDropdownToToolbar({ }, }); +const aspects = ['1:1', '1:2', '2:1', '0.5:1', '1:0.5', '3:17']; +addDropdownToToolbar({ + id: 'aspect', + options: { + values: aspects, + defaultValue: aspects[0], + }, + onSelectedValueChange: (value) => { + const aspect = (value as string).split(':').map((it) => Number(it)); + const renderingEngine = getRenderingEngine(renderingEngineId); + const viewport = renderingEngine.getViewport(viewportId); + viewport.setAspectRatio(aspect, true); + viewport.render(); + }, +}); + +addButtonToToolbar({ + title: 'Rotate Random', + onClick: () => { + // Get the rendering engine + const renderingEngine = getRenderingEngine(renderingEngineId); + + // Get the stack viewport + const viewport = renderingEngine.getViewport(viewportId); + + const rotation = Math.random() * 360; + + viewport.setViewPresentation({ rotation }); + + viewport.render(); + }, +}); + +addButtonToToolbar({ + title: 'Rotate Absolute 0', + onClick: () => { + // Get the rendering engine + const renderingEngine = getRenderingEngine(renderingEngineId); + + // Get the stack viewport + const viewport = renderingEngine.getViewport(viewportId); + + viewport.setViewPresentation({ rotation: 0 }); + + viewport.render(); + }, +}); + +addButtonToToolbar({ + title: 'Rotate Delta 30', + onClick: () => { + // Get the rendering engine + const renderingEngine = getRenderingEngine(renderingEngineId); + + // Get the stack viewport + const viewport = renderingEngine.getViewport(viewportId); + + const { rotation } = viewport.getViewPresentation(); + viewport.setViewPresentation({ rotation: rotation + 30 }); + + viewport.render(); + }, +}); + /** * Runs the demo */ diff --git a/packages/tools/examples/resize/index.ts b/packages/tools/examples/resize/index.ts index 344449d9a6..f9eed41038 100644 --- a/packages/tools/examples/resize/index.ts +++ b/packages/tools/examples/resize/index.ts @@ -284,6 +284,20 @@ addDropdownToToolbar({ }, }); +const aspects = ['1:1', '1:2', '2:1', '0.5:1', '1:0.5', '3:17']; +addDropdownToToolbar({ + id: 'aspect', + options: { + values: aspects, + defaultValue: aspects[0], + }, + onSelectedValueChange: (value) => { + const aspect = (value as string).split(':').map((it) => Number(it)); + viewport.setAspectRatio(aspect, storeAsInitialCamera); + viewport.render(); + }, +}); + // ============================= // addToggleButtonToToolbar({ diff --git a/packages/tools/src/tools/annotation/CircleROITool.ts b/packages/tools/src/tools/annotation/CircleROITool.ts index ddb11b9cf4..bfb531a3b7 100644 --- a/packages/tools/src/tools/annotation/CircleROITool.ts +++ b/packages/tools/src/tools/annotation/CircleROITool.ts @@ -27,6 +27,7 @@ import { drawCircle as drawCircleSvg, drawHandles as drawHandlesSvg, drawLinkedTextBox as drawLinkedTextBoxSvg, + drawEllipseByCoordinates, } from '../../drawingSvg'; import { state } from '../../store/state'; import { ChangeTypes, Events, MeasurementType } from '../../enums'; @@ -56,9 +57,13 @@ import { getCanvasCircleCorners, getCanvasCircleRadius, } from '../../utilities/math/circle'; -import { pointInEllipse } from '../../utilities/math/ellipse'; +import { + getCanvasEllipseCorners, + pointInEllipse, +} from '../../utilities/math/ellipse'; import { BasicStatsCalculator } from '../../utilities/math/basic'; import { getStyleProperty } from '../../stateManagement/annotation/config/helpers'; +import getEllipseWorldCoordinates from '../../utilities/getEllipseWorldCoordinates'; const { transformWorldToIndex } = csUtils; @@ -235,14 +240,84 @@ class CircleROITool extends AnnotationTool { const { viewport } = enabledElement; const { points } = annotation.data.handles; - const canvasHandles = points.map((p) => viewport.worldToCanvas(p)); - const canvasCenter = canvasHandles[0]; - const radius = getCanvasCircleRadius([canvasCenter, canvasHandles[1]]); - const radiusPoint = getCanvasCircleRadius([canvasCenter, canvasCoords]); + // Get the radius in world units from the drag distance + const ellipseWorldCoordinates = getEllipseWorldCoordinates( + points.slice(0, 2) as [Types.Point3, Types.Point3], + viewport + ); + + const ellipseCanvasCoordinates = ellipseWorldCoordinates.map((p) => + viewport.worldToCanvas(p) + ) as [Types.Point2, Types.Point2, Types.Point2, Types.Point2]; + const canvasCorners = getCanvasEllipseCorners( + ellipseCanvasCoordinates as [ + Types.Point2, + Types.Point2, + Types.Point2, + Types.Point2, + ] + ); + + const [canvasPoint1, canvasPoint2] = canvasCorners; + + const minorEllipse = { + left: Math.min(canvasPoint1[0], canvasPoint2[0]) + proximity / 2, + top: Math.min(canvasPoint1[1], canvasPoint2[1]) + proximity / 2, + width: Math.abs(canvasPoint1[0] - canvasPoint2[0]) - proximity, + height: Math.abs(canvasPoint1[1] - canvasPoint2[1]) - proximity, + }; + + const majorEllipse = { + left: Math.min(canvasPoint1[0], canvasPoint2[0]) - proximity / 2, + top: Math.min(canvasPoint1[1], canvasPoint2[1]) - proximity / 2, + width: Math.abs(canvasPoint1[0] - canvasPoint2[0]) + proximity, + height: Math.abs(canvasPoint1[1] - canvasPoint2[1]) + proximity, + }; + + const pointInMinorEllipse = this._pointInEllipseCanvas( + minorEllipse, + canvasCoords + ); + const pointInMajorEllipse = this._pointInEllipseCanvas( + majorEllipse, + canvasCoords + ); - return Math.abs(radiusPoint - radius) < proximity / 2; + if (pointInMajorEllipse && !pointInMinorEllipse) { + return true; + } + + return false; }; + /** + * This is a temporary function to use the old ellipse's canvas-based + * calculation for isPointNearTool, we should move the the world-based + * calculation to the tool's isPointNearTool function. + * + * @param ellipse - The ellipse object + * @param location - The location to check + * @returns True if the point is inside the ellipse + */ + _pointInEllipseCanvas(ellipse, location: Types.Point2): boolean { + const xRadius = ellipse.width / 2; + const yRadius = ellipse.height / 2; + + if (xRadius <= 0.0 || yRadius <= 0.0) { + return false; + } + + const center = [ellipse.left + xRadius, ellipse.top + yRadius]; + const normalized = [location[0] - center[0], location[1] - center[1]]; + + const inEllipse = + (normalized[0] * normalized[0]) / (xRadius * xRadius) + + (normalized[1] * normalized[1]) / (yRadius * yRadius) <= + 1.0; + + return inEllipse; + } + toolSelectedCallback = ( evt: EventTypes.InteractionEventType, annotation: CircleROIAnnotation @@ -746,12 +821,27 @@ class CircleROITool extends AnnotationTool { const dataId = `${annotationUID}-circle`; const circleUID = '0'; - drawCircleSvg( + + // Get the radius in world units from the drag distance + const ellipseWorldCoordinates = getEllipseWorldCoordinates( + [points[0], points[1]], + viewport + ); + + const ellipseCanvasCoordinates = ellipseWorldCoordinates.map((p) => + viewport.worldToCanvas(p) + ) as [Types.Point2, Types.Point2, Types.Point2, Types.Point2]; + + drawEllipseByCoordinates( svgDrawingHelper, annotationUID, circleUID, - center, - radius, + ellipseCanvasCoordinates as [ + Types.Point2, + Types.Point2, + Types.Point2, + Types.Point2, + ], { color, lineDash, diff --git a/packages/tools/src/tools/segmentation/CircleScissorsTool.ts b/packages/tools/src/tools/segmentation/CircleScissorsTool.ts index 5a9faf306e..0f7cc3b054 100644 --- a/packages/tools/src/tools/segmentation/CircleScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/CircleScissorsTool.ts @@ -38,6 +38,8 @@ import { import type { LabelmapSegmentationDataVolume } from '../../types/LabelmapTypes'; import LabelmapBaseTool from './LabelmapBaseTool'; import type { LabelmapMemo } from '../../utilities/segmentation/createLabelmapMemo'; +import getEllipseWorldCoordinates from '../../utilities/getEllipseWorldCoordinates'; +import getCenterAndRadiusInCanvas from '../../utilities/getCenterAndRadiusInCanvas'; /** * Tool for manipulating segmentation data by drawing a circle. It acts on the @@ -238,32 +240,14 @@ class CircleScissorsTool extends LabelmapBaseTool { const { annotation, viewportIdsToRender, centerCanvas } = this.editData; const { data } = annotation; - // Center of circle in canvas Coordinates - - const dX = Math.abs(currentCanvasPoints[0] - centerCanvas[0]); - const dY = Math.abs(currentCanvasPoints[1] - centerCanvas[1]); - const radius = Math.sqrt(dX * dX + dY * dY); - - const bottomCanvas: Types.Point2 = [ - centerCanvas[0], - centerCanvas[1] + radius, - ]; - const topCanvas: Types.Point2 = [centerCanvas[0], centerCanvas[1] - radius]; - const leftCanvas: Types.Point2 = [ - centerCanvas[0] - radius, - centerCanvas[1], - ]; - const rightCanvas: Types.Point2 = [ - centerCanvas[0] + radius, - centerCanvas[1], - ]; - - data.handles.points = [ - canvasToWorld(bottomCanvas), - canvasToWorld(topCanvas), - canvasToWorld(leftCanvas), - canvasToWorld(rightCanvas), - ]; + // Convert center and current point to world coordinates + const centerWorld = canvasToWorld(centerCanvas as Types.Point2); + const currentWorld = canvasToWorld(currentCanvasPoints as Types.Point2); + + data.handles.points = getEllipseWorldCoordinates( + [centerWorld, currentWorld], + viewport + ) as [Types.Point3, Types.Point3, Types.Point3, Types.Point3]; annotation.invalidated = true; @@ -368,17 +352,7 @@ class CircleScissorsTool extends LabelmapBaseTool { const data = annotation.data; const { points } = data.handles; - const canvasCoordinates = points.map((p) => viewport.worldToCanvas(p)); - - const bottom = canvasCoordinates[0]; - const top = canvasCoordinates[1]; - - const center = [ - Math.floor((bottom[0] + top[0]) / 2), - Math.floor((bottom[1] + top[1]) / 2), - ]; - - const radius = Math.abs(bottom[1] - Math.floor((bottom[1] + top[1]) / 2)); + const { center, radius } = getCenterAndRadiusInCanvas(points, viewport); const color = `rgb(${toolMetadata.segmentColor.slice(0, 3)})`; diff --git a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts index d76e284451..61372105b5 100644 --- a/packages/tools/src/tools/segmentation/SphereScissorsTool.ts +++ b/packages/tools/src/tools/segmentation/SphereScissorsTool.ts @@ -28,6 +28,8 @@ import { import { getSegmentation } from '../../stateManagement/segmentation/segmentationState'; import LabelmapBaseTool from './LabelmapBaseTool'; +import getEllipseWorldCoordinates from '../../utilities/getEllipseWorldCoordinates'; +import { getCenterAndRadiusInCanvas } from '../../utilities/getCenterAndRadiusInCanvas'; /** * Tool for manipulating segmentation data by drawing a sphere in 3d space. It acts on the @@ -216,35 +218,17 @@ class SphereScissorsTool extends LabelmapBaseTool { const { annotation, viewportIdsToRender, centerCanvas } = this.editData; const { data } = annotation; - const dX = Math.abs(currentCanvasPoints[0] - centerCanvas[0]); - const dY = Math.abs(currentCanvasPoints[1] - centerCanvas[1]); - const radius = Math.sqrt(dX * dX + dY * dY); - - const bottomCanvas: Types.Point2 = [ - centerCanvas[0], - centerCanvas[1] + radius, - ]; - const topCanvas: Types.Point2 = [centerCanvas[0], centerCanvas[1] - radius]; - const leftCanvas: Types.Point2 = [ - centerCanvas[0] - radius, - centerCanvas[1], - ]; - const rightCanvas: Types.Point2 = [ - centerCanvas[0] + radius, - centerCanvas[1], - ]; - - data.handles.points = [ - canvasToWorld(bottomCanvas), - canvasToWorld(topCanvas), - canvasToWorld(leftCanvas), - canvasToWorld(rightCanvas), - ]; + // Convert center and current point to world coordinates + const centerWorld = canvasToWorld(centerCanvas as Types.Point2); + const currentWorld = canvasToWorld(currentCanvasPoints as Types.Point2); - annotation.invalidated = true; + data.handles.points = getEllipseWorldCoordinates( + [centerWorld, currentWorld], + viewport + ) as [Types.Point3, Types.Point3, Types.Point3, Types.Point3]; + annotation.invalidated = true; this.editData.hasMoved = true; - triggerAnnotationRenderForViewportIds(viewportIdsToRender); }; @@ -351,17 +335,7 @@ class SphereScissorsTool extends LabelmapBaseTool { const data = annotation.data; const { points } = data.handles; - const canvasCoordinates = points.map((p) => viewport.worldToCanvas(p)); - - const bottom = canvasCoordinates[0]; - const top = canvasCoordinates[1]; - - const center = [ - Math.floor((bottom[0] + top[0]) / 2), - Math.floor((bottom[1] + top[1]) / 2), - ]; - - const radius = Math.abs(bottom[1] - Math.floor((bottom[1] + top[1]) / 2)); + const { center, radius } = getCenterAndRadiusInCanvas(points, viewport); const color = `rgb(${toolMetadata.segmentColor.slice(0, 3)})`; diff --git a/packages/tools/src/tools/segmentation/strategies/__tests__/fillCircle.spec.ts b/packages/tools/src/tools/segmentation/strategies/__tests__/fillCircle.spec.ts index 84d5aeae66..a846af7537 100644 --- a/packages/tools/src/tools/segmentation/strategies/__tests__/fillCircle.spec.ts +++ b/packages/tools/src/tools/segmentation/strategies/__tests__/fillCircle.spec.ts @@ -24,7 +24,8 @@ describe('createPointInEllipse', () => { [-2, 0, 0] as Types.Point3, [2, 0, 0] as Types.Point3, ], - radius: 1, + xRadius: 1, + yRadius: 1, }); expect(predicate([0, 0, 0] as Types.Point3)).toBe(true); diff --git a/packages/tools/src/tools/segmentation/strategies/compositions/circularCursor.ts b/packages/tools/src/tools/segmentation/strategies/compositions/circularCursor.ts index 326761b861..70a70cd5b4 100644 --- a/packages/tools/src/tools/segmentation/strategies/compositions/circularCursor.ts +++ b/packages/tools/src/tools/segmentation/strategies/compositions/circularCursor.ts @@ -7,6 +7,7 @@ import type { SVGDrawingHelper } from '../../../../types'; import StrategyCallbacks from '../../../../enums/StrategyCallbacks'; import { drawCircle as drawCircleSvg } from '../../../../drawingSvg'; +import { getCenterAndRadiusInCanvas } from '../../../../utilities/getCenterAndRadiusInCanvas'; export default { [StrategyCallbacks.CalculateCursorGeometry]: function ( @@ -112,17 +113,8 @@ export default { const data = brushCursor.data; const { points } = data.handles; - const canvasCoordinates = points.map((p) => viewport.worldToCanvas(p)); - const bottom = canvasCoordinates[0]; - const top = canvasCoordinates[1]; - - const center = [ - Math.floor((bottom[0] + top[0]) / 2), - Math.floor((bottom[1] + top[1]) / 2), - ]; - - const radius = Math.abs(bottom[1] - Math.floor((bottom[1] + top[1]) / 2)); + const { center, radius } = getCenterAndRadiusInCanvas(points, viewport); const color = `rgb(${toolMetadata.segmentColor?.slice(0, 3) || [0, 0, 0]})`; diff --git a/packages/tools/src/tools/segmentation/strategies/fillCircle.ts b/packages/tools/src/tools/segmentation/strategies/fillCircle.ts index 613d588290..f02373641e 100644 --- a/packages/tools/src/tools/segmentation/strategies/fillCircle.ts +++ b/packages/tools/src/tools/segmentation/strategies/fillCircle.ts @@ -12,7 +12,12 @@ import { StrategyCallbacks } from '../../../enums'; import compositions from './compositions'; import { pointInSphere } from '../../../utilities/math/sphere'; -const { transformWorldToIndex, transformIndexToWorld, isEqual } = csUtils; +const { + transformWorldToIndex, + transformIndexToWorld, + isEqual, + getNormalizedAspectRatio, +} = csUtils; /** * Returns the corners of an ellipse in canvas coordinates. @@ -36,21 +41,22 @@ function createCircleCornersForCenter( center: Types.Point3, viewUp: ReadonlyVec3, viewRight: ReadonlyVec3, - radius: number + yRadius: number, + xRadius: number ): Types.Point3[] { const centerVec = vec3.fromValues(center[0], center[1], center[2]); const top = vec3.create(); - vec3.scaleAndAdd(top, centerVec, viewUp, radius); + vec3.scaleAndAdd(top, centerVec, viewUp, yRadius); const bottom = vec3.create(); - vec3.scaleAndAdd(bottom, centerVec, viewUp, -radius); + vec3.scaleAndAdd(bottom, centerVec, viewUp, -yRadius); const right = vec3.create(); - vec3.scaleAndAdd(right, centerVec, viewRight, radius); + vec3.scaleAndAdd(right, centerVec, viewRight, xRadius); const left = vec3.create(); - vec3.scaleAndAdd(left, centerVec, viewRight, -radius); + vec3.scaleAndAdd(left, centerVec, viewRight, -xRadius); return [ bottom as Types.Point3, @@ -65,12 +71,17 @@ function createCircleCornersForCenter( // strategy for many intermediate samples, which was unnecessarily expensive // and still missed fast mouse moves. This predicate lets us describe the full // swept volume in constant time per segment when the strategy runs. -function createStrokePredicate(centers: Types.Point3[], radius: number) { - if (!centers.length || radius <= 0) { +function createStrokePredicate( + centers: Types.Point3[], + xRadius: number, + yRadius: number +) { + if (!centers.length || xRadius <= 0 || yRadius <= 0) { return null; } - const radiusSquared = radius * radius; + const xRadiusSquared = xRadius * xRadius; + const yRadiusSquared = yRadius * yRadius; const centerVecs = centers.map( (point) => [point[0], point[1], point[2]] as Types.Point3 ); @@ -100,7 +111,10 @@ function createStrokePredicate(centers: Types.Point3[], radius: number) { const dx = worldPoint[0] - centerVec[0]; const dy = worldPoint[1] - centerVec[1]; const dz = worldPoint[2] - centerVec[2]; - if (dx * dx + dy * dy + dz * dz <= radiusSquared) { + if ( + (dx * dx) / xRadiusSquared + (dy * dy) / yRadiusSquared + dz * dz <= + 1 + ) { return true; } } @@ -110,7 +124,10 @@ function createStrokePredicate(centers: Types.Point3[], radius: number) { const dx = worldPoint[0] - start[0]; const dy = worldPoint[1] - start[1]; const dz = worldPoint[2] - start[2]; - if (dx * dx + dy * dy + dz * dz <= radiusSquared) { + if ( + (dx * dx) / xRadiusSquared + (dy * dy) / yRadiusSquared + dz * dz <= + 1 + ) { return true; } continue; @@ -128,7 +145,12 @@ function createStrokePredicate(centers: Types.Point3[], radius: number) { const distY = worldPoint[1] - projY; const distZ = worldPoint[2] - projZ; - if (distX * distX + distY * distY + distZ * distZ <= radiusSquared) { + if ( + (distX * distX) / xRadiusSquared + + (distY * distY) / yRadiusSquared + + distZ * distZ <= + 1 + ) { return true; } } @@ -169,8 +191,18 @@ const initializeCircle = { center as Types.Point3 ); - const brushRadius = - points.length >= 2 ? vec3.distance(points[0], points[1]) / 2 : 0; + // Get your aspect ratio values + const aspectRatio = getNormalizedAspectRatio(viewport.getAspectRatio()); + + const yRadius = + points.length >= 2 + ? vec3.distance(points[0], points[1]) / 2 / aspectRatio[1] + : 0; + + const xRadius = + points.length >= 2 + ? vec3.distance(points[2], points[3]) / 2 / aspectRatio[0] + : 0; const canvasCoordinates = points.map((p) => viewport.worldToCanvas(p) @@ -214,7 +246,8 @@ const initializeCircle = { centerPoint, normalizedViewUp, viewRight, - brushRadius + yRadius, + xRadius ) ); @@ -231,7 +264,9 @@ const initializeCircle = { operationData.isInObject = createPointInEllipse(cornersInWorld, { strokePointsWorld: strokeCenters, segmentationImageData, - radius: brushRadius, + xRadius, + yRadius, + aspectRatio, }); operationData.isInObjectBoundsIJK = boundsIJK; @@ -251,7 +286,9 @@ function createPointInEllipse( options: { strokePointsWorld?: Types.Point3[]; segmentationImageData?: vtkImageData; - radius?: number; + xRadius?: number; + yRadius?: number; + aspectRatio?: [number, number]; } = {} ) { if (!cornersInWorld || cornersInWorld.length !== 4) { @@ -259,33 +296,38 @@ function createPointInEllipse( } const [topLeft, bottomRight, bottomLeft, topRight] = cornersInWorld; + const aspectRatio = options.aspectRatio || [1, 1]; + // Center is the midpoint of the diagonal const center = vec3.create(); vec3.add(center, topLeft, bottomRight); vec3.scale(center, center, 0.5); - // Major axis: from topLeft to topRight + //Calculate a SINGLE original radius to ensure the base shape is a circle. + // We'll use the width (major axis) as the definitive diameter. const majorAxisVec = vec3.create(); vec3.subtract(majorAxisVec, topRight, topLeft); - const xRadius = vec3.length(majorAxisVec) / 2; - vec3.normalize(majorAxisVec, majorAxisVec); + const originalRadius = vec3.length(majorAxisVec) / 2; + vec3.normalize(majorAxisVec, majorAxisVec); // This is the 'X' direction vector - // Minor axis: from topLeft to bottomLeft + // We still need the minor axis for its direction, but not its length. const minorAxisVec = vec3.create(); vec3.subtract(minorAxisVec, bottomLeft, topLeft); - const yRadius = vec3.length(minorAxisVec) / 2; - vec3.normalize(minorAxisVec, minorAxisVec); + vec3.normalize(minorAxisVec, minorAxisVec); // This is the 'Y' direction vector - // Plane normal - const normal = vec3.create(); - vec3.cross(normal, majorAxisVec, minorAxisVec); - vec3.normalize(normal, normal); + //Apply the inverse aspect ratio stretch CORRECTLY and ALWAYS the same way. + // To counteract the viewport's stretching and make the shape appear circular, + // we must "pre-squash" it in world space. + const xRadius = originalRadius / aspectRatio[0]; + const yRadius = originalRadius / aspectRatio[1]; // If radii are equal, treat as sphere - const radiusForStroke = options.radius ?? Math.max(xRadius, yRadius); + const xRadiusForStroke = options.xRadius ?? xRadius; + const yRadiusForStroke = options.yRadius ?? yRadius; const strokePredicate = createStrokePredicate( options.strokePointsWorld || [], - radiusForStroke + xRadiusForStroke, + yRadiusForStroke ); if (isEqual(xRadius, yRadius)) { @@ -344,19 +386,8 @@ function createPointInEllipse( // conversions happened on callers for every interpolated point. const pointVec = vec3.create(); vec3.subtract(pointVec, worldPoint, center); - // Remove component along normal - const distToPlane = vec3.dot(pointVec, normal); - const proj = vec3.create(); - vec3.scaleAndAdd(proj, pointVec, normal, -distToPlane); - - // Express proj in (majorAxis, minorAxis) coordinates - // Project from center, so shift origin to topLeft - const fromTopLeft = vec3.create(); - const centerToTopLeft = vec3.create(); - vec3.subtract(centerToTopLeft, center, topLeft); - vec3.subtract(fromTopLeft, proj, centerToTopLeft); - const x = vec3.dot(fromTopLeft, majorAxisVec); - const y = vec3.dot(fromTopLeft, minorAxisVec); + const x = vec3.dot(pointVec, majorAxisVec); + const y = vec3.dot(pointVec, minorAxisVec); // Ellipse equation: (x/xRadius)^2 + (y/yRadius)^2 <= 1 return (x * x) / (xRadius * xRadius) + (y * y) / (yRadius * yRadius) <= 1; diff --git a/packages/tools/src/tools/segmentation/strategies/fillSphere.ts b/packages/tools/src/tools/segmentation/strategies/fillSphere.ts index 6d70e3ab7a..fcff7a7272 100644 --- a/packages/tools/src/tools/segmentation/strategies/fillSphere.ts +++ b/packages/tools/src/tools/segmentation/strategies/fillSphere.ts @@ -10,7 +10,7 @@ import { createEllipseInPoint, getEllipseCornersFromCanvasCoordinates, } from './fillCircle'; -const { transformWorldToIndex } = csUtils; +const { transformWorldToIndex, getNormalizedAspectRatio } = csUtils; import { getSphereBoundsInfoFromViewport } from '../../../utilities/getSphereBoundsInfo'; import type { CanvasCoordinates } from '../../../types'; @@ -54,8 +54,17 @@ const sphereComposition = { viewport.canvasToWorld(corner) ); - const strokeRadius = - points.length >= 2 ? vec3.distance(points[0], points[1]) / 2 : undefined; + const aspectRatio = getNormalizedAspectRatio(viewport.getAspectRatio()); + + const yRadius = + points.length >= 2 + ? vec3.distance(points[0], points[1]) / 2 / aspectRatio[1] + : 0; + + const xRadius = + points.length >= 2 + ? vec3.distance(points[2], points[3]) / 2 / aspectRatio[0] + : 0; const strokeCenters = operationData.strokePointsWorld && @@ -143,7 +152,9 @@ const sphereComposition = { operationData.isInObject = createEllipseInPoint(cornersInWorld, { strokePointsWorld: operationData.strokePointsWorld, segmentationImageData, - radius: strokeRadius, + xRadius, + yRadius, + aspectRatio, }); // } }, diff --git a/packages/tools/src/utilities/getCenterAndRadiusInCanvas.ts b/packages/tools/src/utilities/getCenterAndRadiusInCanvas.ts new file mode 100644 index 0000000000..40d83b7bef --- /dev/null +++ b/packages/tools/src/utilities/getCenterAndRadiusInCanvas.ts @@ -0,0 +1,55 @@ +import { type Types, type VolumeViewport } from '@cornerstonejs/core'; +import { vec2, vec3 } from 'gl-matrix'; + +const EPSILON = 1e-4; + +/** + * Calculates the center point and radius in canvas coordinate of a circle + * from a set of world coordinates within the given viewport. + * + * The function projects the provided world coordinates into the + * viewport's canvas coordinates and returns both the + * calculated center and radius in canvas coordinates. + * + * @param points - The list of 3D points defining the circle - center and point on circle. + * @param viewport - The current viewport. + * @returns An array contains: + * - The first element: center in canvas coordinate. + * - The second element: radius in canvas coordinate. + */ + +export function getCenterAndRadiusInCanvas( + points: Types.Point3[], + viewport: Types.IStackViewport | VolumeViewport +): { center: Types.Point2; radius: number } { + const canvasPoints = points.map((p) => viewport.worldToCanvas(p)); + const [cBottom, cTop, cLeft, cRight] = canvasPoints; + + const center: Types.Point2 = [ + (cBottom[0] + cTop[0]) / 2, + (cBottom[1] + cTop[1]) / 2, + ]; + + const worldHeight = vec3.distance(points[0], points[1]); + const worldWidth = vec3.distance(points[2], points[3]); + + const canvasHeight = vec2.distance(cBottom, cTop); + const canvasWidth = vec2.distance(cLeft, cRight); + + const scaleX = canvasWidth / worldWidth; + const scaleY = canvasHeight / worldHeight; + + const worldRadius = worldHeight / 2; + + const radius = + Math.abs(scaleX - scaleY) > EPSILON + ? worldRadius * Math.min(scaleX, scaleY) + : canvasHeight / 2; + + return { + center: center, + radius, + }; +} + +export default getCenterAndRadiusInCanvas; diff --git a/packages/tools/src/utilities/getEllipseWorldCoordinates.ts b/packages/tools/src/utilities/getEllipseWorldCoordinates.ts new file mode 100644 index 0000000000..caa64dc784 --- /dev/null +++ b/packages/tools/src/utilities/getEllipseWorldCoordinates.ts @@ -0,0 +1,61 @@ +import type { VolumeViewport, Types } from '@cornerstonejs/core'; +import { vec3 } from 'gl-matrix'; + +/** + * Computes the ellipse boundary points (top, bottom, left, right) based on two + * given world-space points and the viewport's camera orientation. + * + * Depending on the `returnWorldCoordinates` flag, it returns either: + * - The ellipse boundary points in world coordinates or + * - The corresponding points in canvas coordinates (projected using the viewport). + * + * This function: + * - Uses the camera's `viewUp` and `viewPlaneNormal` to derive the orientation. + * - Computes the perpendicular `viewRight` vector via cross product. + * - Calculates top, bottom, left, and right points relative to the ellipse center. + * + * @param points Array containing: + * - `[0]`: The center of the ellipse in world coordinates. + * - `[1]`: A point on the ellipse radius in world coordinates. + * @param viewport The viewport instance + * @returns Returns an array of world-space coordinates representing: + * 1. Bottom + * 2. Top + * 3. Left + * 4. Right + */ +export default function getEllipseWorldCoordinates( + points: [Types.Point3, Types.Point3], + viewport: Types.IStackViewport | VolumeViewport +): Types.Point3[] { + const camera = viewport.getCamera(); + const { viewUp, viewPlaneNormal } = camera; + + // Calculate view right vector + const viewRight = vec3.create(); + vec3.cross(viewRight, viewUp, viewPlaneNormal); + + const [centerWorld, endWorld] = points; + const centerToEndDistance = vec3.distance(centerWorld, endWorld); + + // Calculate the four boundary points in world coordinates + const bottomWorld = vec3.create(); + const topWorld = vec3.create(); + const leftWorld = vec3.create(); + const rightWorld = vec3.create(); + + for (let i = 0; i <= 2; i++) { + bottomWorld[i] = centerWorld[i] - viewUp[i] * centerToEndDistance; + topWorld[i] = centerWorld[i] + viewUp[i] * centerToEndDistance; + leftWorld[i] = centerWorld[i] - viewRight[i] * centerToEndDistance; + rightWorld[i] = centerWorld[i] + viewRight[i] * centerToEndDistance; + } + + const ellipseWorldCoordinates = [ + bottomWorld, + topWorld, + leftWorld, + rightWorld, + ] as [Types.Point3, Types.Point3, Types.Point3, Types.Point3]; + return ellipseWorldCoordinates; +} diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json index e3f5ad8309..a5a056573e 100644 --- a/utils/ExampleRunner/example-info.json +++ b/utils/ExampleRunner/example-info.json @@ -176,6 +176,10 @@ "smoothing": { "name": "Image Smoothing For Stack & Volume Viewports", "description": "Demonstrates how to apply image smoothing to stack and volume viewports" + }, + "axialBasedImageStretching": { + "name": "Axial-based Image Stretching", + "description": "Here we demonstrate axial based stretching with annotation and segmentation tools" } }, "tools-basic": {