diff --git a/Documentation/public/gallery/InteractorStyleTrackballActor.jpg b/Documentation/public/gallery/InteractorStyleTrackballActor.jpg new file mode 100644 index 00000000000..62d5c533d33 Binary files /dev/null and b/Documentation/public/gallery/InteractorStyleTrackballActor.jpg differ diff --git a/Sources/Interaction/Style/InteractorStyleTrackballActor/example/index.js b/Sources/Interaction/Style/InteractorStyleTrackballActor/example/index.js new file mode 100644 index 00000000000..f5c3ddabd5b --- /dev/null +++ b/Sources/Interaction/Style/InteractorStyleTrackballActor/example/index.js @@ -0,0 +1,60 @@ +import '@kitware/vtk.js/favicon'; + +// Load the rendering pieces we want to use (for both WebGL and WebGPU) +import '@kitware/vtk.js/Rendering/Profiles/Geometry'; + +import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; +import vtkCubeSource from '@kitware/vtk.js/Filters/Sources/CubeSource'; +import vtkFullScreenRenderWindow from '@kitware/vtk.js/Rendering/Misc/FullScreenRenderWindow'; +import vtkInteractorStyleTrackballActor from '@kitware/vtk.js/Interaction/Style/InteractorStyleTrackballActor'; +import vtkMapper from '@kitware/vtk.js/Rendering/Core/Mapper'; + +import GUI from 'lil-gui'; + +// ---------------------------------------------------------------------------- +// Example code +// ---------------------------------------------------------------------------- + +const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance(); +const renderer = fullScreenRenderer.getRenderer(); +const renderWindow = fullScreenRenderer.getRenderWindow(); +const interactor = fullScreenRenderer.getInteractor(); + +const cubeSource = vtkCubeSource.newInstance({ + xLength: 1.2, + yLength: 0.8, + zLength: 0.6, +}); +const mapper = vtkMapper.newInstance(); +mapper.setInputConnection(cubeSource.getOutputPort()); + +const actor = vtkActor.newInstance(); +actor.setMapper(mapper); +renderer.addActor(actor); + +renderer.resetCamera(); +renderWindow.render(); + +interactor.setInteractorStyle(vtkInteractorStyleTrackballActor.newInstance()); +const style = interactor.getInteractorStyle(); +const gui = new GUI(); +const params = { + motionFactor: style.getMotionFactor(), + left: 'rotate picked actor', + shiftLeft: 'pan picked actor', + ctrlLeft: 'spin picked actor', + middle: 'pan picked actor', + ctrlMiddle: 'dolly picked actor', + right: 'uniform scale picked actor', +}; + +gui.add(params, 'motionFactor', 1, 30, 1).onChange((value) => { + style.setMotionFactor(Number(value)); + renderWindow.render(); +}); +gui.add(params, 'left').name('Left').disable(); +gui.add(params, 'shiftLeft').name('Shift + Left').disable(); +gui.add(params, 'ctrlLeft').name('Ctrl + Left').disable(); +gui.add(params, 'middle').name('Middle').disable(); +gui.add(params, 'ctrlMiddle').name('Ctrl + Middle').disable(); +gui.add(params, 'right').name('Right').disable(); diff --git a/Sources/Interaction/Style/InteractorStyleTrackballActor/index.d.ts b/Sources/Interaction/Style/InteractorStyleTrackballActor/index.d.ts new file mode 100644 index 00000000000..024d001d025 --- /dev/null +++ b/Sources/Interaction/Style/InteractorStyleTrackballActor/index.d.ts @@ -0,0 +1,175 @@ +import vtkInteractorStyle from '../../../Rendering/Core/InteractorStyle'; +import vtkCellPicker from '../../../Rendering/Core/CellPicker'; +import vtkProp3D from '../../../Rendering/Core/Prop3D'; +import vtkRenderer from '../../../Rendering/Core/Renderer'; +import { Nullable } from '../../../types'; +import { IRenderWindowInteractorEvent } from '../../../Rendering/Core/RenderWindowInteractor'; + +export interface IInteractorStyleTrackballActorInitialValues { + motionFactor?: number; + interactionProp?: Nullable; + interactionPicker?: Nullable; +} + +export interface vtkInteractorStyleTrackballActor extends vtkInteractorStyle { + /** + * Find the picked actor and pick position. + * @param {vtkRenderer} renderer + * @param position + */ + findPickedActor( + renderer: vtkRenderer, + position: { x: number; y: number } + ): void; + + /** + * Handle mouse move event. + * @param {IRenderWindowInteractorEvent} callData + */ + handleMouseMove(callData: IRenderWindowInteractorEvent): void; + + /** + * Handle left mouse button press event. + * @param {IRenderWindowInteractorEvent} callData + */ + handleLeftButtonPress(callData: IRenderWindowInteractorEvent): void; + + /** + * Handle left mouse button release event. + * @param {IRenderWindowInteractorEvent} callData + */ + handleLeftButtonRelease(callData: IRenderWindowInteractorEvent): void; + + /** + * Handle middle mouse button press event. + * @param {IRenderWindowInteractorEvent} callData + */ + handleMiddleButtonPress(callData: IRenderWindowInteractorEvent): void; + + /** + * Handle middle mouse button release event. + * @param {IRenderWindowInteractorEvent} callData + */ + handleMiddleButtonRelease(callData: IRenderWindowInteractorEvent): void; + + /** + * Handle right mouse button press event. + * @param {IRenderWindowInteractorEvent} callData + */ + handleRightButtonPress(callData: IRenderWindowInteractorEvent): void; + + /** + * Handle right mouse button release event. + * @param {IRenderWindowInteractorEvent} callData + */ + handleRightButtonRelease(callData: IRenderWindowInteractorEvent): void; + + /** + * Handle mouse rotate event. + * @param {vtkRenderer} renderer + * @param position + */ + handleMouseRotate( + renderer: vtkRenderer, + position: { x: number; y: number } + ): void; + + /** + * Handle mouse spin event. + * @param {vtkRenderer} renderer + * @param position + */ + handleMouseSpin( + renderer: vtkRenderer, + position: { x: number; y: number } + ): void; + + /** + * Handle mouse pan event. + * @param {vtkRenderer} renderer + * @param position + */ + handleMousePan( + renderer: vtkRenderer, + position: { x: number; y: number } + ): void; + + /** + * Handle mouse dolly event. + * @param {vtkRenderer} renderer + * @param position + */ + handleMouseDolly( + renderer: vtkRenderer, + position: { x: number; y: number } + ): void; + + /** + * Handle mouse uniform scale event. + * @param {vtkRenderer} renderer + * @param position + */ + handleMouseUniformScale( + renderer: vtkRenderer, + position: { x: number; y: number } + ): void; + + /** + * Start uniform scale interaction. + */ + startUniformScale(): void; + + /** + * End uniform scale interaction. + */ + endUniformScale(): void; + + /** + * Get the interaction picker. + */ + getInteractionPicker(): Nullable; + + /** + * Get the interaction prop. + */ + getInteractionProp(): Nullable; + + /** + * Set the interaction prop. + * @param {vtkProp3D | null} prop + */ + setInteractionProp(prop: Nullable): boolean; + + /** + * Get the motion factor. + */ + getMotionFactor(): number; + + /** + * Set the motion factor. + * @param {Number} factor + */ + setMotionFactor(factor: number): boolean; +} + +export function newInstance( + initialValues?: IInteractorStyleTrackballActorInitialValues +): vtkInteractorStyleTrackballActor; + +export function extend( + publicAPI: object, + model: object, + initialValues?: IInteractorStyleTrackballActorInitialValues +): void; + +/** + * vtkInteractorStyleTrackballActor allows the user to interact with (rotate, pan, etc.) objects in the scene independent of each other. + * In trackball interaction, the magnitude of the mouse motion is proportional to the actor motion associated with a particular mouse binding. + * For example, small left-button motions cause small changes in the rotation of the actor around its center point. + */ +export const vtkInteractorStyleTrackballActor: { + newInstance: typeof newInstance; + extend: typeof extend; +}; + +export default vtkInteractorStyleTrackballActor; diff --git a/Sources/Interaction/Style/InteractorStyleTrackballActor/index.js b/Sources/Interaction/Style/InteractorStyleTrackballActor/index.js new file mode 100644 index 00000000000..735ba102fb0 --- /dev/null +++ b/Sources/Interaction/Style/InteractorStyleTrackballActor/index.js @@ -0,0 +1,369 @@ +import { quat } from 'gl-matrix'; +import macro from 'vtk.js/Sources/macros'; +import vtkCellPicker from 'vtk.js/Sources/Rendering/Core/CellPicker'; +import vtkInteractorStyle from 'vtk.js/Sources/Rendering/Core/InteractorStyle'; +import vtkInteractorStyleConstants from 'vtk.js/Sources/Rendering/Core/InteractorStyle/Constants'; +import * as vtkMath from 'vtk.js/Sources/Common/Core/Math'; + +const { States } = vtkInteractorStyleConstants; +const IS_USCALE = 5; + +function applyWorldRotation(prop, degrees, axis) { + if (!degrees || !axis) { + return; + } + + const normalizedAxis = [...axis]; + if (vtkMath.normalize(normalizedAxis) === 0.0) { + return; + } + + const currentOrientation = prop.getOrientationQuaternion(new Float64Array(4)); + const rotationDelta = quat.setAxisAngle( + quat.create(), + normalizedAxis, + vtkMath.radiansFromDegrees(degrees) + ); + + quat.multiply(currentOrientation, rotationDelta, currentOrientation); + quat.normalize(currentOrientation, currentOrientation); + prop.setOrientationFromQuaternion(currentOrientation); +} + +function vtkInteractorStyleTrackballActor(publicAPI, model) { + model.classHierarchy.push('vtkInteractorStyleTrackballActor'); + + macro.event(publicAPI, model, 'StartUniformScaleEvent'); + macro.event(publicAPI, model, 'EndUniformScaleEvent'); + + publicAPI.startUniformScale = () => { + if (model.state !== States.IS_NONE) { + return; + } + model.state = IS_USCALE; + model._interactor.requestAnimation(publicAPI); + publicAPI.invokeStartInteractionEvent({ type: 'StartInteractionEvent' }); + publicAPI.invokeStartUniformScaleEvent({ type: 'StartUniformScaleEvent' }); + }; + + publicAPI.endUniformScale = () => { + if (model.state !== IS_USCALE) { + return; + } + model.state = States.IS_NONE; + model._interactor.cancelAnimation(publicAPI); + publicAPI.invokeEndInteractionEvent({ type: 'EndInteractionEvent' }); + publicAPI.invokeEndUniformScaleEvent({ type: 'EndUniformScaleEvent' }); + model._interactor.render(); + }; + + publicAPI.findPickedActor = (renderer, position) => { + model.currentRenderer = renderer; + model.interactionPicker.pick([position.x, position.y, 0.0], renderer); + model.interactionProp = model.interactionPicker.getActors()[0] ?? null; + }; + + publicAPI.handleMouseMove = (callData) => { + const pos = callData.position; + const renderer = model.getRenderer(callData); + + switch (model.state) { + case States.IS_ROTATE: + publicAPI.handleMouseRotate(renderer, pos); + publicAPI.invokeInteractionEvent({ type: 'InteractionEvent' }); + break; + case States.IS_PAN: + publicAPI.handleMousePan(renderer, pos); + publicAPI.invokeInteractionEvent({ type: 'InteractionEvent' }); + break; + case States.IS_DOLLY: + publicAPI.handleMouseDolly(renderer, pos); + publicAPI.invokeInteractionEvent({ type: 'InteractionEvent' }); + break; + case States.IS_SPIN: + publicAPI.handleMouseSpin(renderer, pos); + publicAPI.invokeInteractionEvent({ type: 'InteractionEvent' }); + break; + case IS_USCALE: + publicAPI.handleMouseUniformScale(renderer, pos); + publicAPI.invokeInteractionEvent({ type: 'InteractionEvent' }); + break; + default: + break; + } + + model.previousPosition = pos; + }; + + publicAPI.handleLeftButtonPress = (callData) => { + const renderer = model.getRenderer(callData); + const pos = callData.position; + model.previousPosition = pos; + publicAPI.findPickedActor(renderer, pos); + if (!renderer || !model.interactionProp) { + return; + } + if (callData.shiftKey) { + publicAPI.startPan(); + } else if (callData.controlKey || callData.altKey) { + publicAPI.startSpin(); + } else { + publicAPI.startRotate(); + } + }; + + publicAPI.handleLeftButtonRelease = () => { + switch (model.state) { + case States.IS_PAN: + publicAPI.endPan(); + break; + case States.IS_SPIN: + publicAPI.endSpin(); + break; + case States.IS_ROTATE: + publicAPI.endRotate(); + break; + default: + break; + } + model.interactionProp = null; + }; + + publicAPI.handleMiddleButtonPress = (callData) => { + const renderer = model.getRenderer(callData); + const pos = callData.position; + model.previousPosition = pos; + publicAPI.findPickedActor(renderer, pos); + if (!renderer || !model.interactionProp) { + return; + } + if (callData.controlKey || callData.altKey) { + publicAPI.startDolly(); + } else { + publicAPI.startPan(); + } + }; + + publicAPI.handleMiddleButtonRelease = () => { + switch (model.state) { + case States.IS_DOLLY: + publicAPI.endDolly(); + break; + case States.IS_PAN: + publicAPI.endPan(); + break; + default: + break; + } + model.interactionProp = null; + }; + + publicAPI.handleRightButtonPress = (callData) => { + const renderer = model.getRenderer(callData); + const pos = callData.position; + model.previousPosition = pos; + publicAPI.findPickedActor(renderer, pos); + if (!renderer || !model.interactionProp) { + return; + } + publicAPI.startUniformScale(); + }; + + publicAPI.handleRightButtonRelease = () => { + if (model.state === IS_USCALE) { + publicAPI.endUniformScale(); + } + model.interactionProp = null; + }; + + publicAPI.applyTransform = (renderer) => { + if (model.autoAdjustCameraClippingRange) { + renderer.resetCameraClippingRange(); + } + model._interactor.render(); + }; + + publicAPI.handleMouseRotate = (renderer, position) => { + if (!model.previousPosition || !model.interactionProp) { + return; + } + + const dx = position.x - model.previousPosition.x; + const dy = position.y - model.previousPosition.y; + const size = model._interactor.getView().getViewportSize(renderer); + const deltaElevation = -20.0 / (size[1] || 1); + const deltaAzimuth = -20.0 / (size[0] || 1); + + const camera = renderer.getActiveCamera(); + const center = model.interactionProp.getCenter(); + camera.orthogonalizeViewUp(); + + const viewUp = camera.getViewUp(); + vtkMath.normalize(viewUp); + const viewLook = camera.getViewPlaneNormal(); + const viewRight = []; + vtkMath.cross(viewUp, viewLook, viewRight); + vtkMath.normalize(viewRight); + + const xAngle = -dx * deltaAzimuth * model.motionFactor; + const yAngle = dy * deltaElevation * model.motionFactor; + + model.interactionProp.setOrigin(...center); + applyWorldRotation(model.interactionProp, xAngle, viewUp); + applyWorldRotation(model.interactionProp, yAngle, viewRight); + publicAPI.applyTransform(renderer); + }; + + publicAPI.handleMouseSpin = (renderer, position) => { + if (!model.previousPosition || !model.interactionProp) { + return; + } + + const camera = renderer.getActiveCamera(); + const center = model.interactionProp.getCenter(); + let motionVector = null; + + if (camera.getParallelProjection()) { + motionVector = [...camera.getViewPlaneNormal()]; + } else { + const viewPoint = camera.getPosition(); + motionVector = [ + viewPoint[0] - center[0], + viewPoint[1] - center[1], + viewPoint[2] - center[2], + ]; + vtkMath.normalize(motionVector); + } + + const displayCenter = publicAPI.computeWorldToDisplay( + renderer, + center[0], + center[1], + center[2] + ); + + const newAngle = vtkMath.degreesFromRadians( + Math.atan2(position.y - displayCenter[1], position.x - displayCenter[0]) + ); + const oldAngle = vtkMath.degreesFromRadians( + Math.atan2( + model.previousPosition.y - displayCenter[1], + model.previousPosition.x - displayCenter[0] + ) + ); + + model.interactionProp.setOrigin(...center); + applyWorldRotation( + model.interactionProp, + newAngle - oldAngle, + motionVector + ); + publicAPI.applyTransform(renderer); + }; + + publicAPI.handleMousePan = (renderer, position) => { + if (!model.previousPosition || !model.interactionProp) { + return; + } + + const center = model.interactionProp.getCenter(); + const displayCenter = publicAPI.computeWorldToDisplay( + renderer, + center[0], + center[1], + center[2] + ); + const focalDepth = displayCenter[2]; + + const newPickPoint = publicAPI.computeDisplayToWorld( + renderer, + position.x, + position.y, + focalDepth + ); + const oldPickPoint = publicAPI.computeDisplayToWorld( + renderer, + model.previousPosition.x, + model.previousPosition.y, + focalDepth + ); + const motionVector = [ + newPickPoint[0] - oldPickPoint[0], + newPickPoint[1] - oldPickPoint[1], + newPickPoint[2] - oldPickPoint[2], + ]; + + model.interactionProp.addPosition(motionVector); + publicAPI.applyTransform(renderer); + }; + + publicAPI.handleMouseDolly = (renderer, position) => { + if (!model.previousPosition || !model.interactionProp) { + return; + } + + const camera = renderer.getActiveCamera(); + const viewPoint = camera.getPosition(); + const viewFocus = camera.getFocalPoint(); + const center = model._interactor.getView().getViewportCenter(renderer); + const dy = position.y - model.previousPosition.y; + const yf = (dy / center[1]) * model.motionFactor; + const dollyFactor = 1.1 ** yf - 1.0; + const motionVector = [ + (viewPoint[0] - viewFocus[0]) * dollyFactor, + (viewPoint[1] - viewFocus[1]) * dollyFactor, + (viewPoint[2] - viewFocus[2]) * dollyFactor, + ]; + + model.interactionProp.addPosition(motionVector); + publicAPI.applyTransform(renderer); + }; + + publicAPI.handleMouseUniformScale = (renderer, position) => { + if (!model.previousPosition || !model.interactionProp) { + return; + } + + const center = model._interactor.getView().getViewportCenter(renderer); + const dy = position.y - model.previousPosition.y; + const yf = (dy / center[1]) * model.motionFactor; + const scaleFactor = 1.1 ** yf; + const currentScale = model.interactionProp.getScale(); + model.interactionProp.setOrigin(...model.interactionProp.getCenter()); + model.interactionProp.setScale( + currentScale[0] * scaleFactor, + currentScale[1] * scaleFactor, + currentScale[2] * scaleFactor + ); + publicAPI.applyTransform(renderer); + }; +} + +const DEFAULT_VALUES = { + motionFactor: 10.0, + interactionProp: null, + interactionPicker: null, + currentRenderer: null, +}; + +export function extend(publicAPI, model, initialValues = {}) { + Object.assign(model, DEFAULT_VALUES, initialValues); + + vtkInteractorStyle.extend(publicAPI, model, initialValues); + + if (!model.interactionPicker) { + model.interactionPicker = vtkCellPicker.newInstance({ tolerance: 0.001 }); + } + + macro.setGet(publicAPI, model, ['motionFactor', 'interactionProp']); + macro.get(publicAPI, model, ['interactionPicker']); + + vtkInteractorStyleTrackballActor(publicAPI, model); +} + +export const newInstance = macro.newInstance( + extend, + 'vtkInteractorStyleTrackballActor' +); + +export default { newInstance, extend }; diff --git a/Sources/Interaction/Style/index.js b/Sources/Interaction/Style/index.js index 7b75f1441da..ca25c03b672 100644 --- a/Sources/Interaction/Style/index.js +++ b/Sources/Interaction/Style/index.js @@ -2,6 +2,7 @@ import vtkInteractorStyleImage from './InteractorStyleImage'; import vtkInteractorStyleManipulator from './InteractorStyleManipulator'; import vtkInteractorStyleMPRSlice from './InteractorStyleMPRSlice'; import vtkInteractorStyleRemoteMouse from './InteractorStyleRemoteMouse'; +import vtkInteractorStyleTrackballActor from './InteractorStyleTrackballActor'; import vtkInteractorStyleTrackballCamera from './InteractorStyleTrackballCamera'; export default { @@ -9,5 +10,6 @@ export default { vtkInteractorStyleManipulator, vtkInteractorStyleMPRSlice, vtkInteractorStyleRemoteMouse, + vtkInteractorStyleTrackballActor, vtkInteractorStyleTrackballCamera, };