diff --git a/Sources/Filters/Sources/TorusSource/index.js b/Sources/Filters/Sources/TorusSource/index.js new file mode 100644 index 00000000000..dad7e4b3529 --- /dev/null +++ b/Sources/Filters/Sources/TorusSource/index.js @@ -0,0 +1,123 @@ +import macro from 'vtk.js/Sources/macros'; +import vtkPolyData from 'vtk.js/Sources/Common/DataModel/PolyData'; +import vtkMatrixBuilder from 'vtk.js/Sources/Common/Core/MatrixBuilder'; + +// ---------------------------------------------------------------------------- +// vtkTorusSource methods +// Adapted from three.js TorusGeometry +// ---------------------------------------------------------------------------- + +const TAU = Math.PI * 2; + +function vtkTorusSource(publicAPI, model) { + // Set our className + model.classHierarchy.push('vtkTorusSource'); + + function requestData(inData, outData) { + let dataset = outData[0]; + + // Points + const points = macro.newTypedArray( + model.pointType, + 3 * (model.resolution + 1) * (model.tubeResolution + 1) + ); + let pointIdx = 0; + + for (let ti = 0; ti <= model.tubeResolution; ti++) { + const v = (ti / model.tubeResolution) * TAU; + const cosV = Math.cos(v); + const sinV = Math.sin(v); + for (let ri = 0; ri <= model.resolution; ri++) { + const u = (ri / model.resolution) * model.arcLength; + points[pointIdx++] = + (model.radius + model.tubeRadius * cosV) * Math.cos(u); + points[pointIdx++] = + (model.radius + model.tubeRadius * cosV) * Math.sin(u); + points[pointIdx++] = model.tubeRadius * sinV; + } + } + + // Cells + const cellArraySize = 4 * 2 * (model.resolution * model.tubeResolution); + let cellLocation = 0; + const polys = new Uint32Array(cellArraySize); + + for (let ti = 1; ti <= model.tubeResolution; ti++) { + for (let ri = 1; ri <= model.resolution; ri++) { + const a = (model.resolution + 1) * ti + ri - 1; + const b = (model.resolution + 1) * (ti - 1) + ri - 1; + const c = (model.resolution + 1) * (ti - 1) + ri; + const d = (model.resolution + 1) * ti + ri; + + polys[cellLocation++] = 3; + polys[cellLocation++] = a; + polys[cellLocation++] = b; + polys[cellLocation++] = d; + + polys[cellLocation++] = 3; + polys[cellLocation++] = b; + polys[cellLocation++] = c; + polys[cellLocation++] = d; + } + } + + // Apply transformation to the points coordinates + vtkMatrixBuilder + .buildFromRadian() + .translate(...model.center) + .rotateFromDirections([1, 0, 0], model.direction) + .apply(points); + + dataset = vtkPolyData.newInstance(); + dataset.getPoints().setData(points, 3); + dataset.getPolys().setData(polys, 1); + + // Update output + outData[0] = dataset; + } + + // Expose methods + publicAPI.requestData = requestData; +} + +// ---------------------------------------------------------------------------- +// Object factory +// ---------------------------------------------------------------------------- + +const DEFAULT_VALUES = { + radius: 0.5, + tubeRadius: 0.01, + resolution: 64, + tubeResolution: 64, + arcLength: TAU, + center: [0, 0, 0], + direction: [1.0, 0.0, 0.0], + pointType: 'Float64Array', +}; + +// ---------------------------------------------------------------------------- + +export function extend(publicAPI, model, initialValues = {}) { + Object.assign(model, DEFAULT_VALUES, initialValues); + + // Build VTK API + macro.obj(publicAPI, model); + macro.setGet(publicAPI, model, [ + 'radius', + 'tubeRadius', + 'resolution', + 'tubeResolution', + 'arcLength', + ]); + macro.setGetArray(publicAPI, model, ['center', 'direction'], 3); + macro.algo(publicAPI, model, 0, 1); + vtkTorusSource(publicAPI, model); +} + +// ---------------------------------------------------------------------------- + +export const newInstance = macro.newInstance(extend, 'vtkTorusSource'); + +// ---------------------------------------------------------------------------- + +export default { newInstance, extend }; diff --git a/Sources/Filters/Sources/index.js b/Sources/Filters/Sources/index.js index 51624152d23..9e36a2546ab 100644 --- a/Sources/Filters/Sources/index.js +++ b/Sources/Filters/Sources/index.js @@ -13,6 +13,7 @@ import vtkPointSource from './PointSource'; import vtkRTAnalyticSource from './RTAnalyticSource'; import vtkSLICSource from './SLICSource'; import vtkSphereSource from './SphereSource'; +import vtkTorusSource from './TorusSource'; export default { vtkArrowSource, @@ -30,4 +31,5 @@ export default { vtkRTAnalyticSource, vtkSLICSource, vtkSphereSource, + vtkTorusSource, }; diff --git a/Sources/Rendering/Core/Prop3D/index.js b/Sources/Rendering/Core/Prop3D/index.js index a35aeb8a9f9..748b1972398 100644 --- a/Sources/Rendering/Core/Prop3D/index.js +++ b/Sources/Rendering/Core/Prop3D/index.js @@ -116,6 +116,17 @@ function vtkProp3D(publicAPI, model) { return true; }; + publicAPI.setOrientationFromQuaternion = (q) => { + const rotation = mat4.create(); + mat4.fromQuat(rotation, q); + if (!vtkMath.areMatricesEqual(rotation, model.rotation)) { + model.rotation = rotation; + publicAPI.modified(); + return true; + } + return false; + }; + publicAPI.setUserMatrix = (matrix) => { if (vtkMath.areMatricesEqual(model.userMatrix, matrix)) { return false; diff --git a/Sources/Widgets/Representations/RotateTransformHandleRepresentation/index.js b/Sources/Widgets/Representations/RotateTransformHandleRepresentation/index.js new file mode 100644 index 00000000000..423be122ead --- /dev/null +++ b/Sources/Widgets/Representations/RotateTransformHandleRepresentation/index.js @@ -0,0 +1,44 @@ +import macro from 'vtk.js/Sources/macros'; +import vtkGlyphRepresentation from 'vtk.js/Sources/Widgets/Representations/GlyphRepresentation'; +import vtkTorusSource from 'vtk.js/Sources/Filters/Sources/TorusSource'; + +// ---------------------------------------------------------------------------- +// vtkRotateTransformHandleRepresentation methods +// ---------------------------------------------------------------------------- + +function vtkRotateTransformHandleRepresentation(publicAPI, model) { + // Set our className + model.classHierarchy.push('vtkRotateTransformHandleRepresentation'); +} + +// ---------------------------------------------------------------------------- +// Object factory +// ---------------------------------------------------------------------------- + +// ---------------------------------------------------------------------------- +function defaultValues(initialValues) { + return { + _pipeline: { + glyph: vtkTorusSource.newInstance({}), + }, + ...initialValues, + }; +} + +export function extend(publicAPI, model, initialValues = {}) { + vtkGlyphRepresentation.extend(publicAPI, model, defaultValues(initialValues)); + + // Object specific methods + vtkRotateTransformHandleRepresentation(publicAPI, model); +} + +// ---------------------------------------------------------------------------- + +export const newInstance = macro.newInstance( + extend, + 'vtkRotateTransformHandleRepresentation' +); + +// ---------------------------------------------------------------------------- + +export default { newInstance, extend }; diff --git a/Sources/Widgets/Representations/ScaleTransformHandleRepresentation/index.js b/Sources/Widgets/Representations/ScaleTransformHandleRepresentation/index.js new file mode 100644 index 00000000000..4e020510d42 --- /dev/null +++ b/Sources/Widgets/Representations/ScaleTransformHandleRepresentation/index.js @@ -0,0 +1,59 @@ +import macro from 'vtk.js/Sources/macros'; +import vtkGlyphRepresentation from 'vtk.js/Sources/Widgets/Representations/GlyphRepresentation'; +import vtkCubeSource from 'vtk.js/Sources/Filters/Sources/CubeSource'; + +import vtkTransformHandleSource from 'vtk.js/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/TransformHandleSource'; + +// ---------------------------------------------------------------------------- +// vtkScaleTransformHandleRepresentation methods +// ---------------------------------------------------------------------------- + +function vtkScaleTransformHandleRepresentation(publicAPI, model) { + // Set our className + model.classHierarchy.push('vtkScaleTransformHandleRepresentation'); +} + +// ---------------------------------------------------------------------------- +// Object factory +// ---------------------------------------------------------------------------- + +// ---------------------------------------------------------------------------- +function defaultValues(initialValues) { + const source = vtkTransformHandleSource.newInstance({ + height: initialValues.height ?? 1, + radius: initialValues.radius ?? 1, + resolution: initialValues.glyphResolution ?? 12, + direction: [0, 0, 1], + }); + + const cube1 = vtkCubeSource.newInstance(initialValues.cubeSource); + const cube2 = vtkCubeSource.newInstance(initialValues.cubeSource); + + source.addInputConnection(cube1.getOutputPort()); + source.addInputConnection(cube2.getOutputPort()); + + return { + _pipeline: { + glyph: source, + }, + ...initialValues, + }; +} + +export function extend(publicAPI, model, initialValues = {}) { + vtkGlyphRepresentation.extend(publicAPI, model, defaultValues(initialValues)); + + // Object specific methods + vtkScaleTransformHandleRepresentation(publicAPI, model); +} + +// ---------------------------------------------------------------------------- + +export const newInstance = macro.newInstance( + extend, + 'vtkScaleTransformHandleRepresentation' +); + +// ---------------------------------------------------------------------------- + +export default { newInstance, extend }; diff --git a/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/TransformHandleSource.js b/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/TransformHandleSource.js new file mode 100644 index 00000000000..c6f32f538dd --- /dev/null +++ b/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/TransformHandleSource.js @@ -0,0 +1,105 @@ +import macro from 'vtk.js/Sources/macros'; +import vtkMatrixBuilder from 'vtk.js/Sources/Common/Core/MatrixBuilder'; +import vtkAppendPolyData from 'vtk.js/Sources/Filters/General/AppendPolyData'; +import vtkCylinderSource from 'vtk.js/Sources/Filters/Sources/CylinderSource'; + +function rotatePolyData(pd, direction) { + const points = pd.getPoints().getData(); + + vtkMatrixBuilder + .buildFromRadian() + .rotateFromDirections([0, 1, 0], direction) + .apply(points); + + pd.getPoints().modified(); + pd.modified(); +} + +function translatePolyData(pd, translation) { + const points = pd.getPoints().getData(); + + vtkMatrixBuilder + .buildFromRadian() + .translate(...translation) + .apply(points); + + pd.modified(); +} + +function vtkTransformHandleSource(publicAPI, model) { + // Set our className + model.classHierarchy.push('vtkTransformHandleSource'); + + function requestData(inData, outData) { + const cylinderSource = vtkCylinderSource.newInstance({ + height: model.height, + initAngle: model.initAngle, + radius: model.radius, + resolution: model.resolution, + capping: model.capping, + pointType: model.pointType, + center: [0, 0, 0], + direction: [0, 1, 0], + }); + + const appendFilter = vtkAppendPolyData.newInstance(); + appendFilter.setInputConnection(cylinderSource.getOutputPort(), 0); + + if (inData[0]) { + translatePolyData(inData[0], [0, model.height / 2, 0]); + appendFilter.addInputData(inData[0]); + } + if (inData[1]) { + rotatePolyData(inData[1], [0, -1, 0]); + translatePolyData(inData[1], [0, -model.height / 2, 0]); + appendFilter.addInputData(inData[1]); + } + + const poly = appendFilter.getOutputData(); + const points = poly.getPoints().getData(); + + // Apply transformation to the points coordinates + vtkMatrixBuilder + .buildFromRadian() + .translate(...model.center) + .rotateFromDirections([0, 1, 0], model.direction) + .translate(...model.center.map((c) => c * -1)) + .apply(points); + + // Update output + outData[0] = poly; + } + + // Expose methods + publicAPI.requestData = requestData; +} + +// ---------------------------------------------------------------------------- +// Object factory +// ---------------------------------------------------------------------------- + +const DEFAULT_VALUES = { + capPolyData: null, +}; + +// ---------------------------------------------------------------------------- + +export function extend(publicAPI, model, initialValues = {}) { + Object.assign(model, DEFAULT_VALUES, initialValues); + + vtkCylinderSource.extend(publicAPI, model, initialValues); + macro.algo(publicAPI, model, 2, 1); + + vtkTransformHandleSource(publicAPI, model); +} + +// ---------------------------------------------------------------------------- + +export const newInstance = macro.newInstance( + extend, + 'vtkTransformHandleSource' +); + +// ---------------------------------------------------------------------------- + +export default { newInstance, extend }; diff --git a/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/index.js b/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/index.js new file mode 100644 index 00000000000..4b9da0df47b --- /dev/null +++ b/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/index.js @@ -0,0 +1,59 @@ +import macro from 'vtk.js/Sources/macros'; +import vtkGlyphRepresentation from 'vtk.js/Sources/Widgets/Representations/GlyphRepresentation'; +import vtkConeSource from 'vtk.js/Sources/Filters/Sources/ConeSource'; + +import vtkTransformHandleSource from './TransformHandleSource'; + +// ---------------------------------------------------------------------------- +// vtkTranslateTransformHandleRepresentation methods +// ---------------------------------------------------------------------------- + +function vtkTranslateTransformHandleRepresentation(publicAPI, model) { + // Set our className + model.classHierarchy.push('vtkTranslateTransformHandleRepresentation'); +} + +// ---------------------------------------------------------------------------- +// Object factory +// ---------------------------------------------------------------------------- + +// ---------------------------------------------------------------------------- +function defaultValues(initialValues) { + const source = vtkTransformHandleSource.newInstance({ + height: initialValues.height ?? 1, + radius: initialValues.radius ?? 1, + resolution: initialValues.glyphResolution ?? 12, + direction: [0, 0, 1], + }); + + const cone1 = vtkConeSource.newInstance(initialValues.coneSource); + const cone2 = vtkConeSource.newInstance(initialValues.coneSource); + + source.addInputConnection(cone1.getOutputPort()); + source.addInputConnection(cone2.getOutputPort()); + + return { + _pipeline: { + glyph: source, + }, + ...initialValues, + }; +} + +export function extend(publicAPI, model, initialValues = {}) { + vtkGlyphRepresentation.extend(publicAPI, model, defaultValues(initialValues)); + + // Object specific methods + vtkTranslateTransformHandleRepresentation(publicAPI, model); +} + +// ---------------------------------------------------------------------------- + +export const newInstance = macro.newInstance( + extend, + 'vtkTranslateTransformHandleRepresentation' +); + +// ---------------------------------------------------------------------------- + +export default { newInstance, extend }; diff --git a/Sources/Widgets/Representations/index.js b/Sources/Widgets/Representations/index.js index 0234036b107..979531cf6a9 100644 --- a/Sources/Widgets/Representations/index.js +++ b/Sources/Widgets/Representations/index.js @@ -8,8 +8,11 @@ import vtkImplicitPlaneRepresentation from './ImplicitPlaneRepresentation'; import vtkLineHandleRepresentation from './LineHandleRepresentation'; import vtkOutlineContextRepresentation from './OutlineContextRepresentation'; import vtkPolyLineRepresentation from './PolyLineRepresentation'; +import vtkRotateTransformHandleRepresentation from './RotateTransformHandleRepresentation'; +import vtkScaleTransformHandleRepresentation from './ScaleTransformHandleRepresentation'; import vtkSphereHandleRepresentation from './SphereHandleRepresentation'; import vtkSplineContextRepresentation from './SplineContextRepresentation'; +import vtkTranslateTransformHandleRepresentation from './TranslateTransformHandleRepresentation'; import vtkWidgetRepresentation from './WidgetRepresentation'; export default { @@ -23,7 +26,10 @@ export default { vtkLineHandleRepresentation, vtkOutlineContextRepresentation, vtkPolyLineRepresentation, + vtkRotateTransformHandleRepresentation, + vtkScaleTransformHandleRepresentation, vtkSphereHandleRepresentation, vtkSplineContextRepresentation, + vtkTranslateTransformHandleRepresentation, vtkWidgetRepresentation, }; diff --git a/Sources/Widgets/Widgets3D/TransformControlsWidget/behavior.js b/Sources/Widgets/Widgets3D/TransformControlsWidget/behavior.js new file mode 100644 index 00000000000..0b93ab1ebf3 --- /dev/null +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/behavior.js @@ -0,0 +1,237 @@ +import { quat, vec3 } from 'gl-matrix'; +import macro from 'vtk.js/Sources/macros'; +import vtkBoundingBox from 'vtk.js/Sources/Common/DataModel/BoundingBox'; +import vtkPlaneManipulator from 'vtk.js/Sources/Widgets/Manipulators/PlaneManipulator'; +import vtkLineManipulator from 'vtk.js/Sources/Widgets/Manipulators/LineManipulator'; + +export default function widgetBehavior(publicAPI, model) { + let isDragging = false; + + model.rotationManipulator = vtkPlaneManipulator.newInstance(); + model.lineManipulator = vtkLineManipulator.newInstance(); + + const rotateState = { + startQuat: quat.create(), + dragStartVec: [0, 0, 0], + }; + const scaleState = { + startDistFromOrigin: 0, + startScale: 1, + }; + const translateState = { + startPos: 0, + dragStartCoord: [0, 0, 0], + }; + + publicAPI.getBounds = () => [...vtkBoundingBox.INIT_BOUNDS]; + + publicAPI.setDisplayCallback = (callback) => + model.representations[0].setDisplayCallback(callback); + + publicAPI.handleLeftButtonPress = (callData) => { + if ( + !model.activeState || + !model.activeState.getActive() || + !model.pickable + ) { + return macro.VOID; + } + + const [type, axis] = model.activeState.getName().split(':'); + const axisIndex = 'XYZ'.indexOf(axis); + if (type === 'translate') { + publicAPI.handleTranslateStartEvent(callData, axis, axisIndex); + } else if (type === 'scale') { + publicAPI.handleScaleStartEvent(callData, axis, axisIndex); + } else if (type === 'rotate') { + publicAPI.handleRotateStartEvent(callData, axis, axisIndex); + } + + model._interactor.requestAnimation(publicAPI); + return macro.EVENT_ABORT; + }; + + publicAPI.handleTranslateStartEvent = (callData, axis, axisIndex) => { + model.lineManipulator.setHandleOrigin(model.activeState.getOrigin()); + model.lineManipulator.setHandleNormal(model.activeState.getDirection()); + + const { worldCoords } = model.lineManipulator.handleEvent( + callData, + model._apiSpecificRenderWindow + ); + + if (worldCoords.length) { + isDragging = true; + translateState.dragStartCoord = worldCoords; + translateState.startPos = model.widgetState + .getTransform() + .getTranslation()[axisIndex]; + } + }; + + publicAPI.handleScaleStartEvent = (callData, axis, axisIndex) => { + model.lineManipulator.setHandleOrigin(model.activeState.getOrigin()); + model.lineManipulator.setHandleNormal(model.activeState.getDirection()); + + const { worldCoords } = model.lineManipulator.handleEvent( + callData, + model._apiSpecificRenderWindow + ); + + if (worldCoords.length) { + isDragging = true; + scaleState.startScale = model.widgetState.getTransform().getScale()[ + axisIndex + ]; + scaleState.startDistFromOrigin = + vec3.dist(worldCoords, model.activeState.getOrigin()) || 0.0001; + } + }; + + publicAPI.handleRotateStartEvent = (callData, axis) => { + model.rotationManipulator.setHandleOrigin(model.activeState.getOrigin()); + model.rotationManipulator.setHandleNormal(model.activeState.getDirection()); + + // compute unit vector from center of rotation + // to the click point on the plane defined by + // the center of rotation and the rotation normal. + const { worldCoords } = model.rotationManipulator.handleEvent( + callData, + model._apiSpecificRenderWindow + ); + + if (worldCoords.length) { + isDragging = true; + vec3.sub( + rotateState.dragStartVec, + worldCoords, + model.activeState.getOrigin() + ); + vec3.normalize(rotateState.dragStartVec, rotateState.dragStartVec); + + rotateState.startQuat = model.widgetState.getTransform().getRotation(); + } + }; + + publicAPI.handleMouseMove = (callData) => { + if (isDragging && model.pickable) { + return publicAPI.handleEvent(callData); + } + return macro.VOID; + }; + + publicAPI.handleLeftButtonRelease = () => { + if (isDragging && model.pickable) { + model._interactor.cancelAnimation(publicAPI); + } + isDragging = false; + model.widgetState.deactivate(); + }; + + publicAPI.handleEvent = (callData) => { + if (model.pickable && model.activeState && model.activeState.getActive()) { + const [type, axis] = model.activeState.getName().split(':'); + const axisIndex = 'XYZ'.indexOf(axis); + if (type === 'translate') { + return publicAPI.handleTranslateEvent(callData, axis, axisIndex); + } + if (type === 'scale') { + return publicAPI.handleScaleEvent(callData, axis, axisIndex); + } + if (type === 'rotate') { + return publicAPI.handleRotateEvent(callData, axis, axisIndex); + } + } + return macro.VOID; + }; + + publicAPI.handleTranslateEvent = (callData, axis, axisIndex) => { + model.lineManipulator.setHandleOrigin(model.activeState.getOrigin()); + model.lineManipulator.setHandleNormal(model.activeState.getDirection()); + + const { worldCoords } = model.lineManipulator.handleEvent( + callData, + model._apiSpecificRenderWindow + ); + + if (worldCoords.length) { + const positiveDir = [0, 0, 0]; + positiveDir[axisIndex] = 1; + + const toWorldCoords = [0, 0, 0]; + vec3.sub(toWorldCoords, worldCoords, translateState.dragStartCoord); + + const dir = Math.sign(vec3.dot(positiveDir, toWorldCoords)); + const dist = vec3.len(toWorldCoords); + const delta = dir * dist; + + const translation = model.widgetState.getTransform().getTranslation(); + translation[axisIndex] = translateState.startPos + delta; + model.widgetState.getTransform().setTranslation(translation); + } + }; + + publicAPI.handleScaleEvent = (callData, axis, axisIndex) => { + model.lineManipulator.setHandleOrigin(model.activeState.getOrigin()); + model.lineManipulator.setHandleNormal(model.activeState.getDirection()); + + const { worldCoords } = model.lineManipulator.handleEvent( + callData, + model._apiSpecificRenderWindow + ); + + if (worldCoords.length) { + const dist = vec3.dist(model.activeState.getOrigin(), worldCoords); + const scale = + (dist / scaleState.startDistFromOrigin) * scaleState.startScale; + + const scales = model.widgetState.getTransform().getScale(); + scales[axisIndex] = scale; + model.widgetState.getTransform().setScale(scales); + } + }; + + publicAPI.handleRotateEvent = (callData) => { + model.rotationManipulator.setHandleOrigin(model.activeState.getOrigin()); + model.rotationManipulator.setHandleNormal(model.activeState.getDirection()); + + const { worldCoords } = model.rotationManipulator.handleEvent( + callData, + model._apiSpecificRenderWindow + ); + + const curPointerVec = [0, 0, 0]; + if (worldCoords.length) { + vec3.sub(curPointerVec, worldCoords, model.activeState.getOrigin()); + vec3.normalize(curPointerVec, curPointerVec); + + const angle = vec3.angle(rotateState.dragStartVec, curPointerVec); + + const signVec = [0, 0, 0]; + vec3.cross(signVec, curPointerVec, rotateState.dragStartVec); + vec3.normalize(signVec, signVec); + const sign = vec3.dot(signVec, model.activeState.getDirection()); + + const q = quat.create(); + quat.setAxisAngle(q, model.activeState.getDirection(), -sign * angle); + + quat.mul(q, q, rotateState.startQuat); + quat.normalize(q, q); + + // do not amplify fp errors when editing a particular direction + const direction = model.activeState.getDirection(); + model.widgetState.getTransform().setRotation(q); + model.activeState.setDirection(direction); + } + + return macro.EVENT_ABORT; + }; + + // -------------------------------------------------------------------------- + // initialization + // -------------------------------------------------------------------------- + + model.camera = model._renderer.getActiveCamera(); + + model.classHierarchy.push('vtkTransformControlsWidgetProp'); +} diff --git a/Sources/Widgets/Widgets3D/TransformControlsWidget/constants.js b/Sources/Widgets/Widgets3D/TransformControlsWidget/constants.js new file mode 100644 index 00000000000..6482b351c53 --- /dev/null +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/constants.js @@ -0,0 +1,15 @@ +export const ROTATE_HANDLE_PIXEL_SCALE = 240; +export const TRANSLATE_HANDLE_RADIUS = 3; +export const SCALE_HANDLE_RADIUS = 3; +export const SCALE_HANDLE_CUBE_SIDE_LENGTH = 20; +export const SCALE_HANDLE_PIXEL_SCALE = 320; + +export const TransformMode = { + TRANSLATE: 'translate', + SCALE: 'scale', + ROTATE: 'rotate', +}; + +export default { + TransformMode, +}; diff --git a/Sources/Widgets/Widgets3D/TransformControlsWidget/example/index.js b/Sources/Widgets/Widgets3D/TransformControlsWidget/example/index.js new file mode 100644 index 00000000000..91582531440 --- /dev/null +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/example/index.js @@ -0,0 +1,100 @@ +/* eslint-disable */ +// Load the rendering pieces we want to use (for both WebGL and WebGPU) +import '@kitware/vtk.js/Rendering/Profiles/Geometry'; +import '@kitware/vtk.js/Rendering/Profiles/Glyph'; + +import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; +import vtkConeSource from '@kitware/vtk.js/Filters/Sources/ConeSource'; +import vtkFullScreenRenderWindow from '@kitware/vtk.js/Rendering/Misc/FullScreenRenderWindow'; +import vtkMapper from '@kitware/vtk.js/Rendering/Core/Mapper'; +import vtkWidgetManager from '@kitware/vtk.js/Widgets/Core/WidgetManager'; + +import vtkTransformControlsWidget from '@kitware/vtk.js/Widgets/Widgets3D/TransformControlsWidget'; +const { TransformMode } = vtkTransformControlsWidget; + +// ---------------------------------------------------------------------------- +// Standard rendering code setup +// ---------------------------------------------------------------------------- + +const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance({ + background: [0, 0, 0], +}); +const renderer = fullScreenRenderer.getRenderer(); +const renderWindow = fullScreenRenderer.getRenderWindow(); + +// ---------------------------------------------------------------------------- +// Widget manager +// ---------------------------------------------------------------------------- + +const widgetManager = vtkWidgetManager.newInstance(); +widgetManager.setRenderer(renderer); + +const widget = vtkTransformControlsWidget.newInstance(); +const viewWidget = widgetManager.addWidget(widget); +viewWidget.setScaleInPixels(true); + +renderer.resetCamera(); +renderer.resetCameraClippingRange(); +widgetManager.enablePicking(); +fullScreenRenderer.getInteractor().render(); + +// -------- + +const coneSource = vtkConeSource.newInstance({ + center: [0, 0, 0], +}); + +const mapper = vtkMapper.newInstance(); +mapper.setInputConnection(coneSource.getOutputPort()); + +const actor = vtkActor.newInstance(); +actor.setMapper(mapper); +// actor.getProperty().setAmbient(1); +// actor.getProperty().setColor(1, 0, 1); + +renderer.addActor(actor); +renderer.resetCamera(); +renderWindow.render(); + +viewWidget.setActiveScaleFactor(1); +viewWidget.setUseActiveColor(true); +viewWidget.setActiveColor([255, 255, 0]); + +viewWidget.getRepresentations().forEach((rep) => + rep.getActors().forEach((actor) => { + actor.getProperty().setAmbient(1); + }) +); + +widget + .getWidgetState() + .getTransform() + .onModified((state) => { + actor.setPosition(state.getTranslation()); + actor.setScale(state.getScale()); + actor.setOrientationFromQuaternion(state.getRotation()); + }); + +renderer.resetCamera(); +renderWindow.render(); + +global.r = renderer; +global.rw = renderWindow; +global.vw = viewWidget; + +window.onkeydown = (ev) => { + switch (ev.key) { + case 'q': + widget.setMode(TransformMode.ROTATE); + renderWindow.render(); + break; + case 't': + widget.setMode(TransformMode.TRANSLATE); + renderWindow.render(); + break; + case 'x': + widget.setMode(TransformMode.SCALE); + renderWindow.render(); + break; + } +}; diff --git a/Sources/Widgets/Widgets3D/TransformControlsWidget/index.js b/Sources/Widgets/Widgets3D/TransformControlsWidget/index.js new file mode 100644 index 00000000000..c2684d9f1b7 --- /dev/null +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/index.js @@ -0,0 +1,179 @@ +import { mat3 } from 'gl-matrix'; +import macro from 'vtk.js/Sources/macros'; +import vtkAbstractWidgetFactory from 'vtk.js/Sources/Widgets/Core/AbstractWidgetFactory'; + +import vtkTranslateTransformHandleRepresentation from 'vtk.js/Sources/Widgets/Representations/TranslateTransformHandleRepresentation'; +import vtkScaleTransformHandleRepresentation from 'vtk.js/Sources/Widgets/Representations/ScaleTransformHandleRepresentation'; +import vtkRotateTransformHandleRepresentation from 'vtk.js/Sources/Widgets/Representations/RotateTransformHandleRepresentation'; + +import { + TRANSLATE_HANDLE_RADIUS, + SCALE_HANDLE_RADIUS, + SCALE_HANDLE_CUBE_SIDE_LENGTH, + SCALE_HANDLE_PIXEL_SCALE, + TransformMode, +} from './constants'; + +import widgetBehavior from './behavior'; +import stateGenerator from './state'; + +function updateHandleTransforms(widgetState) { + const transformState = widgetState.getTransform(); + + const sx = widgetState.getScaleHandleX(); + const sy = widgetState.getScaleHandleY(); + const sz = widgetState.getScaleHandleZ(); + + const hx = widgetState.getRotateHandleX(); + const hy = widgetState.getRotateHandleY(); + const hz = widgetState.getRotateHandleZ(); + + // translation + widgetState.getStatesWithLabel('handles').forEach((state) => { + state.setOrigin(transformState.getTranslation()); + }); + + // rotation + const m3 = mat3.create(); + mat3.fromQuat(m3, transformState.getRotation()); + + [sx, hx].forEach((state) => { + state.setDirection(m3.slice(0, 3)); + state.setUp(m3.slice(3, 6).map((c) => -c)); + state.setRight(m3.slice(6, 9)); + }); + + [sy, hy].forEach((state) => { + state.setDirection(m3.slice(3, 6)); + state.setUp(m3.slice(6, 9)); + state.setRight(m3.slice(0, 3)); + }); + + [sz, hz].forEach((state) => { + state.setDirection(m3.slice(6, 9)); + state.setUp(m3.slice(3, 6)); + state.setRight(m3.slice(0, 3)); + }); +} + +// ---------------------------------------------------------------------------- +// Factory +// ---------------------------------------------------------------------------- + +function vtkTransformControlsWidget(publicAPI, model) { + model.classHierarchy.push('vtkTransformControlsWidget'); + + // --- Widget Requirement --------------------------------------------------- + + model.behavior = widgetBehavior; + model.widgetState = stateGenerator(); + + model.methodsToLink = [ + 'scaleInPixels', + 'activeScaleFactor', + 'useActiveColor', + 'activeColor', + ]; + + publicAPI.getRepresentationsForViewType = (viewType) => { + switch (viewType) { + default: + return [ + { + builder: vtkTranslateTransformHandleRepresentation, + labels: ['translateHandles'], + initialValues: { + radius: TRANSLATE_HANDLE_RADIUS, + glyphResolution: 12, + coneSource: { + radius: 8, + height: 0.05, + direction: [0, 1, 0], + }, + }, + }, + { + builder: vtkScaleTransformHandleRepresentation, + labels: ['scaleHandles'], + initialValues: { + radius: SCALE_HANDLE_RADIUS, + glyphResolution: 12, + cubeSource: { + xLength: SCALE_HANDLE_CUBE_SIDE_LENGTH, + yLength: + SCALE_HANDLE_CUBE_SIDE_LENGTH / SCALE_HANDLE_PIXEL_SCALE, + zLength: SCALE_HANDLE_CUBE_SIDE_LENGTH, + }, + }, + }, + { + builder: vtkRotateTransformHandleRepresentation, + labels: ['rotateHandles'], + }, + ]; + } + }; + + publicAPI.updateHandleVisibility = () => { + model.widgetState + .getStatesWithLabel('translateHandles') + .forEach((state) => { + state.setVisible(model.mode === 'translate'); + }); + model.widgetState.getStatesWithLabel('scaleHandles').forEach((state) => { + state.setVisible(model.mode === 'scale'); + }); + model.widgetState.getStatesWithLabel('rotateHandles').forEach((state) => { + state.setVisible(model.mode === 'rotate'); + }); + }; + + model._onModeChanged = () => { + publicAPI.updateHandleVisibility(); + }; + + // --- Widget Requirement --------------------------------------------------- + + // sync translation/scale/rotation states to the handle states + const transformSubscription = model.widgetState + .getTransform() + .onModified((state) => { + updateHandleTransforms(model.widgetState); + }); + + publicAPI.delete = macro.chain(publicAPI.delete, () => { + transformSubscription.unsubscribe(); + }); + + updateHandleTransforms(model.widgetState); + publicAPI.updateHandleVisibility(); +} + +// ---------------------------------------------------------------------------- + +const DEFAULT_VALUES = { + mode: TransformMode.TRANSLATE, +}; + +// ---------------------------------------------------------------------------- + +export function extend(publicAPI, model, initialValues = {}) { + Object.assign(model, DEFAULT_VALUES, initialValues); + + vtkAbstractWidgetFactory.extend(publicAPI, model, initialValues); + + macro.setGet(publicAPI, model, ['mode']); + macro.get(publicAPI, model, ['lineManipulator', 'rotateManipulator']); + vtkTransformControlsWidget(publicAPI, model); +} + +// ---------------------------------------------------------------------------- + +export const newInstance = macro.newInstance( + extend, + 'vtkTransformControlsWidget' +); + +// ---------------------------------------------------------------------------- + +export default { newInstance, extend, TransformMode }; diff --git a/Sources/Widgets/Widgets3D/TransformControlsWidget/state.js b/Sources/Widgets/Widgets3D/TransformControlsWidget/state.js new file mode 100644 index 00000000000..d06551ab2ad --- /dev/null +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/state.js @@ -0,0 +1,238 @@ +import vtkStateBuilder from 'vtk.js/Sources/Widgets/Core/StateBuilder'; +import { + ROTATE_HANDLE_PIXEL_SCALE, + SCALE_HANDLE_PIXEL_SCALE, +} from './constants'; + +export default function stateGenerator() { + const transformState = vtkStateBuilder + .createBuilder() + .addField({ + name: 'translation', + initialValue: [0, 0, 0], + }) + .addField({ + name: 'scale', + initialValue: [1, 1, 1], + }) + .addField({ + name: 'rotation', + initialValue: [0, 0, 0, 1], + }) + .build(); + + return ( + vtkStateBuilder + .createBuilder() + .addStateFromInstance({ + labels: [], + name: 'transform', + instance: transformState, + }) + + // translate state + .addStateFromMixin({ + labels: ['handles', 'translateHandles'], + mixins: [ + 'name', + 'origin', + 'color3', + 'scale3', + 'orientation', + 'visible', + ], + name: 'translateHandleZ', + initialValues: { + name: 'translate:Z', + scale3: [1, 1, SCALE_HANDLE_PIXEL_SCALE], + origin: [0, 0, 0], + color3: [0, 255, 0], + // these are fixed to the world axes + up: [0, 1, 0], + right: [1, 0, 0], + direction: [0, 0, 1], + }, + }) + .addStateFromMixin({ + labels: ['handles', 'translateHandles'], + mixins: [ + 'name', + 'origin', + 'color3', + 'scale3', + 'orientation', + 'visible', + ], + name: 'translateHandleX', + initialValues: { + name: 'translate:X', + scale3: [1, 1, SCALE_HANDLE_PIXEL_SCALE], + origin: [0, 0, 0], + color3: [0, 0, 255], + // these are fixed to the world axes + up: [0, 1, 0], + right: [0, 0, -1], + direction: [1, 0, 0], + }, + }) + .addStateFromMixin({ + labels: ['handles', 'translateHandles'], + mixins: [ + 'name', + 'origin', + 'color3', + 'scale3', + 'orientation', + 'visible', + ], + name: 'translateHandleY', + initialValues: { + name: 'translate:Y', + scale3: [1, 1, SCALE_HANDLE_PIXEL_SCALE], + origin: [0, 0, 0], + color3: [255, 0, 0], + // these are fixed to the world axes + up: [0, 0, 1], + right: [1, 0, 0], + direction: [0, 1, 0], + }, + }) + + // scale state + .addStateFromMixin({ + labels: ['handles', 'scaleHandles'], + mixins: [ + 'name', + 'origin', + 'color3', + 'scale3', + 'orientation', + 'visible', + ], + name: 'scaleHandleZ', + initialValues: { + name: 'scale:Z', + scale3: [1, 1, SCALE_HANDLE_PIXEL_SCALE], + origin: [0, 0, 0], + color3: [0, 255, 0], + // these are set via setHandleOrientationsFromQuat + up: [0, 0, 1], + right: [0, 1, 0], + direction: [1, 0, 0], + }, + }) + .addStateFromMixin({ + labels: ['handles', 'scaleHandles'], + mixins: [ + 'name', + 'origin', + 'color3', + 'scale3', + 'orientation', + 'visible', + ], + name: 'scaleHandleX', + initialValues: { + name: 'scale:X', + scale3: [1, 1, SCALE_HANDLE_PIXEL_SCALE], + origin: [0, 0, 0], + color3: [0, 0, 255], + // these are set via setHandleOrientationsFromQuat + up: [1, 0, 0], + right: [0, -1, 0], + direction: [0, 0, 1], + }, + }) + .addStateFromMixin({ + labels: ['handles', 'scaleHandles'], + mixins: [ + 'name', + 'origin', + 'color3', + 'scale3', + 'orientation', + 'visible', + ], + name: 'scaleHandleY', + initialValues: { + name: 'scale:Y', + scale3: [1, 1, SCALE_HANDLE_PIXEL_SCALE], + origin: [0, 0, 0], + color3: [255, 0, 0], + // these are set via setHandleOrientationsFromQuat + up: [0, 1, 0], + right: [1, 0, 0], + direction: [0, 0, 1], + }, + }) + + // rotation state + .addStateFromMixin({ + labels: ['handles', 'rotateHandles'], + mixins: [ + 'name', + 'origin', + 'color3', + 'scale1', + 'orientation', + 'visible', + ], + name: 'rotateHandleZ', + initialValues: { + name: 'rotate:Z', + scale1: ROTATE_HANDLE_PIXEL_SCALE, + origin: [0, 0, 0], + color3: [0, 255, 0], + // these are set via setHandleOrientationsFromQuat + up: [0, 1, 0], + right: [1, 0, 0], + direction: [0, 0, 1], + }, + }) + .addStateFromMixin({ + labels: ['handles', 'rotateHandles'], + mixins: [ + 'name', + 'origin', + 'color3', + 'scale1', + 'orientation', + 'visible', + ], + name: 'rotateHandleX', + initialValues: { + name: 'rotate:X', + scale1: ROTATE_HANDLE_PIXEL_SCALE, + origin: [0, 0, 0], + color3: [0, 0, 255], + // these are set via setHandleOrientationsFromQuat + up: [0, 1, 0], + right: [0, 0, -1], + direction: [1, 0, 0], + }, + }) + .addStateFromMixin({ + labels: ['handles', 'rotateHandles'], + mixins: [ + 'name', + 'origin', + 'color3', + 'scale1', + 'orientation', + 'visible', + ], + name: 'rotateHandleY', + initialValues: { + name: 'rotate:Y', + scale1: ROTATE_HANDLE_PIXEL_SCALE, + origin: [0, 0, 0], + color3: [255, 0, 0], + // these are set via setHandleOrientationsFromQuat + up: [0, 0, 1], + right: [1, 0, 0], + direction: [0, 1, 0], + }, + }) + .build() + ); +} diff --git a/Sources/Widgets/Widgets3D/index.js b/Sources/Widgets/Widgets3D/index.js index 16d114e04f5..e94482e686e 100644 --- a/Sources/Widgets/Widgets3D/index.js +++ b/Sources/Widgets/Widgets3D/index.js @@ -12,6 +12,7 @@ import vtkResliceCursorWidget from './ResliceCursorWidget'; import vtkShapeWidget from './ShapeWidget'; import vtkSphereWidget from './SphereWidget'; import vtkSplineWidget from './SplineWidget'; +import vtkTransformControlsWidget from './TransformControlsWidget'; export default { vtkAngleWidget, @@ -28,4 +29,5 @@ export default { vtkShapeWidget, vtkSphereWidget, vtkSplineWidget, + vtkTransformControlsWidget, };