Skip to content

Commit

Permalink
feat: Add maintainFrame flag for viewports resetCamera (#239)
Browse files Browse the repository at this point in the history
* wip

* feat: Add maintainFrame flag for viewports resetCamera

* feat: Add boundary check for intersections

* node version

* fix: Broken test for stackViewport

* build trigger

* fix: netlify Node version

* review comments

* review comments

* revert node version
  • Loading branch information
sedghi authored and swederik committed Mar 21, 2022
1 parent 8b684e1 commit d2fc40d
Show file tree
Hide file tree
Showing 10 changed files with 260 additions and 39 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/cornerstone-render/src/RenderingEngine/StackViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down
188 changes: 162 additions & 26 deletions packages/cornerstone-render/src/RenderingEngine/Viewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -289,7 +288,7 @@ class Viewport {

protected resetCameraNoEvent() {
this._suppressCameraModifiedEvents = true
this.resetCamera()
this.resetViewportCamera()
this._suppressCameraModifiedEvents = false
}

Expand All @@ -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()
Expand All @@ -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)
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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`.
*
Expand Down Expand Up @@ -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<number>): 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
10 changes: 10 additions & 0 deletions packages/cornerstone-render/src/RenderingEngine/VolumeViewport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
2 changes: 2 additions & 0 deletions packages/cornerstone-render/src/utilities/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -18,6 +19,7 @@ export {
imageIdToURI,
calibratedPixelSpacingMetadataProvider,
uuidv4,
planar,
getMinMax,
getRuntimeId,
isEqual,
Expand Down
34 changes: 34 additions & 0 deletions packages/cornerstone-render/src/utilities/planar.ts
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit d2fc40d

Please sign in to comment.