Cracking the three.js object fitting (to camera) nut

Tl;dr

This article explains both backstory and solution for fitting an object within a Perspective camera in three.js library. In other words, how far to pull back a camera on z axis to get an object displayed as big as possible without clipping.

Problem statement

Initially I’ve been using viewstl to display the 3D (STL) models on this blog.

But I wasn’t happy with the auto_resize functionality. So much that I filed an issue for viewstl. But the solution offered (to use manual zoom) also doesn’t fully work for me1.

And because during my research of this problem I stumbled upon Anthony Biondo’s Simple STL Viewer post, I decided to also roll out my own – simpler – STL viewer based on his2. After all, three.js has all the components needed. So all I needed was a bit of a glue.

When further researching the “fit camera to object” problem, I found some discussion on three.js that offered some solutions. But none of the solutions I’ve found really worked well for me.

Alas, I think I have finally cracked the nut. :)

Solution

There are essentially two problems with most of the algorithms I’ve found:

  1. They ignore the discrepancy between vertical FOV and horizontal FOV with cameras that have aspect ratio different than 1.0
  2. They take max of the three dimensions of the object and compute based on that

Since my simplified STL viewer has a single model always centered on (0, 0, 0), I can basically take a shortcut that allows me to compute optimal fitting:

The z distance of camera is half of z-size of the bounding box, and then the bigger of:

Why does that work? Because I need to have the entire front size of the bounding box (BB) visible. So the half of z-size gets me from center to the front of the BB, and then a bit of trigonometry gets me to the correct distance given aspect ratio and size of the side.

It’s sort of hard to explain but much easier to draw:

bounding box fit

I also tried documenting it in the code:

const fitCameraToCenteredObject = function (camera, object, offset, orbitControls ) {
    const boundingBox = new THREE.Box3();
    boundingBox.setFromObject( object );

    var middle = new THREE.Vector3();
    var size = new THREE.Vector3();
    boundingBox.getSize(size);

    // figure out how to fit the box in the view:
    // 1. figure out horizontal FOV (on non-1.0 aspects)
    // 2. figure out distance from the object in X and Y planes
    // 3. select the max distance (to fit both sides in)
    //
    // The reason is as follows:
    //
    // Imagine a bounding box (BB) is centered at (0,0,0).
    // Camera has vertical FOV (camera.fov) and horizontal FOV
    // (camera.fov scaled by aspect, see fovh below)
    //
    // Therefore if you want to put the entire object into the field of view,
    // you have to compute the distance as: z/2 (half of Z size of the BB
    // protruding towards us) plus for both X and Y size of BB you have to
    // figure out the distance created by the appropriate FOV.
    //
    // The FOV is always a triangle:
    //
    //  (size/2)
    // +--------+
    // |       /
    // |      /
    // |     /
    // | F° /
    // |   /
    // |  /
    // | /
    // |/
    //
    // F° is half of respective FOV, so to compute the distance (the length
    // of the straight line) one has to: `size/2 / Math.tan(F)`.
    //
    // FTR, from https://threejs.org/docs/#api/en/cameras/PerspectiveCamera
    // the camera.fov is the vertical FOV.

    const fov = camera.fov * ( Math.PI / 180 );
    const fovh = 2*Math.atan(Math.tan(fov/2) * camera.aspect);
    let dx = size.z / 2 + Math.abs( size.x / 2 / Math.tan( fovh / 2 ) );
    let dy = size.z / 2 + Math.abs( size.y / 2 / Math.tan( fov / 2 ) );
    let cameraZ = Math.max(dx, dy);

    // offset the camera, if desired (to avoid filling the whole canvas)
    if( offset !== undefined && offset !== 0 ) cameraZ *= offset;

    camera.position.set( 0, 0, cameraZ );

    // set the far plane of the camera so that it easily encompasses the whole object
    const minZ = boundingBox.min.z;
    const cameraToFarEdge = ( minZ < 0 ) ? -minZ + cameraZ : cameraZ - minZ;

    camera.far = cameraToFarEdge * 3;
    camera.updateProjectionMatrix();

    if ( orbitControls !== undefined ) {
        // set camera to rotate around the center
        orbitControls.target = new THREE.Vector3(0, 0, 0);

        // prevent camera from zooming out far enough to create far plane cutoff
        orbitControls.maxDistance = cameraToFarEdge * 2;
    }
};

With that, the auto-sizing works well (to my eyes anyway).

If you want to get inspired by the full stlviewer, check out the stlviewer-datatag.js.

Demo

Check out the auto-positioning of the same model in 4 different rotations (with bounding box drawn around the object)3:

First:

[Right here should have been an interactive 3D model.]

Second:

[Right here should have been an interactive 3D model.]

Third:

[Right here should have been an interactive 3D model.]

Fourth:

[Right here should have been an interactive 3D model.]
  1. Because mobile version of this site uses different aspect ratio, which always throws the static sizing off on one of them.

  2. And partially on the viewstl lib, because there are some great ideas in the library. Like binding the light to the camera.

  3. I have to admit that the bounding box in the third example looks off, but I’m not enough of a three.js whiz to poke it with a stick to figure out what the problem is.