Home Reference Source

viewer/camera.js

import * as mat4 from "./glmatrix/mat4.js";
import * as mat3 from "./glmatrix/mat3.js";
import * as vec3 from "./glmatrix/vec3.js";

import {Perspective} from "./perspective.js";
import {Orthographic} from "./orthographic.js";

/**
 A **Camera** defines viewing and projection transforms for its Viewer.
 */
export class Camera {

    constructor(viewer) {
        this.viewer = viewer;

        this.perspective = new Perspective(viewer);

        this.orthographic = new Orthographic(viewer);

        this._projection = this.perspective; // Currently active projection
        this._viewMatrix = mat4.create();
        this._viewProjMatrix = mat4.create();
        this._viewMatrixInverted = mat4.create();
        this._viewProjMatrixInverted = mat4.create();

        this._viewNormalMatrix = mat3.create();

        this._eye = vec3.fromValues(0.0, 0.0, -10.0); // World-space eye position
        this._target = vec3.fromValues(0.0, 0.0, 0.0); // World-space point-of-interest
        this._up = vec3.fromValues(0.0, 1.0, 0.0); // Camera's "up" vector, always orthogonal to eye->target
        this._center = vec3.copy(vec3.create(), this._target);
        this._negatedCenter = vec3.create();
        vec3.negate(this._negatedCenter, this._center);

        this._worldAxis = new Float32Array([1, 0, 0, 0, 1, 0, 0, 0, 1]);
        this._worldUp = vec3.fromValues(0.0, 1.0, 0.0); // Direction of "up" in World-space
        this._worldRight = vec3.fromValues(1, 0, 0); // Direction of "right" in World-space
        this._worldForward = vec3.fromValues(0, 0, -1); // Direction of "forward" in World-space

        this._gimbalLock = true; // When true, orbiting world-space "up", else orbiting camera's local "up"
        this._constrainPitch = true; // When true, will prevent camera} from being rotated upside-down

        this._dirty = true; // Lazy-builds view matrix
        this._locked = false;

        this._modelBounds = null;

        this.tempMat4 = mat4.create();
        this.tempMat4b = mat4.create();
        this.tempVec3 = vec3.create();
        this.tempVec3b = vec3.create();
        this.tempVec3c = vec3.create();
        this.tempVec3d = vec3.create();
        this.tempVec3e = vec3.create();
        this.tempVecBuild = vec3.create();

        this.tmp_modelBounds = vec3.create();

        this.yawMatrix = mat4.create();
        
        // Until there is a proper event handler mechanism, just do it manually.
        this.listeners = [];
        this.lowVolumeListeners = [];

        this._orbitting = false;

        this.autonear = true;
    }

    lock() {
        this._locked = true;
    }

    unlock() {
        this._locked = false;
        this._build();
    }

    _setDirty() {
        this._dirty = true;
        this.viewer.dirty = 2;
    }

    setModelBounds(bounds) {
        this._modelBounds = [];

        this.perspective.setModelBounds(vec3.clone(bounds));
        this.orthographic.setModelBounds(vec3.clone(bounds));
        
        // Store aabb calculated} from points
        let a = vec3.fromValues(+Infinity, +Infinity, +Infinity);
        let b = vec3.fromValues(-Infinity, -Infinity, -Infinity);

        let zero_one = [0,1];

        for (let i of zero_one) {
            for (let j of zero_one) {
                for (let k of zero_one) {
                    let v = vec3.fromValues(bounds[3*i+0], bounds[3*j+1], bounds[3*k+2]);
                    this._modelBounds.push(v);

                    for (let l = 0; l < 3; ++l) {
                        if (v[l] < a[l]) {
                            a[l] = v[l];
                        }
                        if (v[l] > b[l]) {
                            b[l] = v[l];
                        }
                    }
                }
            }   
        }

        vec3.add(a, a, b);
        vec3.scale(a, a, 0.5);

        this._center.set(a);
        vec3.negate(this._negatedCenter, this._center);
        this._dirty = true;
    }

    forceBuild() {
   		vec3.set(this._up, 0, 0, 1);
        vec3.subtract(this.tempVecBuild, this._target, this._eye);
        vec3.normalize(this.tempVecBuild, this.tempVecBuild);
        vec3.cross(this._up, this.tempVecBuild, this._up);
        vec3.cross(this._up, this._up, this.tempVecBuild);
        if (vec3.equals(this._up, vec3.fromValues(0, 0, 0))) {
        	// Not good, choose something
        	vec3.set(this._up, 0, 1, 0);
        }

        mat4.lookAt(this._viewMatrix, this._eye, this._target, this._up);
        mat4.identity(this.tempMat4);
        mat4.multiply(this._viewMatrix, this.tempMat4, this._viewMatrix); // Why?
        mat3.fromMat4(this.tempMat4b, this._viewMatrix);
        mat3.invert(this.tempMat4b, this.tempMat4b);
        mat3.transpose(this._viewNormalMatrix, this.tempMat4b);
        
        let [near, far] = [+Infinity, -Infinity];

        if (this.autonear) {
        	for (var v of this._modelBounds) {
                vec3.transformMat4(this.tmp_modelBounds, v, this._viewMatrix);
                let z = -this.tmp_modelBounds[2];
                if (z < near) {
                    near = z;
                }
                if (z > far) {
                    far = z;
                }
            }

            if (near < 1.e-3) {
                near = far / 1000.;
            }
        } else {
            [near, far] = [+100, +1000000.];
        }

        this.perspective.near = near;
        this.perspective.far = far;
        this.orthographic.near = near;
        this.orthographic.far = far;        

        mat4.invert(this._viewMatrixInverted, this._viewMatrix);
        mat4.multiply(this._viewProjMatrix, this.projMatrix, this._viewMatrix);
        mat4.invert(this._viewProjMatrixInverted, this._viewProjMatrix);

        this._dirty = false;
        
//        console.log("Rebuilt", this._up, this._viewMatrix);
        
        for (var listener of this.listeners) {
        	listener();
        }
    }
    
    _build() {
        if (this._dirty && !this._locked && this._modelBounds) {
        	this.forceBuild();
        }
    }

    /**
     Gets the current viewing transform matrix.

     @return {Float32Array} 4x4 column-order matrix as an array of 16 contiguous floats.
     */
    get viewMatrix() {
        if (this._dirty) {
            this._build();
        }
        return this._viewMatrix;
    }

    /**
     Gets the current view projection matrix.

     @return {Float32Array} 4x4 column-order matrix as an array of 16 contiguous floats.
     */
    get viewProjMatrix() {
        if (this._dirty) {
            this._build();
        }
        return this._viewProjMatrix;
    }

    /**
     Gets the current inverted view projection matrix.

     @return {Float32Array} 4x4 column-order matrix as an array of 16 contiguous floats.
     */
    get viewProjMatrixInverted() {
        if (this._dirty) {
            this._build();
        }
        return this._viewProjMatrixInverted;
    }

    get viewMatrixInverted() {
        if (this._dirty) {
            this._build();
        }
        return this._viewMatrixInverted;
    }

    /**
     Gets the current viewing transform matrix for normals.

     This is the transposed inverse of the view matrix.

     @return {Float32Array} 4x4 column-order matrix as an array of 16 contiguous floats.
     */
    get viewNormalMatrix() {
        if (this._dirty) {
            this._build();
        }
        return this._viewNormalMatrix;
    }

    /**
     Gets the current projection transform matrix.

     @return {Float32Array} 4x4 column-order matrix as an array of 16 contiguous floats.
     */
    get projMatrix() {
        return this._projection.projMatrix;
    }

    /**
     Selects the current projection type.

     @param {String} projectionType Accepted values are "persp" or "ortho".
     */
    set projectionType(projectionType) {
        if (projectionType.toLowerCase().startsWith("persp")) {
            this._projection = this.perspective;
        } else if (projectionType.toLowerCase().startsWith("ortho")) {
            this._projection = this.orthographic;
        } else {
            console.error("Unsupported projectionType: " + projectionType);
        }
        this.viewer.dirty = 2;
    }

    /**
     Gets the current projection type.

     @return {String} projectionType "persp" or "ortho".
     */
    get projectionType() {
        return this._projection.constructor.name.substr(0,5).toLowerCase();
    }

    /**
     Gets the component that represents the current projection type.

     @return {Perspective|Orthographic}
     */
    get projection() {
        return this._projection;
    }

    /**
     Sets the position of the camera.
     @param {Float32Array} eye 3D position of the camera in World space.
     */
    set eye(eye) {
    	if (!vec3.equals(this._eye, eye)) {
    		this._eye.set(eye || [0.0, 0.0, -10.0]);
    		this._setDirty();
    		for (var listener of this.lowVolumeListeners) {
    			listener();
    		}
    	}
    }

    /**
     Gets the position of the camera.
     @return {Float32Array} 3D position of the camera in World space.
     */
    get eye() {
        return this._eye;
    }

    /**
     Sets the point the camera is looking at.
     @param {Float32Array} target 3D position of the point of interest in World space.
     */
    set target(target) {
    	if (!vec3.equals(this._target, target)) {
    		this._target.set(target || [0.0, 0.0, 0.0]);
    		this._setDirty();
    		for (var listener of this.lowVolumeListeners) {
    			listener();
    		}
    	}
    }

    /**
     Gets the point tha camera is looking at.
     @return {Float32Array} 3D position of the point of interest in World space.
     */
    get target() {
        return this._target;
    }

    set center(v) {
    	if (!vec3.equals(this._center, v)) {
    		this._center.set(v);
    		vec3.negate(this._negatedCenter, this._center);
    		this.listeners.forEach((fn) => { fn(); });
    	}
    }

    get center() {
        return this._center;
    }

    /**
     Sets the camera's "up" direction.
     @param {Float32Array} up 3D vector indicating the camera's "up" direction in World-space.
     */
    set up(up) {
        this._up.set(up || [0.0, 1.0, 0.0]);
        this._setDirty();
    }

    /**
     Gets the camera's "up" direction.
     @return {Float32Array} 3D vector indicating the camera's "up" direction in World-space.
     */
    get up() {
        return this._up;
    }

    /**
     Sets whether camera rotation is gimbal locked.

     When true, yaw rotation will always pivot about the World-space "up" axis.

     @param {Boolean} gimbalLock Whether or not to enable gimbal locking.
     */
    set gimbalLock(gimbalLock) {
        this._gimbalLock = gimbalLock;
    }

    /**
     Sets whether camera rotation is gimbal locked.

     When true, yaw rotation will always pivot about the World-space "up" axis.

     @return {Boolean} True if gimbal locking is enabled.
     */
    get gimbalLock() {
        return this._gimbalLock;
    }

    /**
     Sets whether its currently possible to pitch the camera to look at the model upside-down.

     When this is true, camera will ignore attempts to orbit (camera or model) about the horizontal axis
     that would result in the model being viewed upside-down.

     @param {Boolean} constrainPitch Whether or not to activate the constraint.
     */
    set constrainPitch(constrainPitch) {
        this._constrainPitch = constrainPitch;
    }

    /**
     Gets whether its currently possible to pitch the camera to look at the model upside-down.

     @return {Boolean}
     */
    get constrainPitch() {
        return this._constrainPitch;
    }

    /**
     Indicates the up, right and forward axis of the World coordinate system.

     This is used for deriving rotation axis for yaw orbiting, and for moving camera to axis-aligned positions.

     Has format: ````[rightX, rightY, rightZ, upX, upY, upZ, forwardX, forwardY, forwardZ]````

     @type {Float32Array}
     */
    set worldAxis(worldAxis) {
        this._worldAxis.set(worldAxis || [1, 0, 0, 0, 1, 0, 0, 0, 1]);
        this._worldRight[0] = this._worldAxis[0];
        this._worldRight[1] = this._worldAxis[1];
        this._worldRight[2] = this._worldAxis[2];
        this._worldUp[0] = this._worldAxis[3];
        this._worldUp[1] = this._worldAxis[4];
        this._worldUp[2] = this._worldAxis[5];
        this._worldForward[0] = this._worldAxis[6];
        this._worldForward[1] = this._worldAxis[7];
        this._worldForward[2] = this._worldAxis[8];
        this._setDirty();
    }

    /**
     Indicates the up, right and forward axis of the World coordinate system.

     This is used for deriving rotation axis for yaw orbiting, and for moving camera to axis-aligned positions.

     Has format: ````[rightX, rightY, rightZ, upX, upY, upZ, forwardX, forwardY, forwardZ]````

     @type {Float32Array}
     */
    get worldAxis() {
        return this._worldAxis;
    }

    /**
     Direction of World-space "up".

     @type Float32Array
     */
    get worldUp() {
        return this._worldUp;
    }

    /**
     Direction of World-space "right".

     @type Float32Array
     */
    get worldRight() {
        return this._worldRight;
    }

    /**
     Direction of World-space "forwards".

     @type Float32Array
     */
    get worldForward() {
        return this._worldForward;
    }

    set orbitting(orbitting) {
    	if (this._orbitting != orbitting) {
        	for (var listener of this.lowVolumeListeners) {
        		listener();
        	}
    	}
    	this._orbitting = orbitting;
    }

    get orbitting() {
    	return this._orbitting;
    }
    
    /**
     Rotates the eye position about the target position, pivoting around the up vector.

     @param {Number} degrees Angle of rotation in degrees
     */
    orbitYaw(degrees) {
        // @todo, these functions are not efficient nor numerically stable, but simple to understand.
        
    	mat4.identity(this.yawMatrix);
    	mat4.translate(this.yawMatrix, this.yawMatrix, this._center);
    	mat4.rotate(this.yawMatrix, this.yawMatrix, degrees * 0.0174532925 * 2, this._worldUp);
    	mat4.translate(this.yawMatrix, this.yawMatrix, this._negatedCenter);
    	
        vec3.transformMat4(this._eye, this._eye, this.yawMatrix);
        vec3.transformMat4(this._target, this._target, this.yawMatrix);

        this._setDirty();
        return;
    }

    /**
     Rotates the eye position about the target position, pivoting around the right axis (orthogonal to up vector and eye->target vector).

     @param {Number} degrees Angle of rotation in degrees
     */
    orbitPitch(degrees) { // Rotate (pitch) 'eye' and 'up' about 'target', pivoting around vector ortho to (target->eye) and camera 'up'
        let currentPitch = Math.acos(this._viewMatrix[10]);
        let adjustment = - degrees * 0.0174532925 * 2;
        if (currentPitch + adjustment < 0.01) {
            adjustment = 0.01 - currentPitch;
        }
        if (currentPitch + adjustment > Math.PI - 0.01) {
            adjustment = Math.PI - 0.01 - currentPitch;
        }

        if (Math.abs(adjustment) < 1.e-5) {
            return;
        }

        var T1 = mat4.fromTranslation(mat4.create(), this._center);
        var R = mat4.fromRotation(mat4.create(), adjustment, this._viewMatrixInverted);
        var T2 = mat4.fromTranslation(mat4.create(), vec3.negate(vec3.create(), this._center));

        vec3.transformMat4(this._eye, this._eye, T2);
        vec3.transformMat4(this._eye, this._eye, R);
        vec3.transformMat4(this._eye, this._eye, T1);

        vec3.transformMat4(this._target, this._target, T2);
        vec3.transformMat4(this._target, this._target, R);
        vec3.transformMat4(this._target, this._target, T1);

        this._setDirty();
        return;
    }

    /**
     Rotates the target position about the eye, pivoting around the up vector.

     @param {Number} degrees Angle of rotation in degrees
     */
    yaw(degrees) { // Rotate (yaw) 'target' and 'up' about 'eye', pivoting around 'up'
        var eyeToTarget = vec3.subtract(this.tempVec3, this._target, this._eye);
        mat4.fromRotation(this.tempMat4, degrees * 0.0174532925, this._gimbalLock ? this._worldUp : this._up);
        vec3.transformMat4(eyeToTarget, eyeToTarget, this.tempMat4); // Rotate vector
        vec3.add(this._target, this._eye, eyeToTarget); // Derive 'target'} from eye and vector
        if (this._gimbalLock) {
            vec3.transformMat4(this._up, this._up, this.tempMat4); // Rotate 'up' vector
        }
        this._setDirty();
    }

    /**
     Rotates the target position about the eye, pivoting around the right axis (orthogonal to up vector and eye->target vector).

     @param {Number} degrees Angle of rotation in degrees
     */
    pitch(degrees) { // Rotate (pitch) 'eye' and 'up' about 'target', pivoting around horizontal vector ortho to (target->eye) and camera 'up'
        var eyeToTarget = vec3.subtract(this.tempVec3, this._target, this._eye);
        var a = vec3.normalize(this.tempVec3c, eyeToTarget);
        var b = vec3.normalize(this.tempVec3d, this._up);
        var axis = vec3.cross(this.tempVec3b, a, b); // Pivot vector is orthogonal to target->eye
        mat4.fromRotation(this.tempMat4, degrees * 0.0174532925, axis);
        vec3.transformMat4(eyeToTarget, eyeToTarget, this.tempMat4); // Rotate vector
        var newUp = vec3.transformMat4(this.tempVec3d, this._up, this.tempMat4); // Rotate 'up' vector
        if (this._constrainPitch) {
            var angle = vec3.dot(newUp, this._worldUp) / 0.0174532925; // Don't allow 'up' to go up[side-down with respect to World 'up'
            if (angle < 1) {
                return;
            }
        }
        this._up.set(newUp);
        vec3.add(this._target, this._eye, eyeToTarget); // Derive 'target'} from eye and vector
        this._setDirty();
    }

    /**
     Pans the camera along the camera's local X, Y and Z axis.

     @param {Array} pan The pan vector
     */
    pan(pan) { // Translate 'eye' and 'target' along local camera axis
        var eyeToTarget = vec3.subtract(this.tempVec3, this._eye, this._target);
        var vec = [0, 0, 0];
        if (pan[0] !== 0) {
            let a = vec3.normalize(this.tempVec3b, eyeToTarget); // Get  vector orthogonal to 'up' and eye->target
            let b = vec3.normalize(this.tempVec3c, this._up);
            let v = vec3.cross(this.tempVec3d, a, b);
            vec3.scale(v, v, pan[0]);
            vec[0] += v[0];
            vec[1] += v[1];
            vec[2] += v[2];
        }
        if (pan[1] !== 0) {
            let v = vec3.scale(this.tempVec3, vec3.normalize(this.tempVec3b, this._up), pan[1]);
            vec[0] += v[0];
            vec[1] += v[1];
            vec[2] += v[2];
        }
        if (pan[2] !== 0) {
            let v = vec3.scale(this.tempVec3, vec3.normalize(this.tempVec3b, eyeToTarget), pan[2]);
            vec[0] += v[0];
            vec[1] += v[1];
            vec[2] += v[2];
        }
        vec3.add(this._eye, this._eye, vec);
        this._target = vec3.add(this._target, this._target, vec);
        this._setDirty();
    }

    /**
     Moves the camera along a ray through unprojected mouse coordinates

     @param {Number} delta Zoom increment
     @param canvasPos Mouse position relative to canvas to determine ray along which to move
     */
    zoom(delta, canvasPos) { // Translate 'eye' by given increment on (eye->target) vector
        // @todo: also not efficient

    	this.orthographic.zoom(delta);
    	
        let [x,y] = canvasPos;
        vec3.set(this.tempVec3, x / this.viewer.width * 2 - 1, - y / this.viewer.height * 2 + 1, 1.);
        vec3.transformMat4(this.tempVec3, this.tempVec3, this.projection.projMatrixInverted);
        vec3.transformMat4(this.tempVec3, this.tempVec3, this.viewMatrixInverted);
        vec3.subtract(this.tempVec3, this.tempVec3, this._eye);
        vec3.normalize(this.tempVec3, this.tempVec3);
        vec3.scale(this.tempVec3, this.tempVec3, -delta);

        vec3.add(this._eye, this._eye, this.tempVec3);
        vec3.add(this._target, this._target, this.tempVec3);

        this._setDirty();

        this.updateLowVolumeListeners();
    }
    
    updateLowVolumeListeners() {
        for (var listener of this.lowVolumeListeners) {
        	listener();
        }
    }

    /**
     Jumps the camera to look at the given axis-aligned World-space bounding box.

     @param {Float32Array} aabb The axis-aligned World-space bounding box (AABB).
     @param {Number} fitFOV Field-of-view occupied by the AABB when the camera has fitted it to view.
     */
    viewFit(aabb, fitFOV) {
        aabb = aabb || this.viewer.modelBounds;
        fitFOV = fitFOV || this.perspective.fov;
        var eyeToTarget = vec3.normalize(this.tempVec3b, vec3.subtract(this.tempVec3, this._eye, this._target));
        var diagonal = Math.sqrt(
            Math.pow(aabb[3] - aabb[0], 2) +
            Math.pow(aabb[4] - aabb[1], 2) +
            Math.pow(aabb[5] - aabb[2], 2));
        var center = [
            (aabb[3] + aabb[0]) / 2,
            (aabb[4] + aabb[1]) / 2,
            (aabb[5] + aabb[2]) / 2
        ];
        this._target.set(center);
        var sca = Math.abs(diagonal / Math.tan(fitFOV * 0.0174532925));
        this._eye[0] = this._target[0] + (eyeToTarget[0] * sca);
        this._eye[1] = this._target[1] + (eyeToTarget[1] * sca);
        this._eye[2] = this._target[2] + (eyeToTarget[2] * sca);

        this._setDirty();
    }

    restore(params) {
        if (params.type) {
            this.projectionType = params.type;
        }
        if (this._projection instanceof Perspective && params.fovy) {
            this._projection.fov = params.fovy;
        }
        ["eye", "target", "up"].forEach((k) => {
            if (params[k]) {
                let fn = Object.getOwnPropertyDescriptor(this, k).set;
                fn(this, params[k]);
            }
        });
    }
}