diff --git a/Examples/Volume/VolumePicker/controlPanel.html b/Examples/Volume/VolumePicker/controlPanel.html new file mode 100644 index 00000000000..6b77ba13fd9 --- /dev/null +++ b/Examples/Volume/VolumePicker/controlPanel.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + +
Clip Plane 1
+ Position + + + Rotation + +
Clip Plane 2
+ Position + + + Rotation + +
diff --git a/Examples/Volume/VolumePicker/index.js b/Examples/Volume/VolumePicker/index.js new file mode 100644 index 00000000000..534a2e2c040 --- /dev/null +++ b/Examples/Volume/VolumePicker/index.js @@ -0,0 +1,248 @@ +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 '@kitware/vtk.js/Rendering/Profiles/Volume'; + +// Force DataAccessHelper to have access to various data source +import '@kitware/vtk.js/IO/Core/DataAccessHelper/HtmlDataAccessHelper'; +import '@kitware/vtk.js/IO/Core/DataAccessHelper/HttpDataAccessHelper'; +import '@kitware/vtk.js/IO/Core/DataAccessHelper/JSZipDataAccessHelper'; + +import vtkColorTransferFunction from '@kitware/vtk.js/Rendering/Core/ColorTransferFunction'; +import vtkFullScreenRenderWindow from '@kitware/vtk.js/Rendering/Misc/FullScreenRenderWindow'; +import vtkHttpDataSetReader from '@kitware/vtk.js/IO/Core/HttpDataSetReader'; +import vtkPiecewiseFunction from '@kitware/vtk.js/Common/DataModel/PiecewiseFunction'; +import vtkVolume from '@kitware/vtk.js/Rendering/Core/Volume'; +import vtkVolumeMapper from '@kitware/vtk.js/Rendering/Core/VolumeMapper'; +import vtkPlane from '@kitware/vtk.js/Common/DataModel/Plane'; +import vtkMatrixBuilder from '@kitware/vtk.js/Common/Core/MatrixBuilder'; +import vtkActor from '@kitware/vtk.js/Rendering/Core/Actor'; +import vtkMapper from '@kitware/vtk.js/Rendering/Core/Mapper'; +import vtkSphereSource from '@kitware/vtk.js/Filters/Sources/SphereSource'; + +import controlPanel from './controlPanel.html'; +import vtkCellPicker from '../../../Sources/Rendering/Core/CellPicker/index'; + +// ---------------------------------------------------------------------------- +// Standard rendering code setup +// ---------------------------------------------------------------------------- + +const fullScreenRenderer = vtkFullScreenRenderWindow.newInstance(); +const renderer = fullScreenRenderer.getRenderer(); +const renderWindow = fullScreenRenderer.getRenderWindow(); + +fullScreenRenderer.addController(controlPanel); + +// ---------------------------------------------------------------------------- +// Example code +// ---------------------------------------------------------------------------- +// Server is not sending the .gz and with the compress header +// Need to fetch the true file name and uncompress it locally +// ---------------------------------------------------------------------------- + +const reader = vtkHttpDataSetReader.newInstance({ fetchGzip: true }); + +const actor = vtkVolume.newInstance(); +const mapper = vtkVolumeMapper.newInstance(); +// mapper.setSampleDistance(1.1); +actor.setMapper(mapper); + +const clipPlane1 = vtkPlane.newInstance(); +const clipPlane2 = vtkPlane.newInstance(); +let clipPlane1Position = 0; +let clipPlane2Position = 0; +let clipPlane1RotationAngle = 0; +let clipPlane2RotationAngle = 0; +const clipPlane1Normal = [-1, 1, 0]; +const clipPlane2Normal = [0, 0, 1]; +const rotationNormal = [0, 1, 0]; + +// create color and opacity transfer functions +const ctfun = vtkColorTransferFunction.newInstance(); +ctfun.addRGBPoint(0, 85 / 255.0, 0, 0); +ctfun.addRGBPoint(95, 1.0, 1.0, 1.0); +ctfun.addRGBPoint(225, 0.66, 0.66, 0.5); +ctfun.addRGBPoint(255, 0.3, 1.0, 0.5); +const ofun = vtkPiecewiseFunction.newInstance(); +ofun.addPoint(0.0, 0.0); +ofun.addPoint(255.0, 1.0); +actor.getProperty().setRGBTransferFunction(0, ctfun); +actor.getProperty().setScalarOpacity(0, ofun); +actor.getProperty().setScalarOpacityUnitDistance(0, 3.0); +actor.getProperty().setInterpolationTypeToLinear(); +actor.getProperty().setUseGradientOpacity(0, true); +actor.getProperty().setGradientOpacityMinimumValue(0, 2); +actor.getProperty().setGradientOpacityMinimumOpacity(0, 0.0); +actor.getProperty().setGradientOpacityMaximumValue(0, 20); +actor.getProperty().setGradientOpacityMaximumOpacity(0, 1.0); +actor.getProperty().setShade(true); +actor.getProperty().setAmbient(0.2); +actor.getProperty().setDiffuse(0.7); +actor.getProperty().setSpecular(0.3); +actor.getProperty().setSpecularPower(8.0); + +mapper.setInputConnection(reader.getOutputPort()); + +reader.setUrl(`${__BASE_PATH__}/data/volume/headsq.vti`).then(() => { + reader.loadData().then(() => { + const data = reader.getOutputData(); + const extent = data.getExtent(); + const spacing = data.getSpacing(); + const sizeX = extent[1] * spacing[0]; + const sizeY = extent[3] * spacing[1]; + + clipPlane1Position = sizeX / 4; + clipPlane2Position = sizeY / 2; + const clipPlane1Origin = [ + clipPlane1Position * clipPlane1Normal[0], + clipPlane1Position * clipPlane1Normal[1], + clipPlane1Position * clipPlane1Normal[2], + ]; + const clipPlane2Origin = [ + clipPlane2Position * clipPlane2Normal[0], + clipPlane2Position * clipPlane2Normal[1], + clipPlane2Position * clipPlane2Normal[2], + ]; + + clipPlane1.setNormal(clipPlane1Normal); + clipPlane1.setOrigin(clipPlane1Origin); + clipPlane2.setNormal(clipPlane2Normal); + clipPlane2.setOrigin(clipPlane2Origin); + mapper.addClippingPlane(clipPlane1); + mapper.addClippingPlane(clipPlane2); + + renderer.addVolume(actor); + const interactor = renderWindow.getInteractor(); + interactor.setDesiredUpdateRate(15.0); + renderer.resetCamera(); + renderer.getActiveCamera().elevation(70); + renderWindow.render(); + + let el = document.querySelector('.plane1Position'); + el.setAttribute('min', -sizeX); + el.setAttribute('max', sizeX); + el.setAttribute('value', clipPlane1Position); + + el = document.querySelector('.plane2Position'); + el.setAttribute('min', -sizeY); + el.setAttribute('max', sizeY); + el.setAttribute('value', clipPlane2Position); + + el = document.querySelector('.plane1Rotation'); + el.setAttribute('min', 0); + el.setAttribute('max', 180); + el.setAttribute('value', clipPlane1RotationAngle); + + el = document.querySelector('.plane2Rotation'); + el.setAttribute('min', 0); + el.setAttribute('max', 180); + el.setAttribute('value', clipPlane2RotationAngle); + }); +}); + +document.querySelector('.plane1Position').addEventListener('input', (e) => { + clipPlane1Position = Number(e.target.value); + const clipPlane1Origin = [ + clipPlane1Position * clipPlane1Normal[0], + clipPlane1Position * clipPlane1Normal[1], + clipPlane1Position * clipPlane1Normal[2], + ]; + clipPlane1.setOrigin(clipPlane1Origin); + renderWindow.render(); +}); + +document.querySelector('.plane1Rotation').addEventListener('input', (e) => { + const changedDegree = Number(e.target.value) - clipPlane1RotationAngle; + clipPlane1RotationAngle = Number(e.target.value); + vtkMatrixBuilder + .buildFromDegree() + .rotate(changedDegree, rotationNormal) + .apply(clipPlane1Normal); + clipPlane1.setNormal(clipPlane1Normal); + renderWindow.render(); +}); + +document.querySelector('.plane2Position').addEventListener('input', (e) => { + clipPlane2Position = Number(e.target.value); + const clipPlane2Origin = [ + clipPlane2Position * clipPlane2Normal[0], + clipPlane2Position * clipPlane2Normal[1], + clipPlane2Position * clipPlane2Normal[2], + ]; + clipPlane2.setOrigin(clipPlane2Origin); + renderWindow.render(); +}); + +document.querySelector('.plane2Rotation').addEventListener('input', (e) => { + const changedDegree = Number(e.target.value) - clipPlane2RotationAngle; + clipPlane2RotationAngle = Number(e.target.value); + vtkMatrixBuilder + .buildFromDegree() + .rotate(changedDegree, rotationNormal) + .apply(clipPlane2Normal); + clipPlane2.setNormal(clipPlane2Normal); + renderWindow.render(); +}); + +const picker = vtkCellPicker.newInstance({ opacityThreshold: 0.0001 }); +picker.setPickFromList(1); +picker.setTolerance(0); +picker.initializePickList(); +picker.addPickList(actor); + +// Pick on mouse right click +renderWindow.getInteractor().onRightButtonPress((callData) => { + if (renderer !== callData.pokedRenderer) { + return; + } + + const pos = callData.position; + const point = [pos.x, pos.y, 0.0]; + console.log(`Pick at: ${point}`); + picker.pick(point, renderer); + + const pickedPoints = picker.getPickedPositions(); + for (let i = 0; i < pickedPoints.length; i++) { + const pickedPoint = pickedPoints[i]; + console.log(`Picked: ${pickedPoint}`); + const sphere = vtkSphereSource.newInstance(); + sphere.setCenter(pickedPoint); + sphere.setRadius(5); + const sphereMapper = vtkMapper.newInstance(); + sphereMapper.setInputData(sphere.getOutputData()); + const sphereActor = vtkActor.newInstance(); + sphereActor.setMapper(sphereMapper); + sphereActor.getProperty().setColor(0.0, 0.0, 1.0); + renderer.addActor(sphereActor); + } + renderWindow.render(); +}); + +function setOpacityFromSlider(opacityValue) { + picker.setOpacityThreshold(opacityValue); +} + +const opacity = document.getElementById('opacity'); +const opacityLabel = document.getElementById('opacityLabel'); + +opacity.addEventListener('input', () => { + setOpacityFromSlider(Number.parseFloat(opacity.value, 10)); + opacityLabel.innerHTML = `Opacity ( ${opacity.value} )`; +}); + +// ----------------------------------------------------------- +// Make some variables global so that you can inspect and +// modify objects in your browser's developer console: +// ----------------------------------------------------------- + +global.source = reader; +global.mapper = mapper; +global.actor = actor; +global.ctfun = ctfun; +global.ofun = ofun; +global.renderer = renderer; +global.renderWindow = renderWindow; +global.clipPlane1 = clipPlane1; +global.clipPlane2 = clipPlane2; +global.picker = renderWindow.getInteractor().getPicker(); diff --git a/Sources/Rendering/Core/CellPicker/index.d.ts b/Sources/Rendering/Core/CellPicker/index.d.ts index 52ac5260e8d..a15a1a69d77 100755 --- a/Sources/Rendering/Core/CellPicker/index.d.ts +++ b/Sources/Rendering/Core/CellPicker/index.d.ts @@ -13,6 +13,7 @@ export interface ICellPickerInitialValues extends IPickerInitialValues { cellIJK?: number[]; pickNormal?: number[]; mapperNormal?: number[]; + opacityThreshold?:number; } export interface vtkCellPicker extends vtkPicker { @@ -42,6 +43,16 @@ export interface vtkCellPicker extends vtkPicker { */ getMapperNormalByReference(): number[]; + /** + * Get the opacity threshold for volume picking + */ + getOpacityThreshold(): number; + + /** + * Get the opacity threshold for volume picking + */ + setOpacityThreshold(value: number); + /** * Get the parametric coordinates of the picked cell. */ diff --git a/Sources/Rendering/Core/CellPicker/index.js b/Sources/Rendering/Core/CellPicker/index.js index fb6a4b81658..ae52d5c8586 100644 --- a/Sources/Rendering/Core/CellPicker/index.js +++ b/Sources/Rendering/Core/CellPicker/index.js @@ -7,7 +7,8 @@ import vtkTriangle from 'vtk.js/Sources/Common/DataModel/Triangle'; import vtkQuad from 'vtk.js/Sources/Common/DataModel/Quad'; import * as vtkMath from 'vtk.js/Sources/Common/Core/Math'; import { CellType } from 'vtk.js/Sources/Common/DataModel/CellTypes/Constants'; -import { vec3 } from 'gl-matrix'; +import { vec3, vec4 } from 'gl-matrix'; +import vtkMatrixBuilder from 'vtk.js/Sources/Common/Core/MatrixBuilder'; // ---------------------------------------------------------------------------- // Global methods @@ -170,7 +171,7 @@ function vtkCellPicker(publicAPI, model) { return pickResult; }; - publicAPI.intersectWithLine = (p1, p2, tol, mapper) => { + publicAPI.intersectWithLine = (p1, p2, tol, actor, mapper) => { let tMin = Number.MAX_VALUE; const t1 = 0.0; const t2 = 1.0; @@ -196,6 +197,15 @@ function vtkCellPicker(publicAPI, model) { model.cellIJK = pickData.ijk; model.pCoords = pickData.pCoords; } + } else if (mapper.isA('vtkVolumeMapper')) { + tMin = publicAPI.intersectVolumeWithLine( + p1, + p2, + clipLine.t1, + clipLine.t2, + tol, + actor + ); } else if (mapper.isA('vtkMapper')) { tMin = publicAPI.intersectActorWithLine(p1, p2, t1, t2, tol, mapper); } @@ -244,6 +254,124 @@ function vtkCellPicker(publicAPI, model) { return tMin; }; + publicAPI.intersectVolumeWithLine = (p1, p2, t1, t2, tol, volume) => { + let tMin = Number.MAX_VALUE; + const mapper = volume.getMapper(); + const imageData = mapper.getInputData(); + const origin = imageData.getOrigin(); + const spacing = imageData.getSpacing(); + const dims = imageData.getDimensions(); + const scalars = imageData.getPointData().getScalars().getData(); + const extent = imageData.getExtent(); + const direction = imageData.getDirection(); + let imageTransform; + if (!vtkMath.isIdentity3x3(direction)) { + imageTransform = vtkMatrixBuilder + .buildFromRadian() + .translate(origin[0], origin[1], origin[2]) + .multiply3x3(direction) + .translate(-origin[0], -origin[1], -origin[2]) + .invert() + .getMatrix(); + } + + // calculate opacity table + const numIComps = 1; + const oWidth = 1024; + const tmpTable = new Float32Array(oWidth); + const opacityArray = new Float32Array(oWidth); + let ofun; + let oRange; + const sampleDist = volume.getMapper().getSampleDistance(); + + for (let c = 0; c < numIComps; ++c) { + ofun = volume.getProperty().getScalarOpacity(c); + oRange = ofun.getRange(); + ofun.getTable(oRange[0], oRange[1], oWidth, tmpTable, 1); + const opacityFactor = + sampleDist / volume.getProperty().getScalarOpacityUnitDistance(c); + + // adjust for sample distance etc + for (let i = 0; i < oWidth; ++i) { + opacityArray[i] = 1.0 - (1.0 - tmpTable[i]) ** opacityFactor; + } + } + const scale = oWidth / (oRange[1] - oRange[0] + 1); + + // Make a new p1 and p2 using the clipped t1 and t2 + const q1 = [0, 0, 0]; + const q2 = [0, 0, 0]; + q1[0] = p1[0]; + q1[1] = p1[1]; + q1[2] = p1[2]; + q2[0] = p2[0]; + q2[1] = p2[1]; + q2[2] = p2[2]; + if (t1 !== 0.0 || t2 !== 1.0) { + for (let j = 0; j < 3; j++) { + q1[j] = p1[j] * (1.0 - t1) + p2[j] * t1; + q2[j] = p1[j] * (1.0 - t2) + p2[j] * t2; + } + } + + const x1 = [0, 0, 0]; + const x2 = [0, 0, 0]; + for (let i = 0; i < 3; i++) { + x1[i] = (p1[i] - origin[i]) / spacing[i]; + x2[i] = (p2[i] - origin[i]) / spacing[i]; + } + const x = [0, 0, 0, 0]; + const xi = [0, 0, 0]; + + const sliceSize = dims[1] * dims[0]; + const rowSize = dims[0]; + const nSteps = 100; + let insideVolume; + for (let t = t1; t < t2; t += 1 / nSteps) { + // calculate the location of the point + insideVolume = true; + for (let j = 0; j < 3; j++) { + // "t" is the fractional distance between endpoints x1 and x2 + x[j] = x1[j] * (1.0 - t) + x2[j] * t; + } + x[3] = 1.0; + if (imageTransform) { + vec4.transformMat4(x, x, imageTransform); + } + + for (let j = 0; j < 3; j++) { + // Bounds check + if (x[j] < extent[2 * j]) { + x[j] = extent[2 * j]; + insideVolume = false; + } else if (x[j] > extent[2 * j + 1]) { + x[j] = extent[2 * j + 1]; + insideVolume = false; + } + + xi[j] = Math.round(x[j]); + } + + if (insideVolume) { + const index = xi[2] * sliceSize + xi[1] * rowSize + xi[0]; + let value = scalars[index]; + if (value < oRange[0]) { + value = oRange[0]; + } else if (value > oRange[1]) { + value = oRange[1]; + } + value = Math.floor((value - oRange[0]) * scale); + const opacity = tmpTable[value]; + if (opacity > model.opacityThreshold) { + tMin = t; + break; + } + } + } + + return tMin; + }; + publicAPI.intersectActorWithLine = (p1, p2, t1, t2, tol, mapper) => { let tMin = Number.MAX_VALUE; const minXYZ = [0, 0, 0]; @@ -427,6 +555,7 @@ const DEFAULT_VALUES = { cellIJK: [], pickNormal: [], mapperNormal: [], + opacityThreshold: 0.2, }; // ---------------------------------------------------------------------------- @@ -443,6 +572,9 @@ export function extend(publicAPI, model, initialValues = {}) { 'pCoords', 'cellIJK', ]); + + macro.setGet(publicAPI, model, ['opacityThreshold']); + macro.get(publicAPI, model, ['cellId']); // Object methods diff --git a/Sources/Rendering/Core/Picker/index.js b/Sources/Rendering/Core/Picker/index.js index 6b5c66a30a5..9065bfe1f65 100644 --- a/Sources/Rendering/Core/Picker/index.js +++ b/Sources/Rendering/Core/Picker/index.js @@ -236,7 +236,8 @@ function vtkPicker(publicAPI, model) { props.forEach((prop) => { const mapper = prop.getMapper(); pickable = prop.getNestedPickable() && prop.getNestedVisibility(); - if (prop.getProperty().getOpacity() <= 0.0) { + + if (prop.getProperty().getOpacity?.() <= 0.0) { pickable = false; } @@ -293,6 +294,7 @@ function vtkPicker(publicAPI, model) { p1Mapper, p2Mapper, tol * 0.333 * (scale[0] + scale[1] + scale[2]), + prop, mapper ); if (t[0] < Number.MAX_VALUE) { @@ -326,6 +328,33 @@ function vtkPicker(publicAPI, model) { publicAPI.invokePickChange(model.pickedPositions); return 1; }); + // sort array by distance + const tempArray = []; + for (let i = 0; i < model.pickedPositions.length; i++) { + tempArray.push({ + actor: model.actors[i], + pickedPosition: model.pickedPositions[i], + distance2: vtkMath.distance2BetweenPoints( + p1World, + model.pickedPositions[i] + ), + }); + } + tempArray.sort((a, b) => { + const keyA = a.distance2; + const keyB = b.distance2; + // order the actors based on the distance2 attribute, so the near actors comes + // first in the list + if (keyA < keyB) return -1; + if (keyA > keyB) return 1; + return 0; + }); + model.pickedPositions = []; + model.actors = []; + tempArray.forEach((obj) => { + model.pickedPositions.push(obj.pickedPosition); + model.actors.push(obj.actor); + }); }; } diff --git a/Sources/Rendering/Core/PointPicker/index.js b/Sources/Rendering/Core/PointPicker/index.js index b5c5c0e2541..16da547772e 100644 --- a/Sources/Rendering/Core/PointPicker/index.js +++ b/Sources/Rendering/Core/PointPicker/index.js @@ -12,7 +12,7 @@ function vtkPointPicker(publicAPI, model) { // Set our className model.classHierarchy.push('vtkPointPicker'); - publicAPI.intersectWithLine = (p1, p2, tol, mapper) => { + publicAPI.intersectWithLine = (p1, p2, tol, actor, mapper) => { let tMin = Number.MAX_VALUE; if (mapper.isA('vtkImageMapper') || mapper.isA('vtkImageArrayMapper')) {