UNPKG

itowns

Version:

A JS/WebGL framework for 3D geospatial data visualization

392 lines (370 loc) 15.2 kB
import TWEEN from '@tweenjs/tween.js'; import * as THREE from 'three'; import { MAIN_LOOP_EVENTS } from "../Core/MainLoop.js"; import FirstPersonControls from "./FirstPersonControls.js"; const material = new THREE.MeshBasicMaterial({ color: 0xffffff, depthTest: false, transparent: true, opacity: 0.5 }); function createCircle() { const geomCircle = new THREE.CircleGeometry(1, 32); return new THREE.Mesh(geomCircle, material); } function createRectangle() { const geomPlane = new THREE.PlaneGeometry(4, 2, 1); const rectangle = new THREE.Mesh(geomPlane, material); rectangle.rotateX(-Math.PI * 0.5); return rectangle; } // update a surfaces node function updateSurfaces(surfaces, position, norm) { surfaces.position.copy(position); surfaces.up.copy(position).normalize(); surfaces.lookAt(norm); surfaces.updateMatrixWorld(true); } // vector use in the pick method const target = new THREE.Vector3(); const normal = new THREE.Vector3(); const normalMatrix = new THREE.Matrix3(); const up = new THREE.Vector3(); const startQuaternion = new THREE.Quaternion(); function pick(event, view, buildingsLayer) { let pickGround = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : () => {}; let pickObject = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : () => {}; let pickNothing = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : () => {}; // get real distance to ground, with a specific method to pick on the elevation layer view.getPickingPositionFromDepth(view.eventToViewCoords(event), target); const distanceToGround = view.camera3D.position.distanceTo(target); // pick on building layer const buildings = buildingsLayer ? view.pickObjectsAt(event, -1, buildingsLayer) : []; // to detect pick on building, compare first picked building distance to ground distance if (buildings.length && buildings[0].distance < distanceToGround) { // pick buildings // callback normalMatrix.getNormalMatrix(buildings[0].object.matrixWorld); normal.copy(buildings[0].face.normal).applyNormalMatrix(normalMatrix); pickObject(buildings[0].point, normal); } else if (view.tileLayer) { const far = view.camera3D.far * 0.95; if (distanceToGround < far) { // compute normal if (view.tileLayer.isGlobeLayer) { up.copy(target).multiplyScalar(1.1); } else { up.set(0, 0, 1); } // callback pickGround(target, up); } else { // callback pickNothing(); } } else { pickNothing(); } } // default function to compute time (in millis), used for the animation to move to a distance (in meter) function computeTime(distance) { return 100 + Math.sqrt(distance) * 30; } /** * Camera controls that can follow a path. * It is used to simulate a street view. * It stores a currentPosition and nextPosition, and do a camera traveling to go to next position. * It also manages picking on the ground and on other object, like building. * <ul> It manages 2 surfaces, used as helpers for the end user : * <li> a circle is shown when mouse is moving on the ground </li> * <li> a rectangle is shown when mouse is moving on other 3d object </li> * </ul> * <ul> * This controls is designed * <li> to move forward when user click on the ground (click and go) </li> * <li> to rotate the camera when user click on other object (click to look at) </li> * </ul> * <ul> Bindings inherited from FirstPersonControls * <li><b> up + down keys : </b> forward/backward </li> * <li><b> left + right keys: </b> strafing movements </li> * <li><b> pageUp + pageDown: </b> vertical movements </li> * <li><b> mouse click+drag: </b> pitch and yaw movements (as looking at a panorama) </li> * </ul> * <ul> Bindings added * <li><b> keys Z : </b> Move camera to the next position </li> * <li><b> keys S : </b> Move camera to the previous position </li> * <li><b> keys A : </b> Set camera to current position and look at next position</li> * <li><b> keys Q : </b> Set camera to current position and look at previous position</li> * </ul> * Note that it only works in globe view. * @property {number} keyGoToNextPosition key code to go to next position, default to 90 for key Z * @property {number} keyGoToPreviousPosition key code to go to previous position, default to 83 for key S * @property {number} keySetCameraToCurrentPositionAndLookAtNext key code set camera to current position, default to 65 for key A * @property {number} keySetCameraToCurrentPositionAndLookAtPrevious key code set camera to current position, default to 81 for key Q * @extends FirstPersonControls */ class StreetControls extends FirstPersonControls { /** * @param { View } view - View where this control will be used * @param { Object } options - Configuration of this controls * @param { number } [options.wallMaxDistance=1000] - Maximum distance to click on a wall, in meter. * @param { number } [options.animationDurationWall=200] - Time in millis for the animation when clicking on a wall. * @param { THREE.Mesh } [options.surfaceGround] - Surface helper to see when mouse is on the ground, default is a transparent circle. * @param { THREE.Mesh } [options.surfaceWall] - Surface helper to see when mouse is on a wall, default is a transparent rectangle. * @param { string } [options.buildingsLayer='Buildings'] - Name of the building layer (used to pick on wall). * @param { function } [options.computeTime] - Function to compute time (in millis), used for the animation to move to a distance (in meter) * @param { number } [options.offset=4] - Altitude in meter up to the ground to move to when click on a target on the ground. */ constructor(view) { let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; super(view, options); this.isStreetControls = true; this._onMouseOut = super.onMouseUp.bind(this); view.domElement.addEventListener('mouseout', this._onMouseOut); // two positions used by this control : current and next this.previousPosition = undefined; this.currentPosition = undefined; this.nextPosition = undefined; this.keyGoToNextPosition = 90; this.keyGoToPreviousPosition = 83; this.keySetCameraToCurrentPositionAndLookAtNext = 65; this.keySetCameraToCurrentPositionAndLookAtPrevious = 81; // Tween is used to make smooth animations this.tweenGroup = new TWEEN.Group(); // init surfaces used as helper for end user this.surfaceGround = options.surfaceGround || createCircle(); this.surfaceWall = options.surfaceWall || createRectangle(); // surfaces is an object3D containing the two surfaces this.surfaces = new THREE.Object3D(); this.surfaces.add(this.surfaceGround); this.surfaces.add(this.surfaceWall); this.view.scene.add(this.surfaces); this.wallMaxDistance = options.wallMaxDistance || 1000; this.animationDurationWall = options.animationDurationWall || 200; this.buildingsLayer = options.buildingsLayer; this.computeTime = options.computeTime || computeTime; this.offset = options.offset || 4; this.transformationPositionPickOnTheGround = options.transformationPositionPickOnTheGround || (position => position); this.end = this.camera.clone(); } setCurrentPosition(newCurrentPosition) { this.currentPosition = newCurrentPosition; } setNextPosition(newNextPosition) { this.nextPosition = newNextPosition; } setPreviousPosition(newPreviousPosition) { this.previousPosition = newPreviousPosition; } onMouseUp(event) { if (this.enabled == false) { return; } super.onMouseUp(); if (this._stateOnMouseDrag) { this._stateOnMouseDrag = false; } else { pick(event, this.view, this.buildingsLayer, this.onClickOnGround.bind(this), this.onClickOnWall.bind(this)); } } onMouseMove(event) { if (this.enabled == false) { return; } super.onMouseMove(event); if (this._isMouseDown) { // state mouse drag (move + mouse click) this._stateOnMouseDrag = true; this.stopAnimations(); } else if (!this.tween) { // mouse pick and manage surfaces pick(event, this.view, this.buildingsLayer, (groundTarget, normal) => { updateSurfaces(this.surfaces, groundTarget, normal); this.surfaceGround.visible = true; this.surfaceWall.visible = false; }, (wallTarget, normal) => { updateSurfaces(this.surfaces, wallTarget, normal); this.surfaceWall.visible = true; this.surfaceGround.visible = false; }); this.view.notifyChange(this.surfaces); } } /** * Sets the camera to the current position (stored in this controls), looking at the next position (also stored in this controls). * * @param { boolean } lookAtPrevious look at the previous position rather than the next one */ setCameraToCurrentPosition(lookAtPrevious) { if (lookAtPrevious) { this.setCameraOnPosition(this.currentPosition, this.previousPosition); } else { this.setCameraOnPosition(this.currentPosition, this.nextPosition); } } /** * Set the camera on a position, looking at another position. * * @param { THREE.Vector3 } position The position to set the camera * @param { THREE.Vector3 } lookAt The position where the camera look at. */ setCameraOnPosition(position, lookAt) { if (!position || !lookAt) { return; } this.camera.position.copy(position); if (this.view.tileLayer && this.view.tileLayer.isGlobeLayer) { this.camera.up.copy(position).normalize(); } else { this.camera.up.set(0, 0, 1); } this.camera.lookAt(lookAt); this.camera.updateMatrixWorld(); this.reset(); } /** * Method called when user click on the ground.</br> * Note that this funtion contains default values that can be overrided, by overriding this class. * * @param {THREE.Vector3} position - The position */ onClickOnGround(position) { position = this.transformationPositionPickOnTheGround(position); if (this.view.tileLayer && this.view.tileLayer.isGlobeLayer) { up.copy(position).normalize(); } else { up.set(0, 0, 1); } position.add(up.multiplyScalar(this.offset)); // compute time to go there const distance = this.camera.position.distanceTo(position); // 500 millis constant, plus an amount of time depending of the distance (but not linearly) const time = this.computeTime(distance); // move the camera this.moveCameraTo(position, time); } /** * Method called when user click on oject that is not the ground.</br> * Note that this function contains default values that can be overrided, by overriding this class. * * @param {THREE.Vector3} position - The position */ onClickOnWall(position) { const distance = this.camera.position.distanceTo(position); // can't click on a wall that is at 1km distance. if (distance < this.wallMaxDistance) { this.animateCameraLookAt(position, this.animationDurationWall); } } /** * Animate the camera to make it look at a position, in a given time * * @param { THREE.Vector3 } position - Position to look at * @param { number } time - Time in millisecond */ animateCameraLookAt(position, time) { // stop existing animation this.stopAnimations(); // prepare start point and end point startQuaternion.copy(this.camera.quaternion); this.end.copy(this.camera); this.end.lookAt(position); this.tween = new TWEEN.Tween({ t: 0 }).to({ t: 1 }, time).easing(TWEEN.Easing.Quadratic.Out).onComplete(() => { this.stopAnimations(); }).onUpdate(d => { // 'manually' slerp the Quaternion to avoid rotation issues this.camera.quaternion.slerpQuaternions(startQuaternion, this.end.quaternion, d.t); }).start(); this.tweenGroup.add(this.tween); this.animationFrameRequester = () => { this.tweenGroup.update(); // call reset from super class FirsPersonControls to make mouse rotation managed by FirstPersonControl still aligned this.reset(); this.view.notifyChange(this.camera); }; this.view.addFrameRequester(MAIN_LOOP_EVENTS.BEFORE_RENDER, this.animationFrameRequester); this.view.notifyChange(this.camera); } /** * Move the camera smoothly to the position, in a given time. * * @param { THREE.Vector3 } position - Destination of the movement. * @param { number } time - Time in millisecond * @return { Promise } */ moveCameraTo(position) { let time = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 50; if (!position) { return Promise.resolve(); } let resolve; const promise = new Promise(r => { resolve = r; }); this.stopAnimations(); this.tween = new TWEEN.Tween(this.camera.position) // Create a new tween that modifies camera position .to(position.clone(), time).easing(TWEEN.Easing.Quadratic.Out) // Use an easing function to make the animation smooth. .onComplete(() => { this.stopAnimations(); resolve(); }).start(); this.tweenGroup.add(this.tween); this.animationFrameRequester = () => { this.tweenGroup.update(); this.view.notifyChange(this.camera); }; this.view.addFrameRequester(MAIN_LOOP_EVENTS.BEFORE_RENDER, this.animationFrameRequester); this.view.notifyChange(this.camera); return promise; } stopAnimations() { if (this.tween) { this.tween.stop(); this.tween = undefined; this.tweenGroup.removeAll(); } if (this.animationFrameRequester) { this.view.removeFrameRequester(MAIN_LOOP_EVENTS.BEFORE_RENDER, this.animationFrameRequester); this.animationFrameRequester = null; } } /** * Move the camera to the 'currentPosition' stored in this control. */ moveCameraToCurrentPosition() { this.moveCameraTo(this.currentPosition); } onKeyDown(e) { if (this.enabled == false) { return; } super.onKeyDown(e); // key to move to next position (default to Z) if (e.keyCode == this.keyGoToNextPosition) { this.moveCameraTo(this.nextPosition); } // key to move to previous position (default to S) if (e.keyCode == this.keyGoToPreviousPosition) { this.moveCameraTo(this.previousPosition); } // key to set to camera to current position looking at next position (default to A) if (e.keyCode == this.keySetCameraToCurrentPositionAndLookAtNext) { this.setCameraToCurrentPosition(); this.view.notifyChange(this.view.camera3D); } // key to set to camera to current position looking at previous position (default to Q) if (e.keyCode == this.keySetCameraToCurrentPositionAndLookAtPrevious) { this.setCameraToCurrentPosition(true); this.view.notifyChange(this.view.camera3D); } } dispose() { this.view.domElement.removeEventListener('mouseout', this._onMouseOut, false); super.dispose(); } } export default StreetControls;