itowns
Version:
A JS/WebGL framework for 3D geospatial data visualization
554 lines (540 loc) • 21.2 kB
JavaScript
import * as THREE from 'three';
import TWEEN from '@tweenjs/tween.js';
import DEMUtils from "./DEMUtils.js";
import { MAIN_LOOP_EVENTS } from "../Core/MainLoop.js";
import { Coordinates, Ellipsoid } from '@itowns/geographic';
import OBB from "../Renderer/OBB.js";
import { VIEW_EVENTS } from "../Core/View.js";
THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
const targetPosition = new THREE.Vector3();
const targetCoord = new Coordinates('EPSG:4326', 0, 0, 0);
const ellipsoid = new Ellipsoid();
const rigs = [];
const obb = new OBB();
const size = new THREE.Vector3();
const deferred = () => {
let resolve;
let reject;
return {
promise: new Promise((re, rej) => {
resolve = re;
reject = rej;
}),
resolve,
reject
};
};
// Wrap angle in degrees to [-180 180]
function wrapTo180(angle) {
return angle - Math.floor((angle + 180.0) / 360) * 360;
}
function tileLayer(view) {
return view.getLayers(l => l.isTiledGeometryLayer)[0];
}
export function getLookAtFromMath(view, camera) {
const direction = new THREE.Vector3(0, 0, 0.5);
direction.unproject(camera);
direction.sub(camera.position).normalize();
if (view.referenceCrs == 'EPSG:4978') {
// Intersect Ellispoid
return ellipsoid.intersection({
direction,
origin: camera.position
});
} else {
// Intersect plane
const distance = camera.position.z / direction.z;
return direction.multiplyScalar(distance).add(camera.position);
}
}
function proxyProperty(view, camera, rig, key) {
rig.proxy.position[key] = camera.position[key];
Object.defineProperty(camera.position, key, {
get: () => rig.proxy.position[key],
set: newValue => {
rig.removeProxy(view, camera);
camera.position[key] = newValue;
}
});
}
// the rig is used to manipulate the camera
// It consists of a tree of 3D objects, each element is assigned a task
//
// Transformation
//
// rig position on Coordinate (for the globe is rotation)
// |
// └── sealevel position on altitude zero
// |
// └── target position on DEM, and rotation (pitch and heading)
// |
// └── camera distance to target
//
// When all transformations are calculated,
// this.camera's transformation is applied to view.camera.camera
class CameraRig extends THREE.Object3D {
constructor() {
super();
// seaLevel is on rig's z axis, it's at altitude zero
this.seaLevel = new THREE.Object3D();
// target is on seaLevel's z axis and target.position.z is the DEM altitude
this.target = new THREE.Object3D();
this.target.rotation.order = 'ZXY';
// camera look at target
this.camera = new THREE.Camera();
this.add(this.seaLevel);
this.seaLevel.add(this.target);
this.target.add(this.camera);
// target's geographic coordinate
this.coord = new Coordinates('EPSG:4978', 0, 0);
// sea level's worldPoistion
this.targetWorldPosition = new THREE.Vector3();
this.removeAll = () => {};
this._onChangeCallback = null;
}
// apply rig.camera's transformation to camera
applyTransformToCamera(view, camera) {
if (this.proxy) {
camera.quaternion._onChange(this._onChangeCallback);
this.camera.matrixWorld.decompose(this.proxy.position, camera.quaternion, camera.scale);
camera.quaternion._onChange(() => this.removeProxy(view, camera));
} else {
this.camera.matrixWorld.decompose(camera.position, camera.quaternion, camera.scale);
}
view.dispatchEvent({
type: VIEW_EVENTS.CAMERA_MOVED,
coord: this.coord,
range: this.range,
heading: this.heading,
tilt: this.tilt
});
}
setProxy(view, camera) {
if (!this.proxy && view && camera) {
this.proxy = {
position: new THREE.Vector3()
};
Object.keys(camera.position).forEach(key => proxyProperty(view, camera, this, key));
this._onChangeCallback = camera.quaternion._onChangeCallback;
camera.quaternion._onChange(() => this.removeProxy(view, camera));
}
}
removeProxy(view, camera) {
this.stop(view);
if (this.proxy && view && camera) {
Object.keys(camera.position).forEach(key => Object.defineProperty(camera.position, key, {
value: this.proxy.position[key],
writable: true
}));
camera.quaternion._onChange(this._onChangeCallback);
this.proxy = null;
}
}
setTargetFromCoordinate(view, coord) {
// compute precise coordinate (coord) altitude and clamp it above seaLevel
coord.as(tileLayer(view).extent.crs, this.coord);
const altitude = Math.max(0, DEMUtils.getElevationValueAt(tileLayer(view), this.coord, DEMUtils.PRECISE_READ_Z) || this.coord.z);
this.coord.z = altitude;
// adjust target's position with clamped altitude
this.coord.as(view.referenceCrs).toVector3(targetPosition);
if (view.referenceCrs == 'EPSG:4978') {
// ellipsoid geocentric projection
this.lookAt(targetPosition);
this.seaLevel.position.set(0, 0, targetPosition.length() - altitude);
} else {
// planar projection
this.position.set(targetPosition.x, targetPosition.y, 0);
this.seaLevel.position.set(0, 0, 0);
}
// place camera's target
this.target.position.set(0, 0, altitude);
}
// set rig's objects transformation from camera's position and target's position
setFromPositions(view, cameraPosition) {
this.setTargetFromCoordinate(view, new Coordinates(view.referenceCrs).setFromVector3(targetPosition));
this.target.rotation.set(0, 0, 0);
this.updateMatrixWorld(true);
this.camera.position.copy(cameraPosition);
this.target.worldToLocal(this.camera.position);
const range = this.camera.position.length();
this.target.rotation.x = Math.asin(this.camera.position.z / range);
const cosPlanXY = THREE.MathUtils.clamp(this.camera.position.y / (Math.cos(this.target.rotation.x) * range), -1, 1);
this.target.rotation.z = Math.sign(-this.camera.position.x || 1) * Math.acos(cosPlanXY);
this.camera.position.set(0, range, 0);
}
// set from target's coordinate, rotation and range between target and camera
applyParams(view, params) {
if (params.coord) {
this.setTargetFromCoordinate(view, params.coord);
}
if (params.tilt != undefined) {
this.target.rotation.x = THREE.MathUtils.degToRad(params.tilt);
}
if (params.heading != undefined) {
this.target.rotation.z = THREE.MathUtils.degToRad(-wrapTo180(params.heading + 180));
}
if (params.range) {
this.camera.position.set(0, params.range, 0);
}
this.camera.rotation.set(-Math.PI * 0.5, 0, Math.PI);
this.updateMatrixWorld(true);
this.targetWorldPosition.setFromMatrixPosition(this.seaLevel.matrixWorld);
}
getParams() {
return {
coord: this.coord.clone(),
tilt: this.tilt,
heading: this.heading,
range: this.range,
targetWorldPosition: this.targetWorldPosition
};
}
setfromCamera(view, camera, pickedPosition) {
camera.updateMatrixWorld(true);
if (pickedPosition == undefined) {
pickedPosition = view.getPickingPositionFromDepth() || getLookAtFromMath(view, camera);
}
const range = pickedPosition && !isNaN(pickedPosition.x) ? camera.position.distanceTo(pickedPosition) : 100;
camera.localToWorld(targetPosition.set(0, 0, -range));
this.setFromPositions(view, camera.position);
}
copyObject3D(rig) {
this.copy(rig, false);
this.seaLevel.copy(rig.seaLevel, false);
this.target.copy(rig.target, false);
this.camera.copy(rig.camera);
return this;
}
animateCameraToLookAtTarget(view, camera, params) {
params.easing = params.easing || TWEEN.Easing.Quartic.InOut;
this.setfromCamera(view, camera);
const tweenGroup = new TWEEN.Group();
this.start = (this.start || new CameraRig()).copyObject3D(this);
this.end = (this.end || new CameraRig()).copyObject3D(this);
const time = params.time || 2500;
const factor = {
t: 0
};
const animations = [];
const def = deferred();
this.addPlaceTargetOnGround(view, camera, params.coord, factor);
this.end.applyParams(view, params);
// compute the angle along z-axis between the starting position and the end position
const difference = this.end.target.rotation.z - this.start.target.rotation.z;
// if that angle is superior to 180°, recompute the rotation as the complementary angle.
if (Math.abs(difference) > Math.PI) {
this.end.target.rotation.z = this.start.target.rotation.z + difference - Math.sign(difference) * 2 * Math.PI;
}
animations.push(new TWEEN.Tween(factor).to({
t: 1
}, time).easing(params.easing).onUpdate(d => {
// rotate to coord destination in geocentric projection
if (view.referenceCrs == 'EPSG:4978') {
this.quaternion.slerpQuaternions(this.start.quaternion, this.end.quaternion, d.t);
}
// camera rotation
this.camera.quaternion.slerpQuaternions(this.start.camera.quaternion, this.end.camera.quaternion, d.t);
// camera's target rotation
this.target.rotation.set(0, 0, 0);
this.target.rotateZ(THREE.MathUtils.lerp(this.start.target.rotation.z, this.end.target.rotation.z, d.t));
this.target.rotateX(THREE.MathUtils.lerp(this.start.target.rotation.x, this.end.target.rotation.x, d.t));
}));
// translate to coordinate destination in planar projection
if (view.referenceCrs != 'EPSG:4978') {
animations.push(new TWEEN.Tween(this.position).to(this.end.position, time).easing(params.easing));
}
// translate to altitude zero
animations.push(new TWEEN.Tween(this.seaLevel.position).to(this.end.seaLevel.position, time).easing(params.easing));
// translate camera position
animations.push(new TWEEN.Tween(this.camera.position).to(this.end.camera.position, time).easing(params.easing));
tweenGroup.add(...animations);
// update animations, transformation and view
this.animationFrameRequester = () => {
tweenGroup.update();
this.updateMatrixWorld(true);
this.applyTransformToCamera(view, camera);
this.targetWorldPosition.setFromMatrixPosition(this.seaLevel.matrixWorld);
if (params.callback) {
params.callback(this);
}
targetCoord.crs = view.referenceCrs;
targetCoord.setFromVector3(this.targetWorldPosition).as(tileLayer(view).extent.crs, this.coord);
view.notifyChange(camera);
};
this.removeAll = function (o) {
this.removeAll = () => {};
tweenGroup.removeAll();
if (this.animationFrameRequester) {
view.removeFrameRequester(MAIN_LOOP_EVENTS.BEFORE_RENDER, this.animationFrameRequester);
}
def.resolve(o !== undefined);
this.animationFrameRequester = null;
};
// Waiting last animation complete,
// we assume that the animation that completes last is the one that was started last
animations[animations.length - 1].onComplete(this.removeAll);
animations.forEach(anim => anim.start());
view.addFrameRequester(MAIN_LOOP_EVENTS.BEFORE_RENDER, this.animationFrameRequester);
view.notifyChange(camera);
return def;
}
stop(view) {
this.removePlaceTargetOnGround(view);
this.removeAll();
}
// update target position to coordinate's altitude
addPlaceTargetOnGround(view, camera, coord) {
let options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {
t: 1.0
};
this.removePlaceTargetOnGround(view);
if (view && camera) {
const startAltitude = this.target.position.z;
this.placeTargetOnGround = () => {
const altitude = Math.max(0, DEMUtils.getElevationValueAt(tileLayer(view), coord || this.coord, DEMUtils.PRECISE_READ_Z) || 0);
this.target.position.z = startAltitude * (1.0 - options.t) + altitude * options.t;
this.target.updateMatrixWorld(true);
this.applyTransformToCamera(view, camera);
};
this.placeTargetOnGround();
view.addFrameRequester(MAIN_LOOP_EVENTS.BEFORE_RENDER, this.placeTargetOnGround);
}
}
removePlaceTargetOnGround(view) {
if (view && this.placeTargetOnGround) {
view.removeFrameRequester(MAIN_LOOP_EVENTS.BEFORE_RENDER, this.placeTargetOnGround);
this.placeTargetOnGround = null;
}
}
get tilt() {
return THREE.MathUtils.radToDeg(this.target.rotation.x);
}
get heading() {
return -wrapTo180(THREE.MathUtils.radToDeg(this.target.rotation.z) + 180);
}
get range() {
return this.camera.position.y;
}
}
export function getRig(camera) {
rigs[camera.uuid] = rigs[camera.uuid] || new CameraRig();
return rigs[camera.uuid];
}
/**
* @module CameraUtils
*/
export default {
/**
* @typedef {Object} CameraTransformOptions
* @property {Coordinate} [coord=currentCoordinate] Camera look at geographic coordinate
* @property {Number} [tilt=currentTilt] camera's tilt, in degree
* @property {Number} [heading=currentHeading] camera's heading, in degree
* @property {Number} [range=currentRange] camera distance to target coordinate, in meter
* @property {Number} [time=2500] duration of the animation, in ms
* @property {boolean} [proxy=true] use proxy to handling camera's transformation. if proxy == true, other camera's transformation stops rig's transformation
* @property {Number} [easing=TWEEN.Easing.Quartic.InOut] in and out easing animation
* @property {function} [callback] callback call each animation's frame (params are current cameraTransform and worldTargetPosition)
* @property {boolean} [stopPlaceOnGroundAtEnd=false] stop place target on the ground at animation ending
*/
/**
* Default value for option to stop place target
* on the ground at animation ending.
* Default value is false.
*/
defaultStopPlaceOnGroundAtEnd: false,
Easing: TWEEN.Easing,
/**
* Stop camera's animation
*
* @param {View} view The camera view
* @param {Camera} camera The camera to stop animation
*/
stop(view, camera) {
getRig(camera).stop(view);
},
/**
* Gets the current parameters transform camera looking at target.
*
* @param {View} view The camera view
* @param {Camera} camera The camera to get transform
* @param {THREE.Vector3} [target] - The optional target
* @return {CameraUtils~CameraTransformOptions} The transform camera looking at target
*/
getTransformCameraLookingAtTarget(view, camera, target) {
const rig = getRig(camera);
rig.setfromCamera(view, camera, target);
return rig.getParams();
},
/**
* Apply transform to camera
*
* @param {View} view The camera view
* @param {Camera} camera The camera to transform
* @param {CameraUtils~CameraTransformOptions|Extent} params The parameters
* @return {Promise} promise with resolve final CameraUtils~CameraTransformOptions
*/
transformCameraToLookAtTarget(view, camera) {
let params = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
if (params.isExtent) {
params = this.getCameraTransformOptionsFromExtent(view, camera, params);
}
params.proxy = params.proxy === undefined || params.proxy;
const rig = getRig(camera);
rig.stop(view);
rig.setfromCamera(view, camera);
if (params.proxy) {
rig.setProxy(view, camera);
}
rig.applyParams(view, params);
rig.addPlaceTargetOnGround(view, camera, params.coord);
rig.applyTransformToCamera(view, camera);
view.notifyChange(camera);
return Promise.resolve(rig.getParams());
},
/**
* Compute the CameraTransformOptions that allow a given camera to display a given extent in its entirety.
*
* @param {View} view The camera view
* @param {THREE.Camera} camera The camera to get the CameraTransformOptions from
* @param {Extent} extent The extent the camera must display
*
* @return {CameraUtils~CameraTransformOptions} The CameraTransformOptions allowing camera to display the extent.
*/
getCameraTransformOptionsFromExtent(view, camera, extent) {
const cameraTransformOptions = {
coord: new Coordinates(extent.crs, 0, 0, 0),
heading: 0,
tilt: view.isPlanarView ? 90 : 89.9
};
let dimensions;
if (view.isGlobeView) {
extent = extent.as('EPSG:4326');
// compute extent's bounding box dimensions
obb.setFromExtent(extent);
// /!\ WARNING x and y are inverted, see issue #XXXX
obb.box3D.getSize(size);
dimensions = {
x: size.y,
y: size.x
};
} else {
extent = extent.as(view.referenceCrs);
dimensions = extent.planarDimensions();
}
extent.center(cameraTransformOptions.coord);
if (camera.isOrthographicCamera) {
// setup camera zoom
if (dimensions.x / dimensions.y > camera.aspect) {
camera.zoom = (camera.right - camera.left) / dimensions.x;
} else {
camera.zoom = (camera.top - camera.bottom) / dimensions.y;
}
camera.updateProjectionMatrix();
// setup camera placement
cameraTransformOptions.range = 1000;
} else if (camera.isPerspectiveCamera) {
// setup range for camera placement
const verticalFOV = THREE.MathUtils.degToRad(camera.fov);
if (dimensions.x / dimensions.y > camera.aspect) {
const focal = view.domElement.clientHeight * 0.5 / Math.tan(verticalFOV * 0.5);
const horizontalFOV = 2 * Math.atan(view.domElement.clientWidth * 0.5 / focal);
cameraTransformOptions.range = dimensions.x / (2 * Math.tan(horizontalFOV * 0.5));
} else {
cameraTransformOptions.range = dimensions.y / (2 * Math.tan(verticalFOV * 0.5));
}
}
return cameraTransformOptions;
},
/**
* Apply transform to camera with animation
*
* @param {View} view The camera view
* @param {Camera} camera The camera to animate
* @param {CameraUtils~CameraTransformOptions} params The parameters
* @return {Promise} promise with resolve final CameraUtils~CameraTransformOptions
*/
animateCameraToLookAtTarget(view, camera) {
let params = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {};
params.proxy = params.proxy === undefined || params.proxy;
const rig = getRig(camera);
rig.stop(view);
if (params.proxy) {
rig.setProxy(view, camera);
}
return rig.animateCameraToLookAtTarget(view, camera, params).promise.then(finished => {
const stopPlaceOnGround = params.stopPlaceOnGroundAtEnd === undefined ? this.defaultStopPlaceOnGroundAtEnd : params.stopPlaceOnGroundAtEnd;
const newTransformation = rig.getParams();
if (stopPlaceOnGround) {
rig.stop(view);
}
newTransformation.finished = finished;
return newTransformation;
});
},
/**
* chain animation transform to camera
*
* @param {View} view The camera view
* @param {Camera} camera The camera to animate
* @param {CameraUtils~CameraTransformOptions[]} params array parameters, each parameters transforms are apply to camera, in serial
* @return {Promise} promise with resolve final CameraUtils~CameraTransformOptions
*/
sequenceAnimationsToLookAtTarget(view, camera) {
let params = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : [{}];
// convert each param to a function
const funcs = params.map(param => () => this.animateCameraToLookAtTarget(view, camera, param));
// execute Promises in serial
return (funcs => funcs.reduce((promise, func) => promise.then(result => {
const finished = result.length ? result[result.length - 1].finished : true;
if (finished) {
return func().then(Array.prototype.concat.bind(result));
} else {
return Promise.resolve([{
finished: false
}]);
}
}), Promise.resolve([])))(funcs);
},
/**
* Gets the difference camera transformation
*
* @param {CameraUtils~CameraTransformOptions} first param to compare with the second
* @param {CameraUtils~CameraTransformOptions} second param to compare with the first
* @return {object} The difference parameters
*/
getDiffParams(first, second) {
if (!first || !second) {
return;
}
let diff;
if (Math.abs(first.range - second.range) / first.range > 0.001) {
diff = diff || {};
diff.range = {
previous: first.range,
new: second.range
};
}
if (Math.abs(first.tilt - second.tilt) > 0.1) {
diff = diff || {};
diff.tilt = {
previous: first.tilt,
new: second.tilt
};
}
if (Math.abs(first.heading - second.heading) > 0.1) {
diff = diff || {};
diff.heading = {
previous: first.heading,
new: second.heading
};
}
if (Math.abs(first.coord.x - second.coord.x) > 0.000001 || Math.abs(first.coord.y - second.coord.y) > 0.000001) {
diff = diff || {};
diff.coord = {
previous: first.coord,
new: second.coord
};
}
return diff;
}
};