UNPKG

itowns

Version:

A JS/WebGL framework for 3D geospatial data visualization

1,025 lines (926 loc) 39.5 kB
import * as THREE from 'three'; import { MAIN_LOOP_EVENTS } from "../Core/MainLoop.js"; // event keycode export const keys = { CTRL: 17, SPACE: 32, T: 84, Y: 89 }; const mouseButtons = { LEFTCLICK: THREE.MOUSE.LEFT, MIDDLECLICK: THREE.MOUSE.MIDDLE, RIGHTCLICK: THREE.MOUSE.RIGHT }; let currentPressedButton; // starting camera position and orientation target const startPosition = new THREE.Vector3(); const startQuaternion = new THREE.Quaternion(); // camera initial zoom value if orthographic let cameraInitialZoom = 0; // point under the cursor const pointUnderCursor = new THREE.Vector3(); // control state export const STATE = { NONE: -1, DRAG: 0, PAN: 1, ROTATE: 2, TRAVEL: 3, ORTHO_ZOOM: 4 }; // cursor shape linked to control state const cursor = { default: 'auto', drag: 'move', pan: 'cell', travel: 'wait', rotate: 'move', ortho_zoom: 'wait' }; const vectorZero = new THREE.Vector3(); // mouse movement const mousePosition = new THREE.Vector2(); const lastMousePosition = new THREE.Vector2(); const deltaMousePosition = new THREE.Vector2(0, 0); // drag movement const dragStart = new THREE.Vector3(); const dragEnd = new THREE.Vector3(); const dragDelta = new THREE.Vector3(); // camera focus point : ground point at screen center const centerPoint = new THREE.Vector3(0, 0, 0); // camera rotation let phi = 0.0; // displacement and rotation vectors const vect = new THREE.Vector3(); const quat = new THREE.Quaternion(); const vect2 = new THREE.Vector2(); // animated travel const travelEndPos = new THREE.Vector3(); const travelStartPos = new THREE.Vector3(); const travelStartRot = new THREE.Quaternion(); const travelEndRot = new THREE.Quaternion(); let travelAlpha = 0; let travelDuration = 0; let travelUseRotation = false; let travelUseSmooth = false; // zoom changes (for orthographic camera) let startZoom = 0; let endZoom = 0; // ray caster for drag movement const rayCaster = new THREE.Raycaster(); const plane = new THREE.Plane(new THREE.Vector3(0, 0, -1)); // default parameters : const defaultOptions = { enabled: true, enableRotation: true, rotateSpeed: 2.0, minPanSpeed: 0.05, maxPanSpeed: 15, zoomTravelTime: 0.2, // must be a number zoomFactor: 2, maxResolution: 1 / Infinity, minResolution: Infinity, maxAltitude: 50000000, groundLevel: 200, autoTravelTimeMin: 1.5, autoTravelTimeMax: 4, autoTravelTimeDist: 50000, smartTravelHeightMin: 75, smartTravelHeightMax: 500, instantTravel: false, minZenithAngle: 0, maxZenithAngle: 82.5, handleCollision: true, minDistanceCollision: 30, enableSmartTravel: true, enablePan: true }; export const PLANAR_CONTROL_EVENT = { MOVED: 'moved' }; /** * Planar controls is a camera controller adapted for a planar view, with animated movements. * Usage is as follow : * <ul> * <li><b>Left mouse button:</b> drag the camera (translation on the (xy) world plane).</li> * <li><b>Right mouse button:</b> pan the camera (translation on the vertical (z) axis of the world plane).</li> * <li><b>CTRL + Left mouse button:</b> rotate the camera around the focus point.</li> * <li><b>Wheel scrolling:</b> zoom toward the cursor position.</li> * <li><b>Wheel clicking:</b> smart zoom toward the cursor position (animated).</li> * <li><b>Y key:</b> go to the starting view (animated).</li> * <li><b>T key:</b> go to the top view (animated).</li> * </ul> * * @class PlanarControls * @param {PlanarView} view the view where the controls will be used * @param {object} options * @param {boolean} [options.enabled=true] Set to false to disable this control * @param {boolean} [options.enableRotation=true] Enable the rotation with the `CTRL + Left mouse button` * and in animations, like the smart zoom. * @param {boolean} [options.enableSmartTravel=true] Enable smart travel with the `wheel-click / space-bar`. * @param {boolean} [options.enablePan=true] Enable pan movements with the `right-click`. * @param {number} [options.rotateSpeed=2.0] Rotate speed. * @param {number} [options.maxPanSpeed=15] Pan speed when close to maxAltitude. * @param {number} [options.minPanSpeed=0.05] Pan speed when close to the ground. * @param {number} [options.zoomTravelTime=0.2] Animation time when zooming. * @param {number} [options.zoomFactor=2] The factor the scale is multiplied by when zooming * in and divided by when zooming out. This factor can't be null. * @param {number} [options.maxResolution=0] The smallest size in meters a pixel at the center of the * view can represent. * @param {number} [options.minResolution=Infinity] The biggest size in meters a pixel at the center of the * view can represent. * @param {number} [options.maxAltitude=12000] Maximum altitude reachable when panning or zooming out. * @param {number} [options.groundLevel=200] Minimum altitude reachable when panning. * @param {number} [options.autoTravelTimeMin=1.5] Minimum duration for animated travels with the `auto` * parameter. * @param {number} [options.autoTravelTimeMax=4] Maximum duration for animated travels with the `auto` * parameter. * @param {number} [options.autoTravelTimeDist=20000] Maximum travel distance for animated travel with the * `auto` parameter. * @param {number} [options.smartTravelHeightMin=75] Minimum height above ground reachable after a smart * travel. * @param {number} [options.smartTravelHeightMax=500] Maximum height above ground reachable after a smart * travel. * @param {boolean} [options.instantTravel=false] If set to true, animated travels will have no duration. * @param {number} [options.minZenithAngle=0] The minimum reachable zenith angle for a camera * rotation, in degrees. * @param {number} [options.maxZenithAngle=82.5] The maximum reachable zenith angle for a camera * rotation, in degrees. * @param {boolean} [options.handleCollision=true] */ class PlanarControls extends THREE.EventDispatcher { constructor(view) { let options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; super(); this.view = view; this.camera = view.camera3D; // Set to false to disable this control this.enabled = typeof options.enabled == 'boolean' ? options.enabled : defaultOptions.enabled; if (this.camera.isOrthographicCamera) { cameraInitialZoom = this.camera.zoom; // enable rotation movements this.enableRotation = false; // enable pan movements this.enablePan = false; // Camera altitude is clamped under maxAltitude. // This is not relevant for an orthographic camera (since the orthographic camera altitude won't change). // Therefore, neutralizing by default the maxAltitude limit allows zooming out with an orthographic camera, // no matter its initial position. this.maxAltitude = Infinity; // the zoom travel time (stored in `this.zoomTravelTime`) can't be `auto` with an orthographic camera this.zoomTravelTime = typeof options.zoomTravelTime === 'number' ? options.zoomTravelTime : defaultOptions.zoomTravelTime; } else { // enable rotation movements this.enableRotation = options.enableRotation === undefined ? defaultOptions.enableRotation : options.enableRotation; this.rotateSpeed = options.rotateSpeed || defaultOptions.rotateSpeed; // enable pan movements this.enablePan = options.enablePan === undefined ? defaultOptions.enablePan : options.enablePan; // minPanSpeed when close to the ground, maxPanSpeed when close to maxAltitude this.minPanSpeed = options.minPanSpeed || defaultOptions.minPanSpeed; this.maxPanSpeed = options.maxPanSpeed || defaultOptions.maxPanSpeed; // camera altitude is clamped under maxAltitude this.maxAltitude = options.maxAltitude || defaultOptions.maxAltitude; // animation duration for the zoom this.zoomTravelTime = options.zoomTravelTime || defaultOptions.zoomTravelTime; } // zoom movement is equal to the distance to the zoom target, multiplied by zoomFactor if (options.zoomInFactor) { console.warn('Controls zoomInFactor parameter is deprecated. Use zoomFactor instead.'); options.zoomFactor = options.zoomFactor || options.zoomInFactor; } if (options.zoomOutFactor) { console.warn('Controls zoomOutFactor parameter is deprecated. Use zoomFactor instead.'); options.zoomFactor = options.zoomFactor || options.zoomInFactor || 1 / options.zoomOutFactor; } if (options.zoomFactor === 0) { console.warn('Controls zoomFactor parameter can not be equal to 0. Its value will be set to default.'); options.zoomFactor = defaultOptions.zoomFactor; } this.zoomInFactor = options.zoomFactor || defaultOptions.zoomFactor; this.zoomOutFactor = 1 / (options.zoomFactor || defaultOptions.zoomFactor); // the maximum and minimum size (in meters) a pixel at the center of the view can represent this.maxResolution = options.maxResolution || defaultOptions.maxResolution; this.minResolution = options.minResolution || defaultOptions.minResolution; // approximate ground altitude value. Camera altitude is clamped above groundLevel this.groundLevel = options.groundLevel || defaultOptions.groundLevel; // min and max duration in seconds, for animated travels with `auto` parameter this.autoTravelTimeMin = options.autoTravelTimeMin || defaultOptions.autoTravelTimeMin; this.autoTravelTimeMax = options.autoTravelTimeMax || defaultOptions.autoTravelTimeMax; // max travel duration is reached for this travel distance (empirical smoothing value) this.autoTravelTimeDist = options.autoTravelTimeDist || defaultOptions.autoTravelTimeDist; // after a smartZoom, camera height above ground will be between these two values if (options.smartZoomHeightMin) { console.warn('Controls smartZoomHeightMin parameter is deprecated. Use smartTravelHeightMin instead.'); options.smartTravelHeightMin = options.smartTravelHeightMin || options.smartZoomHeightMin; } if (options.smartZoomHeightMax) { console.warn('Controls smartZoomHeightMax parameter is deprecated. Use smartTravelHeightMax instead.'); options.smartTravelHeightMax = options.smartTravelHeightMax || options.smartZoomHeightMax; } this.smartTravelHeightMin = options.smartTravelHeightMin || defaultOptions.smartTravelHeightMin; this.smartTravelHeightMax = options.smartTravelHeightMax || defaultOptions.smartTravelHeightMax; // if set to true, animated travels have 0 duration this.instantTravel = options.instantTravel || defaultOptions.instantTravel; // the zenith angle for a camera rotation will be between these two values this.minZenithAngle = (options.minZenithAngle || defaultOptions.minZenithAngle) * Math.PI / 180; // max value should be less than 90 deg (90 = parallel to the ground) this.maxZenithAngle = (options.maxZenithAngle || defaultOptions.maxZenithAngle) * Math.PI / 180; // focus policy options if (options.focusOnMouseOver) { console.warn('Planar controls \'focusOnMouseOver\' optional parameter has been removed.'); } if (options.focusOnMouseClick) { console.warn('Planar controls \'focusOnMouseClick\' optional parameter has been removed.'); } // set collision options this.handleCollision = options.handleCollision === undefined ? defaultOptions.handleCollision : options.handleCollision; this.minDistanceCollision = defaultOptions.minDistanceCollision; // enable smart travel this.enableSmartTravel = options.enableSmartTravel === undefined ? defaultOptions.enableSmartTravel : options.enableSmartTravel; startPosition.copy(this.camera.position); startQuaternion.copy(this.camera.quaternion); // control state this.state = STATE.NONE; this.cursor = cursor; if (this.view.controls) { // esLint-disable-next-line no-console console.warn('Deprecated use of PlanarControls. See examples to correct PlanarControls implementation.'); this.view.controls.dispose(); } this.view.controls = this; // eventListeners handlers this._handlerOnKeyDown = this.onKeyDown.bind(this); this._handlerOnMouseDown = this.onMouseDown.bind(this); this._handlerOnMouseUp = this.onMouseUp.bind(this); this._handlerOnMouseMove = this.onMouseMove.bind(this); this._handlerOnMouseWheel = this.onMouseWheel.bind(this); this._handlerContextMenu = this.onContextMenu.bind(this); this._handlerUpdate = this.update.bind(this); // add this PlanarControl instance to the view's frameRequesters // with this, PlanarControl.update() will be called each frame this.view.addFrameRequester(MAIN_LOOP_EVENTS.AFTER_CAMERA_UPDATE, this._handlerUpdate); // event listeners for user input (to activate the controls) this.addInputListeners(); } dispose() { this.removeInputListeners(); this.view.removeFrameRequester(MAIN_LOOP_EVENTS.AFTER_CAMERA_UPDATE, this._handlerUpdate); } /** * update the view and camera if needed, and handles the animated travel * @param {number} dt the delta time between two updates in millisecond * @param {boolean} updateLoopRestarted true if we just started rendering * @ignore */ update(dt, updateLoopRestarted) { // dt will not be relevant when we just started rendering. We consider a 1-frame move in this case if (updateLoopRestarted) { dt = 16; } const onMovement = this.state !== STATE.NONE; switch (this.state) { case STATE.TRAVEL: this.handleTravel(dt); this.view.notifyChange(this.camera); break; case STATE.ORTHO_ZOOM: this.handleZoomOrtho(dt); this.view.notifyChange(this.camera); break; case STATE.DRAG: this.handleDragMovement(); this.view.notifyChange(this.camera); break; case STATE.ROTATE: this.handleRotation(); this.view.notifyChange(this.camera); break; case STATE.PAN: this.handlePanMovement(); this.view.notifyChange(this.camera); break; case STATE.NONE: default: break; } // We test if camera collides to the geometry layer or is too close to the ground, and adjust its altitude in // case if (this.handleCollision) { // check distance to the ground/surface geometry (could be another geometry layer) this.view.camera.adjustAltitudeToAvoidCollisionWithLayer(this.view, this.view.tileLayer, this.minDistanceCollision); } if (onMovement) { this.view.dispatchEvent({ type: PLANAR_CONTROL_EVENT.MOVED }); } deltaMousePosition.set(0, 0); } /** * Initiate a drag movement (translation on (xy) plane). The movement value is derived from the actual world * point under the mouse cursor. This allows user to 'grab' a world point and drag it to move. * * @ignore */ initiateDrag() { this.state = STATE.DRAG; // the world point under mouse cursor when the drag movement is started dragStart.copy(this.getWorldPointAtScreenXY(mousePosition)); // the difference between start and end cursor position dragDelta.set(0, 0, 0); } /** * Handle the drag movement (translation on (xy) plane) when user moves the mouse while in STATE.DRAG. The * drag movement is previously initiated by [initiateDrag]{@link PlanarControls#initiateDrag}. Compute the * drag value and update the camera controls. The movement value is derived from the actual world point under * the mouse cursor. This allows the user to 'grab' a world point and drag it to move. * * @ignore */ handleDragMovement() { // the world point under the current mouse cursor position, at same altitude than dragStart this.getWorldPointFromMathPlaneAtScreenXY(mousePosition, dragStart.z, dragEnd); // the difference between start and end cursor position dragDelta.subVectors(dragStart, dragEnd); // update the camera position this.camera.position.add(dragDelta); dragDelta.set(0, 0, 0); } /** * Initiate a pan movement (local translation on (xz) plane). * * @ignore */ initiatePan() { this.state = STATE.PAN; } /** * Handle the pan movement (translation on local x / world z plane) when user moves the mouse while * STATE.PAN. The drag movement is previously initiated by [initiatePan]{@link PlanarControls#initiatePan}. * Compute the pan value and update the camera controls. * * @ignore */ handlePanMovement() { vect.set(-deltaMousePosition.x, deltaMousePosition.y, 0); this.camera.localToWorld(vect); this.camera.position.copy(vect); } /** * Initiate a rotate (orbit) movement. * * @ignore */ initiateRotation() { this.state = STATE.ROTATE; centerPoint.copy(this.getWorldPointAtScreenXY(new THREE.Vector2(0.5 * this.view.mainLoop.gfxEngine.width, 0.5 * this.view.mainLoop.gfxEngine.height))); const radius = this.camera.position.distanceTo(centerPoint); phi = Math.acos((this.camera.position.z - centerPoint.z) / radius); } /** * Handle the rotate movement (orbit) when user moves the mouse while in STATE.ROTATE. The movement is an * orbit around `centerPoint`, the camera focus point (ground point at screen center). The rotate movement * is previously initiated in [initiateRotation]{@link PlanarControls#initiateRotation}. * Compute the new position value and update the camera controls. * * @ignore */ handleRotation() { // angle deltas // deltaMousePosition is computed in onMouseMove / onMouseDowns const thetaDelta = -this.rotateSpeed * deltaMousePosition.x / this.view.mainLoop.gfxEngine.width; const phiDelta = -this.rotateSpeed * deltaMousePosition.y / this.view.mainLoop.gfxEngine.height; // the vector from centerPoint (focus point) to camera position const offset = this.camera.position.clone().sub(centerPoint); if (thetaDelta !== 0 || phiDelta !== 0) { if (phi + phiDelta >= this.minZenithAngle && phi + phiDelta <= this.maxZenithAngle && phiDelta !== 0) { // rotation around X (altitude) phi += phiDelta; vect.set(0, 0, 1); quat.setFromUnitVectors(this.camera.up, vect); offset.applyQuaternion(quat); vect.setFromMatrixColumn(this.camera.matrix, 0); quat.setFromAxisAngle(vect, phiDelta); offset.applyQuaternion(quat); vect.set(0, 0, 1); quat.setFromUnitVectors(this.camera.up, vect).invert(); offset.applyQuaternion(quat); } if (thetaDelta !== 0) { // rotation around Z (azimuth) vect.set(0, 0, 1); quat.setFromAxisAngle(vect, thetaDelta); offset.applyQuaternion(quat); } } this.camera.position.copy(offset); // TODO : lookAt calls an updateMatrixWorld(). It should be replaced by a new method that does not. this.camera.lookAt(vectorZero); this.camera.position.add(centerPoint); this.camera.updateMatrixWorld(); } /** * Triggers a Zoom animated movement (travel) toward / away from the world point under the mouse cursor. The * zoom intensity varies according to the distance between the camera and the point. The closer to the ground, * the lower the intensity. Orientation will not change (null parameter in the call to * [initiateTravel]{@link PlanarControls#initiateTravel} function). * * @param {Event} event the mouse wheel event. * @ignore */ initiateZoom(event) { const delta = -event.deltaY; pointUnderCursor.copy(this.getWorldPointAtScreenXY(mousePosition)); const newPos = new THREE.Vector3(); if (delta > 0 || delta < 0 && this.maxAltitude > this.camera.position.z) { const zoomFactor = delta > 0 ? this.zoomInFactor : this.zoomOutFactor; // do not zoom if the resolution after the zoom is outside resolution limits const endResolution = this.view.getPixelsToMeters() / zoomFactor; if (this.maxResolution > endResolution || endResolution > this.minResolution) { return; } // change the camera field of view if the camera is orthographic if (this.camera.isOrthographicCamera) { // switch state to STATE.ZOOM this.state = STATE.ORTHO_ZOOM; this.view.notifyChange(this.camera); // camera zoom at the beginning of zoom movement startZoom = this.camera.zoom; // camera zoom at the end of zoom movement endZoom = startZoom * zoomFactor; // the altitude of the target must be the same as camera's pointUnderCursor.z = this.camera.position.z; travelAlpha = 0; travelDuration = this.zoomTravelTime; this.updateMouseCursorType(); } else { // target position newPos.lerpVectors(this.camera.position, pointUnderCursor, 1 - 1 / zoomFactor); // initiate travel this.initiateTravel(newPos, this.zoomTravelTime, null, false); } } } /** * Handle the animated zoom change for an orthographic camera, when state is `ZOOM`. * * @param {number} dt the delta time between two updates in milliseconds * @ignore */ handleZoomOrtho(dt) { travelAlpha = Math.min(travelAlpha + dt / 1000 / travelDuration, 1); // new zoom const zoom = startZoom + travelAlpha * (endZoom - startZoom); if (this.camera.zoom !== zoom) { // zoom has changed this.camera.zoom = zoom; this.camera.updateProjectionMatrix(); // current world coordinates under the mouse this.view.viewToNormalizedCoords(mousePosition, vect); vect.z = 0; vect.unproject(this.camera); // new camera position this.camera.position.x += pointUnderCursor.x - vect.x; this.camera.position.y += pointUnderCursor.y - vect.y; this.camera.updateMatrixWorld(true); } // completion test this.testAnimationEnd(); } /** * Triggers a 'smart zoom' animated movement (travel) toward the point under mouse cursor. The camera will be * smoothly moved and oriented close to the target, at a determined height and distance. * * @ignore */ initiateSmartTravel() { const pointUnderCursor = this.getWorldPointAtScreenXY(mousePosition); // direction of the movement, projected on xy plane and normalized const dir = new THREE.Vector3(); dir.copy(pointUnderCursor).sub(this.camera.position); dir.z = 0; dir.normalize(); const distanceToPoint = this.camera.position.distanceTo(pointUnderCursor); // camera height (altitude above ground) at the end of the travel, 5000 is an empirical smoothing distance const targetHeight = THREE.MathUtils.lerp(this.smartTravelHeightMin, this.smartTravelHeightMax, Math.min(distanceToPoint / 5000, 1)); // camera position at the end of the travel const moveTarget = new THREE.Vector3(); moveTarget.copy(pointUnderCursor); if (this.enableRotation) { moveTarget.add(dir.multiplyScalar(-targetHeight * 2)); } moveTarget.z = pointUnderCursor.z + targetHeight; if (this.camera.isOrthographicCamera) { startZoom = this.camera.zoom; // camera zoom at the end of the travel, 5000 is an empirical smoothing distance endZoom = startZoom * (1 + Math.min(distanceToPoint / 5000, 1)); moveTarget.z = this.camera.position.z; } // initiate the travel this.initiateTravel(moveTarget, 'auto', pointUnderCursor, true); } /** * Triggers an animated movement and rotation for the camera. * * @param {THREE.Vector3} targetPos The target position of the camera (reached at the end). * @param {number|string} travelTime Set to `auto` or set to a duration in seconds. If set to `auto`, * travel time will be set to a duration between `autoTravelTimeMin` and `autoTravelTimeMax` according to * the distance and the angular difference between start and finish. * @param {(string|THREE.Vector3|THREE.Quaternion)} targetOrientation define the target rotation of * the camera : * <ul> * <li>if targetOrientation is a world point (Vector3) : the camera will lookAt() this point</li> * <li>if targetOrientation is a quaternion : this quaternion will define the final camera orientation </li> * <li>if targetOrientation is neither a world point nor a quaternion : the camera will keep its starting * orientation</li> * </ul> * @param {boolean} useSmooth animation is smoothed using the `smooth(value)` function (slower * at start and finish). * * @ignore */ initiateTravel(targetPos, travelTime, targetOrientation, useSmooth) { this.state = STATE.TRAVEL; this.view.notifyChange(this.camera); // the progress of the travel (animation alpha) travelAlpha = 0; // update cursor this.updateMouseCursorType(); travelUseRotation = this.enableRotation && targetOrientation && (targetOrientation.isQuaternion || targetOrientation.isVector3); travelUseSmooth = useSmooth; // start position (current camera position) travelStartPos.copy(this.camera.position); // start rotation (current camera rotation) travelStartRot.copy(this.camera.quaternion); // setup the end rotation : if (travelUseRotation) { if (targetOrientation.isQuaternion) { // case where targetOrientation is a quaternion travelEndRot.copy(targetOrientation); } else if (targetOrientation.isVector3) { // case where targetOrientation is a Vector3 if (targetPos === targetOrientation) { this.camera.lookAt(targetOrientation); travelEndRot.copy(this.camera.quaternion); this.camera.quaternion.copy(travelStartRot); } else { this.camera.position.copy(targetPos); this.camera.lookAt(targetOrientation); travelEndRot.copy(this.camera.quaternion); this.camera.quaternion.copy(travelStartRot); this.camera.position.copy(travelStartPos); } } } // end position travelEndPos.copy(targetPos); // beginning of the travel duration setup if (this.instantTravel) { travelDuration = 0; } else if (travelTime === 'auto') { // case where travelTime is set to `auto` : travelDuration will be a value between autoTravelTimeMin and // autoTravelTimeMax depending on travel distance and travel angular difference // a value between 0 and 1 according to the travel distance. Adjusted by autoTravelTimeDist parameter const normalizedDistance = Math.min(1, targetPos.distanceTo(this.camera.position) / this.autoTravelTimeDist); travelDuration = THREE.MathUtils.lerp(this.autoTravelTimeMin, this.autoTravelTimeMax, normalizedDistance); // if travel changes camera orientation, travel duration is adjusted according to angularDifference // this allows for a smoother travel (more time for the camera to rotate) // final duration will not exceed autoTravelTimeMax if (travelUseRotation) { // value is normalized between 0 and 1 const angularDifference = 0.5 - 0.5 * travelEndRot.normalize().dot(this.camera.quaternion.normalize()); travelDuration *= 1 + 2 * angularDifference; travelDuration = Math.min(travelDuration, this.autoTravelTimeMax); } } else { // case where travelTime !== `auto` : travelTime is a duration in seconds given as parameter travelDuration = travelTime; } } /** * Handle the animated movement and rotation of the camera in `travel` state. * * @param {number} dt the delta time between two updates in milliseconds * @ignore */ handleTravel(dt) { travelAlpha = Math.min(travelAlpha + dt / 1000 / travelDuration, 1); // the animation alpha, between 0 (start) and 1 (finish) const alpha = travelUseSmooth ? this.smooth(travelAlpha) : travelAlpha; // new position this.camera.position.lerpVectors(travelStartPos, travelEndPos, alpha); const zoom = startZoom + alpha * (endZoom - startZoom); // new zoom if (this.camera.isOrthographicCamera && this.camera.zoom !== zoom) { this.camera.zoom = zoom; this.camera.updateProjectionMatrix(); } // new rotation if (travelUseRotation === true) { this.camera.quaternion.slerpQuaternions(travelStartRot, travelEndRot, alpha); } // completion test this.testAnimationEnd(); } /** * Test if the currently running animation is finished (travelAlpha reached 1). * If it is, reset controls to state NONE. * * @ignore */ testAnimationEnd() { if (travelAlpha === 1) { // Resume normal behaviour after animation is completed this.state = STATE.NONE; this.updateMouseCursorType(); } } /** * Triggers an animated movement (travel) to set the camera to top view, above the focus point, * at altitude = distanceToFocusPoint. * * @ignore */ goToTopView() { const topViewPos = new THREE.Vector3(); const targetQuat = new THREE.Quaternion(); // the top view position is above the camera focus point, at an altitude = distanceToPoint topViewPos.copy(this.getWorldPointAtScreenXY(new THREE.Vector2(0.5 * this.view.mainLoop.gfxEngine.width, 0.5 * this.view.mainLoop.gfxEngine.height))); topViewPos.z += Math.min(this.maxAltitude, this.camera.position.distanceTo(topViewPos)); targetQuat.setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0); // initiate the travel this.initiateTravel(topViewPos, 'auto', targetQuat, true); } /** * Triggers an animated movement (travel) to set the camera to starting view * * @ignore */ goToStartView() { // if startZoom and endZoom have not been set yet, give them neutral values if (this.camera.isOrthographicCamera) { startZoom = this.camera.zoom; endZoom = cameraInitialZoom; } this.initiateTravel(startPosition, 'auto', startQuaternion, true); } /** * Returns the world point (xyz) under the posXY screen point. The point belong to an abstract mathematical * plane of specified altitude (does not us actual geometry). * * @param {THREE.Vector2} posXY the mouse position in screen space (unit : pixel) * @param {number} altitude the altitude (z) of the mathematical plane * @param {THREE.Vector3} target the target vector3 * @return {THREE.Vector3} * @ignore */ getWorldPointFromMathPlaneAtScreenXY(posXY, altitude) { let target = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : new THREE.Vector3(); vect2.copy(this.view.viewToNormalizedCoords(posXY)); rayCaster.setFromCamera(vect2, this.camera); plane.constant = altitude; rayCaster.ray.intersectPlane(plane, target); return target; } /** * Returns the world point (xyz) under the posXY screen point. If geometry is under the cursor, the point is * obtained with getPickingPositionFromDepth. If no geometry is under the cursor, the point is obtained with * [getWorldPointFromMathPlaneAtScreenXY]{@link PlanarControls#getWorldPointFromMathPlaneAtScreenXY}. * * @param {THREE.Vector2} posXY the mouse position in screen space (unit : pixel) * @param {THREE.Vector3} target the target World coordinates. * @return {THREE.Vector3} * @ignore */ getWorldPointAtScreenXY(posXY) { let target = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : new THREE.Vector3(); // check if there is a valid geometry under cursor if (this.view.getPickingPositionFromDepth(posXY, target)) { return target; } else { // if not, we use the mathematical plane at altitude = groundLevel this.getWorldPointFromMathPlaneAtScreenXY(posXY, this.groundLevel, target); return target; } } /** * Add all the input event listeners (activate the controls). * * @ignore */ addInputListeners() { this.view.domElement.addEventListener('keydown', this._handlerOnKeyDown, false); this.view.domElement.addEventListener('mousedown', this._handlerOnMouseDown, false); this.view.domElement.addEventListener('mouseup', this._handlerOnMouseUp, false); this.view.domElement.addEventListener('mouseleave', this._handlerOnMouseUp, false); this.view.domElement.addEventListener('mousemove', this._handlerOnMouseMove, false); this.view.domElement.addEventListener('wheel', this._handlerOnMouseWheel, false); // prevent the default context menu from appearing when right-clicking // this allows to use right-click for input without the menu appearing this.view.domElement.addEventListener('contextmenu', this._handlerContextMenu, false); } /** * Removes all the input listeners (deactivate the controls). * * @ignore */ removeInputListeners() { this.view.domElement.removeEventListener('keydown', this._handlerOnKeyDown, true); this.view.domElement.removeEventListener('mousedown', this._handlerOnMouseDown, false); this.view.domElement.removeEventListener('mouseup', this._handlerOnMouseUp, false); this.view.domElement.removeEventListener('mouseleave', this._handlerOnMouseUp, false); this.view.domElement.removeEventListener('mousemove', this._handlerOnMouseMove, false); this.view.domElement.removeEventListener('wheel', this._handlerOnMouseWheel, false); this.view.domElement.removeEventListener('contextmenu', this._handlerContextMenu, false); } /** * Update the cursor image according to the control state. * * @ignore */ updateMouseCursorType() { switch (this.state) { case STATE.NONE: this.view.domElement.style.cursor = this.cursor.default; break; case STATE.DRAG: this.view.domElement.style.cursor = this.cursor.drag; break; case STATE.PAN: this.view.domElement.style.cursor = this.cursor.pan; break; case STATE.TRAVEL: this.view.domElement.style.cursor = this.cursor.travel; break; case STATE.ORTHO_ZOOM: this.view.domElement.style.cursor = this.cursor.ortho_zoom; break; case STATE.ROTATE: this.view.domElement.style.cursor = this.cursor.rotate; break; default: break; } } updateMousePositionAndDelta(event) { this.view.eventToViewCoords(event, mousePosition); deltaMousePosition.copy(mousePosition).sub(lastMousePosition); lastMousePosition.copy(mousePosition); } /** * cursor modification for a specifique state. * * @param {string} state the state in which we want to change the cursor ('default', 'drag', 'pan', 'travel', 'rotate'). * @param {string} newCursor the css cursor we want to have for the specified state. * @ignore */ setCursor(state, newCursor) { this.cursor[state] = newCursor; this.updateMouseCursorType(); } /** * Catch and manage the event when a touch on the mouse is downs. * * @param {Event} event the current event (mouse left or right button clicked, mouse wheel button actioned). * @ignore */ onMouseDown(event) { if (!this.enabled) { return; } event.preventDefault(); this.view.domElement.focus(); if (STATE.NONE !== this.state) { return; } currentPressedButton = event.button; this.updateMousePositionAndDelta(event); if (mouseButtons.LEFTCLICK === event.button) { if (event.ctrlKey) { if (this.enableRotation) { this.initiateRotation(); } else { return; } } else { this.initiateDrag(); } } else if (mouseButtons.MIDDLECLICK === event.button) { if (this.enableSmartTravel) { this.initiateSmartTravel(); } else { return; } } else if (mouseButtons.RIGHTCLICK === event.button) { if (this.enablePan) { this.initiatePan(); } else { return; } } this.updateMouseCursorType(); } /** * Catch and manage the event when a touch on the mouse is released. * * @param {Event} event the current event * @ignore */ onMouseUp(event) { event.preventDefault(); // Does not interrupt ongoing camera action if state is TRAVEL or CAMERA_OTHO. This prevents interrupting a zoom // movement or a smart travel by pressing any movement key. // The camera action is also uninterrupted if the released button does not match the button triggering the // ongoing action. This prevents for instance exiting drag mode when right-clicking while dragging the view. if (STATE.TRAVEL !== this.state && STATE.ORTHO_ZOOM !== this.state && currentPressedButton === event.button) { this.state = STATE.NONE; } this.updateMouseCursorType(); } /** * Catch and manage the event when the mouse is moved. * * @param {Event} event the current event. * @ignore */ onMouseMove(event) { if (!this.enabled) { return; } event.preventDefault(); this.updateMousePositionAndDelta(event); // notify change if moving if (STATE.NONE !== this.state) { this.view.notifyChange(); } } /** * Catch and manage the event when a key is down. * * @param {Event} event the current event * @ignore */ onKeyDown(event) { if (STATE.NONE !== this.state || !this.enabled) { return; } switch (event.keyCode) { case keys.T: // going to top view is not relevant for an orthographic camera, since it is always top view if (!this.camera.isOrthographicCamera) { this.goToTopView(); } break; case keys.Y: this.goToStartView(); break; case keys.SPACE: if (this.enableSmartTravel) { this.initiateSmartTravel(event); } break; default: break; } } /** * Catch and manage the event when the mouse wheel is rolled. * * @param {Event} event the current event * @ignore */ onMouseWheel(event) { if (!this.enabled) { return; } event.preventDefault(); event.stopPropagation(); if (STATE.NONE === this.state) { this.initiateZoom(event); } } /** * Catch and manage the event when the context menu is called (by a right-click on the window). We use this * to prevent the context menu from appearing so we can use right click for other inputs. * * @param {Event} event the current event * @ignore */ onContextMenu(event) { event.preventDefault(); } /** * Smoothing function (sigmoid) : based on h01 Hermite function. * * @param {number} value the value to be smoothed, between 0 and 1. * @return {number} a value between 0 and 1. * @ignore */ smooth(value) { // p between 1.0 and 1.5 (empirical) return (value ** 2 * (3 - 2 * value)) ** 1.20; } } export default PlanarControls;