ResliceCursorWidget

Methods

getPlaneExtremities

Return an array of the first and the last possible points of the plane
along its normal.

Argument Type Required Description
viewType ViewTypes Yes

Returns

Type Description
Array. two Vector3 arrays (first and last points)

getRepresentationsForViewType

Argument Type Required Description
viewType ViewTypes Yes

Source

Constants.d.ts
import { Vector3 } from '../../../types';
import { ViewTypes } from '../../Core/WidgetManager/Constants';

// Different types of plane from ViewTypes:
export type PlaneViewType = ViewTypes.YZ_PLANE | ViewTypes.XZ_PLANE | ViewTypes.XY_PLANE;

// 0, 1, 2 for X, Y, Z
export type AxisIndex = 0 | 1 | 2;

// Should be X, Y, Z
export type PlaneName = typeof planeNames extends (infer U)[] ? U : never;


export declare enum ScrollingMethods {
MIDDLE_MOUSE_BUTTON = 0,
LEFT_MOUSE_BUTTON = 1,
RIGHT_MOUSE_BUTTON = 2,
}

// Note: These strings are used in ResliceCursorWidget/behavior.js
// as method's names
export declare enum InteractionMethodsName {
TranslateAxis = 'translateAxis',
RotateLine = 'rotateLine',
TranslateCenter = 'translateCenter',
TranslateCenterAndUpdatePlanes = 'translateCenterAndUpdatePlanes',
}

export declare const defaultViewUpFromViewType: { [plane in PlaneViewType]: Vector3 };

export declare const xyzToViewType: [PlaneViewType, PlaneViewType, PlaneViewType];

export declare const viewTypeToXYZ: { [plane in PlaneViewType]: AxisIndex };

export declare const planeNames: ['X', 'Y', 'Z'];

export declare const viewTypeToPlaneName: { [plane in PlaneViewType]: PlaneName };

export declare const planeNameToViewType: { [planeName in PlaneName]: PlaneViewType };

export declare const lineNames: ['YinX', 'ZinX', 'XinY', 'ZinY', 'XinZ', 'YinZ'];

declare const _default: {
ScrollingMethods: typeof ScrollingMethods;
InteractionMethodsName: typeof InteractionMethodsName;
xyzToViewType: typeof xyzToViewType;
viewTypeToXYZ: typeof viewTypeToXYZ;
planeNames: typeof planeNames;
viewTypeToPlaneName: typeof viewTypeToPlaneName;
planeNameToViewType: typeof planeNameToViewType;
lineNames: typeof lineNames;
};

export default _default;
Constants.js
import { ViewTypes } from 'vtk.js/Sources/Widgets/Core/WidgetManager/Constants';

export const ScrollingMethods = {
MIDDLE_MOUSE_BUTTON: 0,
LEFT_MOUSE_BUTTON: 1,
RIGHT_MOUSE_BUTTON: 2,
};

// Note: These strings are used in ResliceCursorWidget/behavior.js
// as method's names
export const InteractionMethodsName = {
TranslateAxis: 'translateAxis',
RotateLine: 'rotateLine',
TranslateCenter: 'translateCenter',
TranslateCenterAndUpdatePlanes: 'translateCenterAndUpdatePlanes',
};

export const defaultViewUpFromViewType = {
[ViewTypes.YZ_PLANE]: [0, 0, 1], // Sagittal
[ViewTypes.XZ_PLANE]: [0, 0, 1], // Coronal
[ViewTypes.XY_PLANE]: [0, -1, 0], // Axial
};

export const xyzToViewType = [
ViewTypes.YZ_PLANE,
ViewTypes.XZ_PLANE,
ViewTypes.XY_PLANE,
];

export const viewTypeToXYZ = {
[ViewTypes.YZ_PLANE]: 0,
[ViewTypes.XZ_PLANE]: 1,
[ViewTypes.XY_PLANE]: 2,
};

export const planeNames = ['X', 'Y', 'Z'];

export const viewTypeToPlaneName = {
[ViewTypes.YZ_PLANE]: 'X',
[ViewTypes.XZ_PLANE]: 'Y',
[ViewTypes.XY_PLANE]: 'Z',
};

export const planeNameToViewType = {
X: ViewTypes.YZ_PLANE,
Y: ViewTypes.XZ_PLANE,
Z: ViewTypes.XY_PLANE,
};
export const lineNames = ['YinX', 'ZinX', 'XinY', 'ZinY', 'XinZ', 'YinZ'];

export default {
ScrollingMethods,
InteractionMethodsName,
xyzToViewType,
viewTypeToXYZ,
planeNames,
viewTypeToPlaneName,
planeNameToViewType,
lineNames,
};
behavior.d.ts
import { Nullable } from "../../../types";
import { InteractionMethodsName, lineNames } from "./Constants";
import vtkAbstractWidget from "../../Core/AbstractWidget";

type TLineName = (typeof lineNames)[number];

type TCursorStyles = {
[key in InteractionMethodsName]?: string;
} & {
default?: string
};

export default interface vtkResliceCursorWidgetDefaultInstance extends vtkAbstractWidget {
getActiveInteraction(): Nullable<InteractionMethodsName>;

setKeepOrthogonality(keepOrthogonality: boolean): boolean;
getKeepOrthogonality(): boolean;

setCursorStyles(cursorStyles: TCursorStyles): boolean;
getCursorStyles(): TCursorStyles;

setEnableTranslation(enableTranslation: boolean): void;
setEnableRotation(enableRotation: boolean): void;

getActiveLineName(): TLineName | undefined;
}
behavior.js
import macro from 'vtk.js/Sources/macros';
import vtkBoundingBox from 'vtk.js/Sources/Common/DataModel/BoundingBox';
import vtkLine from 'vtk.js/Sources/Common/DataModel/Line';
import * as vtkMath from 'vtk.js/Sources/Common/Core/Math';

import {
boundPointOnPlane,
rotateVector,
updateState,
getOtherLineName,
getLinePlaneName,
getLineInPlaneName,
getLineNames,
} from 'vtk.js/Sources/Widgets/Widgets3D/ResliceCursorWidget/helpers';

import {
ScrollingMethods,
InteractionMethodsName,
planeNameToViewType,
} from 'vtk.js/Sources/Widgets/Widgets3D/ResliceCursorWidget/Constants';

export default function widgetBehavior(publicAPI, model) {
model._isDragging = false;
let isScrolling = false;
let previousPosition;

macro.setGet(publicAPI, model, [
'keepOrthogonality',
{ type: 'object', name: 'cursorStyles' },
]);

// Set default value for cursorStyles
publicAPI.setCursorStyles({
[InteractionMethodsName.TranslateCenter]: 'move',
[InteractionMethodsName.RotateLine]: 'alias',
[InteractionMethodsName.TranslateAxis]: 'pointer',
default: 'default',
});

publicAPI.setEnableTranslation = (enable) => {
model.representations[0].setPickable(enable); // line handle
model.representations[2].setPickable(enable); // center handle
};

publicAPI.setEnableRotation = (enable) => {
model.representations[1].setPickable(enable); // rotation handle
};

// FIXME: label information should be accessible from activeState instead of parent state.
publicAPI.getActiveInteraction = () => {
if (
model.widgetState
.getStatesWithLabel('rotation')
.includes(model.activeState)
) {
return InteractionMethodsName.RotateLine;
}
if (
model.widgetState.getStatesWithLabel('line').includes(model.activeState)
) {
return InteractionMethodsName.TranslateAxis;
}
if (
model.widgetState.getStatesWithLabel('center').includes(model.activeState)
) {
return InteractionMethodsName.TranslateCenter;
}
return null;
};

/**
* ActiveState can be RotationHandle or a LineHandle
* @returns 'YinX', 'ZinX', 'XinY', 'ZinY', 'XinZ' or 'YinZ'
*/
publicAPI.getActiveLineName = () =>
getLineNames(model.widgetState).find((lineName) =>
model.widgetState.getStatesWithLabel(lineName).includes(model.activeState)
);

// FIXME: label information should be accessible from activeState instead of parent state.
publicAPI.getActiveLineHandle = () =>
model.widgetState[`getAxis${publicAPI.getActiveLineName()}`]?.();

/**
* Return the line handle of the other line in the same view.
* @param {string} lineName name of the line (YinX, ZinX, XinY, ZinY, XinZ, YinZ)
* @returns ZinX if lineName == YinX, YinX if lineName == ZinX, ZinY if lineName == XinY...
*/
publicAPI.getOtherLineHandle = (lineName) =>
model.widgetState[
`getAxis${getOtherLineName(model.widgetState, lineName)}`
]?.();

// FIXME: label information should be accessible from activeState instead of parent state.
/**
* There are 2 rotation handles per axis: 'point0' and 'point1'.
* This function returns which rotation handle (point0 or point1) is currently active.
* ActiveState must be a RotationHandle.
* @returns 'point0', 'point1' or null if no point is active (e.g. line is being rotated)
*/
publicAPI.getActiveRotationPointName = () => {
if (
model.widgetState.getStatesWithLabel('point0').includes(model.activeState)
) {
return 'point0';
}
if (
model.widgetState.getStatesWithLabel('point1').includes(model.activeState)
) {
return 'point1';
}
return null;
};

publicAPI.startScrolling = (newPosition) => {
if (newPosition) {
previousPosition = newPosition;
}
isScrolling = true;
publicAPI.startInteraction();
};

publicAPI.endScrolling = () => {
isScrolling = false;
publicAPI.endInteraction();
};

publicAPI.updateCursor = () => {
const cursorStyles = publicAPI.getCursorStyles();
if (cursorStyles) {
switch (publicAPI.getActiveInteraction()) {
case InteractionMethodsName.TranslateCenter:
model._apiSpecificRenderWindow.setCursor(
cursorStyles.translateCenter
);
break;
case InteractionMethodsName.RotateLine:
model._apiSpecificRenderWindow.setCursor(cursorStyles.rotateLine);
break;
case InteractionMethodsName.TranslateAxis:
model._apiSpecificRenderWindow.setCursor(cursorStyles.translateAxis);
break;
default:
model._apiSpecificRenderWindow.setCursor(cursorStyles.default);
break;
}
}
};

publicAPI.handleLeftButtonPress = (callData) => {
if (model.activeState && model.activeState.getActive()) {
model._isDragging = true;
const viewType = model.viewType;
const currentPlaneNormal = model.widgetState.getPlanes()[viewType].normal;
const manipulator =
model.activeState?.getManipulator?.() ?? model.manipulator;
manipulator.setWidgetOrigin(model.widgetState.getCenter());
manipulator.setWidgetNormal(currentPlaneNormal);
const { worldCoords } = manipulator.handleEvent(
callData,
model._apiSpecificRenderWindow
);
previousPosition = worldCoords;

publicAPI.startInteraction();
} else if (
model.widgetState.getScrollingMethod() ===
ScrollingMethods.LEFT_MOUSE_BUTTON
) {
publicAPI.startScrolling(callData.position);
} else {
return macro.VOID;
}

return macro.EVENT_ABORT;
};

publicAPI.handleMouseMove = (callData) => {
if (model._isDragging) {
return publicAPI.handleEvent(callData);
}
if (isScrolling) {
if (previousPosition.y !== callData.position.y) {
const step = previousPosition.y - callData.position.y;
publicAPI.translateCenterOnPlaneDirection(step);
previousPosition = callData.position;

publicAPI.invokeInteractionEvent(publicAPI.getActiveInteraction());
}
}
return macro.VOID;
};

publicAPI.handleLeftButtonRelease = () => {
if (model._isDragging || isScrolling) {
publicAPI.endScrolling();
}
model._isDragging = false;
model.widgetState.deactivate();
};

publicAPI.handleRightButtonPress = (calldata) => {
if (
model.widgetState.getScrollingMethod() ===
ScrollingMethods.RIGHT_MOUSE_BUTTON
) {
publicAPI.startScrolling(calldata.position);
}
};

publicAPI.handleRightButtonRelease = () => {
if (
model.widgetState.getScrollingMethod() ===
ScrollingMethods.RIGHT_MOUSE_BUTTON
) {
publicAPI.endScrolling();
}
};

publicAPI.handleStartMouseWheel = () => {
publicAPI.startInteraction();
};

publicAPI.handleMouseWheel = (calldata) => {
const step = calldata.spinY;
isScrolling = true;
publicAPI.translateCenterOnPlaneDirection(step);

publicAPI.invokeInteractionEvent(
// Force interaction mode because mouse cursor could be above rotation handle
InteractionMethodsName.TranslateCenter
);
isScrolling = false;

return macro.EVENT_ABORT;
};

publicAPI.handleEndMouseWheel = () => {
publicAPI.endScrolling();
};

publicAPI.handleMiddleButtonPress = (calldata) => {
if (
model.widgetState.getScrollingMethod() ===
ScrollingMethods.MIDDLE_MOUSE_BUTTON
) {
publicAPI.startScrolling(calldata.position);
}
};

publicAPI.handleMiddleButtonRelease = () => {
if (
model.widgetState.getScrollingMethod() ===
ScrollingMethods.MIDDLE_MOUSE_BUTTON
) {
publicAPI.endScrolling();
}
};

publicAPI.handleEvent = (callData) => {
if (model.activeState.getActive()) {
const methodName = publicAPI.getActiveInteraction();
publicAPI[methodName](callData);
publicAPI.invokeInteractionEvent(methodName);
return macro.EVENT_ABORT;
}
return macro.VOID;
};

publicAPI.startInteraction = () => {
publicAPI.invokeStartInteractionEvent();
// When interacting, plane actor and lines must be re-rendered on other views
publicAPI.getViewWidgets().forEach((viewWidget) => {
viewWidget.getInteractor().requestAnimation(publicAPI);
});
};

publicAPI.endInteraction = () => {
publicAPI.invokeEndInteractionEvent();
publicAPI.getViewWidgets().forEach((viewWidget) => {
viewWidget.getInteractor().cancelAnimation(publicAPI);
});
};

publicAPI.translateCenterOnPlaneDirection = (nbSteps) => {
const dirProj = model.widgetState.getPlanes()[model.viewType].normal;

const oldCenter = model.widgetState.getCenter();
const image = model.widgetState.getImage();
const imageSpacing = image.getSpacing();

// Use Chebyshev norm
// https://math.stackexchange.com/questions/71423/what-is-the-term-for-the-projection-of-a-vector-onto-the-unit-cube
const absDirProj = dirProj.map((value) => Math.abs(value));
const index = absDirProj.indexOf(Math.max(...absDirProj));
const movingFactor =
(nbSteps * imageSpacing[index]) / Math.abs(dirProj[index]);

// Define the potentially new center
let newCenter = [
oldCenter[0] + movingFactor * dirProj[0],
oldCenter[1] + movingFactor * dirProj[1],
oldCenter[2] + movingFactor * dirProj[2],
];
newCenter = publicAPI.getBoundedCenter(newCenter);

model.widgetState.setCenter(newCenter);
updateState(
model.widgetState,
model._factory.getScaleInPixels(),
model._factory.getRotationHandlePosition()
);
};

publicAPI[InteractionMethodsName.TranslateAxis] = (calldata) => {
const lineHandle = publicAPI.getActiveLineHandle();
const lineName = publicAPI.getActiveLineName();
const pointOnLine = vtkMath.add(
lineHandle.getOrigin(),
lineHandle.getDirection(),
[]
);
const currentLineVector = lineHandle.getDirection();
vtkMath.normalize(currentLineVector);

// Translate the current line along the other line
const otherLineHandle = publicAPI.getOtherLineHandle(lineName);
const center = model.widgetState.getCenter();
const manipulator =
model.activeState?.getManipulator?.() ?? model.manipulator;
let worldCoords = null;
let newOrigin = [];
if (model.activeState?.getManipulator?.()) {
worldCoords = manipulator.handleEvent(
calldata,
model._apiSpecificRenderWindow
).worldCoords;
const translation = vtkMath.subtract(worldCoords, previousPosition, []);
vtkMath.add(center, translation, newOrigin);
} else if (otherLineHandle) {
const otherLineVector = otherLineHandle.getDirection();
vtkMath.normalize(otherLineVector);
const axisTranslation = otherLineVector;

const dot = vtkMath.dot(currentLineVector, otherLineVector);
// lines are colinear, translate along perpendicular axis from current line
if (dot === 1 || dot === -1) {
vtkMath.cross(
currentLineVector,
manipulator.getWidgetNormal(),
axisTranslation
);
}

const closestPoint = [];
worldCoords = manipulator.handleEvent(
calldata,
model._apiSpecificRenderWindow
).worldCoords;
vtkLine.distanceToLine(
worldCoords,
lineHandle.getOrigin(),
pointOnLine,
closestPoint
);

const translationVector = vtkMath.subtract(worldCoords, closestPoint, []);
const translationDistance = vtkMath.dot(
translationVector,
axisTranslation
);

newOrigin = vtkMath.multiplyAccumulate(
center,
axisTranslation,
translationDistance,
newOrigin
);
}
newOrigin = publicAPI.getBoundedCenter(newOrigin);
model.widgetState.setCenter(newOrigin);
updateState(
model.widgetState,
model._factory.getScaleInPixels(),
model._factory.getRotationHandlePosition()
);
previousPosition = worldCoords;
};

publicAPI.getBoundedCenter = (newCenter) => {
const oldCenter = model.widgetState.getCenter();
const imageBounds = model.widgetState.getImage().getBounds();

if (vtkBoundingBox.containsPoint(imageBounds, ...newCenter)) {
return newCenter;
}

return boundPointOnPlane(newCenter, oldCenter, imageBounds);
};

publicAPI[InteractionMethodsName.TranslateCenter] = (calldata) => {
const manipulator =
model.activeState?.getManipulator?.() ?? model.manipulator;
const { worldCoords } = manipulator.handleEvent(
calldata,
model._apiSpecificRenderWindow
);
const translation = vtkMath.subtract(worldCoords, previousPosition, []);
previousPosition = worldCoords;
let newCenter = vtkMath.add(model.widgetState.getCenter(), translation, []);
newCenter = publicAPI.getBoundedCenter(newCenter);
model.widgetState.setCenter(newCenter);
updateState(
model.widgetState,
model._factory.getScaleInPixels(),
model._factory.getRotationHandlePosition()
);
};

publicAPI[InteractionMethodsName.RotateLine] = (calldata) => {
const activeLineHandle = publicAPI.getActiveLineHandle();
const manipulator =
model.activeState?.getManipulator?.() ?? model.manipulator;
const planeNormal = manipulator.getWidgetNormal();
const { worldCoords } = manipulator.handleEvent(
calldata,
model._apiSpecificRenderWindow
);

if (!worldCoords || !worldCoords.length) {
return;
}
const center = model.widgetState.getCenter();
const currentVectorToOrigin = [0, 0, 0];
vtkMath.subtract(worldCoords, center, currentVectorToOrigin);
vtkMath.normalize(currentVectorToOrigin);

const previousLineDirection = activeLineHandle.getDirection();
vtkMath.normalize(previousLineDirection);
const activePointName = publicAPI.getActiveRotationPointName();
if (
activePointName === 'point1' ||
(!activePointName &&
vtkMath.dot(currentVectorToOrigin, previousLineDirection) < 0)
) {
vtkMath.multiplyScalar(previousLineDirection, -1);
}

const radianAngle = vtkMath.signedAngleBetweenVectors(
previousLineDirection,
currentVectorToOrigin,
planeNormal
);

publicAPI.rotateLineInView(publicAPI.getActiveLineName(), radianAngle);
};

/**
* Rotate a line by a specified angle
* @param {string} lineName The line name to rotate (e.g. YinX, ZinX, XinY, ZinY, XinZ, YinZ)
* @param {Number} radianAngle Applied angle in radian
*/
publicAPI.rotateLineInView = (lineName, radianAngle) => {
const viewType = planeNameToViewType[getLinePlaneName(lineName)];
const inViewType = planeNameToViewType[getLineInPlaneName(lineName)];
const planeNormal = model.widgetState.getPlanes()[inViewType].normal;
publicAPI.rotatePlane(viewType, radianAngle, planeNormal);

if (publicAPI.getKeepOrthogonality()) {
const otherLineName = getOtherLineName(model.widgetState, lineName);
const otherPlaneName = getLinePlaneName(otherLineName);
publicAPI.rotatePlane(
planeNameToViewType[otherPlaneName],
radianAngle,
planeNormal
);
}
updateState(
model.widgetState,
model._factory.getScaleInPixels(),
model._factory.getRotationHandlePosition()
);
};

/**
* Rotate a specified plane around an other specified plane.
* @param {ViewTypes} viewType Define which plane will be rotated
* @param {Number} radianAngle Applied angle in radian
* @param {vec3} planeNormal Define the axis to rotate around
*/
publicAPI.rotatePlane = (viewType, radianAngle, planeNormal) => {
const { normal, viewUp } = model.widgetState.getPlanes()[viewType];
const newNormal = rotateVector(normal, planeNormal, radianAngle);
const newViewUp = rotateVector(viewUp, planeNormal, radianAngle);

model.widgetState.getPlanes()[viewType] = {
normal: newNormal,
viewUp: newViewUp,
};
};

// --------------------------------------------------------------------------
// initialization
// --------------------------------------------------------------------------
}
cprBehavior.js
import resliceCursorBehavior from 'vtk.js/Sources/Widgets/Widgets3D/ResliceCursorWidget/behavior';
import { InteractionMethodsName } from 'vtk.js/Sources/Widgets/Widgets3D/ResliceCursorWidget/Constants';
import { updateState } from 'vtk.js/Sources/Widgets/Widgets3D/ResliceCursorWidget/helpers';
import { ViewTypes } from 'vtk.js/Sources/Widgets/Core/WidgetManager/Constants';
import { vec3 } from 'gl-matrix';
// import macro from 'vtk.js/Sources/macros';

export default function widgetBehavior(publicAPI, model) {
// We inherit resliceCursorBehavior
resliceCursorBehavior(publicAPI, model);

const stretchPlaneName = 'Y';
const crossPlaneName = 'Z';

publicAPI.getActiveInteraction = () => {
if (
model.widgetState
.getStatesWithLabel(`lineIn${stretchPlaneName}`)
.includes(model.activeState)
) {
return InteractionMethodsName.TranslateCenterAndUpdatePlanes;
}
if (
model.widgetState
.getStatesWithLabel(`lineIn${crossPlaneName}`)
.includes(model.activeState) ||
model.widgetState
.getStatesWithLabel(`rotationIn${crossPlaneName}`)
.includes(model.activeState)
) {
return InteractionMethodsName.RotateLine;
}
return null;
};

publicAPI[InteractionMethodsName.TranslateCenterAndUpdatePlanes] = (
calldata
) => {
const manipulator =
model.activeState?.getManipulator?.() ?? model.manipulator;

const { worldCoords, worldDirection } = manipulator.handleEvent(
calldata,
model._apiSpecificRenderWindow
);
publicAPI.updateCenterAndPlanes(worldCoords, worldDirection);
};

publicAPI.updateCenterAndPlanes = (worldCoords, worldDirection) => {
// Update center
const newBoundedCenter = publicAPI.getBoundedCenter(worldCoords);
model.widgetState.setCenter(newBoundedCenter);

// Update planes if axes are given
if (worldDirection) {
const getAxis = (idx) =>
vec3.normalize([], worldDirection.slice(3 * idx, 3 * idx + 3));
const planes = model.widgetState.getPlanes();
Object.keys(planes).forEach((viewType) => {
switch (Number.parseInt(viewType, 10)) {
case ViewTypes.YZ_PLANE:
planes[viewType] = {
normal: getAxis(0),
viewUp: getAxis(2),
};
break;
case ViewTypes.XZ_PLANE:
planes[viewType] = {
normal: getAxis(1),
viewUp: getAxis(2),
};
break;
case ViewTypes.XY_PLANE:
planes[viewType] = {
normal: getAxis(2),
viewUp: getAxis(1),
};
break;
default:
break;
}
});
}

updateState(
model.widgetState,
model._factory.getScaleInPixels(),
model._factory.getRotationHandlePosition()
);
};

publicAPI.translateCenterOnPlaneDirection = (nbSteps) => {
const handleScroll = model._factory.getManipulator()?.handleScroll;
if (handleScroll) {
const { worldCoords, worldDirection } = handleScroll(nbSteps);
publicAPI.updateCenterAndPlanes(worldCoords, worldDirection);
}
};
}
helpers.js
import vtkBoundingBox, {
STATIC,
} from 'vtk.js/Sources/Common/DataModel/BoundingBox';
import vtkCubeSource from 'vtk.js/Sources/Filters/Sources/CubeSource';
import vtkCutter from 'vtk.js/Sources/Filters/Core/Cutter';
import vtkPlane from 'vtk.js/Sources/Common/DataModel/Plane';
import * as vtkMath from 'vtk.js/Sources/Common/Core/Math';
import vtkMatrixBuilder from 'vtk.js/Sources/Common/Core/MatrixBuilder';

import {
planeNames,
planeNameToViewType,
viewTypeToPlaneName,
} from 'vtk.js/Sources/Widgets/Widgets3D/ResliceCursorWidget/Constants';

const EPSILON = 10e-7;

/**
* Fit the plane defined by origin, p1, p2 onto the bounds.
* Plane is untouched if does not intersect bounds.
* @param {Array} bounds
* @param {Array} origin
* @param {Array} p1
* @param {Array} p2
* @return {Boolean} false if no bounds have been found, else true
*/
export function boundPlane(bounds, origin, p1, p2) {
const v1 = [];
vtkMath.subtract(p1, origin, v1);
vtkMath.normalize(v1);

const v2 = [];
vtkMath.subtract(p2, origin, v2);
vtkMath.normalize(v2);

const n = [0, 0, 1];
vtkMath.cross(v1, v2, n);
vtkMath.normalize(n);

// Inflate bounds in order to avoid precision error when cutting cubesource
const inflatedBounds = [...bounds];
const eps = [...n];
vtkMath.multiplyScalar(eps, EPSILON);
vtkBoundingBox.addBounds(
inflatedBounds,
bounds[0] + eps[0],
bounds[1] + eps[0],
bounds[2] + eps[1],
bounds[3] + eps[1],
bounds[4] + eps[2],
bounds[5] + eps[2]
);
vtkBoundingBox.addBounds(
inflatedBounds,
bounds[0] - eps[0],
bounds[1] - eps[0],
bounds[2] - eps[1],
bounds[3] - eps[1],
bounds[4] - eps[2],
bounds[5] - eps[2]
);

const plane = vtkPlane.newInstance();
plane.setOrigin(...origin);
plane.setNormal(...n);

const cubeSource = vtkCubeSource.newInstance();
cubeSource.setBounds(inflatedBounds);

const cutter = vtkCutter.newInstance();
cutter.setCutFunction(plane);
cutter.setInputConnection(cubeSource.getOutputPort());

const cutBounds = cutter.getOutputData();
if (cutBounds.getNumberOfPoints() === 0) {
return false;
}
const localBounds = STATIC.computeLocalBounds(
cutBounds.getPoints(),
v1,
v2,
n
);
for (let i = 0; i < 3; i += 1) {
origin[i] =
localBounds[0] * v1[i] + localBounds[2] * v2[i] + localBounds[4] * n[i];
p1[i] =
localBounds[1] * v1[i] + localBounds[2] * v2[i] + localBounds[4] * n[i];
p2[i] =
localBounds[0] * v1[i] + localBounds[3] * v2[i] + localBounds[4] * n[i];
}
return true;
}
// Project point (inPoint) to the bounds of the image according to a plane
// defined by two vectors (v1, v2)
export function boundPoint(inPoint, v1, v2, bounds) {
const absT1 = v1.map((val) => Math.abs(val));
const absT2 = v2.map((val) => Math.abs(val));

let o1 = 0.0;
let o2 = 0.0;

for (let i = 0; i < 3; i++) {
let axisOffset = 0;

const useT1 = absT1[i] > absT2[i];
const t = useT1 ? v1 : v2;
const absT = useT1 ? absT1 : absT2;

if (inPoint[i] < bounds[i * 2]) {
axisOffset = absT[i] > EPSILON ? (bounds[2 * i] - inPoint[i]) / t[i] : 0;
} else if (inPoint[i] > bounds[2 * i + 1]) {
axisOffset =
absT[i] > EPSILON ? (bounds[2 * i + 1] - inPoint[i]) / t[i] : 0;
}

if (useT1) {
if (Math.abs(axisOffset) > Math.abs(o1)) {
o1 = axisOffset;
}
} else if (Math.abs(axisOffset) > Math.abs(o2)) {
o2 = axisOffset;
}
}

const outPoint = [inPoint[0], inPoint[1], inPoint[2]];

if (o1 !== 0.0) {
vtkMath.multiplyAccumulate(outPoint, v1, o1, outPoint);
}
if (o2 !== 0.0) {
vtkMath.multiplyAccumulate(outPoint, v2, o2, outPoint);
}

return outPoint;
}

// Compute the intersection between p1 and p2 on bounds
export function boundPointOnPlane(p1, p2, bounds) {
const dir12 = [0, 0, 0];
vtkMath.subtract(p2, p1, dir12);

const out = [0, 0, 0];
const tolerance = [0, 0, 0];
vtkBoundingBox.intersectBox(bounds, p1, dir12, out, tolerance);

return out;
}

/**
* Rotates a vector around another.
* @param {vec3} vectorToBeRotated Vector to rate
* @param {vec3} axis Axis to rotate around
* @param {Number} angle Angle in radian
* @returns The rotated vector
*/
export function rotateVector(vectorToBeRotated, axis, angle) {
const rotatedVector = [...vectorToBeRotated];
vtkMatrixBuilder.buildFromRadian().rotate(angle, axis).apply(rotatedVector);
return rotatedVector;
}

/**
* Return ['X', 'Y'] if there are only 2 planes defined in the widget state.
* Return ['X', 'Y', 'Z'] if there are 3 planes defined in the widget state.
* @param {object} widgetState the state of the widget
* @returns An array of plane names
*/
export function getPlaneNames(widgetState) {
return Object.keys(widgetState.getPlanes()).map(
(viewType) => viewTypeToPlaneName[viewType]
);
}

/**
* Return X if lineName == XinY|XinZ, Y if lineName == YinX|YinZ and Z otherwise
* @param {string} lineName name of the line (YinX, ZinX, XinY, ZinY, XinZ, YinZ)
*/
export function getLinePlaneName(lineName) {
return lineName[0];
}
/**
* Return X if lineName == YinX|ZinX, Y if lineName == XinY|ZinY and Z otherwise
* @param {string} lineName name of the line (YinX, ZinX, XinY, ZinY, XinZ, YinZ)
*/
export function getLineInPlaneName(lineName) {
return lineName[3];
}

/**
* Returns ['XinY', 'YinX'] if planes == ['X', 'Y']
* ['XinY', 'XinZ', 'YinX', 'YinZ', 'ZinX', 'ZinY'] if planes == ['X', 'Y', 'Z']
* @param {string} planes name of the planes (e.g. ['X', 'Y'])
*/
export function getPlanesLineNames(planes = planeNames) {
const lines = [];
planes.forEach((plane) => {
planes.forEach((inPlane) => {
if (plane !== inPlane) {
lines.push(`${plane}in${inPlane}`);
}
});
});
return lines;
}

export function getLineNames(widgetState) {
const planes = Object.keys(widgetState.getPlanes()).map(
(viewType) => viewTypeToPlaneName[viewType]
);
return getPlanesLineNames(planes);
}

/**
* Return ZinX if lineName == YinX, YinX if lineName == ZinX, ZinY if lineName == XinY...
* @param {string} lineName name of the line (YinX, ZinX, XinY, ZinY, XinZ, YinZ)
*/
export function getOtherLineName(widgetState, lineName) {
const linePlaneName = getLinePlaneName(lineName);
const lineInPlaneName = getLineInPlaneName(lineName);
const otherLineName = getPlaneNames(widgetState).find(
(planeName) => planeName !== linePlaneName && planeName !== lineInPlaneName
);
return `${otherLineName}in${lineInPlaneName}`;
}

// Compute the offset of the rotation handle origin
function computeRotationHandleOriginOffset(
axis,
rotationHandlePosition,
volumeDiagonalLength,
scaleInPixels
) {
// FIXME: p1 and p2 could be placed on the exact boundaries of the volume.
return vtkMath.multiplyScalar(
[...axis],
(rotationHandlePosition * (scaleInPixels ? 1 : volumeDiagonalLength)) / 2
);
}

// Update the reslice cursor state according to the three planes normals and the origin
export function updateState(
widgetState,
scaleInPixels,
rotationHandlePosition
) {
const planes = Object.keys(widgetState.getPlanes()).map(
(viewType) => viewTypeToPlaneName[viewType]
);
// Generates an object as such:
// axes = {'XY': cross(X, Y), 'YX': cross(X, Y), 'YZ': cross(Y, Z)...}
const axes = planes.reduce((res, plane) => {
planes
.filter((otherPlane) => plane !== otherPlane)
.forEach((otherPlane) => {
const cross = vtkMath.cross(
widgetState.getPlanes()[planeNameToViewType[plane]].normal,
widgetState.getPlanes()[planeNameToViewType[otherPlane]].normal,
[]
);
res[`${plane}${otherPlane}`] = cross;
res[`${otherPlane}${plane}`] = cross;
});
return res;
}, {});

const bounds = widgetState.getImage().getBounds();
const center = widgetState.getCenter();

// Length of the principal diagonal.
const pdLength = vtkBoundingBox.getDiagonalLength(bounds);

widgetState.getCenterHandle().setOrigin(center);

getPlanesLineNames(planes).forEach((lineName) => {
const planeName = getLinePlaneName(lineName);
const inPlaneName = getLineInPlaneName(lineName);
const direction = axes[`${planeName}${inPlaneName}`];
widgetState[`getRotationHandle${lineName}0`]().setOrigin(center);
widgetState[`getRotationHandle${lineName}0`]()
.getManipulator()
?.setHandleOrigin(center);
widgetState[`getRotationHandle${lineName}0`]()
.getManipulator()
?.setHandleNormal(
widgetState.getPlanes()[planeNameToViewType[planeName]].normal
);
widgetState[`getRotationHandle${lineName}0`]().setOffset(
computeRotationHandleOriginOffset(
direction,
rotationHandlePosition,
pdLength,
scaleInPixels
)
);
widgetState[`getRotationHandle${lineName}1`]().setOrigin(center);
widgetState[`getRotationHandle${lineName}1`]()
.getManipulator()
?.setHandleOrigin(center);
widgetState[`getRotationHandle${lineName}1`]()
.getManipulator()
?.setHandleNormal(
widgetState.getPlanes()[planeNameToViewType[planeName]].normal
);
widgetState[`getRotationHandle${lineName}1`]().setOffset(
computeRotationHandleOriginOffset(
direction,
-rotationHandlePosition,
pdLength,
scaleInPixels
)
);
const lineHandle = widgetState[`getAxis${lineName}`]();
lineHandle.setOrigin(center);
lineHandle.getManipulator()?.setHandleOrigin(center);
lineHandle
.getManipulator()
?.setHandleNormal(
widgetState.getPlanes()[planeNameToViewType[planeName]].normal
);
const scale = vtkMath.normalize(direction);
const scale3 = lineHandle.getScale3();
scale3[2] = 2 * scale;
lineHandle.setScale3(scale3);
const right =
widgetState.getPlanes()[planeNameToViewType[inPlaneName]].normal;
const up = vtkMath.cross(direction, right, []);
lineHandle.setRight(right);
lineHandle.setUp(up);
lineHandle.setDirection(direction);
});
}

/**
* First rotate planeToTransform to match targetPlane normal.
* Then rotate around targetNormal to enforce targetViewUp "up" vector (i.e. Origin->p2 ).
* There is an infinite number of options to rotate a plane normal to another. Here we attempt to
* preserve Origin, P1 and P2 when rotating around targetPlane.
* @param {vtkPlaneSource} planeToTransform
* @param {vec3} targetOrigin Center of the plane
* @param {vec3} targetNormal Normal to state to the plane
* @param {vec3} viewType Vector that enforces view up
*/
export function transformPlane(
planeToTransform,
targetCenter,
targetNormal,
targetViewUp
) {
planeToTransform.setNormal(targetNormal);
const viewUp = vtkMath.subtract(
planeToTransform.getPoint2(),
planeToTransform.getOrigin(),
[]
);
const angle = vtkMath.signedAngleBetweenVectors(
viewUp,
targetViewUp,
targetNormal
);
planeToTransform.rotate(angle, targetNormal);
planeToTransform.setCenter(targetCenter);
}
index.d.ts
import { mat4 } from 'gl-matrix';
import { vtkAbstractWidgetFactory, IAbstractWidgetFactoryInitialValues } from '../../Core/AbstractWidgetFactory';
import vtkResliceCursorWidgetDefaultInstance from './behavior';
import vtkAbstractWidget from '../../Core/AbstractWidget'
import vtkImageData from '../../../Common/DataModel/ImageData';
import vtkImageReslice from '../../../Imaging/Core/ImageReslice';
import vtkPlaneSource from '../../../Filters/Sources/PlaneSource';
import vtkRenderer from '../../../Rendering/Core/Renderer';
import vtkPlaneManipulator from '../../Manipulators/PlaneManipulator';
import { ViewTypes } from '../../../Widgets/Core/WidgetManager/Constants';
import { Vector2, Vector3 } from '../../../types';

export interface IDisplayScaleParams {
dispHeightFactor: number,
cameraPosition: Vector3,
cameraDir: Vector3,
isParallel: false,
rendererPixelDims: Vector2
}

export interface vtkResliceCursorWidget<WidgetInstance extends vtkAbstractWidget = vtkResliceCursorWidgetDefaultInstance> extends vtkAbstractWidgetFactory<WidgetInstance> {

/**
* @param {ViewTypes} viewType
*/
getRepresentationsForViewType(viewType: ViewTypes): unknown;

setImage(image: vtkImageData): void;

setCenter(center: Vector3): void;

updateCameraPoints(
renderer: vtkRenderer,
viewType: ViewTypes,
resetFocalPoint: boolean,
computeFocalPointOffset: boolean
): void;

resetCamera(
renderer: vtkRenderer,
viewType: ViewTypes,
resetFocalPoint: boolean,
keepCenterFocalDistance: boolean
): void;


getPlaneSource(viewType: ViewTypes): vtkPlaneSource;

getResliceAxes(viewType: ViewTypes): mat4;

updateReslicePlane(imageReslice: vtkImageReslice, viewType: ViewTypes): boolean;

getPlaneSourceFromViewType(type: ViewTypes): vtkPlaneSource;

getPlaneNormalFromViewType(viewType: ViewTypes): Vector3;

getOtherPlaneNormals(viewType: ViewTypes): Array<Vector3>;

getResliceMatrix(): mat4;

getDisplayScaleParams(): IDisplayScaleParams;

setScaleInPixels(scale: boolean): boolean;

getScaleInPixels(): boolean;

setRotationHandlePosition(position: number): boolean;

getRotationHandlePosition(): number;

setManipulator(manipulator: vtkPlaneManipulator): boolean;

getManipulator(): vtkPlaneManipulator;

/**
* Return an array of the first and the last possible points of the plane
* along its normal.
* @param {ViewTypes} viewType
* @returns {Array<Vector3>} two Vector3 arrays (first and last points)
*/
getPlaneExtremities(viewType: ViewTypes): Array<Vector3>;

}

export interface IResliceCursorWidgetInitialValues<WidgetInstance extends vtkAbstractWidget> extends IAbstractWidgetFactoryInitialValues<WidgetInstance> {}

/**
* Method used to decorate a given object (publicAPI+model) with vtkResliceCursorWidget characteristics.
*
* @param publicAPI object on which methods will be bounds (public)
* @param model object on which data structure will be bounds (protected)
* @param initialValues (default: {})
*/
export function extend<WidgetInstance extends vtkAbstractWidget>(
publicAPI: object,
model: object,
initialValues?: IResliceCursorWidgetInitialValues<WidgetInstance>
): void;

/**
* Method used to create a new instance of vtkResliceCursorWidget
*
* @param initialValues for pre-setting some of its content
*/
export function newInstance<WidgetInstance extends vtkAbstractWidget = vtkResliceCursorWidgetDefaultInstance>(initialValues?: IResliceCursorWidgetInitialValues<WidgetInstance>): vtkResliceCursorWidget<WidgetInstance>;

export declare const vtkResliceCursorWidget: {
newInstance: typeof newInstance;
extend: typeof extend;
};

export default vtkResliceCursorWidget;
index.js
import macro from 'vtk.js/Sources/macros';
import vtkAbstractWidgetFactory from 'vtk.js/Sources/Widgets/Core/AbstractWidgetFactory';
import vtkBoundingBox from 'vtk.js/Sources/Common/DataModel/BoundingBox';
import vtkBox from 'vtk.js/Sources/Common/DataModel/Box';
import vtkPlane from 'vtk.js/Sources/Common/DataModel/Plane';
import vtkPlaneSource from 'vtk.js/Sources/Filters/Sources/PlaneSource';
import vtkPlaneManipulator from 'vtk.js/Sources/Widgets/Manipulators/PlaneManipulator';
import vtkLineHandleRepresentation from 'vtk.js/Sources/Widgets/Representations/LineHandleRepresentation';
import vtkSphereHandleRepresentation from 'vtk.js/Sources/Widgets/Representations/SphereHandleRepresentation';

import * as vtkMath from 'vtk.js/Sources/Common/Core/Math';

import widgetBehavior from 'vtk.js/Sources/Widgets/Widgets3D/ResliceCursorWidget/behavior';
import stateGenerator from 'vtk.js/Sources/Widgets/Widgets3D/ResliceCursorWidget/state';
import {
boundPlane,
updateState,
transformPlane,
} from 'vtk.js/Sources/Widgets/Widgets3D/ResliceCursorWidget/helpers';
import { viewTypeToPlaneName } from 'vtk.js/Sources/Widgets/Widgets3D/ResliceCursorWidget/Constants';

import { ViewTypes } from 'vtk.js/Sources/Widgets/Core/WidgetManager/Constants';

import { mat4 } from 'gl-matrix';
import vtkMatrixBuilder from 'vtk.js/Sources/Common/Core/MatrixBuilder';

const VTK_INT_MAX = 2147483647;
const { vtkErrorMacro } = macro;

// ----------------------------------------------------------------------------
// Factory
// ----------------------------------------------------------------------------

function vtkResliceCursorWidget(publicAPI, model) {
model.classHierarchy.push('vtkResliceCursorWidget');

model.methodsToLink = ['scaleInPixels'];

// --------------------------------------------------------------------------
// Private methods
// --------------------------------------------------------------------------

/**
* Compute the origin of the reslice plane prior to transformations
* It does not take into account the current view normal. (always axis aligned)
* @param {*} viewType axial, coronal or sagittal
*/
function computeReslicePlaneOrigin(viewType) {
const bounds = model.widgetState.getImage().getBounds();

const center = publicAPI.getWidgetState().getCenter();
const imageCenter = model.widgetState.getImage().getCenter();

// Offset based on the center of the image and how far from it the
// reslice cursor is. This allows us to capture the whole image even
// if we resliced in awkward places.
const offset = [];
for (let i = 0; i < 3; i++) {
offset[i] = -Math.abs(center[i] - imageCenter[i]);
offset[i] *= 2; // give us room
}

// Now set the size of the plane based on the location of the cursor so as to
// at least completely cover the viewed region
const planeSource = vtkPlaneSource.newInstance();

if (viewType === ViewTypes.XZ_PLANE) {
planeSource.setOrigin(
bounds[0] + offset[0],
center[1],
bounds[4] + offset[2]
);
planeSource.setPoint1(
bounds[1] - offset[0],
center[1],
bounds[4] + offset[2]
);
planeSource.setPoint2(
bounds[0] + offset[0],
center[1],
bounds[5] - offset[2]
);
} else if (viewType === ViewTypes.XY_PLANE) {
planeSource.setOrigin(
bounds[0] + offset[0],
bounds[2] + offset[1],
center[2]
);
planeSource.setPoint1(
bounds[1] - offset[0],
bounds[2] + offset[1],
center[2]
);
planeSource.setPoint2(
bounds[0] + offset[0],
bounds[3] - offset[1],
center[2]
);
} else if (viewType === ViewTypes.YZ_PLANE) {
planeSource.setOrigin(
center[0],
bounds[2] + offset[1],
bounds[4] + offset[2]
);
planeSource.setPoint1(
center[0],
bounds[3] - offset[1],
bounds[4] + offset[2]
);
planeSource.setPoint2(
center[0],
bounds[2] + offset[1],
bounds[5] - offset[2]
);
}
return planeSource;
}

/**
* Compute the offset between display reslice cursor position and
* display focal point position
* This will be used to keep the same offset between reslice cursor
* center and focal point when needed.
*/
function computeFocalPointOffsetFromResliceCursorCenter(viewType, renderer) {
const worldFocalPoint = renderer.getActiveCamera().getFocalPoint();
const worldResliceCenter = model.widgetState.getCenter();

const view = renderer.getRenderWindow().getViews()[0];
const dims = view.getViewportSize(renderer);
const aspect = dims[0] / dims[1];
const displayFocalPoint = renderer.worldToNormalizedDisplay(
...worldFocalPoint,
aspect
);
const displayResliceCenter = renderer.worldToNormalizedDisplay(
...worldResliceCenter,
aspect
);

const newOffset = vtkMath.subtract(
displayFocalPoint,
displayResliceCenter,
[0, 0, 0]
);

const cameraOffsets = model.widgetState.getCameraOffsets();
cameraOffsets[viewType] = newOffset;

model.widgetState.setCameraOffsets(cameraOffsets);
}

function updateCamera(
renderer,
normal,
viewType,
resetFocalPoint,
keepCenterFocalDistance
) {
// When the reslice plane is changed, update the camera to look at the
// normal to the reslice plane.

const focalPoint = renderer.getActiveCamera().getFocalPoint();

const distance = renderer.getActiveCamera().getDistance();

const estimatedCameraPosition = vtkMath.multiplyAccumulate(
focalPoint,
normal,
distance,
[0, 0, 0]
);

let newFocalPoint = focalPoint;
if (resetFocalPoint) {
// intersect with the plane to get updated focal point
const intersection = vtkPlane.intersectWithLine(
focalPoint,
estimatedCameraPosition,
model.widgetState.getCenter(), // reslice cursor center
normal
);
newFocalPoint = intersection.x;
}

// Update the estimated focal point so that it will be at the same
// distance from the reslice center
if (keepCenterFocalDistance) {
const worldResliceCenter = model.widgetState.getCenter();

const view = renderer.getRenderWindow().getViews()[0];
const dims = view.getViewportSize(renderer);
const aspect = dims[0] / dims[1];
const displayResliceCenter = renderer.worldToNormalizedDisplay(
...worldResliceCenter,
aspect
);

const realOffset = model.widgetState.getCameraOffsets()[viewType];
const displayFocal = vtkMath.add(
displayResliceCenter,
realOffset,
[0, 0, 0]
);

const worldFocal = renderer.normalizedDisplayToWorld(
...displayFocal,
aspect
);

// Reproject focal point on slice in order to keep it on the
// same plane as the reslice cursor center
const intersection2 = vtkPlane.intersectWithLine(
worldFocal,
estimatedCameraPosition,
worldResliceCenter,
normal
);

newFocalPoint[0] = intersection2.x[0];
newFocalPoint[1] = intersection2.x[1];
newFocalPoint[2] = intersection2.x[2];
}

renderer
.getActiveCamera()
.setFocalPoint(newFocalPoint[0], newFocalPoint[1], newFocalPoint[2]);

const newCameraPosition = vtkMath.multiplyAccumulate(
newFocalPoint,
normal,
distance,
[0, 0, 0]
);

renderer
.getActiveCamera()
.setPosition(
newCameraPosition[0],
newCameraPosition[1],
newCameraPosition[2]
);

// Don't clip away any part of the data.
// Renderer may not have yet actor bounds
const bounds = model.widgetState.getImage().getBounds();
if (resetFocalPoint) {
renderer.resetCamera(bounds);
}
renderer.resetCameraClippingRange(bounds);
}

/**
* Convenient function to return the widget for a given viewType
* @param {string} viewType
* @returns the widget instanced in the given viewType.
*/
function findWidgetForViewType(viewType) {
return publicAPI
.getViewIds()
.map((viewId) => publicAPI.getWidgetForView({ viewId }))
.find((widget) => widget.getViewType() === viewType);
}
/**
* Convenient function to return the ResliceCursorRepresentation for a given viewType
* @param {string} viewType
* @returns an array of 3 representations (for line handles, rotation handles, center handle)
* or an empty array if the widget has not yet been added to the view type.
*/
function findRepresentationsForViewType(viewType) {
const widgetForViewType = findWidgetForViewType(viewType);
return widgetForViewType ? widgetForViewType.getRepresentations() : [];
}

// --------------------------------------------------------------------------
// initialization
// --------------------------------------------------------------------------

publicAPI.getRepresentationsForViewType = (viewType) => {
switch (viewType) {
case ViewTypes.XY_PLANE:
case ViewTypes.XZ_PLANE:
case ViewTypes.YZ_PLANE:
return [
{
builder: vtkLineHandleRepresentation,
labels: [`lineIn${viewTypeToPlaneName[viewType]}`],
initialValues: {
useActiveColor: false,
scaleInPixels: model.scaleInPixels,
},
},
{
builder: vtkSphereHandleRepresentation,
labels: [`rotationIn${viewTypeToPlaneName[viewType]}`],
initialValues: {
useActiveColor: false,
scaleInPixels: model.scaleInPixels,
lighting: false,
},
},
{
builder: vtkSphereHandleRepresentation,
labels: ['center'],
initialValues: {
useActiveColor: false,
scaleInPixels: model.scaleInPixels,
lighting: false,
},
},
];
case ViewTypes.DEFAULT:
case ViewTypes.GEOMETRY:
case ViewTypes.SLICE:
case ViewTypes.VOLUME:
default:
return [];
}
};

publicAPI.setImage = (image) => {
model.widgetState.setImage(image);
const center = image.getCenter();
publicAPI.setCenter(center);
};

publicAPI.setCenter = (center) => {
model.widgetState.setCenter(center);
updateState(
model.widgetState,
model.scaleInPixels,
model.rotationHandlePosition
);
publicAPI.modified();
};

// --------------------------------------------------------------------------
// Methods
// --------------------------------------------------------------------------

publicAPI.updateCameraPoints = (
renderer,
viewType,
resetFocalPoint,
computeFocalPointOffset
) => {
publicAPI.resetCamera(
renderer,
viewType,
resetFocalPoint,
!computeFocalPointOffset
);

if (computeFocalPointOffset) {
computeFocalPointOffsetFromResliceCursorCenter(viewType, renderer);
}
};

/**
*
* @param {*} renderer
* @param {*} viewType
* @param {*} resetFocalPoint Defines if the focal point is reset to the image center
* @param {*} keepCenterFocalDistance Defines if the estimated focal point has to be updated
* in order to keep the same distance to the center (according to the computed focal point
* shift)
*/
publicAPI.resetCamera = (
renderer,
viewType,
resetFocalPoint,
keepCenterFocalDistance
) => {
const center = model.widgetState.getImage().getCenter();
const focalPoint = renderer.getActiveCamera().getFocalPoint();
const position = renderer.getActiveCamera().getPosition();

// Distance is preserved
const distance = Math.sqrt(
vtkMath.distance2BetweenPoints(position, focalPoint)
);

const normal = publicAPI.getPlaneNormalFromViewType(viewType);

// ResetFocalPoint will reset focal point to the center of the image
const estimatedFocalPoint = resetFocalPoint ? center : focalPoint;

const estimatedCameraPosition = vtkMath.multiplyAccumulate(
estimatedFocalPoint,
normal,
distance,
[0, 0, 0]
);
renderer.getActiveCamera().setFocalPoint(...estimatedFocalPoint);
renderer.getActiveCamera().setPosition(...estimatedCameraPosition);
renderer
.getActiveCamera()
.setViewUp(model.widgetState.getPlanes()[viewType].viewUp);

// Project focalPoint onto image plane and preserve distance
updateCamera(
renderer,
normal,
viewType,
resetFocalPoint,
keepCenterFocalDistance
);
};

publicAPI.getPlaneSource = (viewType) => {
// Compute original (i.e. before rotation) plane (i.e. origin, p1, p2)
// centered on cursor center.
const planeSource = computeReslicePlaneOrigin(viewType);

const { normal, viewUp } = model.widgetState.getPlanes()[viewType];
// Adapt plane orientation in order to fit the correct viewUp
// so that the rotations will be more understandable than now.
transformPlane(planeSource, model.widgetState.getCenter(), normal, viewUp);

// Clip to bounds
const boundedOrigin = [...planeSource.getOrigin()];
const boundedP1 = [...planeSource.getPoint1()];
const boundedP2 = [...planeSource.getPoint2()];

boundPlane(
model.widgetState.getImage().getBounds(),
boundedOrigin,
boundedP1,
boundedP2
);

planeSource.setOrigin(...boundedOrigin);
planeSource.setPoint1(...boundedP1);
planeSource.setPoint2(...boundedP2);

return planeSource;
};

publicAPI.getResliceAxes = (viewType) => {
// Compute original (i.e. before rotation) plane (i.e. origin, p1, p2)
// centered on cursor center.
const planeSource = publicAPI.getPlaneSource(viewType);

// TBD: use normal from planeSource ?
const { normal } = model.widgetState.getPlanes()[viewType];

const planeOrigin = planeSource.getOrigin();

const p1 = planeSource.getPoint1();
const planeAxis1 = [];
vtkMath.subtract(p1, planeOrigin, planeAxis1);
vtkMath.normalize(planeAxis1);

const p2 = planeSource.getPoint2();
const planeAxis2 = [];
vtkMath.subtract(p2, planeOrigin, planeAxis2);
vtkMath.normalize(planeAxis2);

const newResliceAxes = mat4.identity(new Float64Array(16));

for (let i = 0; i < 3; i++) {
newResliceAxes[i] = planeAxis1[i];
newResliceAxes[4 + i] = planeAxis2[i];
newResliceAxes[8 + i] = normal[i];
newResliceAxes[12 + i] = planeOrigin[i];
}

return newResliceAxes;
};

publicAPI.updateReslicePlane = (imageReslice, viewType) => {
// Calculate appropriate pixel spacing for the reslicing
const spacing = model.widgetState.getImage().getSpacing();

const planeSource = publicAPI.getPlaneSource(viewType);
const newResliceAxes = publicAPI.getResliceAxes(viewType);

const planeOrigin = planeSource.getOrigin();
const p1 = planeSource.getPoint1();
const planeAxis1 = vtkMath.subtract(p1, planeOrigin, []);
const planeSizeX = vtkMath.normalize(planeAxis1);

const p2 = planeSource.getPoint2();
const planeAxis2 = vtkMath.subtract(p2, planeOrigin, []);
const planeSizeY = vtkMath.normalize(planeAxis2);

const spacingX =
Math.abs(planeAxis1[0] * spacing[0]) +
Math.abs(planeAxis1[1] * spacing[1]) +
Math.abs(planeAxis1[2] * spacing[2]);

const spacingY =
Math.abs(planeAxis2[0] * spacing[0]) +
Math.abs(planeAxis2[1] * spacing[1]) +
Math.abs(planeAxis2[2] * spacing[2]);

// Compute a new set of resliced extents
let extentX = 0;
let extentY = 0;

// Pad extent up to a power of two for efficient texture mapping
// make sure we're working with valid values
const realExtentX =
spacingX === 0 ? Number.MAX_SAFE_INTEGER : planeSizeX / spacingX;

// Sanity check the input data:
// * if realExtentX is too large, extentX will wrap
// * if spacingX is 0, things will blow up.

const value = VTK_INT_MAX >> 1; // eslint-disable-line no-bitwise

if (realExtentX > value) {
vtkErrorMacro(
'Invalid X extent: ',
realExtentX,
' on view type : ',
viewType
);
extentX = 0;
} else {
extentX = 1;
while (extentX < realExtentX) {
extentX <<= 1; // eslint-disable-line no-bitwise
}
}

// make sure extentY doesn't wrap during padding
const realExtentY =
spacingY === 0 ? Number.MAX_SAFE_INTEGER : planeSizeY / spacingY;

if (realExtentY > value) {
vtkErrorMacro(
'Invalid Y extent:',
realExtentY,
' on view type : ',
viewType
);
extentY = 0;
} else {
extentY = 1;
while (extentY < realExtentY) {
extentY <<= 1; // eslint-disable-line no-bitwise
}
}

const outputSpacingX = extentX === 0 ? 1.0 : planeSizeX / extentX;
const outputSpacingY = extentY === 0 ? 1.0 : planeSizeY / extentY;

let modified = imageReslice.setResliceAxes(newResliceAxes);
modified =
imageReslice.setOutputSpacing([outputSpacingX, outputSpacingY, 1]) ||
modified;
modified =
imageReslice.setOutputOrigin([
0.5 * outputSpacingX,
0.5 * outputSpacingY,
0,
]) || modified;
modified =
imageReslice.setOutputExtent([0, extentX - 1, 0, extentY - 1, 0, 0]) ||
modified;

return modified;
};

/**
* Returns a plane source with origin at cursor center and
* normal from the view.
* @param {ViewType} type: Axial, Coronal or Sagittal
*/
publicAPI.getPlaneSourceFromViewType = (type) => {
const planeSource = vtkPlaneSource.newInstance();
const origin = publicAPI.getWidgetState().getCenter();
const planeNormal = publicAPI.getPlaneNormalFromViewType(type);

planeSource.setNormal(planeNormal);
planeSource.setOrigin(origin);

return planeSource;
};

publicAPI.getPlaneNormalFromViewType = (viewType) =>
publicAPI.getWidgetState().getPlanes()[viewType].normal;

/**
* Returns the normals of the planes that are not viewType.
* @param {ViewType} viewType ViewType to extract other normals
*/
publicAPI.getOtherPlaneNormals = (viewType) =>
[ViewTypes.YZ_PLANE, ViewTypes.XZ_PLANE, ViewTypes.XY_PLANE]
.filter((vt) => vt !== viewType)
.map((vt) => publicAPI.getPlaneNormalFromViewType(vt));

/**
* Return the reslice cursor matrix built as such: [YZ, XZ, XY, center]
*/
publicAPI.getResliceMatrix = () => {
const resliceMatrix = mat4.identity(new Float64Array(16));

for (let i = 0; i < 3; i++) {
resliceMatrix[4 * i + 0] = publicAPI.getPlaneNormalFromViewType(
ViewTypes.YZ_PLANE
)[i];
resliceMatrix[4 * i + 1] = publicAPI.getPlaneNormalFromViewType(
ViewTypes.XZ_PLANE
)[i];
resliceMatrix[4 * i + 2] = publicAPI.getPlaneNormalFromViewType(
ViewTypes.XY_PLANE
)[i];
}

const origin = publicAPI.getWidgetState().getCenter();

const m = vtkMatrixBuilder
.buildFromRadian()
.translate(...origin)
.multiply(resliceMatrix)
.translate(...vtkMath.multiplyScalar([...origin], -1))
.getMatrix();

return m;
};

publicAPI.getDisplayScaleParams = () =>
[ViewTypes.YZ_PLANE, ViewTypes.XZ_PLANE, ViewTypes.XY_PLANE].reduce(
(res, viewType) => {
res[viewType] =
findRepresentationsForViewType(
viewType
)[0]?.getDisplayScaleParams?.();
return res;
},
{}
);
publicAPI.setScaleInPixels = macro.chain(
publicAPI.setScaleInPixels,
(scale) => {
publicAPI.getViewWidgets().forEach((w) => w.setScaleInPixels(scale));
updateState(
model.widgetState,
model.scaleInPixels,
model.rotationHandlePosition
);
}
);

publicAPI.getPlaneExtremities = (viewType) => {
const dirProj = publicAPI.getWidgetState().getPlanes()[viewType].normal;
const length = vtkBoundingBox.getDiagonalLength(
publicAPI.getWidgetState().getImage().getBounds()
);
const p1 = vtkMath.multiplyAccumulate(
publicAPI.getWidgetState().getCenter(),
dirProj,
-length,
[]
);
const p2 = vtkMath.multiplyAccumulate(
publicAPI.getWidgetState().getCenter(),
dirProj,
length,
[]
);
// FIXME: support oriented bounds
const intersectionPoints = vtkBox.intersectWithLine(
publicAPI.getWidgetState().getImage().getBounds(),
p1,
p2
);
return [intersectionPoints.x1, intersectionPoints.x2];
};
}

// ----------------------------------------------------------------------------
/**
* Initializes the model.
* @param {*} initialValues optional object of member variables. initialValues.planes is an optional list of axis names (e.g. ['X', 'Y'])
* @returns the initial model object
*/
const defaultValues = (initialValues) => ({
behavior: widgetBehavior,
widgetState: stateGenerator(initialValues.planes),
rotationHandlePosition: 0.5,
scaleInPixels: true,
manipulator: vtkPlaneManipulator.newInstance(),
...initialValues,
});

// ----------------------------------------------------------------------------

export function extend(publicAPI, model, initialValues = {}) {
Object.assign(model, defaultValues(initialValues));

vtkAbstractWidgetFactory.extend(publicAPI, model, initialValues);

macro.setGet(publicAPI, model, [
'scaleInPixels',
'rotationHandlePosition',
'manipulator',
]);

vtkResliceCursorWidget(publicAPI, model);
}

// ----------------------------------------------------------------------------

export const newInstance = macro.newInstance(extend, 'vtkResliceCursorWidget');

// ----------------------------------------------------------------------------

export default { newInstance, extend };
state.js
import vtkStateBuilder from 'vtk.js/Sources/Widgets/Core/StateBuilder';
import {
ScrollingMethods,
planeNames,
planeNameToViewType,
} from 'vtk.js/Sources/Widgets/Widgets3D/ResliceCursorWidget/Constants';

const defaultPlanes = {
X: {
normal: [1, 0, 0],
viewUp: [0, 0, 1],
color3: [255, 0, 0],
},
Y: {
normal: [0, -1, 0],
viewUp: [0, 0, 1],
color3: [0, 255, 0],
},
Z: {
normal: [0, 0, -1],
viewUp: [0, -1, 0],
color3: [0, 0, 255],
},
};

const viewsColor3 = {
X: [255, 0, 0], // red
Y: [0, 255, 0], // green
Z: [0, 0, 255], // blue
};

export default function generateState(planes = planeNames) {
const state = vtkStateBuilder
.createBuilder()
.addField({ name: 'center', initialValue: [0, 0, 0] })
.addField({ name: 'image', initialValue: null })
.addField({ name: 'activeViewType', initialValue: null })
.addField({
name: 'planes',
initialValue: planes.reduce(
(res, planeName) => ({
...res,
[planeNameToViewType[planeName]]: {
normal: defaultPlanes[planeName].normal,
viewUp: defaultPlanes[planeName].viewUp,
},
}),
{}
),
})
.addField({
name: 'scrollingMethod',
initialValue: ScrollingMethods.MIDDLE_MOUSE_BUTTON,
})
.addField({ name: 'cameraOffsets', initialValue: {} })
.addField({ name: 'viewUpFromViewType', initialValue: {} })
.addStateFromMixin({
labels: ['handles', 'sphere', 'center'],
mixins: ['origin', 'color3', 'scale1', 'visible', 'manipulator'],
name: 'centerHandle',
initialValues: {
scale1: 30,
color3: [255, 255, 255],
},
});

planes.reduce(
(viewState, view) =>
planes
.filter((v) => v !== view)
.reduce((axisState, axis) => {
// Line handle
axisState.addStateFromMixin({
labels: ['handles', 'line', `lineIn${view}`, `${axis}in${view}`],
mixins: [
'origin',
'color3',
'scale3', // scale3[2] will be automatically overwritten
'orientation',
'visible',
'manipulator',
],
name: `axis${axis}in${view}`,
initialValues: {
scale3: [4, 4, 4],
color3: viewsColor3[axis],
},
});
// Rotation handle
for (let rotationHandle = 0; rotationHandle < 2; ++rotationHandle) {
axisState.addStateFromMixin({
labels: [
'handles',
'sphere',
'rotation',
`rotationIn${view}`,
`${axis}in${view}`,
`point${rotationHandle}`,
],
mixins: ['origin', 'color3', 'scale1', 'visible', 'manipulator'],
name: `rotationHandle${axis}in${view}${rotationHandle}`,
initialValues: {
scale1: 30,
color3: viewsColor3[axis],
},
});
}
return axisState;
}, viewState),
state
);
return state.build();
}