diff --git a/Sources/Rendering/Core/Camera/index.d.ts b/Sources/Rendering/Core/Camera/index.d.ts index f43ca5c3d74..20c86007871 100755 --- a/Sources/Rendering/Core/Camera/index.d.ts +++ b/Sources/Rendering/Core/Camera/index.d.ts @@ -686,6 +686,17 @@ export interface vtkCamera extends vtkObject { /** * Set the model transform matrix for the camera. * This matrix could be used for model related transformations such as scale, shear, rotations and translations. + * It is applied to world coordinates before the camera transform, so the + * resulting view matrix is `view * modelTransform`. A common use is a global + * vertical exaggeration, e.g. a scale of (1, 1, 10) on world Z. + * + * The matrix is in gl-matrix column-major order, like `userMatrix` on + * vtkProp3D, so a matrix from vtkTransform or vtkMatrixBuilder can be passed + * directly. + * + * Note the camera pose (position, focalPoint, viewUp, clippingRange) is + * interpreted after this transform is applied, so it is expressed in + * transformed space rather than world space. * @param {mat4} mat The value of the model transform matrix. */ setModelTransformMatrix(mat: mat4): void; diff --git a/Sources/Rendering/Core/Camera/index.js b/Sources/Rendering/Core/Camera/index.js index e79ab427468..2e29e67d331 100644 --- a/Sources/Rendering/Core/Camera/index.js +++ b/Sources/Rendering/Core/Camera/index.js @@ -31,6 +31,7 @@ function vtkCamera(publicAPI, model) { const upbasis = new Float64Array([0.0, 1.0, 0.0]); const tmpMatrix = mat4.identity(new Float64Array(16)); const tmpMatrix2 = mat4.identity(new Float64Array(16)); + const tmpModelTransform = mat4.identity(new Float64Array(16)); const tmpvec1 = new Float64Array(3); const tmpvec2 = new Float64Array(3); const tmpvec3 = new Float64Array(3); @@ -506,14 +507,24 @@ function vtkCamera(publicAPI, model) { } }; + // Compose the model transform into a row-major view matrix so that the + // resulting transform is view * modelTransform, i.e. the model transform is + // applied to world coordinates before the camera transform. + // modelTransformMatrix is supplied by the caller in gl-matrix column-major + // order (like userMatrix on vtkProp3D), while `out` is in vtk's row-major + // order, so the model transform has to be transposed before multiplying. + const applyModelTransform = (out) => { + if (model.modelTransformMatrix) { + mat4.transpose(tmpModelTransform, model.modelTransformMatrix); + mat4.multiply(out, tmpModelTransform, out); + } + return out; + }; + publicAPI.getViewMatrix = (out = new Float64Array(16)) => { if (model.viewMatrix) { - if (model.modelTransformMatrix) { - mat4.multiply(out, model.modelTransformMatrix, model.viewMatrix); - } else { - mat4.copy(out, model.viewMatrix); - } - return out; + mat4.copy(out, model.viewMatrix); + return applyModelTransform(out); } mat4.lookAt( @@ -525,10 +536,7 @@ function vtkCamera(publicAPI, model) { mat4.transpose(out, out); - if (model.modelTransformMatrix) { - mat4.multiply(out, model.modelTransformMatrix, out); - } - return out; + return applyModelTransform(out); }; publicAPI.setProjectionMatrix = (mat) => { diff --git a/Sources/Rendering/Core/Camera/test/testModelTransformMatrix.js b/Sources/Rendering/Core/Camera/test/testModelTransformMatrix.js index 25c931c3182..7274b517fe4 100644 --- a/Sources/Rendering/Core/Camera/test/testModelTransformMatrix.js +++ b/Sources/Rendering/Core/Camera/test/testModelTransformMatrix.js @@ -1,53 +1,80 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { mat4 } from 'gl-matrix'; +import { mat4, vec3 } from 'gl-matrix'; import { areEquals } from 'vtk.js/Sources/Common/Core/Math'; import vtkCamera from 'vtk.js/Sources/Rendering/Core/Camera'; import vtkTransform from 'vtk.js/Sources/Common/Transform/Transform'; let camera; +// getViewMatrix returns vtk's row-major order; transpose to get a matrix that +// can be applied to points with gl-matrix. +function asPointTransform(rowMajor) { + return mat4.transpose(mat4.create(), rowMajor); +} + +// Where does world point p land in eye coordinates? +function toEye(cam, p) { + return vec3.transformMat4( + vec3.create(), + p, + asPointTransform(cam.getViewMatrix()) + ); +} + describe('Camera Model Transform Matrix', () => { beforeEach(() => { camera = vtkCamera.newInstance(); }); describe('getViewMatrix composition', () => { - it('applies the model transform in world space (modelTransform * viewMatrix), not camera space (viewMatrix * modelTransform)', () => { - camera.setPosition(5, 5, 5); + it('applies the model transform to world coordinates before the camera transform', () => { + camera.setPosition(0, 0, 10); camera.setFocalPoint(0, 0, 0); camera.setViewUp(0, 1, 0); - // A translation does not commute with the view rotation/translation, so - // this genuinely distinguishes the two multiplication orders. + // A translation is neither symmetric nor commuting with the view + // transform, so it distinguishes both the multiplication order and the + // row-major/column-major convention. A pure scale would not. const transform = vtkTransform.newInstance(); transform.translate(1, 0, 0); const modelTransform = transform.getMatrix(); + camera.setModelTransformMatrix(modelTransform); + + // The world origin is moved to (1, 0, 0) by the model transform, so it + // must land one unit off the view axis rather than on it. + const eye = toEye(camera, [0, 0, 0]); + expect(eye[0]).toBeCloseTo(1, 10); + expect(eye[1]).toBeCloseTo(0, 10); + expect(eye[2]).toBeCloseTo(-10, 10); + + transform.delete(); + }); + + it('matches view * modelTransform for a non-symmetric transform', () => { + camera.setPosition(5, 5, 5); + camera.setFocalPoint(0, 0, 0); + camera.setViewUp(0, 1, 0); + + const transform = vtkTransform.newInstance(); + transform.translate(1, 2, 3); + transform.rotateWXYZ(30, 0, 1, 0); + const modelTransform = transform.getMatrix(); + camera.setModelTransformMatrix(null); - const viewMatrix = camera.getViewMatrix(); + const view = asPointTransform(camera.getViewMatrix()); camera.setModelTransformMatrix(modelTransform); - const composed = camera.getViewMatrix(); + const composed = asPointTransform(camera.getViewMatrix()); - const worldSpace = mat4.multiply( - mat4.create(), - modelTransform, - viewMatrix - ); - const cameraSpace = mat4.multiply( - mat4.create(), - viewMatrix, - modelTransform - ); - - expect(areEquals(composed, worldSpace)).toBe(true); - expect(areEquals(composed, cameraSpace)).toBe(false); + const expected = mat4.multiply(mat4.create(), view, modelTransform); + expect(areEquals(composed, expected)).toBe(true); transform.delete(); }); - it('keeps the world-space transform consistent across camera orientations (vertical exaggeration)', () => { - // 2x vertical exaggeration: a non-uniform world-space Z scale. + it('keeps a vertical exaggeration world-space across camera orientations', () => { + // 2x exaggeration along world Z. const transform = vtkTransform.newInstance(); transform.scale(1, 1, 2); const modelTransform = transform.getMatrix(); @@ -56,24 +83,18 @@ describe('Camera Model Transform Matrix', () => { camera.setFocalPoint(0, 0, 0); camera.setViewUp(0, 1, 0); - // The exaggeration must stay world-space (pre-multiplied), so model - // transforms must be applied before the view matrix for every orientation. [() => {}, () => camera.azimuth(37), () => camera.elevation(50)].forEach( (rotate) => { rotate(); camera.setModelTransformMatrix(null); - const viewMatrix = camera.getViewMatrix(); + const view = asPointTransform(camera.getViewMatrix()); camera.setModelTransformMatrix(modelTransform); - const composed = camera.getViewMatrix(); - - const worldSpace = mat4.multiply( - mat4.create(), - modelTransform, - viewMatrix - ); - expect(areEquals(composed, worldSpace)).toBe(true); + const composed = asPointTransform(camera.getViewMatrix()); + + const expected = mat4.multiply(mat4.create(), view, modelTransform); + expect(areEquals(composed, expected)).toBe(true); } ); diff --git a/Sources/Rendering/Core/Renderer/index.js b/Sources/Rendering/Core/Renderer/index.js index c120f480baf..69b0e392ad4 100644 --- a/Sources/Rendering/Core/Renderer/index.js +++ b/Sources/Rendering/Core/Renderer/index.js @@ -35,6 +35,19 @@ function vtkRenderer(publicAPI, model) { renderer: publicAPI, }; + // Counterpart of vtkRenderer::ExpandBounds: transform the 8 corners of bounds + // by matrix and take the axis-aligned bounds of the result. + // matrix is in gl-matrix column-major order, matching the camera's + // modelTransformMatrix and what vtkBoundingBox.transformBounds expects. + // Unlike C++, where ModelTransformMatrix is always allocated, vtk-js leaves it + // null by default, so a null matrix is a no-op rather than an error. + function expandBounds(bounds, matrix) { + if (!matrix) { + return bounds; + } + return vtkBoundingBox.transformBounds(bounds, matrix, []); + } + publicAPI.updateCamera = () => { if (!model.activeCamera) { vtkDebugMacro('No cameras are on, creating one.'); @@ -392,13 +405,22 @@ function vtkRenderer(publicAPI, model) { // the view angle to become very small and cause bad depth sorting. model.activeCamera.setViewAngle(30.0); - center[0] = (boundsToUse[0] + boundsToUse[1]) / 2.0; - center[1] = (boundsToUse[2] + boundsToUse[3]) / 2.0; - center[2] = (boundsToUse[4] + boundsToUse[5]) / 2.0; + // The camera pose is consumed after the model transform (the view matrix is + // lookAt(position, focalPoint, viewUp) * modelTransformMatrix), so it lives + // in transformed space while prop bounds are in world space. Push the + // bounds through the transform before deriving the pose from them. + const expandedBounds = expandBounds( + boundsToUse, + model.activeCamera.getModelTransformMatrix() + ); - let w1 = boundsToUse[1] - boundsToUse[0]; - let w2 = boundsToUse[3] - boundsToUse[2]; - let w3 = boundsToUse[5] - boundsToUse[4]; + center[0] = (expandedBounds[0] + expandedBounds[1]) / 2.0; + center[1] = (expandedBounds[2] + expandedBounds[3]) / 2.0; + center[2] = (expandedBounds[4] + expandedBounds[5]) / 2.0; + + let w1 = expandedBounds[1] - expandedBounds[0]; + let w2 = expandedBounds[3] - expandedBounds[2]; + let w3 = expandedBounds[5] - expandedBounds[4]; w1 *= w1; w2 *= w2; w3 *= w3; @@ -444,6 +466,8 @@ function vtkRenderer(publicAPI, model) { center[2] + distance * vn[2] ); + // Pass the untransformed bounds: resetCameraClippingRange applies the model + // transform itself, so handing it expandedBounds would apply it twice. publicAPI.resetCameraClippingRange(boundsToUse); // setup default parallel scale @@ -479,8 +503,12 @@ function vtkRenderer(publicAPI, model) { return false; } - // Get the exact range for the bounds - const range = model.activeCamera.computeClippingRange(boundsToUse); + // computeClippingRange measures along the camera's direction of projection, + // which is expressed in transformed space, so the world-space bounds have to + // be pushed through the model transform first. + const range = model.activeCamera.computeClippingRange( + expandBounds(boundsToUse, model.activeCamera.getModelTransformMatrix()) + ); // do not let far - near be less than 0.1 of the window height // this is for cases such as 2D images which may have zero range diff --git a/Sources/Rendering/Core/Renderer/test/testResetCameraModelTransform.js b/Sources/Rendering/Core/Renderer/test/testResetCameraModelTransform.js new file mode 100644 index 00000000000..498fe1f6454 --- /dev/null +++ b/Sources/Rendering/Core/Renderer/test/testResetCameraModelTransform.js @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { mat4 } from 'gl-matrix'; +import vtkRenderer from 'vtk.js/Sources/Rendering/Core/Renderer'; +import vtkCamera from 'vtk.js/Sources/Rendering/Core/Camera'; + +// Bounds of a "geology-like" scene: kilometers in X/Y, meters in Z. +const BOUNDS = [-1000, 1000, -1000, 1000, -10, 10]; + +// 10x vertical exaggeration. The camera stores modelTransformMatrix row-major, +// but a diagonal scale is symmetric so the two conventions coincide here. +function zScale(factor) { + const m = new Float64Array(16); + mat4.identity(m); + m[10] = factor; + return m; +} + +let renderer; +let camera; + +describe('Renderer resetCamera with camera model transform', () => { + beforeEach(() => { + renderer = vtkRenderer.newInstance(); + camera = vtkCamera.newInstance(); + renderer.setActiveCamera(camera); + camera.setViewUp(0, 1, 0); + }); + + it('aims the camera at the transformed center, not the world center', () => { + // Off-center in Z so the transform actually moves the center. + const bounds = [-1000, 1000, -1000, 1000, 90, 110]; + camera.setModelTransformMatrix(zScale(10)); + renderer.resetCamera(bounds); + + // World center Z is 100; after a 10x Z scale the scene center sits at 1000. + expect(camera.getFocalPoint()[2]).toBeCloseTo(1000, 6); + }); + + it('grows the fitted radius with the exaggeration factor', () => { + camera.setModelTransformMatrix(null); + renderer.resetCamera(BOUNDS); + const unscaled = camera.getParallelScale(); + + camera.setModelTransformMatrix(zScale(10)); + renderer.resetCamera(BOUNDS); + const scaled = camera.getParallelScale(); + + // Z extent goes from 20 to 200, so the bounding sphere must grow. + expect(scaled).toBeGreaterThan(unscaled); + }); + + it('resetCameraClippingRange covers the transformed scene along the view axis', () => { + // Look straight down world -Z, so the exaggerated axis is the depth axis and + // the clipping range must stretch to match. This is the case that has no + // application-level call site to fix: interaction paths call + // resetCameraClippingRange() with no arguments. + camera.setPosition(0, 0, 5000); + camera.setFocalPoint(0, 0, 0); + // The factor has to be large enough that the error exceeds the minGap + // padding resetCameraClippingRange already applies, or the bug hides. + camera.setModelTransformMatrix(zScale(100)); + + renderer.resetCameraClippingRange(BOUNDS); + const [near, far] = camera.getClippingRange(); + + // Transformed Z extent is [-1000, 1000], so relative to the eye at z=5000 + // the scene spans depths 4000..6000. + expect(near).toBeLessThanOrEqual(4000); + expect(far).toBeGreaterThanOrEqual(6000); + }); + + it('is unchanged when no model transform is set', () => { + camera.setModelTransformMatrix(null); + renderer.resetCamera(BOUNDS); + const focalPoint = [...camera.getFocalPoint()]; + const position = [...camera.getPosition()]; + const range = [...camera.getClippingRange()]; + + camera.setModelTransformMatrix(zScale(1)); + renderer.resetCamera(BOUNDS); + + expect(camera.getFocalPoint()).toEqual(focalPoint); + expect(camera.getPosition()).toEqual(position); + expect(camera.getClippingRange()).toEqual(range); + }); +});