From 91eafa17481fb17c51920f040d5dd7ef7944195d Mon Sep 17 00:00:00 2001 From: Emmanuel Schmuck Date: Tue, 22 Aug 2017 11:17:11 +0200 Subject: [PATCH 1/5] Feature : added new PlanarControls PlanarControls offers ergonomic camera controls for the planar mode : drag, pan, orbit, zoom and animated expression to smoothly move and orient the camera. Modified planar example to use these controls Update contributors Modifications following peppsac review : -all external variables are now within the class, this allows to instanciate more than one controller. -clamp01 function removed, using THREE clamp instead. -various style modifications (separator removed, const array format...) --- CONTRIBUTORS.md | 5 + examples/planar.html | 11 +- examples/planar.js | 2 +- src/Main.js | 1 + src/Renderer/ThreeExtended/PlanarControls.js | 746 +++++++++++++++++++ 5 files changed, 760 insertions(+), 5 deletions(-) create mode 100644 src/Renderer/ThreeExtended/PlanarControls.js diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index c820647199..0cad7f921f 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -16,6 +16,11 @@ The following people have contributed to iTowns 2. * [AtolCD](http://www.atolcd.com) * [Thomas Broyer](https://github.com/tbroyer) + +* [LIRIS](https://liris.cnrs.fr/) + * [Nicolas Saul](https://github.com/NikoSaul) + * [Emmanuel Schmück](https://github.com/EmmanuelSchmuck/) + * [Marie Lamure](https://github.com/mlamure) The following organizations supported iTowns2 : * IGN ( http://www.ign.fr ) diff --git a/examples/planar.html b/examples/planar.html index 1560ee875a..85a712f0b0 100644 --- a/examples/planar.html +++ b/examples/planar.html @@ -59,10 +59,13 @@

Key bindings

diff --git a/examples/planar.js b/examples/planar.js index 36ced7b730..d04683dd11 100644 --- a/examples/planar.js +++ b/examples/planar.js @@ -67,7 +67,7 @@ view.camera.camera3D.lookAt(extent.center().xyz()); // instanciate controls // eslint-disable-next-line no-new -new itowns.FirstPersonControls(view, { focusOnClick: true, moveSpeed: 1000 }); +new itowns.PlanarControls(view, {}); // Request redraw view.notifyChange(true); diff --git a/src/Main.js b/src/Main.js index 01d7f63111..113f8ae3b0 100644 --- a/src/Main.js +++ b/src/Main.js @@ -23,6 +23,7 @@ export { default as PointsMaterial } from './Renderer/PointsMaterial'; export { default as PointCloudProcessing } from './Process/PointCloudProcessing'; export { default as FlyControls } from './Renderer/ThreeExtended/FlyControls'; export { default as FirstPersonControls } from './Renderer/ThreeExtended/FirstPersonControls'; +export { default as PlanarControls } from './Renderer/ThreeExtended/PlanarControls'; export { default as GeoJSON2Three } from './Renderer/ThreeExtended/GeoJSON2Three'; export { CONTROL_EVENTS } from './Renderer/ThreeExtended/GlobeControls'; export { default as DEMUtils } from './utils/DEMUtils'; diff --git a/src/Renderer/ThreeExtended/PlanarControls.js b/src/Renderer/ThreeExtended/PlanarControls.js new file mode 100644 index 0000000000..f5048889b5 --- /dev/null +++ b/src/Renderer/ThreeExtended/PlanarControls.js @@ -0,0 +1,746 @@ +/** Description: Camera controls adapted for a planar view, with animated movements +* Left mouse button : "drag" the ground, translating the camera on the (xy) world plane. +* Right mouse button : translate the camera on local x and world z axis (pan) +* Ctrl + left mouse : rotate (orbit) around the camera's focus point. +* Scroll wheel : zooms toward cursor position (animated). +* Middle mouse button (wheel click) : 'smart zoom' at cursor location (animated). +* S : go to start view (animated) +* T : go to top view (animated) +* How to use : instanciate PlanarControls after camera setup (setPosition and lookAt) +* or you can also setup the camera with options.startPosition and options.startLook +*/ + +import * as THREE from 'three'; + +// event keycode +const keys = { + CTRL: 17, + SPACE: 32, + S: 83, + T: 84 }; + +const mouseButtons = { + LEFTCLICK: THREE.MOUSE.LEFT, + MIDDLECLICK: THREE.MOUSE.MIDDLE, + RIGHTCLICK: THREE.MOUSE.RIGHT, +}; + +// control state +const STATE = { + NONE: -1, + DRAG: 0, + PAN: 1, + ROTATE: 2, + TRAVEL: 3, +}; + +/** +* PlanarControls Constructor +* Numerical values have been adjusted for the example provided in examples/planar.html +* Most of them can be changed with the options parameter +* @param {PlanarView} view : the itowns view (planar view) +* @param {options} options : optional parameters. +*/ +function PlanarControls(view, options = {}) { + this.view = view; + this.camera = view.camera.camera3D; + this.domElement = view.mainLoop.gfxEngine.renderer.domElement; + this.position = this.camera.position; + + this.rotateSpeed = options.rotateSpeed || 2.0; + + // minPanSpeed when close to the ground, maxPanSpeed when close to maxAltitude + this.maxPanSpeed = options.maxPanSpeed || 10; + this.minPanSpeed = options.minPanSpeed || 0.05; + + // animation duration for the zoom + this.zoomTravelTime = options.zoomTravelTime || 0.2; + + // zoom movement is equal to the distance to the zoom target, multiplied by zoomFactor + this.zoomInFactor = options.zoomInFactor || 0.25; + this.zoomOutFactor = options.zoomOutFactor || 0.4; + + // pan movement is clamped between maxAltitude and groundLevel + this.maxAltitude = options.maxAltitude || 12000; + + // approximate ground altitude value + this.groundLevel = options.groundLevel || 200; + + // min and max duration in seconds, for animated travels with 'auto' parameter + this.autoTravelTimeMin = options.autoTravelTimeMin || 1.5; + this.autoTravelTimeMax = options.autoTravelTimeMax || 4; + + // max travel duration is reached for this travel distance + this.autoTravelTimeDist = options.autoTravelTimeDist || 20000; + + // after a smartZoom, camera height above ground will be between these two values + this.smartZoomHeightMin = options.smartZoomHeightMin || 75; + this.smartZoomHeightMax = options.smartZoomHeightMax || 500; + + // if set to true, animated travels have 0 duration + this.instantTravel = options.instantTravel || false; + + this.minZenithAngle = options.minZenithAngle || 0 * Math.PI / 180; + + // should be less than 90 deg (90 = parallel to the ground) + this.maxZenithAngle = (options.maxZenithAngle || 82.5) * Math.PI / 180; + + // prevent the default contextmenu from appearing when right-clicking + // this allows to use right-click for input without the menu appearing + this.domElement.addEventListener('contextmenu', onContextMenu.bind(this), false); + + // add this PlanarControl instance to the view's framerequesters + // with this, PlanarControl.update() will be called each frame + this.view.addFrameRequester(this); + + this.state = STATE.NONE; + this.isCtrlDown = false; + + // mouse movement + this.mousePosition = new THREE.Vector2(); + this.lastMousePosition = new THREE.Vector2(); + this.deltaMousePosition = new THREE.Vector2(0, 0); + + // drag movement + this.dragStart = new THREE.Vector3(); + this.dragEnd = new THREE.Vector3(); + this.dragDelta = new THREE.Vector3(); + + // camera focus point : ground point at screen center + this.centerPoint = new THREE.Vector3(0, 0, 0); + + // camera rotation + this.phi = 0.0; + + // animated travel + this.travelEndPos = new THREE.Vector3(); + this.travelStartPos = new THREE.Vector3(); + this.travelStartRot = new THREE.Quaternion(); + this.travelEndRot = new THREE.Quaternion(); + this.travelAlpha = 0; + this.travelDuration = 0; + this.travelUseRotation = false; + this.travelUseSmooth = false; + + // time management + this.deltaTime = 0; + this.lastElapsedTime = 0; + this.clock = new THREE.Clock(); + + // eventListeners handlers + this._handlerOnKeyDown = onKeyDown.bind(this); + this._handlerOnKeyUp = onKeyUp.bind(this); + this._handlerOnMouseDown = onMouseDown.bind(this); + this._handlerOnMouseUp = onMouseUp.bind(this); + this._handlerOnMouseMove = onMouseMove.bind(this); + this._handlerOnMouseWheel = onMouseWheel.bind(this); + + /** + * PlanarControl update + * Updates the view and camera if needed, and handles the animated travel + */ + this.update = function update() { + this.deltaTime = this.clock.getElapsedTime() - this.lastElapsedTime; + this.lastElapsedTime = this.clock.getElapsedTime(); + + if (this.state === STATE.TRAVEL) { + this.handleTravel(this.deltaTime); + } + if (this.state !== STATE.NONE) { + this.view.camera.update(window.innerWidth, window.innerHeight); + this.view.notifyChange(true); + } + }; + + /** + * Initiate a drag movement (translation on xy plane) when user does a left-click + * 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 (eg : google map) + */ + this.initiateDrag = function initiateDrag() { + this.state = STATE.DRAG; + + // the world point under mouse cursor when the drag movement is started + this.dragStart.copy(this.getWorldPointAtScreenXY(this.mousePosition)); + + // the difference between start and end cursor position + this.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 when user does a left-click, by 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 (eg : google map) + */ + this.handleDragMovement = function handleDragMovement() { + // the world point under the current mouse cursor position, at same altitude than dragStart + this.dragEnd.copy(this.getWorldPointFromMathPlaneAtScreenXY(this.mousePosition, this.dragStart.z)); + + // the difference between start and end cursor position + this.dragDelta.subVectors(this.dragStart, this.dragEnd); + + // new camera position + this.position.add(this.dragDelta); + + // request update + this.update(); + }; + + /** + * Initiate a pan movement (local translation on xz plane) when user does a righ-click + */ + this.initiatePan = function initiatePan() { + this.state = STATE.PAN; + }; + + /** + * Handle the pan movement (translation on local x / world z plane) when user moves the mouse while in STATE.PAN + * The drag movement is previously initiated when user does a right-click, by initiatePan() + * Compute the pan value and update the camera controls. + */ + this.handlePanMovement = function handlePanMovement() { + // normalized (betwwen 0 and 1) distance between groundLevel and maxAltitude + const distToGround = THREE.Math.clamp((this.position.z - this.groundLevel) / this.maxAltitude, 0, 1); + + // pan movement speed, adujsted according to altitude + const panSpeed = THREE.Math.lerp(this.minPanSpeed, this.maxPanSpeed, distToGround); + + // lateral movement (local x axis) + this.position.copy(this.camera.localToWorld(new THREE.Vector3(panSpeed * -1 * this.deltaMousePosition.x, 0, 0))); + + // vertical movement (world z axis) + const newAltitude = this.position.z + panSpeed * this.deltaMousePosition.y; + + // check if altitude is valid + if (newAltitude < this.maxAltitude && newAltitude > this.groundLevel) { + this.position.z = newAltitude; + } + + // request update + this.update(); + }; + + /** + * Initiate a rotate (orbit) movement when user does a right-click or ctrl + left-click + */ + this.initiateRotation = function initiateRotation() { + this.state = STATE.ROTATE; + + const screenCenter = new THREE.Vector2(0.5 * window.innerWidth, 0.5 * window.innerHeight); + + this.centerPoint.copy(this.getWorldPointAtScreenXY(screenCenter)); + + const r = this.position.distanceTo(this.centerPoint); + this.phi = Math.acos((this.position.z - this.centerPoint.z) / r); + }; + + /** + * 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() + * Compute the new position value and update the camera controls. + */ + this.handleRotation = function handleRotation() { + // angle deltas + // deltaMousePosition is computed in onMouseMove / onMouseDown s + const thetaDelta = -this.rotateSpeed * this.deltaMousePosition.x / window.innerWidth; + const phiDelta = -this.rotateSpeed * this.deltaMousePosition.y / window.innerHeight; + + // the vector from centerPoint (focus point) to camera position + const offset = this.position.clone().sub(this.centerPoint); + + const quat = new THREE.Quaternion().setFromUnitVectors(this.camera.up, new THREE.Vector3(0, 0, 1)); + const quatInverse = quat.clone().inverse(); + + if (thetaDelta !== 0 || phiDelta !== 0) { + if ((this.phi + phiDelta >= this.minZenithAngle) + && (this.phi + phiDelta <= this.maxZenithAngle) + && phiDelta !== 0) { + // rotation around X (altitude) + this.phi += phiDelta; + offset.applyQuaternion(quat); + + const rotationXQuaternion = new THREE.Quaternion(); + const vector = new THREE.Vector3(); + + vector.setFromMatrixColumn(this.camera.matrix, 0); + rotationXQuaternion.setFromAxisAngle(vector, phiDelta); + offset.applyQuaternion(rotationXQuaternion); + offset.applyQuaternion(quatInverse); + } + if (thetaDelta !== 0) { + // rotation around Z (azimuth) + + const rotationZQuaternion = new THREE.Quaternion(); + rotationZQuaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), thetaDelta); + offset.applyQuaternion(rotationZQuaternion); + } + } + + this.position.copy(offset).add(this.centerPoint); + + this.camera.lookAt(this.centerPoint); + + this.update(); + }; + + /** + * 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 ('none' parameter in the call to initiateTravel function) + * @param {event} event : the mouse wheel event. + */ + this.initiateZoom = function initiateZoom(event) { + let delta; + + // mousewheel delta + if (event.wheelDelta !== undefined) { + delta = event.wheelDelta; + } else if (event.detail !== undefined) { + delta = -event.detail; + } + + const pointUnderCursor = this.getWorldPointAtScreenXY(this.mousePosition); + const newPos = new THREE.Vector3(); + + // Zoom IN + if (delta > 0) { + // target position + newPos.lerpVectors(this.position, pointUnderCursor, this.zoomInFactor); + // initiate travel + this.initiateTravel(newPos, this.zoomTravelTime, 'none', false); + } + // Zoom OUT + else if (delta < 0 && this.position.z < this.maxAltitude) { + // target position + newPos.lerpVectors(this.position, pointUnderCursor, -1 * this.zoomOutFactor); + // initiate travel + this.initiateTravel(newPos, this.zoomTravelTime, 'none', false); + } + }; + + /** + * 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 + */ + this.initiateSmartZoom = function initiateSmartZoom() { + // point under mouse cursor + const pointUnderCursor = this.getWorldPointAtScreenXY(this.mousePosition); + + // direction of the movement, projected on xy plane and normalized + const dir = new THREE.Vector3(); + dir.copy(pointUnderCursor).sub(this.position); + dir.z = 0; + dir.normalize(); + + const distanceToPoint = this.position.distanceTo(pointUnderCursor); + + // camera height (altitude above ground) at the end of the travel + const targetHeight = THREE.Math.lerp(this.smartZoomHeightMin, this.smartZoomHeightMax, Math.min(distanceToPoint / 5000, 1)); + + // camera position at the end of the travel + const moveTarget = new THREE.Vector3(); + + moveTarget.copy(pointUnderCursor).add(dir.multiplyScalar(-targetHeight * 2)); + moveTarget.z = pointUnderCursor.z + targetHeight; + + // initiate the travel + this.initiateTravel(moveTarget, 'auto', pointUnderCursor, true); + }; + + + /** + * Triggers an animated movement & rotation for the camera + * @param {THREE.Vector3} targetPos : the target position of the camera (reached at the end) + * @param {number} 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 + * if targetOrientation is 'none' : the camera will keep its starting orientation + * if targetOrientation is a world point (Vector3) : the camera will lookAt() this point + * if targetOrientation is a quaternion : this quaternion will define the final camera orientation + * @param {boolean} useSmooth : animation is smoothed using the 'smooth(value)' function (slower at start and finish) + */ + this.initiateTravel = function initiateTravel(targetPos, travelTime, targetOrientation, useSmooth) { + this.state = STATE.TRAVEL; + + // update cursor + this.updateMouseCursorType(); + + this.travelUseRotation = !(targetOrientation === 'none'); + this.travelUseSmooth = useSmooth; + + // start position (current camera position) + this.travelStartPos.copy(this.position); + + // start rotation (current camera rotation) + this.travelStartRot.copy(this.camera.quaternion); + + // setup the end rotation : + + // case where targetOrientation is a quaternion + if (typeof targetOrientation.w !== 'undefined') { + this.travelEndRot.copy(targetOrientation); + } + // case where targetOrientation is a vector3 + else if (targetOrientation.isVector3) { + if (targetPos === targetOrientation) { + this.camera.lookAt(targetOrientation); + this.travelEndRot.copy(this.camera.quaternion); + this.camera.quaternion.copy(this.travelStartRot); + } + else { + this.position.copy(targetPos); + this.camera.lookAt(targetOrientation); + this.travelEndRot.copy(this.camera.quaternion); + this.camera.quaternion.copy(this.travelStartRot); + this.position.copy(this.travelStartPos); + } + } + + // end position + this.travelEndPos.copy(targetPos); + + // beginning of the travel duration setup === + + if (this.instantTravel) { + this.travelDuration = 0; + } + + // case where travelTime is set to 'auto' : travelDuration will be a value between autoTravelTimeMin and autoTravelTimeMax + // depending on travel distance and travel angular difference + else if (travelTime === 'auto') { + // a value between 0 and 1 according to the travel distance. Adjusted by autoTravelTimeDist parameter + const normalizedDistance = Math.min(1, targetPos.distanceTo(this.position) / this.autoTravelTimeDist); + + this.travelDuration = THREE.Math.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 excede autoTravelTimeMax + if (this.travelUseRotation) { + // value is normalized between 0 and 1 + const angularDifference = 0.5 - 0.5 * (this.travelEndRot.normalize().dot(this.camera.quaternion.normalize())); + + this.travelDuration *= 1 + 2 * angularDifference; + this.travelDuration = Math.min(this.travelDuration, this.autoTravelTimeMax); + } + } + // case where traveltime !== 'auto' : travelTime is a duration in seconds given as parameter + else { + this.travelDuration = travelTime; + } + // end of travel duration setup === + + // the progress of the travel (animation alpha) + this.travelAlpha = 0; + + this.update(); + }; + + /** = + * Resume normal behavior after a travel is completed + */ + this.endTravel = function endTravel() { + this.position.copy(this.travelEndPos); + + if (this.travelUseRotation) { + this.camera.quaternion.copy(this.travelEndRot); + } + + this.state = STATE.NONE; + + this.updateMouseCursorType(); + + this.update(); + }; + + /** + * Handle the animated movement and rotation of the camera in 'travel' state + * @param {number} dt : the deltatime between two updates + */ + this.handleTravel = function handleTravel(dt) { + this.travelAlpha += dt / this.travelDuration; + + // the animation alpha, between 0 (start) and 1 (finish) + const alpha = (this.travelUseSmooth) ? smooth(this.travelAlpha) : this.travelAlpha; + + // new position + this.position.lerpVectors(this.travelStartPos, this.travelEndPos, alpha); + + // new rotation + if (this.travelUseRotation === true) { + THREE.Quaternion.slerp(this.travelStartRot, this.travelEndRot, this.camera.quaternion, alpha); + } + // completion test + if (this.travelAlpha > 1) { + this.endTravel(); + } + }; + + /** + * Triggers an animated movement (travel) to set the camera to top view, above the focus point, at altitude=distanceToFocusPoint + */ + this.goToTopView = function goToTopView() { + const topViewPos = new THREE.Vector3(); + const targetQuat = new THREE.Quaternion(); + const screenCenter = new THREE.Vector2(0.5 * window.innerWidth, 0.5 * window.innerHeight); + + topViewPos.copy(this.getWorldPointAtScreenXY(screenCenter)); + topViewPos.z += Math.min(this.maxAltitude, this.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 + */ + this.goToStartView = function goToStartView() { + this.initiateTravel(this.startPosition, 'auto', this.startLook, true); + }; + + /** + * returns the world point (xyz) under the posXY screen point + * the point belong to an abstract mathematical plane of specified altitude (doesnt use actual geometry) + * @param {THREE.Vector2} posXY : the mouse position in screen space (unit : pixel) + * @param {number} altitude : the altitude (z) of the mathematical plane + * @returns {THREE.Vector3} + */ + this.getWorldPointFromMathPlaneAtScreenXY = function getWorldPointFromMathPlaneAtScreenXY(posXY, altitude) { + const vector = new THREE.Vector3(); + vector.set((posXY.x / window.innerWidth) * 2 - 1, -(posXY.y / window.innerHeight) * 2 + 1, 0.5); + vector.unproject(this.camera); + const dir = vector.sub(this.position).normalize(); + const distance = (altitude - this.position.z) / dir.z; + const pointUnderCursor = this.position.clone().add(dir.multiplyScalar(distance)); + + return pointUnderCursor; + }; + + /** + * returns the world point (xyz) under the posXY screen point + * if geometry is under the cursor, the point in obtained with getPickingPositionFromDepth + * if no geometry is under the cursor, the point is obtained with getWorldPointFromMathPlaneAtScreenXY + * @param {THREE.Vector2} posXY : the mouse position in screen space (unit : pixel) + * @returns {THREE.Vector3} + */ + this.getWorldPointAtScreenXY = function getWorldPointAtScreenXY(posXY) { + // the returned value + const pointUnderCursor = new THREE.Vector3(); + + // check if there is valid geometry under cursor + if (typeof this.view.getPickingPositionFromDepth(posXY) !== 'undefined') { + pointUnderCursor.copy(this.view.getPickingPositionFromDepth(posXY)); + } + // if not, we use the mathematical plane at altitude = groundLevel + else { + pointUnderCursor.copy(this.getWorldPointFromMathPlaneAtScreenXY(posXY, this.groundLevel)); + } + return pointUnderCursor; + }; + + /** + * Adds all the input event listeners (activate the controls) + */ + this.addInputListeners = function addInputListeners() { + window.addEventListener('keydown', this._handlerOnKeyDown, true); + window.addEventListener('keyup', this._handlerOnKeyUp, true); + this.domElement.addEventListener('mousedown', this._handlerOnMouseDown, false); + this.domElement.addEventListener('mouseup', this._handlerOnMouseUp, false); + this.domElement.addEventListener('mousemove', this._handlerOnMouseMove, false); + this.domElement.addEventListener('mousewheel', this._handlerOnMouseWheel, false); + // For firefox + this.domElement.addEventListener('MozMousePixelScroll', this._handlerOnMouseWheel, false); + }; + + /** + * removes all the input event listeners (desactivate the controls) + */ + this.removeInputListeners = function removeInputListeners() { + window.removeEventListener('keydown', this._handlerOnKeyDown, true); + window.removeEventListener('keyup', this._handlerOnKeyUp, true); + this.domElement.removeEventListener('mousedown', this._handlerOnMouseDown, false); + this.domElement.removeEventListener('mouseup', this._handlerOnMouseUp, false); + this.domElement.removeEventListener('mousemove', this._handlerOnMouseMove, false); + this.domElement.removeEventListener('mousewheel', this._handlerOnMouseWheel, false); + // For firefox + this.domElement.removeEventListener('MozMousePixelScroll', this._handlerOnMouseWheel, false); + }; + + /** + * update the cursor image according to the control state + */ + this.updateMouseCursorType = function updateMouseCursorType() { + if (this.state === STATE.NONE) { + this.domElement.style.cursor = 'auto'; + } + else if (this.state === STATE.DRAG) { + this.domElement.style.cursor = 'move'; + } + else if (this.state === STATE.PAN) { + this.domElement.style.cursor = 'cell'; + } + else if (this.state === STATE.TRAVEL) { + this.domElement.style.cursor = 'wait'; + } + else if (this.state === STATE.ROTATE) { + this.domElement.style.cursor = 'move'; + } + }; + + PlanarControls.prototype = Object.create(THREE.EventDispatcher.prototype); + PlanarControls.prototype.constructor = PlanarControls; + + // starting position and lookAt target can be set outside this class, before instanciating PlanarControls + // or they can be set with options : startPosition and startLookAt + this.startPosition = options.startPosition || this.position.clone(); + this.startLook = options.startLook || this.camera.quaternion.clone(); + + this.position.copy(this.startPosition); + this.camera.quaternion.copy(this.startLook); + + // event listeners for user input + this.addInputListeners(); +} + +/** +* Catch and manage the event when a touch on the mouse is down. +* @param {event} event : the current event (mouse left button clicked or mouse wheel button actionned) +*/ +var onMouseDown = function onMouseDown(event) { + event.preventDefault(); + + this.mousePosition.set(event.clientX, event.clientY); + + if (this.state === STATE.TRAVEL) { + return; + } + + this.lastMousePosition.copy(this.mousePosition); + + if (event.button === mouseButtons.LEFTCLICK) { + if (this.isCtrlDown) { + this.initiateRotation(); + } else { + this.initiateDrag(); + } + } else if (event.button === mouseButtons.MIDDLECLICK) { + this.initiateSmartZoom(event); + } else if (event.button === mouseButtons.RIGHTCLICK) { + this.initiatePan(); + } + + this.updateMouseCursorType(); +}; + +/** +* Catch the event when a touch on the mouse is uped. Reinit the state of the controller and disable. +* the listener on the move mouse event. +* @param {event} event : the current event +*/ +var onMouseUp = function onMouseUp(event) { + event.preventDefault(); + + this.dragDelta.set(0, 0, 0); + + if (this.state !== STATE.TRAVEL) { + this.state = STATE.NONE; + } + + this.updateMouseCursorType(); +}; + +/** +* Catch and manage the event when the mouse is moved +* @param {event} event : the current event +*/ +var onMouseMove = function onMouseMove(event) { + event.preventDefault(); + + this.mousePosition.set(event.clientX, event.clientY); + + this.deltaMousePosition.copy(this.mousePosition).sub(this.lastMousePosition); + + this.lastMousePosition.copy(this.mousePosition); + + if (this.state === STATE.ROTATE) + { this.handleRotation(); } + else if (this.state === STATE.DRAG) + { this.handleDragMovement(); } + else if (this.state === STATE.PAN) + { this.handlePanMovement(); } +}; + +/** +* Catch and manage the event when a key is up. +* @param {event} event : the current event +*/ +var onKeyUp = function onKeyUp(event) { + if (event.keyCode === keys.CTRL) { + this.isCtrlDown = false; + } +}; + +/** +* Catch and manage the event when a key is down. +* @param {event} event : the current event +*/ +var onKeyDown = function onKeyDown(event) { + if (this.state === STATE.TRAVEL) { + return; + } + if (event.keyCode === keys.T) { + this.goToTopView(); + } + if (event.keyCode === keys.S) { + this.goToStartView(); + } + if (event.keyCode === keys.SPACE) { + this.initiateSmartZoom(event); + } + if (event.keyCode === keys.CTRL) { + this.isCtrlDown = true; + } +}; + +/** +* Catch and manage the event when the mouse wheel is rolled. +* @param {event} event : the current event +*/ +var onMouseWheel = function onMouseWheel(event) { + event.preventDefault(); + event.stopPropagation(); + + if (this.state === STATE.NONE) { + 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 +*/ +var onContextMenu = function onContextMenu(event) { + event.preventDefault(); +}; + + +/** +* smoothing function (sigmoid) : based on h01 Hermite function +* returns a value between 0 and 1 +* @param {number} value : the value to be smoothed, between 0 and 1 +* @returns {number} +*/ +var smooth = function smooth(value) { + // p between 1.0 and 1.5 + return Math.pow((value * value * (3 - 2 * value)), 1.20); +}; + +export default PlanarControls; From 1a616945192d3886fee45a6812ecb9b27e536e82 Mon Sep 17 00:00:00 2001 From: Emmanuel Schmuck Date: Fri, 25 Aug 2017 19:28:25 +0200 Subject: [PATCH 2/5] planarcontrols modifications following peppsac review : update() uses mainloop's deltatime, no longer call camera.update() this.camera.position replaces this.position --- src/Renderer/ThreeExtended/PlanarControls.js | 123 +++++++++---------- 1 file changed, 59 insertions(+), 64 deletions(-) diff --git a/src/Renderer/ThreeExtended/PlanarControls.js b/src/Renderer/ThreeExtended/PlanarControls.js index f5048889b5..cbc8f6d0a7 100644 --- a/src/Renderer/ThreeExtended/PlanarControls.js +++ b/src/Renderer/ThreeExtended/PlanarControls.js @@ -17,7 +17,8 @@ const keys = { CTRL: 17, SPACE: 32, S: 83, - T: 84 }; + T: 84, +}; const mouseButtons = { LEFTCLICK: THREE.MOUSE.LEFT, @@ -45,12 +46,11 @@ function PlanarControls(view, options = {}) { this.view = view; this.camera = view.camera.camera3D; this.domElement = view.mainLoop.gfxEngine.renderer.domElement; - this.position = this.camera.position; this.rotateSpeed = options.rotateSpeed || 2.0; // minPanSpeed when close to the ground, maxPanSpeed when close to maxAltitude - this.maxPanSpeed = options.maxPanSpeed || 10; + this.maxPanSpeed = options.maxPanSpeed || 15; this.minPanSpeed = options.minPanSpeed || 0.05; // animation duration for the zoom @@ -138,18 +138,23 @@ function PlanarControls(view, options = {}) { /** * PlanarControl update * Updates the view and camera if needed, and handles the animated travel + * @param {number} dt : deltatime given by mainLoop + * @param {boolean} updateLoopRestarted : given by mainLoop */ - this.update = function update() { - this.deltaTime = this.clock.getElapsedTime() - this.lastElapsedTime; - this.lastElapsedTime = this.clock.getElapsedTime(); - - if (this.state === STATE.TRAVEL) { - this.handleTravel(this.deltaTime); + this.update = function 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; } - if (this.state !== STATE.NONE) { - this.view.camera.update(window.innerWidth, window.innerHeight); + if (this.state === STATE.TRAVEL) { + this.handleTravel(dt / 1000); this.view.notifyChange(true); } + // drag movement needs to be synchronous with update + if (this.state === STATE.DRAG) { + this.camera.position.add(this.dragDelta); + this.dragDelta.set(0, 0, 0); + } }; /** @@ -180,12 +185,6 @@ function PlanarControls(view, options = {}) { // the difference between start and end cursor position this.dragDelta.subVectors(this.dragStart, this.dragEnd); - - // new camera position - this.position.add(this.dragDelta); - - // request update - this.update(); }; /** @@ -202,24 +201,21 @@ function PlanarControls(view, options = {}) { */ this.handlePanMovement = function handlePanMovement() { // normalized (betwwen 0 and 1) distance between groundLevel and maxAltitude - const distToGround = THREE.Math.clamp((this.position.z - this.groundLevel) / this.maxAltitude, 0, 1); + const distToGround = THREE.Math.clamp((this.camera.position.z - this.groundLevel) / this.maxAltitude, 0, 1); // pan movement speed, adujsted according to altitude const panSpeed = THREE.Math.lerp(this.minPanSpeed, this.maxPanSpeed, distToGround); // lateral movement (local x axis) - this.position.copy(this.camera.localToWorld(new THREE.Vector3(panSpeed * -1 * this.deltaMousePosition.x, 0, 0))); + this.camera.position.copy(this.camera.localToWorld(new THREE.Vector3(panSpeed * -1 * this.deltaMousePosition.x, 0, 0))); // vertical movement (world z axis) - const newAltitude = this.position.z + panSpeed * this.deltaMousePosition.y; + const newAltitude = this.camera.position.z + panSpeed * this.deltaMousePosition.y; // check if altitude is valid if (newAltitude < this.maxAltitude && newAltitude > this.groundLevel) { - this.position.z = newAltitude; + this.camera.position.z = newAltitude; } - - // request update - this.update(); }; /** @@ -232,8 +228,8 @@ function PlanarControls(view, options = {}) { this.centerPoint.copy(this.getWorldPointAtScreenXY(screenCenter)); - const r = this.position.distanceTo(this.centerPoint); - this.phi = Math.acos((this.position.z - this.centerPoint.z) / r); + const r = this.camera.position.distanceTo(this.centerPoint); + this.phi = Math.acos((this.camera.position.z - this.centerPoint.z) / r); }; /** @@ -249,7 +245,7 @@ function PlanarControls(view, options = {}) { const phiDelta = -this.rotateSpeed * this.deltaMousePosition.y / window.innerHeight; // the vector from centerPoint (focus point) to camera position - const offset = this.position.clone().sub(this.centerPoint); + const offset = this.camera.position.clone().sub(this.centerPoint); const quat = new THREE.Quaternion().setFromUnitVectors(this.camera.up, new THREE.Vector3(0, 0, 1)); const quatInverse = quat.clone().inverse(); @@ -279,11 +275,9 @@ function PlanarControls(view, options = {}) { } } - this.position.copy(offset).add(this.centerPoint); + this.camera.position.copy(offset).add(this.centerPoint); this.camera.lookAt(this.centerPoint); - - this.update(); }; /** @@ -309,14 +303,14 @@ function PlanarControls(view, options = {}) { // Zoom IN if (delta > 0) { // target position - newPos.lerpVectors(this.position, pointUnderCursor, this.zoomInFactor); + newPos.lerpVectors(this.camera.position, pointUnderCursor, this.zoomInFactor); // initiate travel this.initiateTravel(newPos, this.zoomTravelTime, 'none', false); } // Zoom OUT - else if (delta < 0 && this.position.z < this.maxAltitude) { + else if (delta < 0 && this.camera.position.z < this.maxAltitude) { // target position - newPos.lerpVectors(this.position, pointUnderCursor, -1 * this.zoomOutFactor); + newPos.lerpVectors(this.camera.position, pointUnderCursor, -1 * this.zoomOutFactor); // initiate travel this.initiateTravel(newPos, this.zoomTravelTime, 'none', false); } @@ -332,11 +326,11 @@ function PlanarControls(view, options = {}) { // direction of the movement, projected on xy plane and normalized const dir = new THREE.Vector3(); - dir.copy(pointUnderCursor).sub(this.position); + dir.copy(pointUnderCursor).sub(this.camera.position); dir.z = 0; dir.normalize(); - const distanceToPoint = this.position.distanceTo(pointUnderCursor); + const distanceToPoint = this.camera.position.distanceTo(pointUnderCursor); // camera height (altitude above ground) at the end of the travel const targetHeight = THREE.Math.lerp(this.smartZoomHeightMin, this.smartZoomHeightMax, Math.min(distanceToPoint / 5000, 1)); @@ -366,7 +360,9 @@ function PlanarControls(view, options = {}) { */ this.initiateTravel = function initiateTravel(targetPos, travelTime, targetOrientation, useSmooth) { this.state = STATE.TRAVEL; - + this.view.notifyChange(true); + // the progress of the travel (animation alpha) + this.travelAlpha = 0; // update cursor this.updateMouseCursorType(); @@ -374,7 +370,7 @@ function PlanarControls(view, options = {}) { this.travelUseSmooth = useSmooth; // start position (current camera position) - this.travelStartPos.copy(this.position); + this.travelStartPos.copy(this.camera.position); // start rotation (current camera rotation) this.travelStartRot.copy(this.camera.quaternion); @@ -393,18 +389,18 @@ function PlanarControls(view, options = {}) { this.camera.quaternion.copy(this.travelStartRot); } else { - this.position.copy(targetPos); + this.camera.position.copy(targetPos); this.camera.lookAt(targetOrientation); this.travelEndRot.copy(this.camera.quaternion); this.camera.quaternion.copy(this.travelStartRot); - this.position.copy(this.travelStartPos); + this.camera.position.copy(this.travelStartPos); } } // end position this.travelEndPos.copy(targetPos); - // beginning of the travel duration setup === + // beginning of the travel duration setup if (this.instantTravel) { this.travelDuration = 0; @@ -414,7 +410,7 @@ function PlanarControls(view, options = {}) { // depending on travel distance and travel angular difference else if (travelTime === 'auto') { // a value between 0 and 1 according to the travel distance. Adjusted by autoTravelTimeDist parameter - const normalizedDistance = Math.min(1, targetPos.distanceTo(this.position) / this.autoTravelTimeDist); + const normalizedDistance = Math.min(1, targetPos.distanceTo(this.camera.position) / this.autoTravelTimeDist); this.travelDuration = THREE.Math.lerp(this.autoTravelTimeMin, this.autoTravelTimeMax, normalizedDistance); @@ -433,19 +429,13 @@ function PlanarControls(view, options = {}) { else { this.travelDuration = travelTime; } - // end of travel duration setup === - - // the progress of the travel (animation alpha) - this.travelAlpha = 0; - - this.update(); }; - /** = + /** * Resume normal behavior after a travel is completed */ this.endTravel = function endTravel() { - this.position.copy(this.travelEndPos); + this.camera.position.copy(this.travelEndPos); if (this.travelUseRotation) { this.camera.quaternion.copy(this.travelEndRot); @@ -454,8 +444,6 @@ function PlanarControls(view, options = {}) { this.state = STATE.NONE; this.updateMouseCursorType(); - - this.update(); }; /** @@ -469,7 +457,7 @@ function PlanarControls(view, options = {}) { const alpha = (this.travelUseSmooth) ? smooth(this.travelAlpha) : this.travelAlpha; // new position - this.position.lerpVectors(this.travelStartPos, this.travelEndPos, alpha); + this.camera.position.lerpVectors(this.travelStartPos, this.travelEndPos, alpha); // new rotation if (this.travelUseRotation === true) { @@ -490,7 +478,7 @@ function PlanarControls(view, options = {}) { const screenCenter = new THREE.Vector2(0.5 * window.innerWidth, 0.5 * window.innerHeight); topViewPos.copy(this.getWorldPointAtScreenXY(screenCenter)); - topViewPos.z += Math.min(this.maxAltitude, this.position.distanceTo(topViewPos)); + topViewPos.z += Math.min(this.maxAltitude, this.camera.position.distanceTo(topViewPos)); targetQuat.setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0); @@ -516,9 +504,9 @@ function PlanarControls(view, options = {}) { const vector = new THREE.Vector3(); vector.set((posXY.x / window.innerWidth) * 2 - 1, -(posXY.y / window.innerHeight) * 2 + 1, 0.5); vector.unproject(this.camera); - const dir = vector.sub(this.position).normalize(); - const distance = (altitude - this.position.z) / dir.z; - const pointUnderCursor = this.position.clone().add(dir.multiplyScalar(distance)); + const dir = vector.sub(this.camera.position).normalize(); + const distance = (altitude - this.camera.position.z) / dir.z; + const pointUnderCursor = this.camera.position.clone().add(dir.multiplyScalar(distance)); return pointUnderCursor; }; @@ -599,10 +587,10 @@ function PlanarControls(view, options = {}) { // starting position and lookAt target can be set outside this class, before instanciating PlanarControls // or they can be set with options : startPosition and startLookAt - this.startPosition = options.startPosition || this.position.clone(); + this.startPosition = options.startPosition || this.camera.position.clone(); this.startLook = options.startLook || this.camera.quaternion.clone(); - this.position.copy(this.startPosition); + this.camera.position.copy(this.startPosition); this.camera.quaternion.copy(this.startLook); // event listeners for user input @@ -669,12 +657,19 @@ var onMouseMove = function onMouseMove(event) { this.lastMousePosition.copy(this.mousePosition); - if (this.state === STATE.ROTATE) - { this.handleRotation(); } - else if (this.state === STATE.DRAG) - { this.handleDragMovement(); } - else if (this.state === STATE.PAN) - { this.handlePanMovement(); } + // notify change if moving + if (this.state !== STATE.NONE) { + this.view.notifyChange(true); + } + if (this.state === STATE.ROTATE) { + this.handleRotation(); + } + else if (this.state === STATE.DRAG) { + this.handleDragMovement(); + } + else if (this.state === STATE.PAN) { + this.handlePanMovement(); + } }; /** From d7ae5da4e7176099d750f8428efb76fd02373b3b Mon Sep 17 00:00:00 2001 From: Emmanuel Schmuck Date: Mon, 28 Aug 2017 10:43:55 +0200 Subject: [PATCH 3/5] removed options.startposition and startlook, start view with Y instead of S (used for picking) --- examples/planar.html | 2 +- src/Renderer/ThreeExtended/PlanarControls.js | 27 ++++++++------------ 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/examples/planar.html b/examples/planar.html index 85a712f0b0..18f8457d4d 100644 --- a/examples/planar.html +++ b/examples/planar.html @@ -64,8 +64,8 @@
  • Ctrl + Left-Click: camera rotation (orbit)
  • Spacebar / Wheel-Click: smart zoom
  • Mouse Wheel: zoom in/out
  • -
  • S: move camera to start position
  • T: orient camera to a top view
  • +
  • Y: move camera to start position
  • diff --git a/src/Renderer/ThreeExtended/PlanarControls.js b/src/Renderer/ThreeExtended/PlanarControls.js index cbc8f6d0a7..dd872d8706 100644 --- a/src/Renderer/ThreeExtended/PlanarControls.js +++ b/src/Renderer/ThreeExtended/PlanarControls.js @@ -4,10 +4,9 @@ * Ctrl + left mouse : rotate (orbit) around the camera's focus point. * Scroll wheel : zooms toward cursor position (animated). * Middle mouse button (wheel click) : 'smart zoom' at cursor location (animated). -* S : go to start view (animated) +* Y : go to start view (animated) * T : go to top view (animated) * How to use : instanciate PlanarControls after camera setup (setPosition and lookAt) -* or you can also setup the camera with options.startPosition and options.startLook */ import * as THREE from 'three'; @@ -16,8 +15,8 @@ import * as THREE from 'three'; const keys = { CTRL: 17, SPACE: 32, - S: 83, T: 84, + Y: 89, }; const mouseButtons = { @@ -85,6 +84,12 @@ function PlanarControls(view, options = {}) { // should be less than 90 deg (90 = parallel to the ground) this.maxZenithAngle = (options.maxZenithAngle || 82.5) * Math.PI / 180; + // starting camera position and orientation target are setup before instanciating PlanarControls + // using: view.camera.setPosition() and view.camera.lookAt() + // startPosition and startQuaternion are stored to be able to return to the start view + this.startPosition = this.camera.position.clone(); + this.startQuaternion = this.camera.quaternion.clone(); + // prevent the default contextmenu from appearing when right-clicking // this allows to use right-click for input without the menu appearing this.domElement.addEventListener('contextmenu', onContextMenu.bind(this), false); @@ -490,7 +495,7 @@ function PlanarControls(view, options = {}) { * Triggers an animated movement (travel) to set the camera to starting view */ this.goToStartView = function goToStartView() { - this.initiateTravel(this.startPosition, 'auto', this.startLook, true); + this.initiateTravel(this.startPosition, 'auto', this.startQuaternion, true); }; /** @@ -585,14 +590,6 @@ function PlanarControls(view, options = {}) { PlanarControls.prototype = Object.create(THREE.EventDispatcher.prototype); PlanarControls.prototype.constructor = PlanarControls; - // starting position and lookAt target can be set outside this class, before instanciating PlanarControls - // or they can be set with options : startPosition and startLookAt - this.startPosition = options.startPosition || this.camera.position.clone(); - this.startLook = options.startLook || this.camera.quaternion.clone(); - - this.camera.position.copy(this.startPosition); - this.camera.quaternion.copy(this.startLook); - // event listeners for user input this.addInputListeners(); } @@ -628,8 +625,7 @@ var onMouseDown = function onMouseDown(event) { }; /** -* Catch the event when a touch on the mouse is uped. Reinit the state of the controller and disable. -* the listener on the move mouse event. +* Catch the event when a touch on the mouse is uped. * @param {event} event : the current event */ var onMouseUp = function onMouseUp(event) { @@ -693,7 +689,7 @@ var onKeyDown = function onKeyDown(event) { if (event.keyCode === keys.T) { this.goToTopView(); } - if (event.keyCode === keys.S) { + if (event.keyCode === keys.Y) { this.goToStartView(); } if (event.keyCode === keys.SPACE) { @@ -726,7 +722,6 @@ var onContextMenu = function onContextMenu(event) { event.preventDefault(); }; - /** * smoothing function (sigmoid) : based on h01 Hermite function * returns a value between 0 and 1 From 851c22386f10b4b7849504de32a08377672de108 Mon Sep 17 00:00:00 2001 From: Emmanuel Schmuck Date: Tue, 29 Aug 2017 19:07:46 +0200 Subject: [PATCH 4/5] many modifications following review by Autra, see PR --- src/Renderer/ThreeExtended/PlanarControls.js | 459 ++++++++++--------- 1 file changed, 233 insertions(+), 226 deletions(-) diff --git a/src/Renderer/ThreeExtended/PlanarControls.js b/src/Renderer/ThreeExtended/PlanarControls.js index dd872d8706..73c8f8dbf7 100644 --- a/src/Renderer/ThreeExtended/PlanarControls.js +++ b/src/Renderer/ThreeExtended/PlanarControls.js @@ -69,7 +69,7 @@ function PlanarControls(view, options = {}) { this.autoTravelTimeMin = options.autoTravelTimeMin || 1.5; this.autoTravelTimeMax = options.autoTravelTimeMax || 4; - // max travel duration is reached for this travel distance + // max travel duration is reached for this travel distance (empirical smoothing value) this.autoTravelTimeDist = options.autoTravelTimeDist || 20000; // after a smartZoom, camera height above ground will be between these two values @@ -84,86 +84,93 @@ function PlanarControls(view, options = {}) { // should be less than 90 deg (90 = parallel to the ground) this.maxZenithAngle = (options.maxZenithAngle || 82.5) * Math.PI / 180; + // focus policy options + this.focusOnMouseOver = options.focusOnMouseOver || true; + this.focusOnMouseClick = options.focusOnMouseClick || true; + // starting camera position and orientation target are setup before instanciating PlanarControls // using: view.camera.setPosition() and view.camera.lookAt() // startPosition and startQuaternion are stored to be able to return to the start view - this.startPosition = this.camera.position.clone(); - this.startQuaternion = this.camera.quaternion.clone(); - - // prevent the default contextmenu from appearing when right-clicking - // this allows to use right-click for input without the menu appearing - this.domElement.addEventListener('contextmenu', onContextMenu.bind(this), false); - - // add this PlanarControl instance to the view's framerequesters - // with this, PlanarControl.update() will be called each frame - this.view.addFrameRequester(this); + const startPosition = this.camera.position.clone(); + const startQuaternion = this.camera.quaternion.clone(); + // control state this.state = STATE.NONE; - this.isCtrlDown = false; // mouse movement - this.mousePosition = new THREE.Vector2(); - this.lastMousePosition = new THREE.Vector2(); - this.deltaMousePosition = new THREE.Vector2(0, 0); + const mousePosition = new THREE.Vector2(); + const lastMousePosition = new THREE.Vector2(); + const deltaMousePosition = new THREE.Vector2(0, 0); // drag movement - this.dragStart = new THREE.Vector3(); - this.dragEnd = new THREE.Vector3(); - this.dragDelta = new THREE.Vector3(); + const dragStart = new THREE.Vector3(); + const dragEnd = new THREE.Vector3(); + const dragDelta = new THREE.Vector3(); // camera focus point : ground point at screen center - this.centerPoint = new THREE.Vector3(0, 0, 0); + const centerPoint = new THREE.Vector3(0, 0, 0); // camera rotation - this.phi = 0.0; + let phi = 0.0; // animated travel - this.travelEndPos = new THREE.Vector3(); - this.travelStartPos = new THREE.Vector3(); - this.travelStartRot = new THREE.Quaternion(); - this.travelEndRot = new THREE.Quaternion(); - this.travelAlpha = 0; - this.travelDuration = 0; - this.travelUseRotation = false; - this.travelUseSmooth = false; - - // time management - this.deltaTime = 0; - this.lastElapsedTime = 0; - this.clock = new THREE.Clock(); + 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; // eventListeners handlers - this._handlerOnKeyDown = onKeyDown.bind(this); - this._handlerOnKeyUp = onKeyUp.bind(this); - this._handlerOnMouseDown = onMouseDown.bind(this); - this._handlerOnMouseUp = onMouseUp.bind(this); - this._handlerOnMouseMove = onMouseMove.bind(this); - this._handlerOnMouseWheel = onMouseWheel.bind(this); + const _handlerOnKeyDown = onKeyDown.bind(this); + const _handlerOnMouseDown = onMouseDown.bind(this); + const _handlerOnMouseUp = onMouseUp.bind(this); + const _handlerOnMouseMove = onMouseMove.bind(this); + const _handlerOnMouseWheel = onMouseWheel.bind(this); + + // focus policy + if (this.focusOnMouseOver) { + this.domElement.addEventListener('mouseover', () => this.domElement.focus()); + } + if (this.focusOnClick) { + this.domElement.addEventListener('click', () => this.domElement.focus()); + } - /** - * PlanarControl update - * Updates the view and camera if needed, and handles the animated travel - * @param {number} dt : deltatime given by mainLoop - * @param {boolean} updateLoopRestarted : given by mainLoop - */ + // prevent the default contextmenu from appearing when right-clicking + // this allows to use right-click for input without the menu appearing + this.domElement.addEventListener('contextmenu', onContextMenu.bind(this), false); + + // add this PlanarControl instance to the view's framerequesters + // with this, PlanarControl.update() will be called each frame + this.view.addFrameRequester(this); + + + // Updates the view and camera if needed, and handles the animated travel this.update = function 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; } if (this.state === STATE.TRAVEL) { - this.handleTravel(dt / 1000); + this.handleTravel(dt); this.view.notifyChange(true); } - // drag movement needs to be synchronous with update if (this.state === STATE.DRAG) { - this.camera.position.add(this.dragDelta); - this.dragDelta.set(0, 0, 0); + this.handleDragMovement(); } + if (this.state === STATE.ROTATE) { + this.handleRotation(); + } + if (this.state === STATE.PAN) { + this.handlePanMovement(); + } + deltaMousePosition.set(0, 0); }; /** - * Initiate a drag movement (translation on xy plane) when user does a left-click + * Initiate a drag movement (translation on xy plane) * 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 (eg : google map) */ @@ -171,29 +178,33 @@ function PlanarControls(view, options = {}) { this.state = STATE.DRAG; // the world point under mouse cursor when the drag movement is started - this.dragStart.copy(this.getWorldPointAtScreenXY(this.mousePosition)); + dragStart.copy(this.getWorldPointAtScreenXY(mousePosition)); // the difference between start and end cursor position - this.dragDelta.set(0, 0, 0); + 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 when user does a left-click, by initiateDrag() + * The drag movement is previously initiated by 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 (eg : google map) */ this.handleDragMovement = function handleDragMovement() { // the world point under the current mouse cursor position, at same altitude than dragStart - this.dragEnd.copy(this.getWorldPointFromMathPlaneAtScreenXY(this.mousePosition, this.dragStart.z)); + dragEnd.copy(this.getWorldPointFromMathPlaneAtScreenXY(mousePosition, dragStart.z)); // the difference between start and end cursor position - this.dragDelta.subVectors(this.dragStart, this.dragEnd); + dragDelta.subVectors(dragStart, dragEnd); + + this.camera.position.add(dragDelta); + + dragDelta.set(0, 0, 0); }; /** - * Initiate a pan movement (local translation on xz plane) when user does a righ-click + * Initiate a pan movement (local translation on xz plane) */ this.initiatePan = function initiatePan() { this.state = STATE.PAN; @@ -201,7 +212,7 @@ function PlanarControls(view, options = {}) { /** * Handle the pan movement (translation on local x / world z plane) when user moves the mouse while in STATE.PAN - * The drag movement is previously initiated when user does a right-click, by initiatePan() + * The drag movement is previously initiated by initiatePan() * Compute the pan value and update the camera controls. */ this.handlePanMovement = function handlePanMovement() { @@ -212,10 +223,10 @@ function PlanarControls(view, options = {}) { const panSpeed = THREE.Math.lerp(this.minPanSpeed, this.maxPanSpeed, distToGround); // lateral movement (local x axis) - this.camera.position.copy(this.camera.localToWorld(new THREE.Vector3(panSpeed * -1 * this.deltaMousePosition.x, 0, 0))); + this.camera.position.copy(this.camera.localToWorld(new THREE.Vector3(panSpeed * -1 * deltaMousePosition.x, 0, 0))); // vertical movement (world z axis) - const newAltitude = this.camera.position.z + panSpeed * this.deltaMousePosition.y; + const newAltitude = this.camera.position.z + panSpeed * deltaMousePosition.y; // check if altitude is valid if (newAltitude < this.maxAltitude && newAltitude > this.groundLevel) { @@ -224,17 +235,15 @@ function PlanarControls(view, options = {}) { }; /** - * Initiate a rotate (orbit) movement when user does a right-click or ctrl + left-click + * Initiate a rotate (orbit) movement */ this.initiateRotation = function initiateRotation() { this.state = STATE.ROTATE; - const screenCenter = new THREE.Vector2(0.5 * window.innerWidth, 0.5 * window.innerHeight); + centerPoint.copy(this.getWorldPointAtScreenXY({ x: 0.5 * this.domElement.clientWidth, y: 0.5 * this.domElement.clientHeight })); - this.centerPoint.copy(this.getWorldPointAtScreenXY(screenCenter)); - - const r = this.camera.position.distanceTo(this.centerPoint); - this.phi = Math.acos((this.camera.position.z - this.centerPoint.z) / r); + const r = this.camera.position.distanceTo(centerPoint); + phi = Math.acos((this.camera.position.z - centerPoint.z) / r); }; /** @@ -243,53 +252,58 @@ function PlanarControls(view, options = {}) { * The rotate movement is previously initiated in initiateRotation() * Compute the new position value and update the camera controls. */ - this.handleRotation = function handleRotation() { - // angle deltas - // deltaMousePosition is computed in onMouseMove / onMouseDown s - const thetaDelta = -this.rotateSpeed * this.deltaMousePosition.x / window.innerWidth; - const phiDelta = -this.rotateSpeed * this.deltaMousePosition.y / window.innerHeight; - - // the vector from centerPoint (focus point) to camera position - const offset = this.camera.position.clone().sub(this.centerPoint); - - const quat = new THREE.Quaternion().setFromUnitVectors(this.camera.up, new THREE.Vector3(0, 0, 1)); - const quatInverse = quat.clone().inverse(); - - if (thetaDelta !== 0 || phiDelta !== 0) { - if ((this.phi + phiDelta >= this.minZenithAngle) - && (this.phi + phiDelta <= this.maxZenithAngle) - && phiDelta !== 0) { - // rotation around X (altitude) - this.phi += phiDelta; - offset.applyQuaternion(quat); - - const rotationXQuaternion = new THREE.Quaternion(); - const vector = new THREE.Vector3(); - - vector.setFromMatrixColumn(this.camera.matrix, 0); - rotationXQuaternion.setFromAxisAngle(vector, phiDelta); - offset.applyQuaternion(rotationXQuaternion); - offset.applyQuaternion(quatInverse); - } - if (thetaDelta !== 0) { - // rotation around Z (azimuth) - - const rotationZQuaternion = new THREE.Quaternion(); - rotationZQuaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), thetaDelta); - offset.applyQuaternion(rotationZQuaternion); + this.handleRotation = (() => { + const quat = new THREE.Quaternion(); + let quatInverse = new THREE.Quaternion(); + + return () => { + // angle deltas + // deltaMousePosition is computed in onMouseMove / onMouseDown s + const thetaDelta = -this.rotateSpeed * deltaMousePosition.x / this.domElement.clientWidth; + const phiDelta = -this.rotateSpeed * deltaMousePosition.y / this.domElement.clientHeight; + + // the vector from centerPoint (focus point) to camera position + const offset = this.camera.position.clone().sub(centerPoint); + + quat.setFromUnitVectors(this.camera.up, new THREE.Vector3(0, 0, 1)); + quatInverse = quat.clone().inverse(); + + if (thetaDelta !== 0 || phiDelta !== 0) { + if ((phi + phiDelta >= this.minZenithAngle) + && (phi + phiDelta <= this.maxZenithAngle) + && phiDelta !== 0) { + // rotation around X (altitude) + phi += phiDelta; + offset.applyQuaternion(quat); + + const rotationXQuaternion = new THREE.Quaternion(); + const vector = new THREE.Vector3(); + + vector.setFromMatrixColumn(this.camera.matrix, 0); + rotationXQuaternion.setFromAxisAngle(vector, phiDelta); + offset.applyQuaternion(rotationXQuaternion); + offset.applyQuaternion(quatInverse); + } + if (thetaDelta !== 0) { + // rotation around Z (azimuth) + + const rotationZQuaternion = new THREE.Quaternion(); + rotationZQuaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), thetaDelta); + offset.applyQuaternion(rotationZQuaternion); + } } - } - this.camera.position.copy(offset).add(this.centerPoint); + this.camera.position.copy(offset).add(centerPoint); - this.camera.lookAt(this.centerPoint); - }; + this.camera.lookAt(centerPoint); + }; + })(); /** * 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 ('none' parameter in the call to initiateTravel function) + * Orientation will not change (null parameter in the call to initiateTravel function) * @param {event} event : the mouse wheel event. */ this.initiateZoom = function initiateZoom(event) { @@ -302,7 +316,7 @@ function PlanarControls(view, options = {}) { delta = -event.detail; } - const pointUnderCursor = this.getWorldPointAtScreenXY(this.mousePosition); + const pointUnderCursor = this.getWorldPointAtScreenXY(mousePosition); const newPos = new THREE.Vector3(); // Zoom IN @@ -310,14 +324,14 @@ function PlanarControls(view, options = {}) { // target position newPos.lerpVectors(this.camera.position, pointUnderCursor, this.zoomInFactor); // initiate travel - this.initiateTravel(newPos, this.zoomTravelTime, 'none', false); + this.initiateTravel(newPos, this.zoomTravelTime, null, false); } // Zoom OUT else if (delta < 0 && this.camera.position.z < this.maxAltitude) { // target position newPos.lerpVectors(this.camera.position, pointUnderCursor, -1 * this.zoomOutFactor); // initiate travel - this.initiateTravel(newPos, this.zoomTravelTime, 'none', false); + this.initiateTravel(newPos, this.zoomTravelTime, null, false); } }; @@ -327,7 +341,15 @@ function PlanarControls(view, options = {}) { */ this.initiateSmartZoom = function initiateSmartZoom() { // point under mouse cursor - const pointUnderCursor = this.getWorldPointAtScreenXY(this.mousePosition); + const pointUnderCursor = new THREE.Vector3(); + + // check if there is valid geometry under cursor + if (typeof this.view.getPickingPositionFromDepth(mousePosition) !== 'undefined') { + pointUnderCursor.copy(this.view.getPickingPositionFromDepth(mousePosition)); + } + else { + return; + } // direction of the movement, projected on xy plane and normalized const dir = new THREE.Vector3(); @@ -337,7 +359,7 @@ function PlanarControls(view, options = {}) { const distanceToPoint = this.camera.position.distanceTo(pointUnderCursor); - // camera height (altitude above ground) at the end of the travel + // camera height (altitude above ground) at the end of the travel, 5000 is an empirical smoothing distance const targetHeight = THREE.Math.lerp(this.smartZoomHeightMin, this.smartZoomHeightMax, Math.min(distanceToPoint / 5000, 1)); // camera position at the end of the travel @@ -358,81 +380,80 @@ function PlanarControls(view, options = {}) { * 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 - * if targetOrientation is 'none' : the camera will keep its starting orientation * if targetOrientation is a world point (Vector3) : the camera will lookAt() this point * if targetOrientation is a quaternion : this quaternion will define the final camera orientation + * if targetOrientation is neither a quaternion nor a world point : the camera will keep its starting orientation * @param {boolean} useSmooth : animation is smoothed using the 'smooth(value)' function (slower at start and finish) */ this.initiateTravel = function initiateTravel(targetPos, travelTime, targetOrientation, useSmooth) { this.state = STATE.TRAVEL; this.view.notifyChange(true); // the progress of the travel (animation alpha) - this.travelAlpha = 0; + travelAlpha = 0; // update cursor this.updateMouseCursorType(); - this.travelUseRotation = !(targetOrientation === 'none'); - this.travelUseSmooth = useSmooth; + travelUseRotation = (targetOrientation instanceof THREE.Quaternion || targetOrientation instanceof THREE.Vector3); + travelUseSmooth = useSmooth; // start position (current camera position) - this.travelStartPos.copy(this.camera.position); + travelStartPos.copy(this.camera.position); // start rotation (current camera rotation) - this.travelStartRot.copy(this.camera.quaternion); + travelStartRot.copy(this.camera.quaternion); // setup the end rotation : // case where targetOrientation is a quaternion - if (typeof targetOrientation.w !== 'undefined') { - this.travelEndRot.copy(targetOrientation); + if (targetOrientation instanceof THREE.Quaternion) { + travelEndRot.copy(targetOrientation); } // case where targetOrientation is a vector3 - else if (targetOrientation.isVector3) { + else if (targetOrientation instanceof THREE.Vector3) { if (targetPos === targetOrientation) { this.camera.lookAt(targetOrientation); - this.travelEndRot.copy(this.camera.quaternion); - this.camera.quaternion.copy(this.travelStartRot); + travelEndRot.copy(this.camera.quaternion); + this.camera.quaternion.copy(travelStartRot); } else { this.camera.position.copy(targetPos); this.camera.lookAt(targetOrientation); - this.travelEndRot.copy(this.camera.quaternion); - this.camera.quaternion.copy(this.travelStartRot); - this.camera.position.copy(this.travelStartPos); + travelEndRot.copy(this.camera.quaternion); + this.camera.quaternion.copy(travelStartRot); + this.camera.position.copy(travelStartPos); } } // end position - this.travelEndPos.copy(targetPos); + travelEndPos.copy(targetPos); // beginning of the travel duration setup if (this.instantTravel) { - this.travelDuration = 0; + travelDuration = 0; } - // case where travelTime is set to 'auto' : travelDuration will be a value between autoTravelTimeMin and autoTravelTimeMax // depending on travel distance and travel angular difference else if (travelTime === 'auto') { // 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); - this.travelDuration = THREE.Math.lerp(this.autoTravelTimeMin, this.autoTravelTimeMax, normalizedDistance); + travelDuration = THREE.Math.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 excede autoTravelTimeMax - if (this.travelUseRotation) { + if (travelUseRotation) { // value is normalized between 0 and 1 - const angularDifference = 0.5 - 0.5 * (this.travelEndRot.normalize().dot(this.camera.quaternion.normalize())); + const angularDifference = 0.5 - 0.5 * (travelEndRot.normalize().dot(this.camera.quaternion.normalize())); - this.travelDuration *= 1 + 2 * angularDifference; - this.travelDuration = Math.min(this.travelDuration, this.autoTravelTimeMax); + travelDuration *= 1 + 2 * angularDifference; + travelDuration = Math.min(travelDuration, this.autoTravelTimeMax); } } // case where traveltime !== 'auto' : travelTime is a duration in seconds given as parameter else { - this.travelDuration = travelTime; + travelDuration = travelTime; } }; @@ -440,10 +461,10 @@ function PlanarControls(view, options = {}) { * Resume normal behavior after a travel is completed */ this.endTravel = function endTravel() { - this.camera.position.copy(this.travelEndPos); + this.camera.position.copy(travelEndPos); - if (this.travelUseRotation) { - this.camera.quaternion.copy(this.travelEndRot); + if (travelUseRotation) { + this.camera.quaternion.copy(travelEndRot); } this.state = STATE.NONE; @@ -453,23 +474,23 @@ function PlanarControls(view, options = {}) { /** * Handle the animated movement and rotation of the camera in 'travel' state - * @param {number} dt : the deltatime between two updates + * @param {number} dt : the deltatime between two updates in milliseconds */ this.handleTravel = function handleTravel(dt) { - this.travelAlpha += dt / this.travelDuration; + travelAlpha += (dt / 1000) / travelDuration; // the animation alpha, between 0 (start) and 1 (finish) - const alpha = (this.travelUseSmooth) ? smooth(this.travelAlpha) : this.travelAlpha; + const alpha = (travelUseSmooth) ? smooth(travelAlpha) : travelAlpha; // new position - this.camera.position.lerpVectors(this.travelStartPos, this.travelEndPos, alpha); + this.camera.position.lerpVectors(travelStartPos, travelEndPos, alpha); // new rotation - if (this.travelUseRotation === true) { - THREE.Quaternion.slerp(this.travelStartRot, this.travelEndRot, this.camera.quaternion, alpha); + if (travelUseRotation === true) { + THREE.Quaternion.slerp(travelStartRot, travelEndRot, this.camera.quaternion, alpha); } // completion test - if (this.travelAlpha > 1) { + if (travelAlpha > 1) { this.endTravel(); } }; @@ -480,9 +501,9 @@ function PlanarControls(view, options = {}) { this.goToTopView = function goToTopView() { const topViewPos = new THREE.Vector3(); const targetQuat = new THREE.Quaternion(); - const screenCenter = new THREE.Vector2(0.5 * window.innerWidth, 0.5 * window.innerHeight); - topViewPos.copy(this.getWorldPointAtScreenXY(screenCenter)); + // the top view position is above the camera focus point, at an altitude = distanceToPoint + topViewPos.copy(this.getWorldPointAtScreenXY({ x: 0.5 * this.domElement.clientWidth, y: 0.5 * this.domElement.clientHeight })); topViewPos.z += Math.min(this.maxAltitude, this.camera.position.distanceTo(topViewPos)); targetQuat.setFromAxisAngle(new THREE.Vector3(1, 0, 0), 0); @@ -495,7 +516,7 @@ function PlanarControls(view, options = {}) { * Triggers an animated movement (travel) to set the camera to starting view */ this.goToStartView = function goToStartView() { - this.initiateTravel(this.startPosition, 'auto', this.startQuaternion, true); + this.initiateTravel(startPosition, 'auto', startQuaternion, true); }; /** @@ -505,16 +526,20 @@ function PlanarControls(view, options = {}) { * @param {number} altitude : the altitude (z) of the mathematical plane * @returns {THREE.Vector3} */ - this.getWorldPointFromMathPlaneAtScreenXY = function getWorldPointFromMathPlaneAtScreenXY(posXY, altitude) { + this.getWorldPointFromMathPlaneAtScreenXY = (() => { const vector = new THREE.Vector3(); - vector.set((posXY.x / window.innerWidth) * 2 - 1, -(posXY.y / window.innerHeight) * 2 + 1, 0.5); - vector.unproject(this.camera); - const dir = vector.sub(this.camera.position).normalize(); - const distance = (altitude - this.camera.position.z) / dir.z; - const pointUnderCursor = this.camera.position.clone().add(dir.multiplyScalar(distance)); - - return pointUnderCursor; - }; + let dir = new THREE.Vector3(); + let pointUnderCursor = new THREE.Vector3(); + return (posXY, altitude) => { + vector.set((posXY.x / this.domElement.clientWidth) * 2 - 1, -(posXY.y / this.domElement.clientHeight) * 2 + 1, 0.5); + vector.unproject(this.camera); + dir = vector.sub(this.camera.position).normalize(); + const distance = (altitude - this.camera.position.z) / dir.z; + pointUnderCursor = this.camera.position.clone().add(dir.multiplyScalar(distance)); + + return pointUnderCursor; + }; + })(); /** * returns the world point (xyz) under the posXY screen point @@ -523,67 +548,78 @@ function PlanarControls(view, options = {}) { * @param {THREE.Vector2} posXY : the mouse position in screen space (unit : pixel) * @returns {THREE.Vector3} */ - this.getWorldPointAtScreenXY = function getWorldPointAtScreenXY(posXY) { + this.getWorldPointAtScreenXY = (() => { // the returned value const pointUnderCursor = new THREE.Vector3(); + return (posXY) => { + // check if there is valid geometry under cursor + if (typeof this.view.getPickingPositionFromDepth(posXY) !== 'undefined') { + pointUnderCursor.copy(this.view.getPickingPositionFromDepth(posXY)); + } + // if not, we use the mathematical plane at altitude = groundLevel + else { + pointUnderCursor.copy(this.getWorldPointFromMathPlaneAtScreenXY(posXY, this.groundLevel)); + } + return pointUnderCursor; + }; + })(); - // check if there is valid geometry under cursor - if (typeof this.view.getPickingPositionFromDepth(posXY) !== 'undefined') { - pointUnderCursor.copy(this.view.getPickingPositionFromDepth(posXY)); - } - // if not, we use the mathematical plane at altitude = groundLevel - else { - pointUnderCursor.copy(this.getWorldPointFromMathPlaneAtScreenXY(posXY, this.groundLevel)); - } - return pointUnderCursor; + this.updateMousePositionAndDelta = function updateMousePositionAndDelta(event) { + mousePosition.set(event.clientX, event.clientY); + + deltaMousePosition.copy(mousePosition).sub(lastMousePosition); + + lastMousePosition.copy(mousePosition); }; /** * Adds all the input event listeners (activate the controls) */ this.addInputListeners = function addInputListeners() { - window.addEventListener('keydown', this._handlerOnKeyDown, true); - window.addEventListener('keyup', this._handlerOnKeyUp, true); - this.domElement.addEventListener('mousedown', this._handlerOnMouseDown, false); - this.domElement.addEventListener('mouseup', this._handlerOnMouseUp, false); - this.domElement.addEventListener('mousemove', this._handlerOnMouseMove, false); - this.domElement.addEventListener('mousewheel', this._handlerOnMouseWheel, false); + this.domElement.addEventListener('keydown', _handlerOnKeyDown, true); + this.domElement.addEventListener('mousedown', _handlerOnMouseDown, false); + this.domElement.addEventListener('mouseup', _handlerOnMouseUp, false); + this.domElement.addEventListener('mousemove', _handlerOnMouseMove, false); + this.domElement.addEventListener('mousewheel', _handlerOnMouseWheel, false); // For firefox - this.domElement.addEventListener('MozMousePixelScroll', this._handlerOnMouseWheel, false); + this.domElement.addEventListener('MozMousePixelScroll', _handlerOnMouseWheel, false); }; /** * removes all the input event listeners (desactivate the controls) */ this.removeInputListeners = function removeInputListeners() { - window.removeEventListener('keydown', this._handlerOnKeyDown, true); - window.removeEventListener('keyup', this._handlerOnKeyUp, true); - this.domElement.removeEventListener('mousedown', this._handlerOnMouseDown, false); - this.domElement.removeEventListener('mouseup', this._handlerOnMouseUp, false); - this.domElement.removeEventListener('mousemove', this._handlerOnMouseMove, false); - this.domElement.removeEventListener('mousewheel', this._handlerOnMouseWheel, false); + this.domElement.removeEventListener('keydown', _handlerOnKeyDown, true); + this.domElement.removeEventListener('mousedown', _handlerOnMouseDown, false); + this.domElement.removeEventListener('mouseup', _handlerOnMouseUp, false); + this.domElement.removeEventListener('mousemove', _handlerOnMouseMove, false); + this.domElement.removeEventListener('mousewheel', _handlerOnMouseWheel, false); // For firefox - this.domElement.removeEventListener('MozMousePixelScroll', this._handlerOnMouseWheel, false); + this.domElement.removeEventListener('MozMousePixelScroll', _handlerOnMouseWheel, false); }; /** * update the cursor image according to the control state */ this.updateMouseCursorType = function updateMouseCursorType() { - if (this.state === STATE.NONE) { - this.domElement.style.cursor = 'auto'; - } - else if (this.state === STATE.DRAG) { - this.domElement.style.cursor = 'move'; - } - else if (this.state === STATE.PAN) { - this.domElement.style.cursor = 'cell'; - } - else if (this.state === STATE.TRAVEL) { - this.domElement.style.cursor = 'wait'; - } - else if (this.state === STATE.ROTATE) { - this.domElement.style.cursor = 'move'; + switch (this.state) { + case STATE.NONE: + this.domElement.style.cursor = 'auto'; + break; + case STATE.DRAG: + this.domElement.style.cursor = 'move'; + break; + case STATE.PAN: + this.domElement.style.cursor = 'cell'; + break; + case STATE.TRAVEL: + this.domElement.style.cursor = 'wait'; + break; + case STATE.ROTATE: + this.domElement.style.cursor = 'move'; + break; + default: + break; } }; @@ -601,16 +637,14 @@ function PlanarControls(view, options = {}) { var onMouseDown = function onMouseDown(event) { event.preventDefault(); - this.mousePosition.set(event.clientX, event.clientY); - if (this.state === STATE.TRAVEL) { return; } - this.lastMousePosition.copy(this.mousePosition); + this.updateMousePositionAndDelta(event); if (event.button === mouseButtons.LEFTCLICK) { - if (this.isCtrlDown) { + if (event.ctrlKey) { this.initiateRotation(); } else { this.initiateDrag(); @@ -631,8 +665,6 @@ var onMouseDown = function onMouseDown(event) { var onMouseUp = function onMouseUp(event) { event.preventDefault(); - this.dragDelta.set(0, 0, 0); - if (this.state !== STATE.TRAVEL) { this.state = STATE.NONE; } @@ -647,35 +679,12 @@ var onMouseUp = function onMouseUp(event) { var onMouseMove = function onMouseMove(event) { event.preventDefault(); - this.mousePosition.set(event.clientX, event.clientY); - - this.deltaMousePosition.copy(this.mousePosition).sub(this.lastMousePosition); - - this.lastMousePosition.copy(this.mousePosition); + this.updateMousePositionAndDelta(event); // notify change if moving if (this.state !== STATE.NONE) { this.view.notifyChange(true); } - if (this.state === STATE.ROTATE) { - this.handleRotation(); - } - else if (this.state === STATE.DRAG) { - this.handleDragMovement(); - } - else if (this.state === STATE.PAN) { - this.handlePanMovement(); - } -}; - -/** -* Catch and manage the event when a key is up. -* @param {event} event : the current event -*/ -var onKeyUp = function onKeyUp(event) { - if (event.keyCode === keys.CTRL) { - this.isCtrlDown = false; - } }; /** @@ -695,9 +704,6 @@ var onKeyDown = function onKeyDown(event) { if (event.keyCode === keys.SPACE) { this.initiateSmartZoom(event); } - if (event.keyCode === keys.CTRL) { - this.isCtrlDown = true; - } }; /** @@ -730,7 +736,8 @@ var onContextMenu = function onContextMenu(event) { */ var smooth = function smooth(value) { // p between 1.0 and 1.5 - return Math.pow((value * value * (3 - 2 * value)), 1.20); + const p = 1.20; + return Math.pow((value * value * (3 - 2 * value)), p); }; export default PlanarControls; From 957e365d2b1825ff4eb40f231c7ac202f616f99d Mon Sep 17 00:00:00 2001 From: Emmanuel Schmuck Date: Wed, 30 Aug 2017 11:51:48 +0200 Subject: [PATCH 5/5] additional changes following @autra second review (opti, cleanup) --- src/Renderer/ThreeExtended/PlanarControls.js | 102 +++++++++---------- 1 file changed, 49 insertions(+), 53 deletions(-) diff --git a/src/Renderer/ThreeExtended/PlanarControls.js b/src/Renderer/ThreeExtended/PlanarControls.js index 73c8f8dbf7..5af535b936 100644 --- a/src/Renderer/ThreeExtended/PlanarControls.js +++ b/src/Renderer/ThreeExtended/PlanarControls.js @@ -146,7 +146,6 @@ function PlanarControls(view, options = {}) { // with this, PlanarControl.update() will be called each frame this.view.addFrameRequester(this); - // Updates the view and camera if needed, and handles the animated travel this.update = function update(dt, updateLoopRestarted) { // dt will not be relevant when we just started rendering, we consider a 1-frame move in this case @@ -215,24 +214,29 @@ function PlanarControls(view, options = {}) { * The drag movement is previously initiated by initiatePan() * Compute the pan value and update the camera controls. */ - this.handlePanMovement = function handlePanMovement() { - // normalized (betwwen 0 and 1) distance between groundLevel and maxAltitude - const distToGround = THREE.Math.clamp((this.camera.position.z - this.groundLevel) / this.maxAltitude, 0, 1); + this.handlePanMovement = (() => { + const vec = new THREE.Vector3(); - // pan movement speed, adujsted according to altitude - const panSpeed = THREE.Math.lerp(this.minPanSpeed, this.maxPanSpeed, distToGround); + return () => { + // normalized (betwwen 0 and 1) distance between groundLevel and maxAltitude + const distToGround = THREE.Math.clamp((this.camera.position.z - this.groundLevel) / this.maxAltitude, 0, 1); - // lateral movement (local x axis) - this.camera.position.copy(this.camera.localToWorld(new THREE.Vector3(panSpeed * -1 * deltaMousePosition.x, 0, 0))); + // pan movement speed, adujsted according to altitude + const panSpeed = THREE.Math.lerp(this.minPanSpeed, this.maxPanSpeed, distToGround); - // vertical movement (world z axis) - const newAltitude = this.camera.position.z + panSpeed * deltaMousePosition.y; + // lateral movement (local x axis) + vec.set(panSpeed * -1 * deltaMousePosition.x, 0, 0); + this.camera.position.copy(this.camera.localToWorld(vec)); - // check if altitude is valid - if (newAltitude < this.maxAltitude && newAltitude > this.groundLevel) { - this.camera.position.z = newAltitude; - } - }; + // vertical movement (world z axis) + const newAltitude = this.camera.position.z + panSpeed * deltaMousePosition.y; + + // check if altitude is valid + if (newAltitude < this.maxAltitude && newAltitude > this.groundLevel) { + this.camera.position.z = newAltitude; + } + }; + })(); /** * Initiate a rotate (orbit) movement @@ -253,8 +257,8 @@ function PlanarControls(view, options = {}) { * Compute the new position value and update the camera controls. */ this.handleRotation = (() => { + const vec = new THREE.Vector3(); const quat = new THREE.Quaternion(); - let quatInverse = new THREE.Quaternion(); return () => { // angle deltas @@ -265,31 +269,30 @@ function PlanarControls(view, options = {}) { // the vector from centerPoint (focus point) to camera position const offset = this.camera.position.clone().sub(centerPoint); - quat.setFromUnitVectors(this.camera.up, new THREE.Vector3(0, 0, 1)); - quatInverse = quat.clone().inverse(); - if (thetaDelta !== 0 || phiDelta !== 0) { if ((phi + phiDelta >= this.minZenithAngle) && (phi + phiDelta <= this.maxZenithAngle) && phiDelta !== 0) { // rotation around X (altitude) phi += phiDelta; + + vec.set(0, 0, 1); + quat.setFromUnitVectors(this.camera.up, vec); offset.applyQuaternion(quat); - const rotationXQuaternion = new THREE.Quaternion(); - const vector = new THREE.Vector3(); + vec.setFromMatrixColumn(this.camera.matrix, 0); + quat.setFromAxisAngle(vec, phiDelta); + offset.applyQuaternion(quat); - vector.setFromMatrixColumn(this.camera.matrix, 0); - rotationXQuaternion.setFromAxisAngle(vector, phiDelta); - offset.applyQuaternion(rotationXQuaternion); - offset.applyQuaternion(quatInverse); + vec.set(0, 0, 1); + quat.setFromUnitVectors(this.camera.up, vec).inverse(); + offset.applyQuaternion(quat); } if (thetaDelta !== 0) { // rotation around Z (azimuth) - - const rotationZQuaternion = new THREE.Quaternion(); - rotationZQuaternion.setFromAxisAngle(new THREE.Vector3(0, 0, 1), thetaDelta); - offset.applyQuaternion(rotationZQuaternion); + vec.set(0, 0, 1); + quat.setFromAxisAngle(vec, thetaDelta); + offset.applyQuaternion(quat); } } @@ -528,16 +531,15 @@ function PlanarControls(view, options = {}) { */ this.getWorldPointFromMathPlaneAtScreenXY = (() => { const vector = new THREE.Vector3(); - let dir = new THREE.Vector3(); - let pointUnderCursor = new THREE.Vector3(); return (posXY, altitude) => { vector.set((posXY.x / this.domElement.clientWidth) * 2 - 1, -(posXY.y / this.domElement.clientHeight) * 2 + 1, 0.5); vector.unproject(this.camera); - dir = vector.sub(this.camera.position).normalize(); + // dir = direction toward the point on the plane + const dir = vector.sub(this.camera.position).normalize(); + // distance from camera to point on the plane const distance = (altitude - this.camera.position.z) / dir.z; - pointUnderCursor = this.camera.position.clone().add(dir.multiplyScalar(distance)); - return pointUnderCursor; + return this.camera.position.clone().add(dir.multiplyScalar(distance)); }; })(); @@ -548,21 +550,17 @@ function PlanarControls(view, options = {}) { * @param {THREE.Vector2} posXY : the mouse position in screen space (unit : pixel) * @returns {THREE.Vector3} */ - this.getWorldPointAtScreenXY = (() => { - // the returned value - const pointUnderCursor = new THREE.Vector3(); - return (posXY) => { - // check if there is valid geometry under cursor - if (typeof this.view.getPickingPositionFromDepth(posXY) !== 'undefined') { - pointUnderCursor.copy(this.view.getPickingPositionFromDepth(posXY)); - } - // if not, we use the mathematical plane at altitude = groundLevel - else { - pointUnderCursor.copy(this.getWorldPointFromMathPlaneAtScreenXY(posXY, this.groundLevel)); - } + this.getWorldPointAtScreenXY = function getWorldPointAtScreenXY(posXY) { + const pointUnderCursor = this.view.getPickingPositionFromDepth(posXY); + // check if there is valid geometry under cursor + if (pointUnderCursor) { return pointUnderCursor; - }; - })(); + } + // if not, we use the mathematical plane at altitude = groundLevel + else { + return this.getWorldPointFromMathPlaneAtScreenXY(posXY, this.groundLevel); + } + }; this.updateMousePositionAndDelta = function updateMousePositionAndDelta(event) { mousePosition.set(event.clientX, event.clientY); @@ -623,12 +621,10 @@ function PlanarControls(view, options = {}) { } }; - PlanarControls.prototype = Object.create(THREE.EventDispatcher.prototype); - PlanarControls.prototype.constructor = PlanarControls; - - // event listeners for user input + // event listeners for user input (to activate the controls) this.addInputListeners(); } +// ===== end of PlanarControls constructor ===== /** * Catch and manage the event when a touch on the mouse is down. @@ -735,7 +731,7 @@ var onContextMenu = function onContextMenu(event) { * @returns {number} */ var smooth = function smooth(value) { - // p between 1.0 and 1.5 + // p between 1.0 and 1.5 (empirical) const p = 1.20; return Math.pow((value * value * (3 - 2 * value)), p); };