Skip to content

Fix camera model transform bounds - #3586

Open
sankhesh wants to merge 2 commits into
Kitware:masterfrom
sankhesh:fix-camera-model-transform-bounds
Open

Fix camera model transform bounds#3586
sankhesh wants to merge 2 commits into
Kitware:masterfrom
sankhesh:fix-camera-model-transform-bounds

Conversation

@sankhesh

Copy link
Copy Markdown
Collaborator

Context

#3538 fixed the composition order so the transform is applied in world space. But the camera pose is consumed after the transform: getViewMatrix() builds lookAt(position, focalPoint, viewUp) · M, so position/focalPoint/clippingRange are interpreted in transformed space, while prop bounds are in world space. Nothing bridged the two.

This is exactly how VTK C++ behaves — vtkCamera.cxx composes ModelView = ViewTransform · ModelTransformMatrix, and vtkCamera.h documents that "Clipping distance is measured in world coordinate unless a scale factor exists in camera's ModelTransformMatrix." The difference is that C++ has vtkRenderer::ExpandBounds (vtkRenderer.cxx), which pushes bounds through the transform before deriving the camera pose from them, called from ResetCamera, ResetCameraClippingRange and ResetCameraScreenSpace. vtk-js had no equivalent.

While adding it, I found a second, unrelated bug: the model transform was being applied transposed.

Results

resetCamera / resetCameraClippingRange. Before, both derived the camera pose and clipping range from untransformed bounds. With a 10x Z exaggeration on bounds [-1000, 1000, -1000, 1000, 90, 110], resetCamera aimed the camera at world Z 100 while the rendered scene center was at 1000 — the scene was mis-framed and clipped. The error is proportional to (factor − 1) and vanishes at 1x, which is why it went unnoticed. After, the camera is aimed at the transformed center and the clipping range covers the transformed scene.

Transposed model transform. getViewMatrix composed view · Mᵀ instead of view · M. Probing a camera at (0,0,10) looking at the origin, with M = translate(1, 0, 0) from vtkTransform:

eye of world origin, actual : [0, 0, -10]
                    view · M   : [1, 0, -10]
                    view · Mᵀ  : [0, 0, -10]   <- what we were producing

A pure scale is symmetric, so M = Mᵀ and vertical exaggeration worked either way — but rotations and translations were applied transposed.

Changes

Behavior change: setModelTransformMatrix now interprets its argument as gl-matrix column-major, consistent with vtkTransform, vtkMatrixBuilder and userMatrix on vtkProp3D — and with what index.d.ts already declared (mat4). This is a silent change for anyone who passed a rotation or translation and pre-transposed to compensate; pure scales are unaffected.

  • Documentation and TypeScript definitions were updated to match those changes

PR and Code Checklist

  • semantic-release commit messages

  • Run npm run reformat to have correctly formatted code

  • This change adds or fixes unit tests

  • Tested environment:

    • vtk.js: master
    • OS: Arch Linux
    • Browser: Chromium

sankhesh added 2 commits July 27, 2026 10:51
The camera pose is consumed after modelTransformMatrix, so resetCamera and
resetCameraClippingRange must transform world-space prop bounds before
deriving the pose and clipping range from them. Ports
vtkRenderer::ExpandBounds.
modelTransformMatrix is supplied column-major like every other user-facing
matrix in vtk-js, but getViewMatrix composed it against a row-major view
matrix, yielding view * transpose(M) instead of view * M. Symmetric
matrices such as a pure scale were unaffected, so vertical exaggeration
hid the bug; rotations and translations were applied transposed.

The previous test asserted the composition in the same mixed convention as
the code, so it passed either way. It now checks where world points land in
eye coordinates.

BREAKING CHANGE: `setModelTransformMatrix` now interprets its argument as gl-matrix column-major,
consistent with `vtkTransform`, `vtkMatrixBuilder` and `userMatrix` on `vtkProp3D` — and with what
`index.d.ts` already declared (`mat4`). This is a silent change for anyone who passed a rotation or
translation and pre-transposed to compensate; pure scales are unaffected.
@sankhesh
sankhesh requested a review from finetjul July 27, 2026 17:44
@sankhesh

sankhesh commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

@daker @finetjul Could you please review?

@daker daker left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, shouldn't this be a BREAKING CHANGE ?

@daker

daker commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

@sankhesh One thing for a follow up, not for this PR. After this change the camera pose (position, focalPoint, clippingRange) is in transformed space, while props and renderer.worldToView are in world space. Consumers that read the pose as a world point are wrong when a model transform is set:

// 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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use vtkMath.areEquals ?

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vtkBoundingBox.getCenter()

center[1] = (expandedBounds[2] + expandedBounds[3]) / 2.0;
center[2] = (expandedBounds[4] + expandedBounds[5]) / 2.0;

let w1 = expandedBounds[1] - expandedBounds[0];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

vtkBoundingBox.getLength(expandedBounds, 0)

// 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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That seems like a too verbose comment... Maybe it could go into the PR but not really in the code...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants