UNPKG

@acransac/vtk.js

Version:

Visualization Toolkit for the Web

611 lines (507 loc) 18 kB
import macro from 'vtk.js/Sources/macro'; import * as vtkMath from 'vtk.js/Sources/Common/Core/Math'; import vtkPlane from 'vtk.js/Sources/Common/DataModel/Plane'; import vtkAbstractWidget from 'vtk.js/Sources/Interaction/Widgets/AbstractWidget'; import vtkImageCroppingRegionsRepresentation from 'vtk.js/Sources/Interaction/Widgets/ImageCroppingRegionsRepresentation'; import Constants from 'vtk.js/Sources/Interaction/Widgets/ImageCroppingRegionsWidget/Constants'; import { vec3, mat4 } from 'gl-matrix'; const { vtkErrorMacro, VOID, EVENT_ABORT } = macro; const { TOTAL_NUM_HANDLES, WidgetState, CropWidgetEvents } = Constants; // Determines the ordering of edge handles for some fixed axis const EDGE_ORDER = [ [0, 0], [0, 1], [1, 0], [1, 1], ]; // ---------------------------------------------------------------------------- // vtkImageCroppingRegionsWidget methods // ---------------------------------------------------------------------------- function arrayEquals(a, b) { if (a.length === b.length) { for (let i = 0; i < a.length; ++i) { if (a[i] !== b[i]) { return false; } } return true; } return false; } function vtkImageCroppingRegionsWidget(publicAPI, model) { // Set our className model.classHierarchy.push('vtkImageCroppingRegionsWidget'); // camera subscription let cameraSub = null; model.indexToWorld = mat4.create(); model.worldToIndex = mat4.create(); let handlesCache = null; model.widgetState = { activeHandleIndex: -1, // index space: xmin, xmax, ymin, ymax, zmin, zmax planes: Array(6).fill(0), controlState: WidgetState.IDLE, }; function worldToIndex(ain) { const vin = vec3.fromValues(ain[0], ain[1], ain[2]); const vout = vec3.create(); vec3.transformMat4(vout, vin, model.worldToIndex); return [vout[0], vout[1], vout[2]]; } function indexToWorld(ain) { const vin = vec3.fromValues(ain[0], ain[1], ain[2]); const vout = vec3.create(); vec3.transformMat4(vout, vin, model.indexToWorld); return [vout[0], vout[1], vout[2]]; } // Overridden method publicAPI.createDefaultRepresentation = () => { if (!model.widgetRep) { model.widgetRep = vtkImageCroppingRegionsRepresentation.newInstance(); publicAPI.updateRepresentation(); } }; publicAPI.getWidgetState = () => ({ ...model.widgetState }); publicAPI.updateWidgetState = (state) => { const needsUpdate = Object.keys(state).reduce( (flag, key) => flag || model.widgetState[key] !== state[key], false ); if (needsUpdate) { const oldState = model.widgetState; model.widgetState = { ...oldState, ...state }; if (!arrayEquals(oldState.planes, model.widgetState.planes)) { // invalidate handles cache handlesCache = null; publicAPI.invokeCroppingPlanesChanged(model.widgetState.planes); } publicAPI.updateRepresentation(); publicAPI.modified(); } }; publicAPI.setVolumeMapper = (volumeMapper) => { if (volumeMapper !== model.volumeMapper) { model.volumeMapper = volumeMapper; publicAPI.resetWidgetState(); if (model.enabled) { publicAPI.updateRepresentation(); } } }; publicAPI.planesToHandles = (planes) => { if (!model.volumeMapper || !model.volumeMapper.getInputData()) { return null; } if (handlesCache) { return handlesCache; } // coords are in world space. // a null handle means it is disabled const handles = Array(TOTAL_NUM_HANDLES).fill(null); if (model.faceHandlesEnabled) { // construct face handles for (let i = 0; i < 6; ++i) { const center = [0, 0, 0].map((c, j) => { if (j === Math.floor(i / 2)) { return planes[i]; } return (planes[j * 2] + planes[j * 2 + 1]) / 2; }); handles[i] = [center[0], center[1], center[2]]; } } if (model.edgeHandlesEnabled) { // construct edge handles for (let i = 0; i < 12; ++i) { // the axis around which edge handles will be placed const fixedAxis = Math.floor(i / 4); const edgeSpec = EDGE_ORDER[i % 4].slice(); const center = []; for (let j = 0; j < 3; ++j) { if (j !== fixedAxis) { // edgeSpec[j] determines whether to pick a min or max cropping // plane for edge selection. center.push(planes[j * 2 + edgeSpec.shift()]); } } // set fixed axis coordinate center.splice( fixedAxis, 0, (planes[fixedAxis * 2] + planes[fixedAxis * 2 + 1]) / 2 ); handles[i + 6] = [center[0], center[1], center[2]]; } } if (model.cornerHandlesEnabled) { // construct corner handles for (let i = 0; i < 8; ++i) { /* eslint-disable no-bitwise */ handles[i + 18] = [ planes[0 + ((i >> 2) & 0x1)], planes[2 + ((i >> 1) & 0x1)], planes[4 + ((i >> 0) & 0x1)], ]; /* eslint-enable no-bitwise */ } } // transform handles from index to world space for (let i = 0; i < handles.length; ++i) { if (handles[i]) { handles[i] = indexToWorld(handles[i]); } } handlesCache = handles; return handles; }; publicAPI.planesToBBoxCorners = (planes) => { if (!model.volumeMapper || !model.volumeMapper.getInputData()) { return null; } return [ [planes[0], planes[2], planes[4]], [planes[0], planes[2], planes[5]], [planes[0], planes[3], planes[4]], [planes[0], planes[3], planes[5]], [planes[1], planes[2], planes[4]], [planes[1], planes[2], planes[5]], [planes[1], planes[3], planes[4]], [planes[1], planes[3], planes[5]], ].map((coord) => indexToWorld(coord)); }; publicAPI.resetWidgetState = () => { if (!model.volumeMapper) { vtkErrorMacro('Volume mapper must be set to update representation'); return; } if (!model.volumeMapper.getInputData()) { vtkErrorMacro('Volume mapper has no input data'); return; } const data = model.volumeMapper.getInputData(); // cache transforms model.indexToWorld = data.getIndexToWorld(); model.worldToIndex = data.getWorldToIndex(); const planes = data.getExtent(); publicAPI.setCroppingPlanes(...planes); }; publicAPI.setEnabled = macro.chain(publicAPI.setEnabled, (enable) => { if (cameraSub) { cameraSub.unsubscribe(); } if (enable) { const camera = publicAPI .getInteractor() .getCurrentRenderer() .getActiveCamera(); cameraSub = camera.onModified(publicAPI.updateRepresentation); publicAPI.updateRepresentation(); } }); publicAPI.setFaceHandlesEnabled = (enabled) => { if (model.faceHandlesEnabled !== enabled) { model.faceHandlesEnabled = enabled; publicAPI.updateRepresentation(); publicAPI.modified(); } }; publicAPI.setEdgeHandlesEnabled = (enabled) => { if (model.edgeHandlesEnabled !== enabled) { model.edgeHandlesEnabled = enabled; publicAPI.updateRepresentation(); publicAPI.modified(); } }; publicAPI.setCornerHandlesEnabled = (enabled) => { if (model.cornerHandlesEnabled !== enabled) { model.cornerHandlesEnabled = enabled; publicAPI.updateRepresentation(); publicAPI.modified(); } }; publicAPI.setHandleSize = (size) => { if (model.handleSize !== size) { model.handleSize = size; publicAPI.updateRepresentation(); publicAPI.modified(); } }; publicAPI.getCroppingPlanes = () => model.widgetState.planes.slice(); publicAPI.setCroppingPlanes = (...planes) => { publicAPI.updateWidgetState({ planes }); }; publicAPI.updateRepresentation = () => { if (model.widgetRep) { const bounds = model.volumeMapper.getBounds(); model.widgetRep.placeWidget(...bounds); const { activeHandleIndex, planes } = model.widgetState; const bboxCorners = publicAPI.planesToBBoxCorners(planes); const handlePositions = publicAPI.planesToHandles(planes); const handleSizes = handlePositions.map((handle) => { if (!handle) { return model.handleSize; } return publicAPI.adjustHandleSize(handle, model.handleSize); }); model.widgetRep.set({ activeHandleIndex, handlePositions, bboxCorners, handleSizes, }); publicAPI.render(); } }; publicAPI.adjustHandleSize = (pos, size) => { const interactor = publicAPI.getInteractor(); if (!interactor && !interactor.getCurrentRenderer()) { return null; } const renderer = interactor.getCurrentRenderer(); if (!renderer.getActiveCamera()) { return null; } const worldCoords = publicAPI.computeWorldToDisplay( renderer, pos[0], pos[1], pos[2] ); const lowerLeft = publicAPI.computeDisplayToWorld( renderer, worldCoords[0] - size / 2.0, worldCoords[1] - size / 2.0, worldCoords[2] ); const upperRight = publicAPI.computeDisplayToWorld( renderer, worldCoords[0] + size / 2.0, worldCoords[1] + size / 2.0, worldCoords[2] ); let radius = 0.0; for (let i = 0; i < 3; i++) { radius += (upperRight[i] - lowerLeft[i]) * (upperRight[i] - lowerLeft[i]); } return Math.sqrt(radius) / 2.0; }; // Given display coordinates and a plane, returns the // point on the plane that corresponds to display coordinates. publicAPI.displayToPlane = (displayCoords, planePoint, planeNormal) => { const view = publicAPI.getInteractor().getView(); const renderer = publicAPI.getInteractor().getCurrentRenderer(); const camera = renderer.getActiveCamera(); const cameraFocalPoint = camera.getFocalPoint(); const cameraPos = camera.getPosition(); // Adapted from vtkPicker const focalPointDispCoords = view.worldToDisplay( ...cameraFocalPoint, renderer ); const worldCoords = view.displayToWorld( displayCoords[0], displayCoords[1], focalPointDispCoords[2], // Use focal point for z coord renderer ); // compute ray from camera to selection const ray = [0, 0, 0]; for (let i = 0; i < 3; ++i) { ray[i] = worldCoords[i] - cameraPos[i]; } const dop = camera.getDirectionOfProjection(); vtkMath.normalize(dop); const rayLength = vtkMath.dot(dop, ray); const clipRange = camera.getClippingRange(); const p1World = [0, 0, 0]; const p2World = [0, 0, 0]; // get line segment coords from ray based on clip range if (camera.getParallelProjection()) { const tF = clipRange[0] - rayLength; const tB = clipRange[1] - rayLength; for (let i = 0; i < 3; i++) { p1World[i] = worldCoords[i] + tF * dop[i]; p2World[i] = worldCoords[i] + tB * dop[i]; } } else { const tF = clipRange[0] / rayLength; const tB = clipRange[1] / rayLength; for (let i = 0; i < 3; i++) { p1World[i] = cameraPos[i] + tF * ray[i]; p2World[i] = cameraPos[i] + tB * ray[i]; } } const r = vtkPlane.intersectWithLine( p1World, p2World, planePoint, planeNormal ); return r.intersection ? r.x : null; }; publicAPI.handleLeftButtonPress = (callData) => publicAPI.pressAction(callData); publicAPI.handleLeftButtonRelease = (callData) => publicAPI.endMoveAction(callData); publicAPI.handleMiddleButtonPress = (callData) => publicAPI.pressAction(callData); publicAPI.handleMiddleButtonRelease = (callData) => publicAPI.endMoveAction(callData); publicAPI.handleRightButtonPress = (callData) => publicAPI.pressAction(callData); publicAPI.handleRightButtonRelease = (callData) => publicAPI.endMoveAction(callData); publicAPI.handleMouseMove = (callData) => publicAPI.moveAction(callData); publicAPI.pressAction = (callData) => { if (model.widgetState.controlState === WidgetState.IDLE) { const handleIndex = model.widgetRep.getEventIntersection(callData); if (handleIndex > -1) { model.activeHandleIndex = handleIndex; publicAPI.updateWidgetState({ activeHandleIndex: handleIndex, controlState: WidgetState.CROPPING, }); return EVENT_ABORT; } } return VOID; }; publicAPI.moveAction = (callData) => { const { controlState, planes, activeHandleIndex } = model.widgetState; if (controlState === WidgetState.IDLE || activeHandleIndex === -1) { return VOID; } const handles = publicAPI.planesToHandles(planes); const mouse = [callData.position.x, callData.position.y]; const handlePos = handles[activeHandleIndex]; const renderer = publicAPI.getInteractor().getCurrentRenderer(); const camera = renderer.getActiveCamera(); const dop = camera.getDirectionOfProjection(); const point = publicAPI.displayToPlane(mouse, handlePos, dop); if (!point) { return EVENT_ABORT; } const newPlanes = planes.slice(); // activeHandleIndex should be > -1 here if (activeHandleIndex < 6) { // face handle, so constrain to axis const moveAxis = Math.floor(activeHandleIndex / 2); // Constrain point to axis const orientation = model.volumeMapper.getInputData().getDirection(); const offset = moveAxis * 3; const constraintAxis = orientation.slice(offset, offset + 3); const newPos = [0, 0, 0]; const relMoveVect = [0, 0, 0]; const projection = [0, 0, 0]; vtkMath.subtract(point, handlePos, relMoveVect); vtkMath.projectVector(relMoveVect, constraintAxis, projection); vtkMath.add(handlePos, projection, newPos); const indexHandle = worldToIndex(newPos); // set correct plane value newPlanes[activeHandleIndex] = indexHandle[moveAxis]; } else if (activeHandleIndex < 18) { // edge handle, so constrain to plane const edgeHandleIndex = activeHandleIndex - 6; const fixedAxis = Math.floor(edgeHandleIndex / 4); /** * edgeHandleIndex: plane, plane * 4: xmin, zmin * 5: xmin, zmax * 6: xmax, zmin * 7: xmax, zmax * 8: xmin, ymin * 9: xmin, ymax * 10: xmax, ymin * 11: xmax, ymax */ const orientation = model.volumeMapper.getInputData().getDirection(); const offset = fixedAxis * 3; const constraintPlaneNormal = orientation.slice(offset, offset + 3); const newPos = [0, 0, 0]; const relMoveVect = [0, 0, 0]; const projection = [0, 0, 0]; vtkMath.subtract(point, handlePos, relMoveVect); vtkPlane.projectVector(relMoveVect, constraintPlaneNormal, projection); vtkMath.add(handlePos, projection, newPos); const indexHandle = worldToIndex(newPos); // get the two planes that are being adjusted const edgeSpec = EDGE_ORDER[edgeHandleIndex % 4].slice(); const modifiedPlanes = []; for (let i = 0; i < 3; ++i) { if (i !== fixedAxis) { modifiedPlanes.push(i * 2 + edgeSpec.shift()); } } // set correct plane value modifiedPlanes.forEach((planeIndex) => { // Math.floor(planeIndex / 2) is the corresponding changed // coordinate (that dictates the plane position) newPlanes[planeIndex] = indexHandle[Math.floor(planeIndex / 2)]; }); } else { // corner handles, so no constraints const cornerHandleIndex = activeHandleIndex - 18; const indexHandle = worldToIndex(point); // get the three planes that are being adjusted /* eslint-disable no-bitwise */ const modifiedPlanes = [ 0 + ((cornerHandleIndex >> 2) & 0x1), 2 + ((cornerHandleIndex >> 1) & 0x1), 4 + ((cornerHandleIndex >> 0) & 0x1), ]; /* eslint-enable no-bitwise */ // set correct plane value modifiedPlanes.forEach((planeIndex) => { // Math.floor(planeIndex / 2) is the corresponding changed // coordinate (that dictates the plane position) newPlanes[planeIndex] = indexHandle[Math.floor(planeIndex / 2)]; }); } publicAPI.setCroppingPlanes(...newPlanes); return EVENT_ABORT; }; publicAPI.endMoveAction = () => { if (model.widgetState.activeHandleIndex > -1) { publicAPI.updateWidgetState({ activeHandleIndex: -1, controlState: WidgetState.IDLE, }); } }; } // ---------------------------------------------------------------------------- // Object factory // ---------------------------------------------------------------------------- const DEFAULT_VALUES = { // volumeMapper: null, handleSize: 5, faceHandlesEnabled: false, edgeHandlesEnabled: false, cornerHandlesEnabled: true, }; // ---------------------------------------------------------------------------- export function extend(publicAPI, model, initialValues = {}) { Object.assign(model, DEFAULT_VALUES, initialValues); // Inheritance // Have our default values override whatever is from parent class vtkAbstractWidget.extend(publicAPI, model, DEFAULT_VALUES, initialValues); CropWidgetEvents.forEach((eventName) => macro.event(publicAPI, model, eventName) ); macro.get(publicAPI, model, [ 'volumeMapper', 'handleSize', 'faceHandlesEnabled', 'edgeHandlesEnabled', 'cornerHandlesEnabled', ]); // Object methods vtkImageCroppingRegionsWidget(publicAPI, model); } // ---------------------------------------------------------------------------- export const newInstance = macro.newInstance( extend, 'vtkImageCroppingRegionsWidget' ); // ---------------------------------------------------------------------------- export default { newInstance, extend };