var inherit = require('./inherit');
var object = require('./object');
var util = require('./util');
var mat3 = require('gl-mat3');
var vec3 = require('gl-vec3');
var mat4 = require('gl-mat4');
var vec4 = require('gl-vec4');
/**
* This class defines the raw interface for a camera. At a low level, the
* camera provides a methods for converting between a map's coordinate system
* to display pixel coordinates.
*
* For the moment, all camera transforms are assumed to be expressible as
* 4x4 matrices. More general cameras may follow that break this assumption.
*
* The interface for the camera is relatively stable for "map-like" views,
* e.g. when the camera is pointing in the direction [0, 0, -1], and placed
* above the z=0 plane. More general view changes and events have not yet
* been defined.
*
* The camera emits the following events when the view changes:
*
* * {@link geo.event.camera.pan} when the camera is translated in the
* x/y plane
* * {@link geo.event.camera.zoom} when the camera is changed in a way
* that modifies the current zoom level
* * {@link geo.event.camera.view} when the visible bounds change for
* any reason
* * {@link geo.event.camera.projection} when the projection type changes
* * {@link geo.event.camera.viewport} when the viewport changes
*
* By convention, protected methods do not update the internal matrix state,
* public methods do. There are a few primary methods that are intended to
* be used by external classes to mutate the internal state:
*
* * bounds: Set the visible bounds (for initialization and zooming)
* * pan: Translate the camera in x/y by an offset (for panning)
* * viewFromCenterSizeRotation: set the camera view based on a center
* point, boundary size, and rotation angle.
*
* @class
* @alias geo.camera
* @extends geo.object
* @param {object?} spec Options argument
* @param {string} spec.projection One of the supported
* {@link geo.camera.projection}.
* @param {object} spec.viewport The initial camera viewport
* @param {object} spec.viewport.width
* @param {object} spec.viewport.height
* @returns {geo.camera}
*/
var camera = function (spec) {
if (!(this instanceof camera)) {
return new camera(spec);
}
var geo_event = require('./event');
spec = spec || {};
object.call(this, spec);
/**
* The view matrix
* @protected
*/
this._view = util.mat4AsArray();
/**
* The projection matrix
* @protected
*/
this._proj = util.mat4AsArray();
/**
* The projection type (one of `this.constructor.projection`)
* @protected
*/
this._projection = null;
/**
* The transform matrix (view * proj)
* @protected
*/
this._transform = util.mat4AsArray();
/**
* The inverse transform matrix (view * proj)^-1
* @protected
*/
this._inverse = util.mat4AsArray();
/**
* Cached bounds object recomputed on demand.
* @protected
*/
this._bounds = null;
/**
* Cached "display" matrix recomputed on demand.
* @see {@link geo.camera.display}
* @protected
*/
this._display = null;
/**
* Cached "world" matrix recomputed on demand.
* @see {@link geo.camera.world}
* @protected
*/
this._world = null;
/**
* The viewport parameters size and offset.
* @property {number} height Viewport height in pixels
* @property {number} width Viewport width in pixels
* @protected
*/
this._viewport = {width: 1, height: 1};
/**
* Set up the projection matrix for the current projection type.
* @protected
*/
this._createProj = function () {
var func = this._projection === 'perspective' ? mat4.frustum : mat4.ortho,
clipbounds = this._clipbounds[this._projection];
func(this._proj, clipbounds.left, clipbounds.right, clipbounds.bottom,
clipbounds.top, clipbounds.near, clipbounds.far);
};
/**
* Update the internal state of the camera on change to camera
* parameters.
* @protected
* @fires geo.event.camera.view
*/
this._update = function () {
this._bounds = null;
this._display = null;
this._world = null;
this._transform = mat4.multiply(util.mat4AsArray(), this._proj, this._view);
mat4.invert(this._inverse, this._transform);
this.geoTrigger(geo_event.camera.view, {
camera: this
});
};
/**
* Getter/setter for the view matrix.
* @note copies the matrix value on set.
* @property {mat4} view The view matrix.
* @name geo.camera#view
*/
Object.defineProperty(this, 'view', {
get: function () {
return this._view;
},
set: function (view) {
mat4.copy(this._view, view);
this._update();
},
configurable: true
});
/**
* Getter/setter for the view bounds.
*
* @property {object} bounds The view bounds.
* @property {number} bounds.left
* @property {number} bounds.top
* @property {number} bounds.right
* @property {number} bounds.bottom
* @name geo.camera#bounds
*/
Object.defineProperty(this, 'bounds', {
get: function () {
if (this._bounds === null) {
this._bounds = this._getBounds();
}
return this._bounds;
},
set: function (bounds) {
this._setBounds(bounds);
this._update();
}
});
/**
* Getter/setter for the render clipbounds. Opposite bounds must have
* different values. There are independent clipbounds for each projection
* (parallel and perspective); switching the projection will switch to the
* clipbounds. Individual values of the clipbounds can be set either via a
* command like `camera.clipbounds = {near: 3, far: 1}` or
* `camera.clipbounds.near = 3`. In the second example, no check is made to
* ensure a non-zero volume clipbounds.
*
* @property {object} clipbounds The clipbounds for the current projection.
* @name geo.camera#clipbounds
*/
Object.defineProperty(this, 'clipbounds', {
get: function () {
return this._clipbounds[this._projection];
},
set: function (bounds) {
var clipbounds = this._clipbounds[this._projection];
bounds = {
left: bounds.left === undefined ? clipbounds.left : bounds.left,
right: bounds.right === undefined ? clipbounds.right : bounds.right,
top: bounds.top === undefined ? clipbounds.top : bounds.top,
bottom: bounds.bottom === undefined ? clipbounds.bottom : bounds.bottom,
near: bounds.near === undefined ? clipbounds.near : bounds.near,
far: bounds.far === undefined ? clipbounds.far : bounds.far
};
if (bounds.left === bounds.right) {
throw new Error('Left and right values must be different');
}
if (bounds.top === bounds.bottom) {
throw new Error('Top and bottom values must be different');
}
if (bounds.near === bounds.far) {
throw new Error('Near and far values must be different');
}
this._clipbounds[this._projection] = bounds;
this._createProj();
this._update();
}
});
/**
* Getter for the "display" matrix. This matrix converts from world
* coordinates into display coordinates. Read only.
*
* @property {mat4} display The display matrix.
* @name geo.camera#display
*/
Object.defineProperty(this, 'display', {
get: function () {
if (this._display === null) {
var b = this._clipbounds[this._projection];
var mat = util.mat4AsArray();
mat4.translate(mat, mat, [
this.viewport.width / 2,
this.viewport.height / 2,
0]);
mat4.scale(mat, mat, [
this.viewport.width / (b.right - b.left),
this.viewport.height / (b.bottom - b.top),
1]);
mat4.translate(mat, mat, [
-(b.left + b.right) / 2,
-(b.top + b.bottom) / 2,
0]);
mat4.multiply(mat, mat, this._transform);
this._display = mat;
}
return this._display;
}
});
/**
* Getter for the "world" matrix. This matrix converts from display
* coordinates into world coordinates. This is the inverse of the "display"
* matrix. Read only.
*
* @property {mat4} world The world matrix.
* @name geo.camera#world
*/
Object.defineProperty(this, 'world', {
get: function () {
if (this._world === null) {
this._world = mat4.invert(
util.mat4AsArray(),
this.display
);
}
return this._world;
}
});
/**
* Getter/setter for the projection type.
*
* @property {string} projection The projection type. One of `parallel` or
* `perspective`.
* @name geo.camera#projection
* @fires geo.event.camera.projection
*/
Object.defineProperty(this, 'projection', {
get: function () {
return this._projection;
},
set: function (type) {
if (!this.constructor.projection[type]) {
throw new Error('Unsupported projection type: ' + type);
}
if (type !== this._projection) {
this._projection = type;
this._createProj();
this._update();
this.geoTrigger(geo_event.camera.projection, {
camera: this,
projection: type
});
}
}
});
/**
* Getter for the projection matrix. Read only.
*
* @property {mat4} projectionMatrix The projection matrix.
* @name geo.camera#projectionMatrix
*/
Object.defineProperty(this, 'projectionMatrix', {
get: function () {
return this._proj;
}
});
/**
* Getter for the transform matrix. This is the projection multiplied by the
* view matrix. Read only.
*
* @property {mat4} transform The transform matrix.
* @name geo.camera#transform
*/
Object.defineProperty(this, 'transform', {
get: function () {
return this._transform;
}
});
/**
* Getter for the inverse transform matrix. Read only.
*
* @property {mat4} inverse The inverse transform matrix.
* @name geo.camera#inverse
*/
Object.defineProperty(this, 'inverse', {
get: function () {
return this._inverse;
}
});
/**
* Getter/setter for the viewport.
*
* The viewport consists of a width and height in pixels, plus a left and
* top offset in pixels. The offsets are only used to determine if pixel
* alignment is possible.
*
* @property {object} viewport The viewport in pixels.
* @property {number} viewport.width
* @property {number} viewport.height
* @property {number} viewport.top
* @property {number} viewport.left
* @name geo.camera#viewport
* @fires geo.event.camera.viewport
*/
Object.defineProperty(this, 'viewport', {
get: function () {
return {
width: this._viewport.width,
height: this._viewport.height,
left: this._viewport.left,
top: this._viewport.top
};
},
set: function (viewport) {
if (!(viewport.width > 0 &&
viewport.height > 0)) {
throw new Error('Invalid viewport dimensions');
}
if (viewport.width === this._viewport.width &&
viewport.height === this._viewport.height) {
return;
}
// apply scaling to the view matrix to account for the new aspect ratio
// without changing the apparent zoom level
if (this._viewport.width && this._viewport.height) {
this._scale([
this._viewport.width / viewport.width,
this._viewport.height / viewport.height,
1
]);
// translate by half the difference to keep the center the same
this._translate([
(viewport.width - this._viewport.width) / 2,
(viewport.height - this._viewport.height) / 2,
0
]);
}
this._viewport = {
width: viewport.width,
height: viewport.height,
left: viewport.left,
top: viewport.top
};
this._update();
this.geoTrigger(geo_event.camera.viewport, {
camera: this,
viewport: this.viewport
});
}
});
/**
* Reset the view matrix to its initial (identity) state.
* @protected
* @returns {this} Chainable.
*/
this._resetView = function () {
mat4.identity(this._view);
return this;
};
/**
* Uses `mat4.translate` to translate the camera by the given vector amount.
* @protected
* @param {vec3|Array} offset The camera translation vector.
* @returns {this} Chainable.
*/
this._translate = function (offset) {
mat4.translate(this._view, this._view, offset);
return this;
};
/**
* Uses `mat4.scale` to scale the camera by the given vector amount.
* @protected
* @param {vec3|Array} scale The scaling vector.
* @returns {this} Chainable.
*/
this._scale = function (scale) {
mat4.scale(this._view, this._view, scale);
return this;
};
/**
* Project a vec4 from world space into clipped space [-1, 1] in place.
* @protected
* @param {vec4} point The point in world coordinates (mutated).
* @returns {vec4} The point in clip space coordinates.
*/
this._worldToClip4 = function (point) {
return vec4.transformMat4(point, point, this._transform);
};
/**
* Project a vec4 from clipped space into world space in place.
* @protected
* @param {vec4} point The point in clipped coordinates (mutated).
* @returns {vec4} The point in world space coordinates.
*/
this._clipToWorld4 = function (point) {
return vec4.transformMat4(point, point, this._inverse);
};
/**
* Apply the camera's projection transform to the given point.
* @param {vec4} pt a point in clipped coordinates.
* @returns {vec4} the point in normalized coordinates.
*/
this.applyProjection = function (pt) {
var w;
if (this._projection === 'perspective') {
w = 1 / (pt[3] || 1);
pt[0] = w * pt[0];
pt[1] = w * pt[1];
pt[2] = w * pt[2];
pt[3] = w;
} else {
pt[3] = 1;
}
return pt;
};
/**
* Unapply the camera's projection transform from the given point.
* @param {vec4} pt a point in normalized coordinates.
* @returns {vec4} the point in clipped coordinates.
*/
this.unapplyProjection = function (pt) {
var w;
if (this._projection === 'perspective') {
w = pt[3] || 1;
pt[0] = w * pt[0];
pt[1] = w * pt[1];
pt[2] = w * pt[2];
pt[3] = w;
} else {
pt[3] = 1;
}
return pt;
};
/**
* Project a vector from world space into viewport (display) space. The
* resulting vector always has the last component (`w`) equal to 1.
*
* @param {vec3|vec4} point The point in world coordinates.
* @returns {vec4} The point in display coordinates.
*/
this.worldToDisplay4 = function (point) {
point = [point[0], point[1], point[2] || 0, point[3] || 1];
point = vec4.transformMat4(point, point, this.display);
if (point[3] && point[3] !== 1) {
point = [point[0] / point[3], point[1] / point[3], point[2] / point[3], 1];
}
return point;
};
/**
* Project a vector from viewport (display) space into world space. The
* resulting vector always has the last component (`w`) equal to 1.
*
* @param {vec3|vec4} point The point in display coordinates.
* @returns {vec4} The point in world space coordinates.
*/
this.displayToWorld4 = function (point) {
point = [point[0], point[1], point[2] || 0, point[3] || 1];
point = vec4.transformMat4(point, point, this.world);
if (point[3] && point[3] !== 1) {
point = [point[0] / point[3], point[1] / point[3], point[2] / point[3], 1];
}
return point;
};
/**
* Project a 2D point object from world space into viewport space. `z` is
* set to `-this.clipbounds.near` to scale with the clip space.
*
* @param {object} point The point in world coordinates.
* @param {number} point.x
* @param {number} point.y
* @returns {object} The point in display coordinates.
*/
this.worldToDisplay = function (point) {
var b = this._clipbounds[this._projection];
point = this.worldToDisplay4([point.x, point.y, -b.near]);
return {x: point[0], y: point[1]};
};
/**
* Project a 2D point object from viewport space into world space. `z` is
* set to -1 to scale with the clip space.
*
* @param {object} point The point in display coordinates.
* @param {number} point.x
* @param {number} point.y
* @returns {object} The point in world coordinates.
*/
this.displayToWorld = function (point) {
point = this.displayToWorld4([point.x, point.y, -1]);
return {x: point[0], y: point[1]};
};
/**
* Calculate the current bounds in world coordinates from the
* current view matrix. This computes a matrix vector multiplication
* so the result is cached for public facing methods.
*
* @protected
* @returns {object} bounds object.
*/
this._getBounds = function () {
var ul, ur, ll, lr, bds = {};
// get corners
ul = this.displayToWorld({x: 0, y: 0});
ur = this.displayToWorld({x: this._viewport.width, y: 0});
ll = this.displayToWorld({x: 0, y: this._viewport.height});
lr = this.displayToWorld({x: this._viewport.width, y: this._viewport.height});
bds.left = Math.min(ul.x, ur.x, ll.x, lr.x);
bds.bottom = Math.min(ul.y, ur.y, ll.y, lr.y);
bds.right = Math.max(ul.x, ur.x, ll.x, lr.x);
bds.top = Math.max(ul.y, ur.y, ll.y, lr.y);
return bds;
};
/**
* Sets the view matrix so that the given world bounds
* are in view. To account for the viewport aspect ratio,
* the resulting bounds may be larger in width or height than
* the requested bound, but should be centered in the frame.
*
* @protected
* @param {object} bounds
* @param {number} bounds.left
* @param {number} bounds.right
* @param {number} bounds.bottom
* @param {number} bounds.top
* @param {number?} bounds.near Currently ignored.
* @param {number?} bounds.far Currently ignored.
* @returns {this} Chainable.
*/
this._setBounds = function (bounds) {
var size = {
width: bounds.right - bounds.left,
height: bounds.top - bounds.bottom
};
var center = {
x: (bounds.left + bounds.right) / 2,
y: (bounds.bottom + bounds.top) / 2
};
this._viewFromCenterSizeRotation(center, size, 0);
return this;
};
/**
* Sets the view matrix so that the given world center is centered, at
* least a certain width and height are visible, and a rotation is applied.
* The resulting bounds may be larger in width or height than the values if
* the viewport is a different aspect ratio.
*
* @protected
* @param {object} center Center of the view in gcs coordinates.
* @param {number} center.x
* @param {number} center.y
* @param {object} size Minimum size of the view in gcs units.
* @param {number} size.width
* @param {number} size.height
* @param {number} rotation in clockwise radians. Optional.
* @returns {this} Chainable.
*/
this._viewFromCenterSizeRotation = function (center, size, rotation) {
var translate = util.vec3AsArray(),
scale = util.vec3AsArray(),
c_ar, v_ar, w, h;
// reset view to the identity
this._resetView();
w = Math.abs(size.width);
h = Math.abs(size.height);
c_ar = w / h;
v_ar = this._viewport.width / this._viewport.height;
if (c_ar >= v_ar) {
// grow camera bounds vertically
h = w / v_ar;
scale[0] = 2 / w;
scale[1] = 2 / h;
} else {
// grow bounds horizontally
w = h * v_ar;
scale[0] = 2 / w;
scale[1] = 2 / h;
}
scale[2] = 1;
this._scale(scale);
if (rotation) {
this._rotate(rotation);
}
// translate to the new center.
translate[0] = -center.x;
translate[1] = -center.y;
translate[2] = 0;
this._translate(translate);
return this;
};
/**
* Sets the view matrix so that the given world center is centered, at
* least a certain width and height are visible, and a rotation is applied.
* The resulting bounds may be larger in width or height than the values if
* the viewport is a different aspect ratio.
*
* @param {object} center Center of the view in gcs coordinates.
* @param {number} center.x
* @param {number} center.y
* @param {object} size Minimum size of the view in gcs units.
* @param {number} size.width
* @param {number} size.height
* @param {number} rotation in clockwise radians. Optional.
* @returns {this} Chainable.
*/
this.viewFromCenterSizeRotation = function (center, size, rotation) {
this._viewFromCenterSizeRotation(center, size, rotation);
this._update();
return this;
};
/**
* Pans the view matrix by the given amount.
*
* @param {object} offset The delta in world space coordinates.
* @param {number} offset.x
* @param {number} offset.y
* @param {number} [offset.z=0]
* @returns {this} Chainable.
*/
this.pan = function (offset) {
if (!offset.x && !offset.y && !offset.z) {
return;
}
this._translate([
offset.x,
offset.y,
offset.z || 0
]);
this._update();
return this;
};
/**
* Zooms the view matrix by the given amount.
*
* @param {number} zoom The zoom scale to apply
* @returns {this} Chainable.
*/
this.zoom = function (zoom) {
if (zoom === 1) {
return;
}
mat4.scale(this._view, this._view, [
zoom,
zoom,
zoom
]);
this._update();
return this;
};
/**
* Rotate the view matrix by the given amount.
*
* @param {number} rotation Counter-clockwise rotation angle in radians.
* @param {object} center Center of rotation in world space coordinates.
* @param {vec3} [axis=[0, 0, -1]] axis of rotation.
* @returns {this} Chainable.
*/
this._rotate = function (rotation, center, axis) {
if (!rotation) {
return;
}
axis = axis || [0, 0, -1];
if (!center) {
center = [0, 0, 0];
} else if (center.x !== undefined) {
center = [center.x || 0, center.y || 0, center.z || 0];
}
var invcenter = [-center[0], -center[1], -center[2]];
mat4.translate(this._view, this._view, center);
mat4.rotate(this._view, this._view, rotation, axis);
mat4.translate(this._view, this._view, invcenter);
return this;
};
/**
* Returns a CSS transform that converts (by default) from world coordinates
* into display coordinates. This allows users of this module to position
* elements using world coordinates directly inside DOM elements. This
* expects that the transform-origin is 0 0.
*
* @param {string} [transform='display'] The transform to return. One of
* `display` or `world`.
* @returns {string} The css transform string.
*/
this.css = function (transform) {
var m;
switch ((transform || '').toLowerCase()) {
case 'display':
case '':
m = this.display;
break;
case 'world':
m = this.world;
break;
default:
throw new Error('Unknown transform ' + transform);
}
return camera.css(m);
};
/**
* Represent a glmatrix as a pretty-printed string.
* @param {mat4} mat A 4 x 4 matrix.
* @param {number} [prec] The number of decimal places.
* @returns {string}
*/
this.ppMatrix = function (mat, prec) {
var t = mat;
prec = prec || 2;
function f(i) {
var d = t[i], s = d.toExponential(prec);
if (d >= 0) {
s = ' ' + s;
}
return s;
}
return [
[f(0), f(4), f(8), f(12)].join(' '),
[f(1), f(5), f(9), f(13)].join(' '),
[f(2), f(6), f(10), f(14)].join(' '),
[f(3), f(7), f(11), f(15)].join(' ')
].join('\n');
};
/**
* Pretty print the transform matrix.
* @returns {string} A string representation of the matrix.
*/
this.toString = function () {
return this.ppMatrix(this._transform);
};
/**
* Return a debugging string of the current camera state.
* @returns {string} A string with the camera state.
*/
this.debug = function () {
return [
'bounds',
JSON.stringify(this.bounds),
'view:',
this.ppMatrix(this._view),
'projection:',
this.ppMatrix(this._proj),
'transform:',
this.ppMatrix(this._transform)
].join('\n');
};
/**
* Represent the value of the camera as its transform matrix.
* @returns {mat4} The transform matrix.
*/
this.valueOf = function () {
return this._transform;
};
this._clipbounds = this.constructor.clipbounds;
// initialize the view matrix
this._resetView();
// set up the projection matrix
this.projection = spec.projection || 'parallel';
// initialize the viewport
if (spec.viewport) {
this.viewport = spec.viewport;
}
// trigger an initial update to set up the camera state
this._update();
return this;
};
/**
* Supported projection types.
* @enum {boolean}
*/
camera.projection = {
perspective: true,
parallel: true
};
/**
* Default camera clipping bounds. Some features and renderers may rely on the
* far clip value being more positive than the near clip value.
* @enum {number}
*/
camera.clipbounds = {
perspective: {
left: -1,
right: 1,
top: 1,
bottom: -1,
far: 2000,
near: 0.01
},
parallel: {
left: -1,
right: 1,
top: 1,
bottom: -1,
far: -1,
near: 1
}
};
/**
* Output a mat4 as a css transform. This expects that the transform-origin is
* 0 0.
*
* @variation 2
* @param {mat4} t A matrix transform.
* @returns {string} A css transform string.
*/
camera.css = function (t) {
return 'matrix3d(' +
t.map(function (val) {
/* Format each value with a certain precision, but don't use scientific
* notation or keep needless trailing zeroes. */
val = (+val).toPrecision(15);
if (val.indexOf('e') >= 0) {
val = (+val).toString();
} else if (val.indexOf('.') >= 0) {
val = val.replace(/(\.|)0+$/, '');
}
return val;
}).join(',') +
')';
};
// expose the vector and matrix functions for convenience
camera.vec3 = vec3;
camera.mat3 = mat3;
camera.vec4 = vec4;
camera.mat4 = mat4;
inherit(camera, object);
module.exports = camera;