diff --git a/example/googleMapsExample.js b/example/googleMapsExample.js
index f8a3c7c7b..5d7408847 100644
--- a/example/googleMapsExample.js
+++ b/example/googleMapsExample.js
@@ -1,39 +1,31 @@
-import { GeoUtils, WGS84_ELLIPSOID, WGS84_RADIUS, DebugGoogleTilesRenderer as GoogleTilesRenderer } from '../src/index.js';
+import { GeoUtils, WGS84_ELLIPSOID, DebugGoogleTilesRenderer as GoogleTilesRenderer } from '../src/index.js';
import {
Scene,
WebGLRenderer,
PerspectiveCamera,
- Raycaster,
- Box3,
MathUtils,
} from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';
import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
-import * as BufferGeometryUtils from 'three/examples/jsm/utils/BufferGeometryUtils.js';
+import { estimateBytesUsed } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
import Stats from 'three/examples/jsm/libs/stats.module.js';
-
-import { MapControls } from './src/lib/MapControls.js';
+import { GlobeControls } from './src/controls/GlobeControls.js';
let camera, controls, scene, renderer, tiles;
let statsContainer, stats;
-const raycaster = new Raycaster();
-raycaster.firstHitOnly = true;
-
const apiKey = localStorage.getItem( 'googleApiKey' ) ?? 'put-your-api-key-here';
const params = {
- 'enableUpdate': true,
- 'enableCacheDisplay': false,
- 'enableRendererStats': false,
- 'apiKey': apiKey,
+ enableCacheDisplay: false,
+ enableRendererStats: false,
+ apiKey: apiKey,
- 'displayBoxBounds': false,
- 'displaySphereBounds': false,
- 'displayRegionBounds': false,
'reload': reinstantiateTiles,
+ displayBoxBounds: false,
+ displayRegionBounds: false,
};
@@ -42,8 +34,6 @@ animate();
function reinstantiateTiles() {
- localStorage.setItem( 'googleApiKey', params.apiKey );
-
if ( tiles ) {
scene.remove( tiles.group );
@@ -52,6 +42,8 @@ function reinstantiateTiles() {
}
+ localStorage.setItem( 'googleApiKey', params.apiKey );
+
tiles = new GoogleTilesRenderer( params.apiKey );
tiles.group.rotation.x = - Math.PI / 2;
@@ -69,6 +61,8 @@ function reinstantiateTiles() {
tiles.setResolutionFromRenderer( camera, renderer );
tiles.setCamera( camera );
+ controls.setScene( tiles.group );
+
}
function init() {
@@ -82,14 +76,11 @@ function init() {
document.body.appendChild( renderer.domElement );
camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 1, 160000000 );
- camera.position.set( 7326000, 10279000, - 823000 );
+ camera.position.set( 4800000, 2570000, 14720000 );
+ camera.lookAt( 0, 0, 0 );
// controls
- controls = new MapControls( camera, renderer.domElement );
- controls.minDistance = 1;
- controls.maxDistance = Infinity;
- controls.minPolarAngle = Math.PI / 4;
- controls.target.set( 0, 0, 1 );
+ controls = new GlobeControls( scene, camera, renderer.domElement );
reinstantiateTiles();
@@ -103,30 +94,14 @@ function init() {
const mapsOptions = gui.addFolder( 'Google Tiles' );
mapsOptions.add( params, 'apiKey' );
mapsOptions.add( params, 'reload' );
- mapsOptions.open();
const debug = gui.addFolder( 'Debug Options' );
debug.add( params, 'displayBoxBounds' );
- debug.add( params, 'displaySphereBounds' );
debug.add( params, 'displayRegionBounds' );
const exampleOptions = gui.addFolder( 'Example Options' );
- exampleOptions.add( params, 'enableUpdate' ).onChange( v => {
-
- tiles.parseQueue.autoUpdate = v;
- tiles.downloadQueue.autoUpdate = v;
-
- if ( v ) {
-
- tiles.parseQueue.scheduleJobRun();
- tiles.downloadQueue.scheduleJobRun();
-
- }
-
- } );
exampleOptions.add( params, 'enableCacheDisplay' );
exampleOptions.add( params, 'enableRendererStats' );
- gui.open();
statsContainer = document.createElement( 'div' );
document.getElementById( 'info' ).appendChild( statsContainer );
@@ -152,39 +127,6 @@ function onWindowResize() {
}
-function updateControls() {
-
- const raycaster = new Raycaster();
- raycaster.ray.origin.copy( controls.target ).normalize().multiplyScalar( WGS84_RADIUS * 1.5 );
- raycaster.ray.direction.copy( raycaster.ray.origin ).normalize().multiplyScalar( - 1 );
- raycaster.firstHitOnly = true;
-
- const hit = raycaster.intersectObject( scene, true )[ 0 ];
- if ( hit ) {
-
- controls.target.copy( hit.point );
-
- } else {
-
- controls.target.normalize().multiplyScalar( WGS84_RADIUS );
-
- }
- controls.panPlane.copy( controls.target ).normalize();
-
- const dist = camera.position.length();
- camera.position.copy( controls.target ).normalize().multiplyScalar( dist );
- camera.lookAt( controls.target );
- controls.update();
-
- const box = new Box3();
- tiles.getBounds( box );
-
- camera.far = dist;
- camera.near = Math.max( 1, dist - Math.max( ...box.min, ...box.max ) );
- camera.updateProjectionMatrix();
-
-}
-
function updateHash() {
if ( ! tiles ) {
@@ -195,7 +137,7 @@ function updateHash() {
const res = {};
const mat = tiles.group.matrixWorld.clone().invert();
- const vec = controls.target.clone().applyMatrix4( mat );
+ const vec = camera.position.clone().applyMatrix4( mat );
WGS84_ELLIPSOID.getPositionToCartographic( vec, res );
res.lat *= MathUtils.RAD2DEG;
@@ -215,11 +157,11 @@ function initFromHash() {
}
const [ lat, lon ] = tokens;
- WGS84_ELLIPSOID.getCartographicToPosition( lat * MathUtils.DEG2RAD, lon * MathUtils.DEG2RAD, 0, controls.target );
+ WGS84_ELLIPSOID.getCartographicToPosition( lat * MathUtils.DEG2RAD, lon * MathUtils.DEG2RAD, 0, camera.position );
tiles.group.updateMatrixWorld();
- controls.target.applyMatrix4( tiles.group.matrixWorld );
- updateControls();
+ camera.position.applyMatrix4( tiles.group.matrixWorld ).multiplyScalar( 2 );
+ camera.lookAt( 0, 0, 0 );
}
@@ -229,36 +171,35 @@ function animate() {
if ( ! tiles ) return;
- updateControls();
+ controls.update();
// update options
tiles.setResolutionFromRenderer( camera, renderer );
tiles.setCamera( camera );
+ tiles.displayBoxBounds = params.displayBoxBounds;
+ tiles.displayRegionBounds = params.displayRegionBounds;
// update tiles
- if ( params.enableUpdate ) {
+ camera.updateMatrixWorld();
+ tiles.update();
- camera.updateMatrixWorld();
- tiles.update();
-
- }
-
- render();
+ renderer.render( scene, camera );
stats.update();
-}
+ updateHtml();
-function render() {
+}
- // render primary view
- renderer.render( scene, camera );
+function updateHtml() {
// render html text updates
const cacheFullness = tiles.lruCache.itemList.length / tiles.lruCache.maxSize;
- let str = `Downloading: ${ tiles.stats.downloading } Parsing: ${ tiles.stats.parsing } Visible: ${ tiles.visibleTiles.size }`;
+ let str = '';
if ( params.enableCacheDisplay ) {
+ str += `Downloading: ${ tiles.stats.downloading } Parsing: ${ tiles.stats.parsing } Visible: ${ tiles.visibleTiles.size }
`;
+
const geomSet = new Set();
tiles.traverse( tile => {
@@ -282,10 +223,10 @@ function render() {
let count = 0;
geomSet.forEach( g => {
- count += BufferGeometryUtils.estimateBytesUsed( g );
+ count += estimateBytesUsed( g );
} );
- str += `
Cache: ${ ( 100 * cacheFullness ).toFixed( 2 ) }% ~${ ( count / 1000 / 1000 ).toFixed( 2 ) }mb`;
+ str += `Cache: ${ ( 100 * cacheFullness ).toFixed( 2 ) }% ~${ ( count / 1000 / 1000 ).toFixed( 2 ) }mb
`;
}
@@ -293,7 +234,7 @@ function render() {
const memory = renderer.info.memory;
const programCount = renderer.info.programs.length;
- str += `
Geometries: ${ memory.geometries } Textures: ${ memory.textures } Programs: ${ programCount }`;
+ str += `Geometries: ${ memory.geometries } Textures: ${ memory.textures } Programs: ${ programCount }`;
}
@@ -303,15 +244,11 @@ function render() {
}
- if ( tiles ) {
-
- const mat = tiles.group.matrixWorld.clone().invert();
- const vec = camera.position.clone().applyMatrix4( mat );
-
- const res = {};
- WGS84_ELLIPSOID.getPositionToCartographic( vec, res );
- document.getElementById( 'credits' ).innerText = GeoUtils.toLatLonString( res.lat, res.lon ) + '\n' + tiles.getCreditsString();
+ const mat = tiles.group.matrixWorld.clone().invert();
+ const vec = camera.position.clone().applyMatrix4( mat );
- }
+ const res = {};
+ WGS84_ELLIPSOID.getPositionToCartographic( vec, res );
+ document.getElementById( 'credits' ).innerText = GeoUtils.toLatLonString( res.lat, res.lon ) + '\n' + tiles.getCreditsString();
}
diff --git a/example/src/FadeTilesRenderer.js b/example/src/FadeTilesRenderer.js
index 0cd8ace9f..0ba4961d4 100644
--- a/example/src/FadeTilesRenderer.js
+++ b/example/src/FadeTilesRenderer.js
@@ -30,7 +30,7 @@ function onTileVisibilityChange( scene, tile, visible ) {
}
- if ( ! isRootTile || ! this.fadeRootTiles || this.initialLayerRendered ) {
+ if ( ! isRootTile || this.fadeRootTiles || this.initialLayerRendered ) {
this._fadeManager.fadeIn( scene );
diff --git a/example/src/controls/GlobeControls.js b/example/src/controls/GlobeControls.js
new file mode 100644
index 000000000..5fb2bd4d9
--- /dev/null
+++ b/example/src/controls/GlobeControls.js
@@ -0,0 +1,286 @@
+import {
+ Matrix4,
+ Quaternion,
+ Vector2,
+ Vector3,
+ MathUtils,
+} from 'three';
+import { TileControls, NONE } from './TileControls.js';
+import { makeRotateAroundPoint } from './utils.js';
+import { WGS84_ELLIPSOID } from '../../../src/index.js';
+
+const _invMatrix = new Matrix4();
+const _rotMatrix = new Matrix4();
+const _vec = new Vector3();
+const _center = new Vector3();
+const _up = new Vector3();
+const _forward = new Vector3();
+const _right = new Vector3();
+const _targetRight = new Vector3();
+const _globalUp = new Vector3();
+const _quaternion = new Quaternion();
+
+const _pointer = new Vector2();
+const _prevPointer = new Vector2();
+const _deltaPointer = new Vector2();
+
+const MAX_GLOBE_DISTANCE = 2 * 1e7;
+const GLOBE_TRANSITION_THRESHOLD = 0.75 * 1e7;
+export class GlobeControls extends TileControls {
+
+ constructor( ...args ) {
+
+ // store which mode the drag stats are in
+ super( ...args );
+ this.ellipsoid = WGS84_ELLIPSOID;
+ this._dragMode = 0;
+ this._rotationMode = 0;
+
+ }
+
+ // get the vector to the center of the provided globe
+ getVectorToCenter( target ) {
+
+ const { scene, camera } = this;
+ return target
+ .setFromMatrixPosition( scene.matrixWorld )
+ .sub( camera.position );
+
+ }
+
+ // get the distance to the center of the globe
+ getDistanceToCenter() {
+
+ return this
+ .getVectorToCenter( _vec )
+ .length();
+
+ }
+
+ getUpDirection( point, target ) {
+
+ // get the "up" direction based on the wgs84 ellipsoid
+ const { scene, ellipsoid } = this;
+ const invMatrix = _invMatrix.copy( scene.matrixWorld ).invert();
+ const pos = _vec.copy( point ).applyMatrix4( invMatrix );
+
+ ellipsoid.getPositionToNormal( pos, target );
+ target.transformDirection( scene.matrixWorld );
+
+ }
+
+ update() {
+
+ super.update();
+
+ const {
+ camera,
+ scene,
+ pivotMesh,
+ } = this;
+
+ // clamp the camera distance
+ let distanceToCenter = this.getDistanceToCenter();
+ if ( distanceToCenter > MAX_GLOBE_DISTANCE ) {
+
+ _vec.setFromMatrixPosition( scene.matrixWorld ).sub( camera.position ).normalize().multiplyScalar( - 1 );
+ camera.position.setFromMatrixPosition( scene.matrixWorld ).addScaledVector( _vec, MAX_GLOBE_DISTANCE );
+ camera.updateMatrixWorld();
+
+ distanceToCenter = MAX_GLOBE_DISTANCE;
+
+ }
+
+ // if we're outside the transition threshold then we toggle some reorientation behavior
+ // when adjusting the up frame while moving hte camera
+ if ( distanceToCenter > GLOBE_TRANSITION_THRESHOLD ) {
+
+ if ( this.state !== NONE && this._dragMode !== 1 && this._rotationMode !== 1 ) {
+
+ pivotMesh.visible = false;
+
+ }
+ this.reorientOnDrag = false;
+ this.reorientOnZoom = true;
+
+ } else {
+
+ this.reorientOnDrag = true;
+ this.reorientOnZoom = false;
+
+ }
+
+ // update the projection matrix
+ const largestDistance = Math.max( ...WGS84_ELLIPSOID.radius );
+ camera.near = Math.max( 1, distanceToCenter - largestDistance * 1.25 );
+ camera.far = distanceToCenter + largestDistance + 0.1;
+ camera.updateProjectionMatrix();
+
+ }
+
+ // resets the "stuck" drag modes
+ resetState() {
+
+ super.resetState();
+ this._dragMode = 0;
+ this._rotationMode = 0;
+
+ }
+
+ // animate the frame to align to an up direction
+ setFrame( ...args ) {
+
+ super.setFrame( ...args );
+
+ if ( this.getDistanceToCenter() < GLOBE_TRANSITION_THRESHOLD ) {
+
+ this._alignCameraUp( this.up );
+
+ }
+
+ }
+
+ _updatePosition( ...args ) {
+
+ if ( this._dragMode === 1 || this.getDistanceToCenter() < GLOBE_TRANSITION_THRESHOLD ) {
+
+ this._dragMode = 1;
+
+ super._updatePosition( ...args );
+
+ } else {
+
+ this._dragMode = - 1;
+
+ const {
+ pointerTracker,
+ rotationSpeed,
+ camera,
+ pivotMesh,
+ dragPoint,
+ } = this;
+
+ // get the delta movement with magic numbers scaled by the distance to the
+ // grabbed point so it feels okay
+ // TODO: it would be better to properly calculate angle based on drag distance
+ pointerTracker.getCenterPoint( _pointer );
+ pointerTracker.getPreviousCenterPoint( _prevPointer );
+ _deltaPointer
+ .subVectors( _pointer, _prevPointer )
+ .multiplyScalar( camera.position.distanceTo( dragPoint ) * 1e-11 * 5 );
+
+ const azimuth = - _deltaPointer.x * rotationSpeed;
+ const altitude = - _deltaPointer.y * rotationSpeed;
+
+ _center.setFromMatrixPosition( this.scene.matrixWorld );
+ _right.set( 1, 0, 0 ).transformDirection( camera.matrixWorld );
+ _up.set( 0, 1, 0 ).transformDirection( camera.matrixWorld );
+
+ // apply the altitude and azimuth adjustment
+ _quaternion.setFromAxisAngle( _right, altitude );
+ camera.quaternion.premultiply( _quaternion );
+ makeRotateAroundPoint( _center, _quaternion, _rotMatrix );
+ camera.matrixWorld.premultiply( _rotMatrix );
+
+ _quaternion.setFromAxisAngle( _up, azimuth );
+ camera.quaternion.premultiply( _quaternion );
+ makeRotateAroundPoint( _center, _quaternion, _rotMatrix );
+ camera.matrixWorld.premultiply( _rotMatrix );
+
+ camera.matrixWorld.decompose( camera.position, camera.quaternion, _vec );
+
+ pivotMesh.visible = false;
+
+ }
+
+ }
+
+ // disable rotation once we're outside the control transition
+ _updateRotation( ...args ) {
+
+ if ( this._rotationMode === 1 || this.getDistanceToCenter() < GLOBE_TRANSITION_THRESHOLD ) {
+
+ this._rotationMode = 1;
+ super._updateRotation( ...args );
+
+ } else {
+
+ this.pivotMesh.visible = false;
+ this._rotationMode = - 1;
+
+ }
+
+ }
+
+ _updateZoom( delta ) {
+
+ if ( this.getDistanceToCenter() < GLOBE_TRANSITION_THRESHOLD || delta > 0 ) {
+
+ super._updateZoom( delta );
+
+ } else {
+
+ // orient the camera to focus on the earth during the zoom
+ const alpha = MathUtils.mapLinear( this.getDistanceToCenter(), GLOBE_TRANSITION_THRESHOLD, MAX_GLOBE_DISTANCE, 0, 1 );
+ this._tiltTowardsCenter( MathUtils.lerp( 1, 0.8, alpha ) );
+ this._alignCameraUpToNorth( MathUtils.lerp( 1, 0.9, alpha ) );
+
+ // zoom out directly from the globe center
+ this.getVectorToCenter( _vec );
+ this.camera.position.addScaledVector( _vec, delta * 0.0025 );
+ this.camera.updateMatrixWorld();
+
+ }
+
+ }
+
+ // tilt the camera to align with north
+ _alignCameraUpToNorth( alpha ) {
+
+ const { scene } = this;
+ _globalUp.set( 0, 0, 1 ).transformDirection( scene.matrixWorld );
+ this._alignCameraUp( _globalUp, alpha );
+
+ }
+
+ // tilt the camera to align with the provided "up" value
+ _alignCameraUp( up, alpha = null ) {
+
+ const { camera } = this;
+ _forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
+ _right.set( - 1, 0, 0 ).transformDirection( camera.matrixWorld );
+ _targetRight.crossVectors( up, _forward );
+
+ if ( alpha === null ) {
+
+ alpha = Math.abs( _forward.dot( up ) );
+
+ }
+
+ _targetRight.lerp( _right, alpha ).normalize();
+
+ _quaternion.setFromUnitVectors( _right, _targetRight );
+ camera.quaternion.premultiply( _quaternion );
+ camera.updateMatrixWorld();
+
+ }
+
+ // tilt the camera to look at the center of the globe
+ _tiltTowardsCenter( alpha ) {
+
+ const {
+ camera,
+ scene,
+ } = this;
+
+ _forward.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld ).normalize();
+ _vec.setFromMatrixPosition( scene.matrixWorld ).sub( camera.position ).normalize();
+ _vec.lerp( _forward, alpha ).normalize();
+
+ _quaternion.setFromUnitVectors( _forward, _vec );
+ camera.quaternion.premultiply( _quaternion );
+ camera.updateMatrixWorld();
+
+ }
+
+}
diff --git a/example/src/controls/PivotPointMesh.js b/example/src/controls/PivotPointMesh.js
new file mode 100644
index 000000000..3b6531479
--- /dev/null
+++ b/example/src/controls/PivotPointMesh.js
@@ -0,0 +1,106 @@
+import { Mesh, PlaneGeometry, ShaderMaterial, Vector2 } from 'three';
+export class PivotPointMesh extends Mesh {
+
+ constructor() {
+
+ super( new PlaneGeometry( 0, 0 ), new PivotMaterial() );
+ this.renderOrder = Infinity;
+
+ }
+
+ onBeforeRender( renderer ) {
+
+ const uniforms = this.material.uniforms;
+ renderer.getSize( uniforms.resolution.value );
+ uniforms.pixelRatio.value = renderer.getPixelRatio();
+
+ }
+
+ updateMatrixWorld() {
+
+ this.matrixWorld.makeTranslation( this.position );
+
+ }
+
+ dispose() {
+
+ this.geometry.dispose();
+ this.material.dispose();
+
+ }
+
+}
+
+class PivotMaterial extends ShaderMaterial {
+
+ constructor() {
+
+ super( {
+
+ depthWrite: false,
+ depthTest: false,
+ transparent: true,
+
+ uniforms: {
+
+ resolution: { value: new Vector2() },
+ pixelRatio: { value: 1 },
+ size: { value: 7.5 },
+ thickness: { value: 1 },
+ opacity: { value: 1 },
+
+ },
+
+ vertexShader: /* glsl */`
+
+ uniform float pixelRatio;
+ uniform float size;
+ uniform float thickness;
+ uniform vec2 resolution;
+ varying vec2 vUv;
+
+ void main() {
+
+ vUv = uv;
+
+ float aspect = resolution.x / resolution.y;
+ vec2 offset = uv * 2.0 - vec2( 1.0 );
+ offset.y *= aspect;
+
+ vec4 screenPoint = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
+ screenPoint.xy += offset * pixelRatio * ( size + thickness ) * screenPoint.w / resolution.x;
+
+ gl_Position = screenPoint;
+
+ }
+ `,
+
+ fragmentShader: /* glsl */`
+
+ uniform float size;
+ uniform float thickness;
+ uniform float opacity;
+
+ varying vec2 vUv;
+ void main() {
+
+ float ht = 0.5 * thickness;
+ float planeDim = size + thickness;
+ float offset = ( planeDim - ht - 2.0 ) / planeDim;
+ float texelThickness = ht / planeDim;
+
+ vec2 vec = vUv * 2.0 - vec2( 1.0 );
+ float dist = abs( length( vec ) - offset );
+ float fw = fwidth( dist );
+ float a = smoothstep( texelThickness - fw, texelThickness + fw, dist );
+
+ gl_FragColor = vec4( 1, 1, 1, opacity * ( 1.0 - a ) );
+
+ }
+ `,
+
+ } );
+
+ }
+
+}
diff --git a/example/src/controls/PointerTracker.js b/example/src/controls/PointerTracker.js
new file mode 100644
index 000000000..2dcaf53a2
--- /dev/null
+++ b/example/src/controls/PointerTracker.js
@@ -0,0 +1,188 @@
+import { Vector2 } from 'three';
+
+export class PointerTracker {
+
+ constructor() {
+
+ this.buttons = 0;
+ this.pointerType = null;
+ this.pointerOrder = [];
+ this.previousPositions = {};
+ this.pointerPositions = {};
+ this.startPositions = {};
+ this.hoverPosition = new Vector2();
+ this.hoverSet = false;
+
+ }
+
+ setHoverEvent( e ) {
+
+ if ( e.pointerType === 'mouse' ) {
+
+ this.hoverPosition.set( e.clientX, e.clientY );
+ this.hoverSet = true;
+
+ }
+
+ }
+
+ getLatestPoint( target ) {
+
+ if ( this.pointerType !== null ) {
+
+ this.getCenterPoint( target );
+ return target;
+
+ } else if ( this.hoverSet ) {
+
+ target.copy( this.hoverPosition );
+ return target;
+
+ } else {
+
+ return null;
+
+ }
+
+ }
+
+ addPointer( e ) {
+
+ const id = e.pointerId;
+ const position = new Vector2( e.clientX, e.clientY );
+ this.pointerOrder.push( id );
+ this.pointerPositions[ id ] = position;
+ this.previousPositions[ id ] = position.clone();
+ this.startPositions[ id ] = position.clone();
+
+ if ( this.getPointerCount() === 1 ) {
+
+ this.pointerType = e.pointerType;
+ this.buttons = e.buttons;
+
+ }
+
+ }
+
+ updatePointer( e ) {
+
+ const id = e.pointerId;
+ if ( ! ( id in this.pointerPositions ) ) {
+
+ return false;
+
+ }
+
+ const position = this.pointerPositions[ id ];
+ this.previousPositions[ id ].copy( position );
+ this.pointerPositions[ id ].set( e.clientX, e.clientY );
+ return true;
+
+ }
+
+ deletePointer( e ) {
+
+ const id = e.pointerId;
+ const pointerOrder = this.pointerOrder;
+ pointerOrder.splice( pointerOrder.indexOf( id ), 1 );
+ delete this.pointerPositions[ id ];
+ delete this.previousPositions[ id ];
+ delete this.startPositions[ id ];
+
+ if ( this.getPointerCount.length === 0 ) {
+
+ this.buttons = 0;
+ this.pointerType = null;
+
+ }
+
+ }
+
+ getPointerCount() {
+
+ return this.pointerOrder.length;
+
+ }
+
+ getCenterPoint( target, pointerPositions = this.pointerPositions ) {
+
+ const pointerOrder = this.pointerOrder;
+ if ( this.getPointerCount() === 1 || this.getPointerType() === 'mouse' ) {
+
+ const id = pointerOrder[ 0 ];
+ target.copy( pointerPositions[ id ] );
+ return target;
+
+ } else if ( this.getPointerCount() === 2 ) {
+
+ const id0 = this.pointerOrder[ 0 ];
+ const id1 = this.pointerOrder[ 1 ];
+
+ const p0 = pointerPositions[ id0 ];
+ const p1 = pointerPositions[ id1 ];
+
+ target.addVectors( p0, p1 ).multiplyScalar( 0.5 );
+ return target;
+
+ }
+
+ return null;
+
+ }
+
+ getPreviousCenterPoint( target ) {
+
+ return this.getCenterPoint( target, this.previousPositions );
+
+ }
+
+ getPointerDistance( pointerPositions = this.pointerPositions ) {
+
+ if ( this.getPointerCount() <= 1 || this.getPointerType() === 'mouse' ) {
+
+ return 0;
+
+ }
+
+ const { pointerOrder } = this;
+ const id0 = pointerOrder[ 0 ];
+ const id1 = pointerOrder[ 1 ];
+
+ const p0 = pointerPositions[ id0 ];
+ const p1 = pointerPositions[ id1 ];
+
+ return p0.distanceTo( p1 );
+
+ }
+
+ getPreviousPointerDistance() {
+
+ return this.getPointerDistance( this.previousPositions );
+
+ }
+
+ getPointerType() {
+
+ return this.pointerType;
+
+ }
+
+ getPointerButtons() {
+
+ return this.buttons;
+
+ }
+
+ isLeftClicked() {
+
+ return Boolean( this.buttons & 1 );
+
+ }
+
+ isRightClicked() {
+
+ return Boolean( this.buttons & 2 );
+
+ }
+
+}
diff --git a/example/src/controls/TileControls.js b/example/src/controls/TileControls.js
new file mode 100644
index 000000000..23128ae25
--- /dev/null
+++ b/example/src/controls/TileControls.js
@@ -0,0 +1,793 @@
+import {
+ Matrix4,
+ Quaternion,
+ Vector2,
+ Vector3,
+ Raycaster,
+ Plane,
+} from 'three';
+import { PivotPointMesh } from './PivotPointMesh.js';
+import { PointerTracker } from './PointerTracker.js';
+import { mouseToCoords, makeRotateAroundPoint } from './utils.js';
+
+export const NONE = 0;
+export const DRAG = 1;
+export const ROTATE = 2;
+export const ZOOM = 3;
+
+const DRAG_PLANE_THRESHOLD = 0.05;
+const DRAG_UP_THRESHOLD = 0.025;
+
+const _rotMatrix = new Matrix4();
+const _delta = new Vector3();
+const _vec = new Vector3();
+const _forward = new Vector3();
+const _rotationAxis = new Vector3();
+const _quaternion = new Quaternion();
+const _plane = new Plane();
+const _localUp = new Vector3();
+
+const _pointer = new Vector2();
+const _prevPointer = new Vector2();
+const _deltaPointer = new Vector2();
+const _centerPoint = new Vector2();
+const _originalCenterPoint = new Vector2();
+
+export class TileControls {
+
+ constructor( scene, camera, domElement ) {
+
+ this.domElement = null;
+ this.camera = null;
+ this.scene = null;
+
+ // settings
+ this.state = NONE;
+ this.cameraRadius = 5;
+ this.rotationSpeed = 5;
+ this.minAltitude = 0;
+ this.maxAltitude = 0.45 * Math.PI;
+ this.minDistance = 10;
+ this.maxDistance = Infinity;
+ this.reorientOnDrag = true;
+ this.reorientOnZoom = false;
+
+ // internal state
+ this.pointerTracker = new PointerTracker();
+
+ this.dragPointSet = false;
+ this.dragPoint = new Vector3();
+ this.startDragPoint = new Vector3();
+
+ this.rotationPointSet = false;
+ this.rotationPoint = new Vector3();
+
+ this.zoomDirectionSet = false;
+ this.zoomPointSet = false;
+ this.zoomDirection = new Vector3();
+ this.zoomPoint = new Vector3();
+
+ this.pivotMesh = new PivotPointMesh();
+ this.pivotMesh.raycast = () => {};
+ this.pivotMesh.scale.setScalar( 0.25 );
+
+ this.raycaster = new Raycaster();
+ this.raycaster.firstHitOnly = true;
+
+ this.up = new Vector3( 0, 1, 0 );
+
+ this._detachCallback = null;
+ this._upInitialized = false;
+
+ // init
+ this.attach( domElement );
+ this.setCamera( camera );
+ this.setScene( scene );
+
+ }
+
+ setScene( scene ) {
+
+ this.scene = scene;
+
+ }
+
+ setCamera( camera ) {
+
+ this.camera = camera;
+
+ }
+
+ attach( domElement ) {
+
+ if ( this.domElement ) {
+
+ throw new Error( 'GlobeControls: 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;
+ domElement.style.touchAction = 'none';
+
+ let pinchAction = NONE;
+ let shiftClicked = false;
+
+ const contextMenuCallback = e => {
+
+ e.preventDefault();
+
+ };
+
+ const keydownCallback = e => {
+
+ if ( e.key === 'Shift' ) {
+
+ shiftClicked = true;
+
+ }
+
+ };
+
+ const keyupCallback = e => {
+
+ if ( e.key === 'Shift' ) {
+
+ shiftClicked = false;
+
+ }
+
+ };
+
+ const pointerdownCallback = e => {
+
+ const {
+ camera,
+ raycaster,
+ domElement,
+ scene,
+ up,
+ pivotMesh,
+ pointerTracker,
+ } = this;
+
+ // init the pointer
+ pointerTracker.addPointer( e );
+
+ // handle cases where we need to capture the pointer or
+ // reset state when we have too many pointers
+ if ( pointerTracker.getPointerType() === 'touch' ) {
+
+ pivotMesh.visible = false;
+
+ if ( pointerTracker.getPointerCount() === 0 ) {
+
+ domElement.setPointerCapture( e.pointerId );
+
+ } else if ( pointerTracker.getPointerCount() > 2 ) {
+
+ this.resetState();
+ pinchAction = NONE;
+ return;
+
+ }
+
+ }
+
+ // the "pointer" for zooming and rotating should be based on the center point
+ pointerTracker.getCenterPoint( _pointer );
+ mouseToCoords( _pointer.x, _pointer.y, domElement, _pointer );
+
+ // find the hit point
+ raycaster.setFromCamera( _pointer, camera );
+ const hit = raycaster.intersectObject( scene )[ 0 ] || null;
+ 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() && shiftClicked
+ ) {
+
+ this.state = ROTATE;
+ this.rotationPoint.copy( hit.point );
+ this.rotationPointSet = true;
+
+ this.pivotMesh.position.copy( hit.point );
+ this.pivotMesh.updateMatrixWorld();
+ this.scene.add( this.pivotMesh );
+
+ } else if ( pointerTracker.isLeftClicked() ) {
+
+ // if the clicked point is coming from below the plane then don't perform the drag
+ if ( raycaster.ray.direction.dot( up ) < 0 ) {
+
+ this.state = DRAG;
+ this.dragPoint.copy( hit.point );
+ this.startDragPoint.copy( hit.point );
+ this.dragPointSet = true;
+
+ this.pivotMesh.position.copy( hit.point );
+ this.pivotMesh.updateMatrixWorld();
+ this.scene.add( this.pivotMesh );
+
+ }
+
+ }
+
+ }
+
+ };
+
+ let _pointerMoveQueued = false;
+ const pointermoveCallback = e => {
+
+ // whenever the pointer moves we need to re-derive the zoom direction and point
+ this.zoomDirectionSet = false;
+ this.zoomPointSet = false;
+
+ const { pointerTracker } = this;
+ pointerTracker.setHoverEvent( e );
+ if ( ! pointerTracker.updatePointer( e ) ) {
+
+ return;
+
+ }
+
+ if ( pointerTracker.getPointerType() === 'touch' ) {
+
+ if ( pointerTracker.getPointerCount() === 1 ) {
+
+ if ( this.state === DRAG ) {
+
+ this._updatePosition();
+
+ }
+
+ } else if ( 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 previousDist = pointerTracker.getPreviousPointerDistance();
+ const pointerDist = pointerTracker.getPointerDistance();
+ const separateDelta = pointerDist - previousDist;
+ if ( pinchAction === NONE ) {
+
+ // 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.getPreviousCenterPoint( _originalCenterPoint );
+
+ const parallelDelta = _centerPoint.distanceTo( _originalCenterPoint );
+ if ( Math.abs( separateDelta ) > 0 || parallelDelta > 0 ) {
+
+ if ( Math.abs( separateDelta ) > parallelDelta ) {
+
+ this.resetState();
+ pinchAction = ZOOM;
+ this.zoomDirectionSet = false;
+
+ } else {
+
+ pinchAction = ROTATE;
+
+ }
+
+ }
+
+ }
+
+ if ( pinchAction === ZOOM ) {
+
+ this._updateZoom( separateDelta );
+
+ } else if ( pinchAction === ROTATE ) {
+
+ this._updateRotation();
+ this.pivotMesh.visible = true;
+
+ }
+
+ } );
+
+ }
+
+ }
+
+ } else if ( pointerTracker.getPointerType() === 'mouse' ) {
+
+ if ( this.state === DRAG ) {
+
+ this._updatePosition();
+
+ } else if ( this.state === ROTATE ) {
+
+ this._updateRotation();
+
+ }
+
+ }
+
+ };
+
+ const pointerupCallback = e => {
+
+ const { pointerTracker } = this;
+
+ pointerTracker.deletePointer( e );
+ pinchAction = NONE;
+
+ if (
+ pointerTracker.getPointerType() === 'touch' &&
+ pointerTracker.getPointerCount() === 0
+ ) {
+
+ domElement.releasePointerCapture( e.pointerId );
+
+ }
+
+ this.resetState();
+
+ };
+
+ const wheelCallback = e => {
+
+ this._updateZoom( - e.deltaY );
+
+ };
+
+ const pointerenterCallback = e => {
+
+ const { pointerTracker } = this;
+
+ shiftClicked = false;
+
+ if ( e.buttons !== pointerTracker.getPointerButtons() ) {
+
+ pointerTracker.deletePointer( e );
+ this.resetState();
+
+ }
+
+ };
+
+ domElement.addEventListener( 'contextmenu', contextMenuCallback );
+ domElement.addEventListener( 'keydown', keydownCallback );
+ domElement.addEventListener( 'keyup', keyupCallback );
+ domElement.addEventListener( 'pointerdown', pointerdownCallback );
+ domElement.addEventListener( 'pointermove', pointermoveCallback );
+ domElement.addEventListener( 'pointerup', pointerupCallback );
+ domElement.addEventListener( 'wheel', wheelCallback );
+ domElement.addEventListener( 'pointerenter', pointerenterCallback );
+
+ this._detachCallback = () => {
+
+ domElement.removeEventListener( 'contextmenu', contextMenuCallback );
+ domElement.removeEventListener( 'keydown', keydownCallback );
+ domElement.removeEventListener( 'keyup', keyupCallback );
+ domElement.removeEventListener( 'pointerdown', pointerdownCallback );
+ domElement.removeEventListener( 'pointermove', pointermoveCallback );
+ domElement.removeEventListener( 'pointerup', pointerupCallback );
+ domElement.removeEventListener( 'wheel', wheelCallback );
+ domElement.removeEventListener( 'pointerenter', pointerenterCallback );
+
+ };
+
+ }
+
+ getUpDirection( point, target ) {
+
+ target.copy( this.up );
+
+ }
+
+ detach() {
+
+ if ( this._detachCallback ) {
+
+ this._detachCallback();
+ this._detachCallback = null;
+ this.pointerTracker = new PointerTracker();
+
+ }
+
+ }
+
+ resetState() {
+
+ this.state = NONE;
+ this.dragPointSet = false;
+ this.rotationPointSet = false;
+ this.scene.remove( this.pivotMesh );
+ this.pivotMesh.visible = true;
+
+ }
+
+ update() {
+
+ const {
+ raycaster,
+ camera,
+ cameraRadius,
+ dragPoint,
+ startDragPoint,
+ up,
+ } = this;
+
+ // reuse the "hit" information since it can be slow to perform multiple hits
+ const hit = this._getPointBelowCamera();
+ if ( this.getUpDirection ) {
+
+ this.getUpDirection( camera.position, _localUp );
+ if ( ! this._upInitialized ) {
+
+ this._upInitialized = true;
+ this.up.copy( _localUp );
+
+ } else {
+
+ this._setFrame( _localUp, hit && hit.point || null );
+
+ }
+
+ }
+
+ // 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 ) {
+
+ _delta.subVectors( startDragPoint, dragPoint );
+ camera.position.add( _delta );
+ dragPoint.copy( startDragPoint );
+
+ }
+
+ // cast down from the camera
+ if ( hit ) {
+
+ const dist = hit.distance;
+ if ( dist < cameraRadius ) {
+
+ const delta = cameraRadius - dist;
+ camera.position.copy( hit.point ).addScaledVector( raycaster.ray.direction, - cameraRadius );
+ dragPoint.addScaledVector( up, delta );
+
+ }
+
+ }
+
+ }
+
+ dispose() {
+
+ this.detach();
+
+ }
+
+ // private
+ _updateZoom( scale ) {
+
+ const {
+ zoomPoint,
+ zoomDirection,
+ camera,
+ minDistance,
+ maxDistance,
+ raycaster,
+ pointerTracker,
+ domElement,
+ } = this;
+
+ // get the latest hover / touch point
+ if ( ! pointerTracker.getLatestPoint( _pointer ) ) {
+
+ return;
+
+ }
+
+ // initialize the zoom direction
+ mouseToCoords( _pointer.x, _pointer.y, domElement, _pointer );
+ raycaster.setFromCamera( _pointer, camera );
+ zoomDirection.copy( raycaster.ray.direction ).normalize();
+ this.zoomDirectionSet = true;
+
+ // track the zoom direction we're going to use
+ const finalZoomDirection = _vec.copy( zoomDirection );
+
+ // always update the zoom target point in case the tiles are changing
+ let dist = Infinity;
+ if ( this._updateZoomPoint() ) {
+
+ 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 - 0 ) * 0.01;
+ scale = Math.max( scale, remainingDistance );
+
+ } else {
+
+ const remainingDistance = Math.max( 0, dist - minDistance );
+ scale = scale * ( dist - minDistance ) * 0.01;
+ 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 ) {
+
+ dist = hit.distance;
+ finalZoomDirection.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld );
+ camera.position.addScaledVector( finalZoomDirection, scale * dist * 0.01 );
+ camera.updateMatrixWorld();
+
+ }
+
+ }
+
+ }
+
+ // update the point being zoomed in to based on the zoom direction
+ _updateZoomPoint() {
+
+ const {
+ camera,
+ zoomDirectionSet,
+ zoomDirection,
+ raycaster,
+ scene,
+ zoomPoint,
+ } = this;
+
+ if ( ! zoomDirectionSet ) {
+
+ return false;
+
+ }
+
+ raycaster.ray.origin.copy( camera.position );
+ raycaster.ray.direction.copy( zoomDirection );
+
+ const hit = raycaster.intersectObject( scene )[ 0 ] || null;
+ if ( hit ) {
+
+ zoomPoint.copy( hit.point );
+ this.zoomPointSet = true;
+ return true;
+
+ }
+
+ return false;
+
+ }
+
+ // returns the point below the camera
+ _getPointBelowCamera() {
+
+ const { camera, raycaster, scene, up } = this;
+ raycaster.ray.direction.copy( up ).multiplyScalar( - 1 );
+ raycaster.ray.origin.copy( camera.position ).addScaledVector( up, 1e5 );
+
+ const hit = raycaster.intersectObject( scene )[ 0 ] || null;
+ if ( hit ) {
+
+ hit.distance -= 1e5;
+
+ }
+
+ return hit;
+
+ }
+
+ // update the drag action
+ _updatePosition() {
+
+ const {
+ raycaster,
+ camera,
+ dragPoint,
+ up,
+ pointerTracker,
+ domElement,
+ } = this;
+
+ // get the pointer and plane
+ pointerTracker.getCenterPoint( _pointer );
+ mouseToCoords( _pointer.x, _pointer.y, domElement, _pointer );
+
+ _plane.setFromNormalAndCoplanarPoint( up, dragPoint );
+ raycaster.setFromCamera( _pointer, camera );
+
+ // prevent the drag distance from getting too severe by limiting the drag point
+ // to a reasonable angle with the drag plane
+ if ( - 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 ( this.getUpDirection ) {
+
+ // 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( dragPoint, _localUp );
+ if ( - 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( dragPoint, _vec );
+ this.camera.position.add( _delta );
+ this.camera.updateMatrixWorld();
+
+ }
+
+ }
+
+ _updateRotation() {
+
+ const {
+ camera,
+ rotationPoint,
+ minAltitude,
+ maxAltitude,
+ up,
+ domElement,
+ pointerTracker,
+ rotationSpeed,
+ } = this;
+
+ // get the rotation motion
+ pointerTracker.getCenterPoint( _pointer );
+ mouseToCoords( _pointer.x, _pointer.y, domElement, _pointer );
+
+ pointerTracker.getPreviousCenterPoint( _prevPointer );
+ mouseToCoords( _prevPointer.x, _prevPointer.y, domElement, _prevPointer );
+
+ _deltaPointer.subVectors( _pointer, _prevPointer );
+
+ const azimuth = - _deltaPointer.x * rotationSpeed;
+ let altitude = - _deltaPointer.y * rotationSpeed;
+
+ // calculate current angles and clamp
+ _forward
+ .set( 0, 0, - 1 )
+ .transformDirection( camera.matrixWorld )
+ .multiplyScalar( - 1 );
+
+ if ( this.getUpDirection ) {
+
+ this.getUpDirection( rotationPoint, _localUp );
+
+ } else {
+
+ _localUp.copy( up );
+
+ }
+
+ // 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
+ const angle = up.angleTo( _forward );
+ if ( altitude > 0 ) {
+
+ altitude = Math.min( angle - minAltitude - 1e-2, 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( rotationPoint, _quaternion, _rotMatrix );
+ camera.matrixWorld.premultiply( _rotMatrix );
+
+ // get a rotation axis for altitude and rotate
+ _rotationAxis.set( - 1, 0, 0 ).transformDirection( camera.matrixWorld );
+
+ _quaternion.setFromAxisAngle( _rotationAxis, altitude );
+ makeRotateAroundPoint( rotationPoint, _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 tile set
+ _setFrame( newUp, pivot ) {
+
+ const { up, camera, state, zoomPoint, zoomDirection } = this;
+ camera.updateMatrixWorld();
+
+ // get the amount needed to rotate
+ _quaternion.setFromUnitVectors( up, newUp );
+
+ if ( this.zoomDirectionSet && ( this.zoomPointSet || this._updateZoomPoint() ) ) {
+
+ if ( this.reorientOnZoom ) {
+
+ // rotates the camera position around the point being zoomed in to
+ makeRotateAroundPoint( zoomPoint, _quaternion, _rotMatrix );
+ camera.matrixWorld.premultiply( _rotMatrix );
+ camera.matrixWorld.decompose( camera.position, camera.quaternion, _vec );
+
+ zoomDirection.subVectors( zoomPoint, camera.position ).normalize();
+
+ }
+
+ } else if ( state === NONE || state === DRAG && this.reorientOnDrag ) {
+
+ // NOTE: We used to derive the pivot point here by getting the point below the camera
+ // but decided to pass it in via "update" to avoid multiple ray casts
+
+ if ( pivot ) {
+
+ // perform a simple realignment by rotating the camera and adjusting the height
+ const dist = pivot.distanceTo( camera.position );
+ camera.position.copy( pivot ).addScaledVector( newUp, dist );
+ camera.quaternion.premultiply( _quaternion );
+ camera.updateMatrixWorld();
+
+ }
+
+ }
+
+ up.copy( newUp );
+
+ }
+
+}
diff --git a/example/src/controls/utils.js b/example/src/controls/utils.js
new file mode 100644
index 000000000..9fb6e0255
--- /dev/null
+++ b/example/src/controls/utils.js
@@ -0,0 +1,26 @@
+import { Matrix4 } from 'three';
+
+const _matrix = new Matrix4();
+
+// helper function for constructing a matrix for rotating around a point
+export function makeRotateAroundPoint( point, quat, target ) {
+
+ target.makeTranslation( - point.x, - point.y, - point.z );
+
+ _matrix.makeRotationFromQuaternion( quat );
+ target.premultiply( _matrix );
+
+ _matrix.makeTranslation( point.x, point.y, point.z );
+ target.premultiply( _matrix );
+
+ return target;
+
+}
+
+// get the three.js pointer coords from an event
+export function mouseToCoords( clientX, clientY, element, target ) {
+
+ target.x = ( ( clientX - element.offsetLeft ) / element.clientWidth ) * 2 - 1;
+ target.y = - ( ( clientY - element.offsetTop ) / element.clientHeight ) * 2 + 1;
+
+}
diff --git a/src/three/renderers/GoogleTilesRenderer.js b/src/three/renderers/GoogleTilesRenderer.js
index 50a538404..e9efa45b0 100644
--- a/src/three/renderers/GoogleTilesRenderer.js
+++ b/src/three/renderers/GoogleTilesRenderer.js
@@ -27,7 +27,7 @@ const GoogleTilesRendererMixin = base => class extends base {
this.downloadQueue.maxJobs = 30;
this.lruCache.minSize = 3000;
this.lruCache.maxSize = 5000;
- this.errorTarget = 20;
+ this.errorTarget = 40;
this.onLoadTileSet = tileset => {