From 87fd840ef4358d2059ce241d8f38c22b6273dc9c Mon Sep 17 00:00:00 2001 From: Forrest Date: Wed, 9 Jul 2025 10:16:52 -0400 Subject: [PATCH 1/4] feat(TransformControlsWidget): add new widget --- Sources/Filters/Sources/TorusSource/index.js | 125 +++++++++ Sources/Filters/Sources/index.js | 2 + Sources/Rendering/Core/Prop3D/index.js | 5 + .../index.js | 44 ++++ .../index.js | 59 +++++ .../TransformHandleSource.js | 107 ++++++++ .../index.js | 59 +++++ Sources/Widgets/Representations/index.js | 6 + .../TransformControlsWidget/behavior.js | 239 ++++++++++++++++++ .../TransformControlsWidget/constants.js | 5 + .../TransformControlsWidget/example/index.js | 101 ++++++++ .../TransformControlsWidget/index.js | 181 +++++++++++++ .../TransformControlsWidget/state.js | 238 +++++++++++++++++ Sources/Widgets/Widgets3D/index.js | 2 + 14 files changed, 1173 insertions(+) create mode 100644 Sources/Filters/Sources/TorusSource/index.js create mode 100644 Sources/Widgets/Representations/RotateTransformHandleRepresentation/index.js create mode 100644 Sources/Widgets/Representations/ScaleTransformHandleRepresentation/index.js create mode 100644 Sources/Widgets/Representations/TranslateTransformHandleRepresentation/TransformHandleSource.js create mode 100644 Sources/Widgets/Representations/TranslateTransformHandleRepresentation/index.js create mode 100644 Sources/Widgets/Widgets3D/TransformControlsWidget/behavior.js create mode 100644 Sources/Widgets/Widgets3D/TransformControlsWidget/constants.js create mode 100644 Sources/Widgets/Widgets3D/TransformControlsWidget/example/index.js create mode 100644 Sources/Widgets/Widgets3D/TransformControlsWidget/index.js create mode 100644 Sources/Widgets/Widgets3D/TransformControlsWidget/state.js diff --git a/Sources/Filters/Sources/TorusSource/index.js b/Sources/Filters/Sources/TorusSource/index.js new file mode 100644 index 00000000000..0e6d912965b --- /dev/null +++ b/Sources/Filters/Sources/TorusSource/index.js @@ -0,0 +1,125 @@ +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) { + if (model.deleted) { + return; + } + + 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; + for (let ri = 0; ri <= model.resolution; ri++) { + const u = (ri / model.resolution) * model.arcLength; + points[pointIdx++] = + (model.radius + model.tubeRadius * Math.cos(v)) * Math.cos(u); + points[pointIdx++] = + (model.radius + model.tubeRadius * Math.cos(v)) * Math.sin(u); + points[pointIdx++] = model.tubeRadius * Math.sin(v); + } + } + + // 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..e2ac46f07bd 100644 --- a/Sources/Rendering/Core/Prop3D/index.js +++ b/Sources/Rendering/Core/Prop3D/index.js @@ -116,6 +116,11 @@ function vtkProp3D(publicAPI, model) { return true; }; + publicAPI.setOrientationFromQuaternion = (q) => { + mat4.fromQuat(model.rotation, q); + publicAPI.modified(); + }; + 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..ac8f9848dcc --- /dev/null +++ b/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/TransformHandleSource.js @@ -0,0 +1,107 @@ +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.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) { + if (model.deleted) { + return; + } + + 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, 1, 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..f4431272d73 --- /dev/null +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/behavior.js @@ -0,0 +1,239 @@ +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() + ); + } + }; + + 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..8b213fd1918 --- /dev/null +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/constants.js @@ -0,0 +1,5 @@ +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; diff --git a/Sources/Widgets/Widgets3D/TransformControlsWidget/example/index.js b/Sources/Widgets/Widgets3D/TransformControlsWidget/example/index.js new file mode 100644 index 00000000000..89d0d804a4c --- /dev/null +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/example/index.js @@ -0,0 +1,101 @@ +/* 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'; + +// ---------------------------------------------------------------------------- +// 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); + actor.getProperty().setSpecular(0); + actor.getProperty().setDiffuse(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('rotate'); + renderWindow.render(); + break; + case 't': + widget.setMode('translate'); + renderWindow.render(); + break; + case 'x': + widget.setMode('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..dbd5721342d --- /dev/null +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/index.js @@ -0,0 +1,181 @@ +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, +} 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'); + }); + }; + + const parentSetMode = publicAPI.setMode; + publicAPI.setMode = (mode) => { + if (parentSetMode(mode)) { + 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: '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 }; 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, }; From 7dd518eb96af761c9068c1cbd6881a6c4914d169 Mon Sep 17 00:00:00 2001 From: Forrest Date: Mon, 28 Jul 2025 12:08:48 -0400 Subject: [PATCH 2/4] fix(TransformControlsWidget): address reviews --- Sources/Filters/Sources/TorusSource/index.js | 12 +++++------- Sources/Rendering/Core/Prop3D/index.js | 10 ++++++++-- .../TransformHandleSource.js | 8 +++----- .../Widgets3D/TransformControlsWidget/behavior.js | 8 +++----- .../Widgets3D/TransformControlsWidget/constants.js | 10 ++++++++++ .../TransformControlsWidget/example/index.js | 9 ++++----- .../Widgets3D/TransformControlsWidget/index.js | 12 +++++------- 7 files changed, 38 insertions(+), 31 deletions(-) diff --git a/Sources/Filters/Sources/TorusSource/index.js b/Sources/Filters/Sources/TorusSource/index.js index 0e6d912965b..dad7e4b3529 100644 --- a/Sources/Filters/Sources/TorusSource/index.js +++ b/Sources/Filters/Sources/TorusSource/index.js @@ -14,10 +14,6 @@ function vtkTorusSource(publicAPI, model) { model.classHierarchy.push('vtkTorusSource'); function requestData(inData, outData) { - if (model.deleted) { - return; - } - let dataset = outData[0]; // Points @@ -29,13 +25,15 @@ function vtkTorusSource(publicAPI, model) { 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 * Math.cos(v)) * Math.cos(u); + (model.radius + model.tubeRadius * cosV) * Math.cos(u); points[pointIdx++] = - (model.radius + model.tubeRadius * Math.cos(v)) * Math.sin(u); - points[pointIdx++] = model.tubeRadius * Math.sin(v); + (model.radius + model.tubeRadius * cosV) * Math.sin(u); + points[pointIdx++] = model.tubeRadius * sinV; } } diff --git a/Sources/Rendering/Core/Prop3D/index.js b/Sources/Rendering/Core/Prop3D/index.js index e2ac46f07bd..748b1972398 100644 --- a/Sources/Rendering/Core/Prop3D/index.js +++ b/Sources/Rendering/Core/Prop3D/index.js @@ -117,8 +117,14 @@ function vtkProp3D(publicAPI, model) { }; publicAPI.setOrientationFromQuaternion = (q) => { - mat4.fromQuat(model.rotation, q); - publicAPI.modified(); + 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) => { diff --git a/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/TransformHandleSource.js b/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/TransformHandleSource.js index ac8f9848dcc..c6f32f538dd 100644 --- a/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/TransformHandleSource.js +++ b/Sources/Widgets/Representations/TranslateTransformHandleRepresentation/TransformHandleSource.js @@ -11,8 +11,10 @@ function rotatePolyData(pd, direction) { .rotateFromDirections([0, 1, 0], direction) .apply(points); + pd.getPoints().modified(); pd.modified(); } + function translatePolyData(pd, translation) { const points = pd.getPoints().getData(); @@ -29,10 +31,6 @@ function vtkTransformHandleSource(publicAPI, model) { model.classHierarchy.push('vtkTransformHandleSource'); function requestData(inData, outData) { - if (model.deleted) { - return; - } - const cylinderSource = vtkCylinderSource.newInstance({ height: model.height, initAngle: model.initAngle, @@ -90,7 +88,7 @@ export function extend(publicAPI, model, initialValues = {}) { Object.assign(model, DEFAULT_VALUES, initialValues); vtkCylinderSource.extend(publicAPI, model, initialValues); - macro.algo(publicAPI, model, 1, 1); + macro.algo(publicAPI, model, 2, 1); vtkTransformHandleSource(publicAPI, model); } diff --git a/Sources/Widgets/Widgets3D/TransformControlsWidget/behavior.js b/Sources/Widgets/Widgets3D/TransformControlsWidget/behavior.js index f4431272d73..0b93ab1ebf3 100644 --- a/Sources/Widgets/Widgets3D/TransformControlsWidget/behavior.js +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/behavior.js @@ -23,7 +23,7 @@ export default function widgetBehavior(publicAPI, model) { dragStartCoord: [0, 0, 0], }; - publicAPI.getBounds = () => [vtkBoundingBox.INIT_BOUNDS]; + publicAPI.getBounds = () => [...vtkBoundingBox.INIT_BOUNDS]; publicAPI.setDisplayCallback = (callback) => model.representations[0].setDisplayCallback(callback); @@ -83,10 +83,8 @@ export default function widgetBehavior(publicAPI, model) { scaleState.startScale = model.widgetState.getTransform().getScale()[ axisIndex ]; - scaleState.startDistFromOrigin = vec3.dist( - worldCoords, - model.activeState.getOrigin() - ); + scaleState.startDistFromOrigin = + vec3.dist(worldCoords, model.activeState.getOrigin()) || 0.0001; } }; diff --git a/Sources/Widgets/Widgets3D/TransformControlsWidget/constants.js b/Sources/Widgets/Widgets3D/TransformControlsWidget/constants.js index 8b213fd1918..6482b351c53 100644 --- a/Sources/Widgets/Widgets3D/TransformControlsWidget/constants.js +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/constants.js @@ -3,3 +3,13 @@ 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 index 89d0d804a4c..91582531440 100644 --- a/Sources/Widgets/Widgets3D/TransformControlsWidget/example/index.js +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/example/index.js @@ -10,6 +10,7 @@ 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 @@ -62,8 +63,6 @@ viewWidget.setActiveColor([255, 255, 0]); viewWidget.getRepresentations().forEach((rep) => rep.getActors().forEach((actor) => { actor.getProperty().setAmbient(1); - actor.getProperty().setSpecular(0); - actor.getProperty().setDiffuse(1); }) ); @@ -86,15 +85,15 @@ global.vw = viewWidget; window.onkeydown = (ev) => { switch (ev.key) { case 'q': - widget.setMode('rotate'); + widget.setMode(TransformMode.ROTATE); renderWindow.render(); break; case 't': - widget.setMode('translate'); + widget.setMode(TransformMode.TRANSLATE); renderWindow.render(); break; case 'x': - widget.setMode('scale'); + widget.setMode(TransformMode.SCALE); renderWindow.render(); break; } diff --git a/Sources/Widgets/Widgets3D/TransformControlsWidget/index.js b/Sources/Widgets/Widgets3D/TransformControlsWidget/index.js index dbd5721342d..c2684d9f1b7 100644 --- a/Sources/Widgets/Widgets3D/TransformControlsWidget/index.js +++ b/Sources/Widgets/Widgets3D/TransformControlsWidget/index.js @@ -11,6 +11,7 @@ import { SCALE_HANDLE_RADIUS, SCALE_HANDLE_CUBE_SIDE_LENGTH, SCALE_HANDLE_PIXEL_SCALE, + TransformMode, } from './constants'; import widgetBehavior from './behavior'; @@ -127,11 +128,8 @@ function vtkTransformControlsWidget(publicAPI, model) { }); }; - const parentSetMode = publicAPI.setMode; - publicAPI.setMode = (mode) => { - if (parentSetMode(mode)) { - publicAPI.updateHandleVisibility(); - } + model._onModeChanged = () => { + publicAPI.updateHandleVisibility(); }; // --- Widget Requirement --------------------------------------------------- @@ -154,7 +152,7 @@ function vtkTransformControlsWidget(publicAPI, model) { // ---------------------------------------------------------------------------- const DEFAULT_VALUES = { - mode: 'translate', + mode: TransformMode.TRANSLATE, }; // ---------------------------------------------------------------------------- @@ -178,4 +176,4 @@ export const newInstance = macro.newInstance( // ---------------------------------------------------------------------------- -export default { newInstance, extend }; +export default { newInstance, extend, TransformMode }; From 66911be25c0b7de8a3b4b4c38bffedaa71af4f24 Mon Sep 17 00:00:00 2001 From: Forrest Date: Wed, 20 Aug 2025 17:34:43 -0400 Subject: [PATCH 3/4] docs(examples): fix examples --- Documentation/content/examples/index.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Documentation/content/examples/index.md b/Documentation/content/examples/index.md index 740eab8b1aa..7662b88be99 100644 --- a/Documentation/content/examples/index.md +++ b/Documentation/content/examples/index.md @@ -149,7 +149,7 @@ This will allow you to see the some live code running in your browser. Just pick [![WarpScalar Example][WarpScalargif]](./WarpScalar.html "WarpScalar") [![WindowedSincPolyDataFilter Example][WindowedSincPolyDataFilter]](./WindowedSincPolyDataFilter.html "WindowedSincPolyDataFilter") -
+
[ArrowSource]: ../docs/gallery/ArrowSource.jpg [CircleSource]: ../docs/gallery/CircleSource.jpg @@ -342,6 +342,7 @@ This will allow you to see the some live code running in your browser. Just pick [InteractorStyleTrackballCamera]: ../docs/gallery/InteractorStyleTrackballCamera.jpg [InteractorStyleUnicam]: ../docs/gallery/InteractorStyleUnicam.jpg [KeyboardCameraManipulator]: ../docs/gallery/KeyboardCameraManipulator.jpg +[KeyPressEvents]: ../docs/gallery/KeyPresEvents.jpg [MouseRangeManipulator]: ../docs/gallery/MouseRangeManipulator.jpg [PiecewiseGaussianWidget]: ../docs/gallery/PiecewiseGaussianWidget.jpg [TimeStepBasedAnimationHandler]: ../docs/gallery/TimeStepBasedAnimationHandler.gif From f1f9ff821f0cf13bbc38a31281e71cd7f0791b42 Mon Sep 17 00:00:00 2001 From: Forrest Date: Wed, 20 Aug 2025 17:42:58 -0400 Subject: [PATCH 4/4] docs(examples): add TransformControls link --- .../docs/gallery/TransformControlsWidget.png | Bin 0 -> 25945 bytes Documentation/content/examples/index.md | 2 ++ 2 files changed, 2 insertions(+) create mode 100644 Documentation/content/docs/gallery/TransformControlsWidget.png diff --git a/Documentation/content/docs/gallery/TransformControlsWidget.png b/Documentation/content/docs/gallery/TransformControlsWidget.png new file mode 100644 index 0000000000000000000000000000000000000000..83acaa7bd28e5f4270157ec20f05cf5a95ec1efb GIT binary patch literal 25945 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 kO8$iwm{$A$grF_+3d?AHeQxa @@ -383,6 +384,7 @@ This will allow you to see the some live code running in your browser. Just pick [SphereWidget]: ../docs/gallery/SphereWidget.jpg [SplineWidget]: ../docs/gallery/SplineWidget.gif [Box]: ../docs/gallery/Box.jpg +[TransformControlsWidget]: ../docs/gallery/TransformControlsWidget.png # Connectivity