3d-tiles-renderer
Version:
https://github.com/AnalyticalGraphicsInc/3d-tiles/tree/master/specification
1,552 lines (1,039 loc) • 37.8 kB
JavaScript
import {
Matrix4,
Quaternion,
Vector2,
Vector3,
Raycaster,
Plane,
EventDispatcher,
MathUtils,
Clock,
Ray,
} from 'three';
import { PivotPointMesh } from './PivotPointMesh.js';
import { PointerTracker } from './PointerTracker.js';
import { mouseToCoords, makeRotateAroundPoint, setRaycasterFromCamera } from './utils.js';
export const NONE = 0;
export const DRAG = 1;
export const ROTATE = 2;
export const ZOOM = 3;
export const WAITING = 4;
const DRAG_PLANE_THRESHOLD = 0.05;
const DRAG_UP_THRESHOLD = 0.025;
const _rotMatrix = /* @__PURE__ */ new Matrix4();
const _invMatrix = /* @__PURE__ */ new Matrix4();
const _delta = /* @__PURE__ */ new Vector3();
const _vec = /* @__PURE__ */ new Vector3();
const _pos = /* @__PURE__ */ new Vector3();
const _center = /* @__PURE__ */ new Vector3();
const _forward = /* @__PURE__ */ new Vector3();
const _right = /* @__PURE__ */ new Vector3();
const _targetRight = /* @__PURE__ */ new Vector3();
const _rotationAxis = /* @__PURE__ */ new Vector3();
const _quaternion = /* @__PURE__ */ new Quaternion();
const _plane = /* @__PURE__ */ new Plane();
const _localUp = /* @__PURE__ */ new Vector3();
const _mouseBefore = /* @__PURE__ */ new Vector3();
const _mouseAfter = /* @__PURE__ */ new Vector3();
const _identityQuat = /* @__PURE__ */ new Quaternion();
const _ray = /* @__PURE__ */ new Ray();
const _zoomPointPointer = /* @__PURE__ */ new Vector2();
const _pointer = /* @__PURE__ */ new Vector2();
const _prevPointer = /* @__PURE__ */ new Vector2();
const _deltaPointer = /* @__PURE__ */ new Vector2();
const _centerPoint = /* @__PURE__ */ new Vector2();
const _startCenterPoint = /* @__PURE__ */ new Vector2();
const _changeEvent = { type: 'change' };
const _startEvent = { type: 'start' };
const _endEvent = { type: 'end' };
export class EnvironmentControls extends EventDispatcher {
get enabled() {
return this._enabled;
}
set enabled( v ) {
if ( v !== this.enabled ) {
this._enabled = v;
this.resetState();
this.pointerTracker.reset();
if ( ! this.enabled ) {
this.dragInertia.set( 0, 0, 0 );
this.rotationInertia.set( 0, 0 );
}
}
}
constructor( scene = null, camera = null, domElement = null, tilesRenderer = null ) {
super();
this.isEnvironmentControls = true;
this.domElement = null;
this.camera = null;
this.scene = null;
this.tilesRenderer = null;
// settings
this._enabled = true;
this.cameraRadius = 5;
this.rotationSpeed = 1;
this.minAltitude = 0;
this.maxAltitude = 0.45 * Math.PI;
this.minDistance = 10;
this.maxDistance = Infinity;
this.minZoom = 0;
this.maxZoom = Infinity;
this.zoomSpeed = 1;
this.adjustHeight = true;
this.enableDamping = false;
this.dampingFactor = 0.15;
this.fallbackPlane = new Plane( new Vector3( 0, 1, 0 ), 0 );
this.useFallbackPlane = true;
// settings for GlobeControls
this.scaleZoomOrientationAtEdges = false;
this.autoAdjustCameraRotation = true;
// internal state
this.state = NONE;
this.pointerTracker = new PointerTracker();
this.needsUpdate = false;
this.actionHeightOffset = 0;
this.pivotPoint = new Vector3();
// used for zoom
this.zoomDirectionSet = false;
this.zoomPointSet = false;
this.zoomDirection = new Vector3();
this.zoomPoint = new Vector3();
this.zoomDelta = 0;
// fields used for inertia
this.rotationInertiaPivot = new Vector3();
this.rotationInertia = new Vector2();
this.dragInertia = new Vector3();
this.inertiaTargetDistance = Infinity; // track the distance from the camera that we want to use to calculate the inertia end threshold
this.inertiaStableFrames = 0; // the number of frames that the camera has not moved while the user is interacting
// circular pivot mesh
this.pivotMesh = new PivotPointMesh();
this.pivotMesh.raycast = () => {};
this.pivotMesh.scale.setScalar( 0.25 );
// raycaster
this.raycaster = new Raycaster();
this.raycaster.firstHitOnly = true;
this.up = new Vector3( 0, 1, 0 );
this.clock = new Clock();
this._detachCallback = null;
this._upInitialized = false;
this._lastUsedState = NONE;
this._zoomPointWasSet = false;
// always update the zoom target point in case the tiles are changing
this._tilesOnChangeCallback = () => this.zoomPointSet = false;
// init
if ( domElement ) this.attach( domElement );
if ( camera ) this.setCamera( camera );
if ( scene ) this.setScene( scene );
if ( tilesRenderer ) this.setTilesRenderer( tilesRenderer );
}
setScene( scene ) {
this.scene = scene;
}
setCamera( camera ) {
this.camera = camera;
this._upInitialized = false;
this.zoomDirectionSet = false;
this.zoomPointSet = false;
this.needsUpdate = true;
this.raycaster.camera = camera;
this.resetState();
}
setTilesRenderer( tilesRenderer ) {
console.warn( 'EnvironmentControls: "setTilesRenderer" has been deprecated. Use "setScene" and "setEllipsoid", instead.' );
this.tilesRenderer = tilesRenderer;
if ( this.tilesRenderer !== null ) {
this.setScene( this.tilesRenderer.group );
}
}
attach( domElement ) {
if ( this.domElement ) {
throw new Error( 'EnvironmentControls: Controls already attached to element' );
}
// set the touch action to none so the browser does not
// drag the page to refresh or scroll
this.domElement = domElement;
this.pointerTracker.domElement = domElement;
domElement.style.touchAction = 'none';
const contextMenuCallback = e => {
// exit early if the controls are disabled
if ( ! this.enabled ) {
return;
}
e.preventDefault();
};
const pointerdownCallback = e => {
// exit early if the controls are disabled
if ( ! this.enabled ) {
return;
}
e.preventDefault();
const {
camera,
raycaster,
domElement,
up,
pivotMesh,
pointerTracker,
scene,
pivotPoint,
enabled,
} = this;
// init the pointer
pointerTracker.addPointer( e );
this.needsUpdate = true;
// handle cases where we need to capture the pointer or
// reset state when we have too many pointers
if ( pointerTracker.isPointerTouch() ) {
pivotMesh.visible = false;
if ( pointerTracker.getPointerCount() === 0 ) {
// TODO: is it correct to only capture a single pointer? Or do we need to do it for
// every unique pointer id?
domElement.setPointerCapture( e.pointerId );
} else if ( pointerTracker.getPointerCount() > 2 ) {
this.resetState();
return;
}
}
// the "pointer" for zooming and rotating should be based on the center point
pointerTracker.getCenterPoint( _pointer );
mouseToCoords( _pointer.x, _pointer.y, domElement, _pointer );
setRaycasterFromCamera( raycaster, _pointer, camera );
// prevent the drag distance from getting too severe by limiting the drag point
// to a reasonable angle and reasonable distance with the drag plane
const dot = Math.abs( raycaster.ray.direction.dot( up ) );
if ( dot < DRAG_PLANE_THRESHOLD || dot < DRAG_UP_THRESHOLD ) {
return;
}
// find the hit point
const hit = this._raycast( raycaster );
if ( hit ) {
// if two fingers, right click, or shift click are being used then we trigger
// a rotation action to begin
if (
pointerTracker.getPointerCount() === 2 ||
pointerTracker.isRightClicked() ||
pointerTracker.isLeftClicked() && e.shiftKey
) {
this.setState( pointerTracker.isPointerTouch() ? WAITING : ROTATE );
pivotPoint.copy( hit.point );
pivotMesh.position.copy( hit.point );
pivotMesh.visible = pointerTracker.isPointerTouch() ? false : enabled;
pivotMesh.updateMatrixWorld();
scene.add( pivotMesh );
} else if ( pointerTracker.isLeftClicked() ) {
// if the clicked point is coming from below the plane then don't perform the drag
this.setState( DRAG );
pivotPoint.copy( hit.point );
pivotMesh.position.copy( hit.point );
pivotMesh.updateMatrixWorld();
scene.add( pivotMesh );
}
}
};
let _pointerMoveQueued = false;
const pointermoveCallback = e => {
// exit early if the controls are disabled
const { pointerTracker } = this;
if ( ! this.enabled ) {
return;
}
e.preventDefault();
const {
pivotMesh,
enabled,
} = this;
// whenever the pointer moves we need to re-derive the zoom direction and point
this.zoomDirectionSet = false;
this.zoomPointSet = false;
if ( this.state !== NONE ) {
this.needsUpdate = true;
}
pointerTracker.setHoverEvent( e );
if ( ! pointerTracker.updatePointer( e ) ) {
return;
}
if ( pointerTracker.isPointerTouch() && pointerTracker.getPointerCount() === 2 ) {
// We queue this event to ensure that all pointers have been updated
if ( ! _pointerMoveQueued ) {
_pointerMoveQueued = true;
queueMicrotask( () => {
_pointerMoveQueued = false;
// adjust the pointer position to be the center point
pointerTracker.getCenterPoint( _centerPoint );
// detect zoom transition
const startDist = pointerTracker.getStartTouchPointerDistance();
const pointerDist = pointerTracker.getTouchPointerDistance();
const separateDelta = pointerDist - startDist;
if ( this.state === NONE || this.state === WAITING ) {
// check which direction was moved in first - if the pointers are pinching then
// it's a zoom. But if they move in parallel it's a rotation
pointerTracker.getCenterPoint( _centerPoint );
pointerTracker.getStartCenterPoint( _startCenterPoint );
// adjust the drag requirement by the dpr
const dragThreshold = 2.0 * window.devicePixelRatio;
const parallelDelta = _centerPoint.distanceTo( _startCenterPoint );
if ( Math.abs( separateDelta ) > dragThreshold || parallelDelta > dragThreshold ) {
if ( Math.abs( separateDelta ) > parallelDelta ) {
this.setState( ZOOM );
this.zoomDirectionSet = false;
} else {
this.setState( ROTATE );
}
}
}
if ( this.state === ZOOM ) {
const previousDist = pointerTracker.getPreviousTouchPointerDistance();
this.zoomDelta += pointerDist - previousDist;
pivotMesh.visible = false;
} else if ( this.state === ROTATE ) {
pivotMesh.visible = enabled;
}
} );
}
}
// TODO: we have the potential to fire change multiple times per frame - should we debounce?
this.dispatchEvent( _changeEvent );
};
const pointerupCallback = e => {
// exit early if the controls are disabled
const { pointerTracker } = this;
if ( ! this.enabled || pointerTracker.getPointerCount() === 0 ) {
return;
}
pointerTracker.deletePointer( e );
if (
pointerTracker.getPointerType() === 'touch' &&
pointerTracker.getPointerCount() === 0
) {
domElement.releasePointerCapture( e.pointerId );
}
this.resetState();
this.needsUpdate = true;
};
const wheelCallback = e => {
// exit early if the controls are disabled
if ( ! this.enabled ) {
return;
}
e.preventDefault();
const { pointerTracker } = this;
pointerTracker.setHoverEvent( e );
pointerTracker.updatePointer( e );
// TODO: do we need events here?
this.dispatchEvent( _startEvent );
let delta;
switch ( e.deltaMode ) {
case 2: // Pages
delta = e.deltaY * 800;
break;
case 1: // Lines
delta = e.deltaY * 40;
break;
case 0: // Pixels
delta = e.deltaY;
break;
}
// use LOG to scale the scroll delta and hopefully normalize them across platforms
const deltaSign = Math.sign( delta );
const normalizedDelta = Math.abs( delta );
this.zoomDelta -= 0.25 * deltaSign * normalizedDelta;
this.needsUpdate = true;
this._lastUsedState = ZOOM;
this.dispatchEvent( _endEvent );
};
const pointerleaveCallback = e => {
// exit early if the controls are disabled
if ( ! this.enabled ) {
return;
}
this.resetState();
};
domElement.addEventListener( 'contextmenu', contextMenuCallback );
domElement.addEventListener( 'pointerdown', pointerdownCallback );
domElement.addEventListener( 'wheel', wheelCallback, { passive: false } );
// Register movement events on the root element so dragging does not break when dragging over other elements.
// Use "getRootNode" to enable offscreenCanvas usage.
// "pointerleave" event fires when leaving the window.
const document = domElement.getRootNode();
document.addEventListener( 'pointermove', pointermoveCallback );
document.addEventListener( 'pointerup', pointerupCallback );
document.addEventListener( 'pointerleave', pointerleaveCallback );
this._detachCallback = () => {
domElement.removeEventListener( 'contextmenu', contextMenuCallback );
domElement.removeEventListener( 'pointerdown', pointerdownCallback );
domElement.removeEventListener( 'wheel', wheelCallback );
document.removeEventListener( 'pointermove', pointermoveCallback );
document.removeEventListener( 'pointerup', pointerupCallback );
document.removeEventListener( 'pointerleave', pointerleaveCallback );
};
}
detach() {
this.domElement = null;
if ( this._detachCallback ) {
this._detachCallback();
this._detachCallback = null;
this.pointerTracker.reset();
}
}
// override-able functions for retrieving the up direction at a point
getUpDirection( point, target ) {
target.copy( this.up );
}
getCameraUpDirection( target ) {
this.getUpDirection( this.camera.position, target );
}
// returns the active / last used pivot point for the scene
getPivotPoint( target ) {
let result = null;
// get the last interacted point as the focus
if ( this._lastUsedState === ZOOM ) {
if ( this._zoomPointWasSet ) {
result = target.copy( this.zoomPoint );
}
} else if ( this._lastUsedState === ROTATE || this._lastUsedState === DRAG ) {
result = target.copy( this.pivotPoint );
}
// If the last used point is outside the camera view then skip it
const { camera, raycaster } = this;
if ( result !== null ) {
_vec.copy( result ).project( camera );
if ( _vec.x < - 1 || _vec.x > 1 || _vec.y < - 1 || _vec.y > 1 ) {
result = null;
}
}
// default to the raycast hit if we have not result or the hit is closer to the camera
// set a ray in the local ellipsoid frame
setRaycasterFromCamera( raycaster, { x: 0, y: 0 }, camera );
const hit = this._raycast( raycaster );
if ( hit ) {
if ( result === null || hit.distance < result.distanceTo( raycaster.ray.origin ) ) {
result = target.copy( hit.point );
}
}
return result;
}
resetState() {
if ( this.state !== NONE ) {
this.dispatchEvent( _endEvent );
}
this.state = NONE;
this.pivotMesh.removeFromParent();
this.pivotMesh.visible = this.enabled;
this.actionHeightOffset = 0;
this.pointerTracker.reset();
}
setState( state = this.state, fireEvent = true ) {
if ( this.state === state ) {
return;
}
if ( this.state === NONE && fireEvent ) {
this.dispatchEvent( _startEvent );
}
this.pivotMesh.visible = this.enabled;
this.dragInertia.set( 0, 0, 0 );
this.rotationInertia.set( 0, 0 );
this.inertiaStableFrames = 0;
this.state = state;
if ( state !== NONE && state !== WAITING ) {
this._lastUsedState = state;
}
}
update( deltaTime = Math.min( this.clock.getDelta(), 64 / 1000 ) ) {
if ( ! this.enabled || ! this.camera || deltaTime === 0 ) {
return;
}
const {
camera,
cameraRadius,
pivotPoint,
up,
state,
adjustHeight,
autoAdjustCameraRotation,
} = this;
camera.updateMatrixWorld();
// set the "up" vector immediately so it's available in the following functions
this.getCameraUpDirection( _localUp );
if ( ! this._upInitialized ) {
this._upInitialized = true;
this.up.copy( _localUp );
}
// we need to update the zoom point whenever we update in case the scene is animating or changing
this.zoomPointSet = false;
// update the actions
const inertiaNeedsUpdate = this._inertiaNeedsUpdate();
const adjustCameraRotation = this.needsUpdate || inertiaNeedsUpdate;
if ( this.needsUpdate || inertiaNeedsUpdate ) {
const zoomDelta = this.zoomDelta;
this._updateZoom();
this._updatePosition( deltaTime );
this._updateRotation( deltaTime );
if ( state === DRAG || state === ROTATE ) {
_forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
this.inertiaTargetDistance = _vec.copy( pivotPoint ).sub( camera.position ).dot( _forward );
} else if ( state === NONE ) {
this._updateInertia( deltaTime );
}
if ( state !== NONE || zoomDelta !== 0 || inertiaNeedsUpdate ) {
this.dispatchEvent( _changeEvent );
}
this.needsUpdate = false;
}
// update the up direction based on where the camera moved to
// if using an orthographic camera then rotate around drag pivot
// reuse the "hit" information since it can be slow to perform multiple hits
const hit = camera.isOrthographicCamera ? null : adjustHeight && this._getPointBelowCamera() || null;
this.getCameraUpDirection( _localUp );
this._setFrame( _localUp );
// when dragging the camera and drag point may be moved
// to accommodate terrain so we try to move it back down
// to the original point.
if ( ( this.state === DRAG || this.state === ROTATE ) && this.actionHeightOffset !== 0 ) {
const { actionHeightOffset } = this;
camera.position.addScaledVector( up, - actionHeightOffset );
pivotPoint.addScaledVector( up, - actionHeightOffset );
// adjust the height
if ( hit ) {
hit.distance -= actionHeightOffset;
}
}
this.actionHeightOffset = 0;
if ( hit ) {
const dist = hit.distance;
if ( dist < cameraRadius ) {
const delta = cameraRadius - dist;
camera.position.addScaledVector( up, delta );
pivotPoint.addScaledVector( up, delta );
this.actionHeightOffset = delta;
}
}
this.pointerTracker.updateFrame();
if ( adjustCameraRotation && autoAdjustCameraRotation ) {
this.getCameraUpDirection( _localUp );
this._alignCameraUp( _localUp, 1 );
this.getCameraUpDirection( _localUp );
this._clampRotation( _localUp );
}
}
// updates the camera to position it based on the constraints of the controls
adjustCamera( camera ) {
const { adjustHeight, cameraRadius } = this;
if ( camera.isPerspectiveCamera ) {
// adjust the camera height
this.getUpDirection( camera.position, _localUp );
const hit = adjustHeight && this._getPointBelowCamera( camera.position, _localUp ) || null;
if ( hit ) {
const dist = hit.distance;
if ( dist < cameraRadius ) {
camera.position.addScaledVector( _localUp, cameraRadius - dist );
}
}
}
}
dispose() {
this.detach();
}
// private
_updateInertia( deltaTime ) {
// update the damping of momentum variables
const {
rotationInertia,
pivotPoint,
dragInertia,
enableDamping,
dampingFactor,
camera,
cameraRadius,
minDistance,
inertiaTargetDistance,
} = this;
if ( ! this.enableDamping || this.inertiaStableFrames > 1 ) {
dragInertia.set( 0, 0, 0 );
rotationInertia.set( 0, 0, 0 );
return;
}
// Based on Freya Holmer's frame-rate independent lerp function
const factor = Math.pow( 2, - deltaTime / dampingFactor );
const stableDistance = Math.max( camera.near, cameraRadius, minDistance, inertiaTargetDistance );
const resolution = 2 * 1e3;
const pixelWidth = 2 / resolution;
const pixelThreshold = 0.25 * pixelWidth;
// scale the residual rotation motion
if ( rotationInertia.lengthSq() > 0 ) {
// calculate two screen points at 1 pixel apart in our notional resolution so we can stop when the delta is ~ 1 pixel
// projected into world space
setRaycasterFromCamera( _ray, _vec.set( 0, 0, - 1 ), camera );
_ray.applyMatrix4( camera.matrixWorldInverse );
_ray.direction.normalize();
_ray.recast( - _ray.direction.dot( _ray.origin ) ).at( stableDistance / _ray.direction.z, _vec );
_vec.applyMatrix4( camera.matrixWorld );
setRaycasterFromCamera( _ray, _delta.set( pixelThreshold, pixelThreshold, - 1 ), camera );
_ray.applyMatrix4( camera.matrixWorldInverse );
_ray.direction.normalize();
_ray.recast( - _ray.direction.dot( _ray.origin ) ).at( stableDistance / _ray.direction.z, _delta );
_delta.applyMatrix4( camera.matrixWorld );
// get implied angle
_vec.sub( pivotPoint ).normalize();
_delta.sub( pivotPoint ).normalize();
// calculate the rotation threshold
const threshold = _vec.angleTo( _delta ) / deltaTime;
rotationInertia.multiplyScalar( factor );
if ( rotationInertia.lengthSq() < threshold ** 2 || ! enableDamping ) {
rotationInertia.set( 0, 0 );
}
}
// scale the residual translation motion
if ( dragInertia.lengthSq() > 0 ) {
// calculate two screen points at 1 pixel apart in our notional resolution so we can stop when the delta is ~ 1 pixel
// projected into world space
setRaycasterFromCamera( _ray, _vec.set( 0, 0, - 1 ), camera );
_ray.applyMatrix4( camera.matrixWorldInverse );
_ray.direction.normalize();
_ray.recast( - _ray.direction.dot( _ray.origin ) ).at( stableDistance / _ray.direction.z, _vec );
_vec.applyMatrix4( camera.matrixWorld );
setRaycasterFromCamera( _ray, _delta.set( pixelThreshold, pixelThreshold, - 1 ), camera );
_ray.applyMatrix4( camera.matrixWorldInverse );
_ray.direction.normalize();
_ray.recast( - _ray.direction.dot( _ray.origin ) ).at( stableDistance / _ray.direction.z, _delta );
_delta.applyMatrix4( camera.matrixWorld );
// calculate movement threshold
const threshold = _vec.distanceTo( _delta ) / deltaTime;
dragInertia.multiplyScalar( factor );
if ( dragInertia.lengthSq() < threshold ** 2 || ! enableDamping ) {
dragInertia.set( 0, 0, 0 );
}
}
// apply the inertia changes
if ( rotationInertia.lengthSq() > 0 ) {
this._applyRotation( rotationInertia.x * deltaTime, rotationInertia.y * deltaTime, pivotPoint );
}
if ( dragInertia.lengthSq() > 0 ) {
camera.position.addScaledVector( dragInertia, deltaTime );
camera.updateMatrixWorld();
}
}
_inertiaNeedsUpdate() {
const { rotationInertia, dragInertia } = this;
return rotationInertia.lengthSq() !== 0 || dragInertia.lengthSq() !== 0;
}
_updateZoom() {
const {
zoomPoint,
zoomDirection,
camera,
minDistance,
maxDistance,
pointerTracker,
domElement,
minZoom,
maxZoom,
zoomSpeed,
state,
} = this;
let scale = this.zoomDelta;
this.zoomDelta = 0;
// get the latest hover / touch point
if ( ! pointerTracker.getLatestPoint( _pointer ) || ( scale === 0 && state !== ZOOM ) ) {
return;
}
// reset momentum
this.rotationInertia.set( 0, 0 );
this.dragInertia.set( 0, 0, 0 );
if ( camera.isOrthographicCamera ) {
// update the zoom direction
this._updateZoomDirection();
// zoom straight into the globe if we haven't hit anything
const zoomIntoPoint = this.zoomPointSet || this._updateZoomPoint();
// get the mouse position before zoom
_mouseBefore.unproject( camera );
// zoom the camera
const normalizedDelta = Math.pow( 0.95, Math.abs( scale * 0.05 ) );
let scaleFactor = scale > 0 ? 1 / Math.abs( normalizedDelta ) : normalizedDelta;
scaleFactor *= zoomSpeed;
if ( scaleFactor > 1 ) {
if ( maxZoom < camera.zoom * scaleFactor ) {
scaleFactor = 1;
}
} else {
if ( minZoom > camera.zoom * scaleFactor ) {
scaleFactor = 1;
}
}
camera.zoom *= scaleFactor;
camera.updateProjectionMatrix();
// adjust the surface point to be in the same position if the globe is hovered over
if ( zoomIntoPoint ) {
// get the mouse position after zoom
mouseToCoords( _pointer.x, _pointer.y, domElement, _mouseAfter );
_mouseAfter.unproject( camera );
// shift the camera on the near plane so the mouse is in the same spot
camera.position.sub( _mouseAfter ).add( _mouseBefore );
camera.updateMatrixWorld();
}
} else {
// initialize the zoom direction
this._updateZoomDirection();
// track the zoom direction we're going to use
const finalZoomDirection = _vec.copy( zoomDirection );
if ( this.zoomPointSet || this._updateZoomPoint() ) {
const dist = zoomPoint.distanceTo( camera.position );
// scale the distance based on how far there is to move
if ( scale < 0 ) {
const remainingDistance = Math.min( 0, dist - maxDistance );
scale = scale * dist * zoomSpeed * 0.0025;
scale = Math.max( scale, remainingDistance );
} else {
const remainingDistance = Math.max( 0, dist - minDistance );
scale = scale * Math.max( dist - minDistance, 0 ) * zoomSpeed * 0.0025;
scale = Math.min( scale, remainingDistance );
}
camera.position.addScaledVector( zoomDirection, scale );
camera.updateMatrixWorld();
} else {
// if we're zooming into nothing then use the distance from the ground to scale movement
const hit = this._getPointBelowCamera();
if ( hit ) {
const dist = hit.distance;
finalZoomDirection.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
camera.position.addScaledVector( finalZoomDirection, scale * dist * 0.01 );
camera.updateMatrixWorld();
}
}
}
}
_updateZoomDirection() {
if ( this.zoomDirectionSet ) {
return;
}
const { domElement, raycaster, camera, zoomDirection, pointerTracker } = this;
pointerTracker.getLatestPoint( _pointer );
mouseToCoords( _pointer.x, _pointer.y, domElement, _mouseBefore );
setRaycasterFromCamera( raycaster, _mouseBefore, camera );
zoomDirection.copy( raycaster.ray.direction ).normalize();
this.zoomDirectionSet = true;
}
// update the point being zoomed in to based on the zoom direction
_updateZoomPoint() {
const {
camera,
zoomDirectionSet,
zoomDirection,
raycaster,
zoomPoint,
pointerTracker,
domElement,
} = this;
this._zoomPointWasSet = false;
if ( ! zoomDirectionSet ) {
return false;
}
// If using an orthographic camera we have to account for the mouse position when picking the point
if ( camera.isOrthographicCamera && pointerTracker.getLatestPoint( _zoomPointPointer ) ) {
mouseToCoords( _zoomPointPointer.x, _zoomPointPointer.y, domElement, _zoomPointPointer );
setRaycasterFromCamera( raycaster, _zoomPointPointer, camera );
} else {
raycaster.ray.origin.copy( camera.position );
raycaster.ray.direction.copy( zoomDirection );
raycaster.near = 0;
raycaster.far = Infinity;
}
// get the hit point
const hit = this._raycast( raycaster );
if ( hit ) {
zoomPoint.copy( hit.point );
this.zoomPointSet = true;
this._zoomPointWasSet = true;
return true;
}
return false;
}
// returns the point below the camera
_getPointBelowCamera( point = this.camera.position, up = this.up ) {
const { raycaster } = this;
raycaster.ray.direction.copy( up ).multiplyScalar( - 1 );
raycaster.ray.origin.copy( point ).addScaledVector( up, 1e5 );
raycaster.near = 0;
raycaster.far = Infinity;
const hit = this._raycast( raycaster );
if ( hit ) {
hit.distance -= 1e5;
}
return hit;
}
// update the drag action
_updatePosition( deltaTime ) {
const {
raycaster,
camera,
pivotPoint,
up,
pointerTracker,
domElement,
state,
dragInertia,
} = this;
if ( state === DRAG ) {
// get the pointer and plane
pointerTracker.getCenterPoint( _pointer );
mouseToCoords( _pointer.x, _pointer.y, domElement, _pointer );
_plane.setFromNormalAndCoplanarPoint( up, pivotPoint );
setRaycasterFromCamera( raycaster, _pointer, camera );
// prevent the drag distance from getting too severe by limiting the drag point
// to a reasonable angle with the drag plane
if ( Math.abs( raycaster.ray.direction.dot( up ) ) < DRAG_PLANE_THRESHOLD ) {
// rotate the pointer direction down to the correct angle for horizontal dragging
const angle = Math.acos( DRAG_PLANE_THRESHOLD );
_rotationAxis
.crossVectors( raycaster.ray.direction, up )
.normalize();
raycaster.ray.direction
.copy( up )
.applyAxisAngle( _rotationAxis, angle )
.multiplyScalar( - 1 );
}
// TODO: dragging causes the camera to rise because we're getting "pushed" up by lower resolution tiles and
// don't lower back down. We should maintain a target height above tiles where possible
// prevent the drag from inverting
// if we drag to a point that's near the edge of the earth then we want to prevent it
// from wrapping around and causing unexpected rotations
this.getUpDirection( pivotPoint, _localUp );
if ( Math.abs( raycaster.ray.direction.dot( _localUp ) ) < DRAG_UP_THRESHOLD ) {
const angle = Math.acos( DRAG_UP_THRESHOLD );
_rotationAxis
.crossVectors( raycaster.ray.direction, _localUp )
.normalize();
raycaster.ray.direction
.copy( _localUp )
.applyAxisAngle( _rotationAxis, angle )
.multiplyScalar( - 1 );
}
// find the point on the plane that we should drag to
if ( raycaster.ray.intersectPlane( _plane, _vec ) ) {
_delta.subVectors( pivotPoint, _vec );
camera.position.add( _delta );
camera.updateMatrixWorld();
// update the drag inertia
_delta.multiplyScalar( 1 / deltaTime );
if ( pointerTracker.getMoveDistance() / deltaTime < 2 * window.devicePixelRatio ) {
this.inertiaStableFrames ++;
} else {
dragInertia.copy( _delta );
this.inertiaStableFrames = 0;
}
}
}
}
_updateRotation( deltaTime ) {
const {
pivotPoint,
pointerTracker,
domElement,
state,
rotationInertia,
} = this;
if ( state === ROTATE ) {
// get the rotation motion and divide out the container height to normalize for element size
pointerTracker.getCenterPoint( _pointer );
pointerTracker.getPreviousCenterPoint( _prevPointer );
_deltaPointer.subVectors( _pointer, _prevPointer ).multiplyScalar( 2 * Math.PI / domElement.clientHeight );
this._applyRotation( _deltaPointer.x, _deltaPointer.y, pivotPoint );
// update rotation inertia
_deltaPointer.multiplyScalar( 1 / deltaTime );
if ( pointerTracker.getMoveDistance() / deltaTime < 2 * window.devicePixelRatio ) {
this.inertiaStableFrames ++;
} else {
rotationInertia.copy( _deltaPointer );
this.inertiaStableFrames = 0;
}
}
}
_applyRotation( x, y, pivotPoint ) {
if ( x === 0 && y === 0 ) {
return;
}
const {
camera,
minAltitude,
maxAltitude,
rotationSpeed,
} = this;
const azimuth = - x * rotationSpeed;
let altitude = y * rotationSpeed;
// calculate current angles and clamp
_forward.set( 0, 0, 1 ).transformDirection( camera.matrixWorld );
_right.set( 1, 0, 0 ).transformDirection( camera.matrixWorld );
this.getUpDirection( pivotPoint, _localUp );
// get the signed angle relative to the top down view
let angle;
if ( _localUp.dot( _forward ) > 1 - 1e-10 ) {
angle = 0;
} else {
_vec.crossVectors( _localUp, _forward ).normalize();
const sign = Math.sign( _vec.dot( _right ) );
angle = sign * _localUp.angleTo( _forward );
}
// clamp the rotation to be within the provided limits
// clamp to 0 here, as well, so we don't "pop" to the the value range
if ( altitude > 0 ) {
altitude = Math.min( angle - minAltitude, altitude );
altitude = Math.max( 0, altitude );
} else {
altitude = Math.max( angle - maxAltitude, altitude );
altitude = Math.min( 0, altitude );
}
// rotate around the up axis
_quaternion.setFromAxisAngle( _localUp, azimuth );
makeRotateAroundPoint( pivotPoint, _quaternion, _rotMatrix );
camera.matrixWorld.premultiply( _rotMatrix );
// get a rotation axis for altitude and rotate
_right.set( 1, 0, 0 ).transformDirection( camera.matrixWorld );
_quaternion.setFromAxisAngle( _right, - altitude );
makeRotateAroundPoint( pivotPoint, _quaternion, _rotMatrix );
camera.matrixWorld.premultiply( _rotMatrix );
// update the transform members
camera.matrixWorld.decompose( camera.position, camera.quaternion, _vec );
}
// sets the "up" axis for the current surface of the tileset
_setFrame( newUp ) {
const {
up,
camera,
zoomPoint,
zoomDirectionSet,
zoomPointSet,
scaleZoomOrientationAtEdges,
} = this;
// If we're zooming then reorient around the zoom point
if ( zoomDirectionSet && ( zoomPointSet || this._updateZoomPoint() ) ) {
// get the amount needed to rotate
_quaternion.setFromUnitVectors( up, newUp );
if ( scaleZoomOrientationAtEdges ) {
this.getUpDirection( zoomPoint, _vec );
let amt = Math.max( _vec.dot( up ) - 0.6, 0 ) / 0.4;
amt = MathUtils.mapLinear( amt, 0, 0.5, 0, 1 );
amt = Math.min( amt, 1 );
// scale the value if we're using an orthographic camera so
// GlobeControls works correctly
if ( camera.isOrthographicCamera ) {
amt *= 0.1;
}
_quaternion.slerp( _identityQuat, 1.0 - amt );
}
// rotates the camera position around the point being zoomed in to
makeRotateAroundPoint( zoomPoint, _quaternion, _rotMatrix );
camera.updateMatrixWorld();
camera.matrixWorld.premultiply( _rotMatrix );
camera.matrixWorld.decompose( camera.position, camera.quaternion, _vec );
// recompute the zoom direction after updating rotation to align with frame
this.zoomDirectionSet = false;
this._updateZoomDirection();
}
up.copy( newUp );
camera.updateMatrixWorld();
}
_raycast( raycaster ) {
const { scene, useFallbackPlane, fallbackPlane } = this;
const result = raycaster.intersectObject( scene )[ 0 ] || null;
if ( result ) {
return result;
} else if ( useFallbackPlane ) {
// if we don't hit any geometry then try to intersect the fallback
// plane so the camera can still be manipulated
const plane = fallbackPlane;
if ( raycaster.ray.intersectPlane( plane, _vec ) ) {
const planeHit = {
point: _vec.clone(),
distance: raycaster.ray.origin.distanceTo( _vec ),
};
return planeHit;
}
}
return null;
}
// tilt the camera to align with the provided "up" value
_alignCameraUp( up, alpha = 1 ) {
const { camera, state, pivotPoint, zoomPoint, zoomPointSet } = this;
// get the transform vectors
camera.updateMatrixWorld();
_forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
_right.set( - 1, 0, 0 ).transformDirection( camera.matrixWorld );
// compute an alpha based on the camera direction so we don't try to update the up direction
// when the camera is facing that way.
let multiplier = MathUtils.mapLinear( 1 - Math.abs( _forward.dot( up ) ), 0, 0.2, 0, 1 );
multiplier = MathUtils.clamp( multiplier, 0, 1 );
alpha *= multiplier;
// calculate the target direction for the right-facing vector
_targetRight.crossVectors( up, _forward );
_targetRight.lerp( _right, 1 - alpha ).normalize();
// adjust the camera transformation
_quaternion.setFromUnitVectors( _right, _targetRight );
camera.quaternion.premultiply( _quaternion );
// calculate the active point
let fixedPoint = null;
if ( state === DRAG || state === ROTATE ) {
fixedPoint = _pos.copy( pivotPoint );
} else if ( zoomPointSet ) {
fixedPoint = _pos.copy( zoomPoint );
}
// shift the camera in an effort to keep the fixed point in the same spot
if ( fixedPoint ) {
_invMatrix.copy( camera.matrixWorld ).invert();
_vec.copy( fixedPoint ).applyMatrix4( _invMatrix );
camera.updateMatrixWorld();
_vec.applyMatrix4( camera.matrixWorld );
_center.subVectors( fixedPoint, _vec );
camera.position.add( _center );
}
camera.updateMatrixWorld();
}
// clamp rotation to the given "up" vector
_clampRotation( up ) {
const { camera, minAltitude, maxAltitude, state, pivotPoint, zoomPoint, zoomPointSet } = this;
camera.updateMatrixWorld();
// calculate current angles and clamp
_forward.set( 0, 0, 1 ).transformDirection( camera.matrixWorld );
_right.set( 1, 0, 0 ).transformDirection( camera.matrixWorld );
// get the signed angle relative to the top down view
let angle;
if ( up.dot( _forward ) > 1 - 1e-10 ) {
angle = 0;
} else {
_vec.crossVectors( up, _forward );
const sign = Math.sign( _vec.dot( _right ) );
angle = sign * up.angleTo( _forward );
}
// find the angle to target
let targetAngle;
if ( angle > maxAltitude ) {
targetAngle = maxAltitude;
} else if ( angle < minAltitude ) {
targetAngle = minAltitude;
} else {
return;
}
// construct a rotation basis
_forward.copy( up );
_quaternion.setFromAxisAngle( _right, targetAngle );
_forward.applyQuaternion( _quaternion ).normalize();
_vec.crossVectors( _forward, _right ).normalize();
_rotMatrix.makeBasis( _right, _vec, _forward );
camera.quaternion.setFromRotationMatrix( _rotMatrix );
// calculate the active point
let fixedPoint = null;
if ( state === DRAG || state === ROTATE ) {
fixedPoint = _pos.copy( pivotPoint );
} else if ( zoomPointSet ) {
fixedPoint = _pos.copy( zoomPoint );
}
// shift the camera in an effort to keep the fixed point in the same spot
if ( fixedPoint ) {
_invMatrix.copy( camera.matrixWorld ).invert();
_vec.copy( fixedPoint ).applyMatrix4( _invMatrix );
camera.updateMatrixWorld();
_vec.applyMatrix4( camera.matrixWorld );
_center.subVectors( fixedPoint, _vec );
camera.position.add( _center );
}
camera.updateMatrixWorld();
}
}