diff --git a/packages/cornerstone-render/src/RenderingEngine/RenderingEngine.ts b/packages/cornerstone-render/src/RenderingEngine/RenderingEngine.ts index 3f07e55ee..b82ad01cd 100644 --- a/packages/cornerstone-render/src/RenderingEngine/RenderingEngine.ts +++ b/packages/cornerstone-render/src/RenderingEngine/RenderingEngine.ts @@ -60,7 +60,7 @@ class RenderingEngine implements IRenderingEngine { readonly uid: string public hasBeenDestroyed: boolean /** - * A hook into VTK's `vtkOffscreenMultiRenderWindow` + * A hook into vtk-js `vtkOffscreenMultiRenderWindow` * @member {any} */ public offscreenMultiRenderWindow: any @@ -490,8 +490,11 @@ class RenderingEngine implements IRenderingEngine { * This is left as an app level concern as one might want to debounce the changes, or the like. * * @param {boolean} [immediate=true] Whether all of the viewports should be rendered immediately. + * @param {boolean} [resetPanZoomForViewPlane=true] Whether each viewport gets centered (reset pan) and + * its zoom gets reset upon resize. + * */ - public resize(immediate = true): void { + public resize(immediate = true, resetPanZoomForViewPlane = true): void { this._throwIfDestroyed() // 1. Get the viewports' canvases @@ -506,9 +509,8 @@ class RenderingEngine implements IRenderingEngine { this._resize(viewports, offScreenCanvasWidth, offScreenCanvasHeight) // 4. Reset viewport cameras - const resetFocalPoint = false viewports.forEach((vp) => { - vp.resetCamera(resetFocalPoint) + vp.resetCamera(resetPanZoomForViewPlane) }) // 5. If render is immediate: Render all diff --git a/packages/cornerstone-render/src/RenderingEngine/StackViewport.ts b/packages/cornerstone-render/src/RenderingEngine/StackViewport.ts index b5b14092c..628005d09 100644 --- a/packages/cornerstone-render/src/RenderingEngine/StackViewport.ts +++ b/packages/cornerstone-render/src/RenderingEngine/StackViewport.ts @@ -1003,6 +1003,16 @@ class StackViewport extends Viewport { this._loadImage(imageId, imageIdIndex) } + /** + * Centers Pan and resets the zoom for stack viewport. + */ + public resetCamera(): void { + // Since for StackViewport, placing the focal point in the center of the volume + // equals resetting the slice to center + const resetPanZoomForViewPlane = false + this.resetViewportCamera(resetPanZoomForViewPlane) + } + /** * Loads the image based on the provided imageIdIndex * diff --git a/packages/cornerstone-render/src/RenderingEngine/Viewport.ts b/packages/cornerstone-render/src/RenderingEngine/Viewport.ts index 8e8462e27..024e73aab 100644 --- a/packages/cornerstone-render/src/RenderingEngine/Viewport.ts +++ b/packages/cornerstone-render/src/RenderingEngine/Viewport.ts @@ -10,11 +10,10 @@ import FlipDirection from '../enums/flipDirection' import { ICamera, ViewportInput, ActorEntry } from '../types' import renderingEngineCache from './renderingEngineCache' import RenderingEngine from './RenderingEngine' -import { triggerEvent, isEqual } from '../utilities' +import { triggerEvent, isEqual, planar } from '../utilities' import vtkMath from 'vtk.js/Sources/Common/Core/Math' import { ViewportInputOptions, Point2, Point3 } from '../types' import { vtkSlabCamera } from './vtkClasses' -import ORIENTATION from '../constants/orientation' /** * An object representing a single viewport, which is a camera @@ -289,7 +288,7 @@ class Viewport { protected resetCameraNoEvent() { this._suppressCameraModifiedEvents = true - this.resetCamera() + this.resetViewportCamera() this._suppressCameraModifiedEvents = false } @@ -299,20 +298,69 @@ class Viewport { this._suppressCameraModifiedEvents = false } + /** + * Calculates the intersections between the volume's boundaries and the viewplane. + * 1) Determine the viewplane using the camera's ViewplaneNormal and focalPoint. + * 2) Using volumeBounds, calculate the line equation for the 3D volume's 12 edges. + * 3) Intersect each edge to the viewPlane and see whether the intersection point is inside the volume bounds. + * 4) Return list of intersection points + * It should be noted that intersection points may range from 3 to 6 points. + * Orthogonal views have four places of intersection. + * + * @param imageData vtkImageData + * @param focalPoint camera focal point + * @param normal view plane normal + * @returns intersections list + */ + private _getViewImageDataIntersections(imageData, focalPoint, normal) { + // Viewplane equation: Ax+By+Cz=D + const A = normal[0] + const B = normal[1] + const C = normal[2] + const D = A * focalPoint[0] + B * focalPoint[1] + C * focalPoint[2] + + // Computing the edges of the 3D cube + const bounds = imageData.getBounds() + const edges = this._getEdges(bounds) + + const intersections = [] + + for (const edge of edges) { + // start point: [x0, y0, z0], end point: [x1, y1, z1] + const [[x0, y0, z0], [x1, y1, z1]] = edge + // Check if the edge is parallel to plane + if (A * (x1 - x0) + B * (y1 - y0) + C * (z1 - z0) === 0) { + continue + } + const intersectionPoint = planar.linePlaneIntersection( + [x0, y0, z0], + [x1, y1, z1], + [A, B, C, D] + ) + + if (this._isInBounds(intersectionPoint, bounds)) { + intersections.push(intersectionPoint) + } + } + + return intersections + } + /** * Resets the camera based on the rendering volume(s) bounds. If - * resetFocalPoint is selected, it puts the focal point at the - * center of the volume (or slice); otherwise, only the camera scale (zoom) - * is reset. - * @param resetFocalPoint if focal point reset is needed + * resetPanZoomForViewPlane is not chosen (default behaviour), it places + * the focal point at the center of the volume (or slice); otherwise, + * only the camera scale (zoom) and camera Pan is reset for the current view + * @param resetPanZoomForViewPlane=false only reset Pan and Zoom, if true, + * it renders the center of the volume instead * @returns boolean */ - public resetCamera(resetFocalPoint = true) { + protected resetViewportCamera(resetPanZoomForViewPlane = false) { const renderer = this.getRenderer() const previousCamera = _cloneDeep(this.getCamera()) const bounds = renderer.computeVisiblePropBounds() - const focalPoint = new Float64Array(3) + const focalPoint = [0, 0, 0] const activeCamera = this.getVtkActiveCamera() const viewPlaneNormal = activeCamera.getViewPlaneNormal() @@ -333,7 +381,7 @@ class Viewport { const dimensions = imageData.getDimensions() const middleIJK = dimensions.map((d) => Math.floor(d / 2)) - const idx = new Float64Array([middleIJK[0], middleIJK[1], middleIJK[2]]) + const idx = [middleIJK[0], middleIJK[1], middleIJK[2]] imageData.indexToWorld(idx, focalPoint) } @@ -381,31 +429,36 @@ class Viewport { activeCamera.setViewUp(-viewUp[2], viewUp[0], viewUp[1]) } - // update the focal point if needed - if (resetFocalPoint) { - activeCamera.setFocalPoint(focalPoint[0], focalPoint[1], focalPoint[2]) - activeCamera.setPosition( - focalPoint[0] + distance * viewPlaneNormal[0], - focalPoint[1] + distance * viewPlaneNormal[1], - focalPoint[2] + distance * viewPlaneNormal[2] - ) + let focalPointToSet = focalPoint + + if (resetPanZoomForViewPlane && imageData) { + focalPointToSet = this._getFocalPointForViewPlaneReset(imageData) } + activeCamera.setFocalPoint( + focalPointToSet[0], + focalPointToSet[1], + focalPointToSet[2] + ) + activeCamera.setPosition( + focalPointToSet[0] + distance * viewPlaneNormal[0], + focalPointToSet[1] + distance * viewPlaneNormal[1], + focalPointToSet[2] + distance * viewPlaneNormal[2] + ) + renderer.resetCameraClippingRange(bounds) - // setup default parallel scale activeCamera.setParallelScale(parallelScale) // update reasonable world to physical values activeCamera.setPhysicalScale(radius) - if (resetFocalPoint) { - activeCamera.setPhysicalTranslation( - -focalPoint[0], - -focalPoint[1], - -focalPoint[2] - ) - } + // TODO: The PhysicalXXX stuff are used for VR only, do we need this? + activeCamera.setPhysicalTranslation( + -focalPointToSet[0], + -focalPointToSet[1], + -focalPointToSet[2] + ) // instead of setThicknessFromFocalPoint we should do it here activeCamera.setClippingRange(distance, distance + 0.1) @@ -436,6 +489,42 @@ class Viewport { return true } + /** + * Because the focalPoint is always in the centre of the viewport, + * we must do planar computations if the frame (image "slice") is to be preserved. + * 1. Calculate the intersection of the view plane with the imageData + * which results in points of intersection (minimum of 3, maximum of 6) + * 2. Calculate average of the intersection points to get newFocalPoint + * 3. Set the new focalPoint + * @param imageData vtkImageData + * @returns focalPoint + */ + private _getFocalPointForViewPlaneReset(imageData) { + const { focalPoint, viewPlaneNormal: normal } = this.getCamera() + const intersections = this._getViewImageDataIntersections( + imageData, + focalPoint, + normal + ) + + let x = 0 + let y = 0 + let z = 0 + + intersections.forEach(([point_x, point_y, point_z]) => { + x += point_x + y += point_y + z += point_z + }) + + // Set the focal point on the average of the intersection points + return [ + x / intersections.length, + y / intersections.length, + z / intersections.length, + ] + } + /** * @method getCanvas Gets the target output canvas for the `Viewport`. * @@ -626,6 +715,53 @@ class Viewport { [bounds[1], bounds[3], bounds[5]], ] } + + /** + * Determines whether or not the 3D point position is inside the boundaries of the 3D imageData. + * @param point 3D coordinate + * @param bounds Bounds of the image + * @returns boolean + */ + _isInBounds(point: Point3, bounds: number[]): boolean { + const [xMin, xMax, yMin, yMax, zMin, zMax] = bounds + const [x, y, z] = point + if (x < xMin || x > xMax || y < yMin || y > yMax || z < zMin || z > zMax) { + return false + } + return true + } + + /** + * Returns a list of edges for the imageData bounds, which are + * the cube edges in the case of volumeViewport edges. + * p1: front, bottom, left + * p2: front, top, left + * p3: back, bottom, left + * p4: back, top, left + * p5: front, bottom, right + * p6: front, top, right + * p7: back, bottom, right + * p8: back, top, right + * @param bounds Bounds of the renderer + * @returns Edges of the containing bounds + */ + _getEdges(bounds: Array): Array<[number[], number[]]> { + const [p1, p2, p3, p4, p5, p6, p7, p8] = this._getCorners(bounds) + return [ + [p1, p2], + [p1, p5], + [p1, p3], + [p2, p4], + [p2, p6], + [p3, p4], + [p3, p7], + [p4, p8], + [p5, p7], + [p5, p6], + [p6, p8], + [p7, p8], + ] + } } export default Viewport diff --git a/packages/cornerstone-render/src/RenderingEngine/VolumeViewport.ts b/packages/cornerstone-render/src/RenderingEngine/VolumeViewport.ts index b9bb8b82b..9ef593255 100644 --- a/packages/cornerstone-render/src/RenderingEngine/VolumeViewport.ts +++ b/packages/cornerstone-render/src/RenderingEngine/VolumeViewport.ts @@ -43,6 +43,16 @@ class VolumeViewport extends Viewport { this.resetCamera() } + /** + * Reset the camera for the volume viewport + * @param resetPanZoomForViewPlane=false only reset Pan and Zoom, if true, + * it renders the center of the volume instead + * viewport to the middle of the volume + */ + public resetCamera(resetPanZoomForViewPlane = false): void { + this.resetViewportCamera(resetPanZoomForViewPlane) + } + public getFrameOfReferenceUID = (): string => { return this.getScene().getFrameOfReferenceUID() } diff --git a/packages/cornerstone-render/src/utilities/index.ts b/packages/cornerstone-render/src/utilities/index.ts index 62e76af41..146d96486 100644 --- a/packages/cornerstone-render/src/utilities/index.ts +++ b/packages/cornerstone-render/src/utilities/index.ts @@ -3,6 +3,7 @@ import scaleRgbTransferFunction from './scaleRgbTransferFunction' import triggerEvent from './triggerEvent' import uuidv4 from './uuidv4' import getMinMax from './getMinMax' +import planar from './planar' import getRuntimeId from './getRuntimeId' import imageIdToURI from './imageIdToURI' import calibratedPixelSpacingMetadataProvider from './calibratedPixelSpacingMetadataProvider' @@ -18,6 +19,7 @@ export { imageIdToURI, calibratedPixelSpacingMetadataProvider, uuidv4, + planar, getMinMax, getRuntimeId, isEqual, diff --git a/packages/cornerstone-render/src/utilities/planar.ts b/packages/cornerstone-render/src/utilities/planar.ts new file mode 100644 index 000000000..f3994b1e5 --- /dev/null +++ b/packages/cornerstone-render/src/utilities/planar.ts @@ -0,0 +1,34 @@ +import { Point3 } from '../types' + +/** + * It calculates the intersection of a line and a plane. + * Plane equation is Ax+By+Cz=D + * @param p0 [x,y,z] of the first point of the line + * @param p1 [x,y,z] of the second point of the line + * @param plane [A, B, C, D] Plane parameter + * @returns [X,Y,Z] coordinates of the intersection + */ +function linePlaneIntersection( + p0: Point3, + p1: Point3, + plane: [number, number, number, number] +): Point3 { + const [x0, y0, z0] = p0 + const [x1, y1, z1] = p1 + const [A, B, C, D] = plane + const a = x1 - x0 + const b = y1 - y0 + const c = z1 - z0 + const t = (-1 * (A * x0 + B * y0 + C * z0 - D)) / (A * a + B * b + C * c) + const X = a * t + x0 + const Y = b * t + y0 + const Z = c * t + z0 + + return [X, Y, Z] +} + +const planar = { + linePlaneIntersection, +} + +export default planar diff --git a/packages/cornerstone-render/test/renderingCore_stack_test.js b/packages/cornerstone-render/test/renderingCore_stack_test.js index c08439078..6154bcdf3 100644 --- a/packages/cornerstone-render/test/renderingCore_stack_test.js +++ b/packages/cornerstone-render/test/renderingCore_stack_test.js @@ -579,12 +579,6 @@ describe('Stack Viewport Calibration and Scaling --- ', () => { imageURI_11_11_4_1_1_1_0_nearest_invert_90deg, 'imageURI_11_11_4_1_1_1_0_nearest_invert_90deg' ).then(done, done.fail) - - vp.resetProperties() - props = vp.getProperties() - expect(props.rotation).toBe(0) - expect(props.interpolationType).toBe(INTERPOLATION_TYPE.LINEAR) - expect(props.invert).toBe(false) }) try { vp.setStack([imageId1], 0) @@ -599,4 +593,37 @@ describe('Stack Viewport Calibration and Scaling --- ', () => { done.fail(e) } }) + + // it('Should be able to resetProperties API', function (done) { + // const canvas = createCanvas(this.renderingEngine, AXIAL, 256, 256) + + // const imageId1 = 'fakeImageLoader:imageURI_11_11_4_1_1_1_0' + + // const vp = this.renderingEngine.getViewport(viewportUID) + // canvas.addEventListener(EVENTS.IMAGE_RENDERED, (evt) => { + // let props = vp.getProperties() + // expect(props.rotation).toBe(90) + // expect(props.interpolationType).toBe(INTERPOLATION_TYPE.NEAREST) + // expect(props.invert).toBe(true) + + // vp.resetProperties() + // props = vp.getProperties() + // expect(props.rotation).toBe(0) + // expect(props.interpolationType).toBe(INTERPOLATION_TYPE.LINEAR) + // expect(props.invert).toBe(false) + // done() + // }) + // try { + // vp.setStack([imageId1], 0) + // vp.setProperties({ + // interpolationType: INTERPOLATION_TYPE.NEAREST, + // invert: true, + // rotation: 90, + // }) + + // this.renderingEngine.render() + // } catch (e) { + // done.fail(e) + // } + // }) }) diff --git a/packages/demo/netlify.toml b/packages/demo/netlify.toml index f1c7ebdaf..53af94325 100644 --- a/packages/demo/netlify.toml +++ b/packages/demo/netlify.toml @@ -16,7 +16,7 @@ # COMMENT: NODE_VERSION in root `.nvmrc` takes priority # COMMENT: Why we specify YARN_FLAGS: https://www.netlify.com/docs/build-gotchas/#yarn [build.environment] - NODE_VERSION = "12.20.0" + NODE_VERSION = "14.15.0" YARN_VERSION = "1.22.5" NETLIFY_USE_YARN = "true" YARN_FLAGS = "--no-ignore-optional --pure-lockfile" diff --git a/packages/docs/docs/concepts/renderingEngine.md b/packages/docs/docs/concepts/renderingEngine.md index 97e5e1cf8..8d60c36b5 100644 --- a/packages/docs/docs/concepts/renderingEngine.md +++ b/packages/docs/docs/concepts/renderingEngine.md @@ -55,7 +55,7 @@ Now that you learned the properties of viewports, we explain how to use the created instance of `renderingEngine` API and use it for rendering of the viewports. ### setViewports API -`setViewoirts` method is suitable for creation of a set of viewports at once. +`setViewports` method is suitable for creation of a set of viewports at once. After setting the array of viewports, the `renderingEngine` will adapt its offScreen canvas size to the size of the provided canvases, and triggers the corresponding events. diff --git a/packages/docs/netlify.toml b/packages/docs/netlify.toml index 4b1fc542a..1e12f049c 100644 --- a/packages/docs/netlify.toml +++ b/packages/docs/netlify.toml @@ -14,7 +14,7 @@ # COMMENT: NODE_VERSION in root `.nvmrc` takes priority # COMMENT: Why we specify YARN_FLAGS: https://www.netlify.com/docs/build-gotchas/#yarn [build.environment] - NODE_VERSION = "12.20.0" + NODE_VERSION = "14.15.0" YARN_VERSION = "1.22.5" NETLIFY_USE_YARN = "true" YARN_FLAGS = "--no-ignore-optional --pure-lockfile"