whs
Version:
Super-fast 3D framework for Web Applications & Games. Based on Three.js
811 lines (566 loc) • 22.9 kB
JavaScript
import {
MOUSE,
Quaternion,
Spherical,
Vector2,
PerspectiveCamera,
OrthographicCamera,
EventDispatcher,
Vector3
} from 'three';
// This set of controls performs orbiting, dollying (zooming), and panning.
// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default).
//
// Orbit - left mouse / touch: one finger move
// Zoom - middle mouse, or mousewheel / touch: two finger spread or squish
// Pan - right mouse, or arrow keys / touch: three finter swipe
export class ThreeOrbitControls extends EventDispatcher {
constructor(object, domElement, eventHandler) {
super();
this.object = object;
this.domElement = (domElement === undefined) ? document : domElement;
this.eventHandler = eventHandler;
// Set to false to disable this control
this.enabled = true;
// "target" sets the location of focus, where the object orbits around
this.target = new Vector3();
// How far you can dolly in and out ( PerspectiveCamera only )
this.minDistance = 0;
this.maxDistance = Infinity;
// How far you can zoom in and out ( OrthographicCamera only )
this.minZoom = 0;
this.maxZoom = Infinity;
// How far you can orbit vertically, upper and lower limits.
// Range is 0 to Math.PI radians.
this.minPolarAngle = 0; // radians
this.maxPolarAngle = Math.PI; // radians
// How far you can orbit horizontally, upper and lower limits.
// If set, must be a sub-interval of the interval [ - Math.PI, Math.PI ].
this.minAzimuthAngle = -Infinity; // radians
this.maxAzimuthAngle = Infinity; // radians
// Set to true to enable damping (inertia)
// If damping is enabled, you must call controls.update() in your animation loop
this.enableDamping = false;
this.dampingFactor = 0.25;
// This option actually enables dollying in and out; left as "zoom" for backwards compatibility.
// Set to false to disable zooming
this.enableZoom = true;
this.zoomSpeed = 1.0;
// Set to false to disable rotating
this.enableRotate = true;
this.rotateSpeed = 1.0;
// Set to false to disable panning
this.enablePan = true;
this.keyPanSpeed = 7.0; // pixels moved per arrow key push
// Set to true to automatically rotate around the target
// If auto-rotate is enabled, you must call controls.update() in your animation loop
this.autoRotate = false;
this.autoRotateSpeed = 2.0; // 30 seconds per round when fps is 60
// Set to false to disable use of the keys
this.enableKeys = true;
// The four arrow keys
this.keys = {LEFT: 37, UP: 38, RIGHT: 39, BOTTOM: 40};
// Mouse buttons
this.mouseButtons = {ORBIT: MOUSE.LEFT, ZOOM: MOUSE.MIDDLE, PAN: MOUSE.RIGHT};
// for reset
this.target0 = this.target.clone();
this.position0 = this.object.position.clone();
this.zoom0 = this.object.zoom;
//
// public methods
//
this.getPolarAngle = () => {
return spherical.phi;
};
this.getAzimuthalAngle = () => {
return spherical.theta;
};
this.reset = () => {
this.target.copy(this.target0);
this.object.position.copy(this.position0);
this.object.zoom = this.zoom0;
this.object.updateProjectionMatrix();
this.dispatchEvent(changeEvent);
this.update();
state = STATE.NONE;
};
// this method is exposed, but perhaps it would be better if we can make it private...
this.update = () => {
const offset = new Vector3();
// so camera.up is the orbit axis
const quat = new Quaternion().setFromUnitVectors(object.up, new Vector3(0, 1, 0));
const quatInverse = quat.clone().inverse();
const lastPosition = new Vector3();
const lastQuaternion = new Quaternion();
return (() => {
const position = this.object.position;
offset.copy(position).sub(this.target);
// rotate offset to "y-axis-is-up" space
offset.applyQuaternion(quat);
// angle from z-axis around y-axis
spherical.setFromVector3(offset);
if (this.autoRotate && state === STATE.NONE)
rotateLeft(getAutoRotationAngle());
spherical.theta += sphericalDelta.theta;
spherical.phi += sphericalDelta.phi;
// restrict theta to be between desired limits
spherical.theta = Math.max(this.minAzimuthAngle, Math.min(this.maxAzimuthAngle, spherical.theta));
// restrict phi to be between desired limits
spherical.phi = Math.max(this.minPolarAngle, Math.min(this.maxPolarAngle, spherical.phi));
spherical.makeSafe();
spherical.radius *= scale;
// restrict radius to be between desired limits
spherical.radius = Math.max(this.minDistance, Math.min(this.maxDistance, spherical.radius));
// move target to panned location
this.target.add(panOffset);
offset.setFromSpherical(spherical);
// rotate offset back to "camera-up-vector-is-up" space
offset.applyQuaternion(quatInverse);
position.copy(this.target).add(offset);
this.object.lookAt(this.target);
if (this.enableDamping === true) {
sphericalDelta.theta *= (1 - this.dampingFactor);
sphericalDelta.phi *= (1 - this.dampingFactor);
} else
sphericalDelta.set(0, 0, 0);
scale = 1;
panOffset.set(0, 0, 0);
// update condition is:
// min(camera displacement, camera rotation in radians)^2 > EPS
// using small-angle approximation cos(x/2) = 1 - x^2 / 8
if (zoomChanged
|| lastPosition.distanceToSquared(this.object.position) > EPS
|| 8 * (1 - lastQuaternion.dot(this.object.quaternion)) > EPS) {
this.dispatchEvent(changeEvent);
lastPosition.copy(this.object.position);
lastQuaternion.copy(this.object.quaternion);
zoomChanged = false;
return true;
}
return false;
})();
};
this.dispose = () => {
this.domElement.removeEventListener('contextmenu', onContextMenu, false);
this.domElement.removeEventListener('mousedown', onMouseDown, false);
this.domElement.removeEventListener('wheel', onMouseWheel, false);
this.domElement.removeEventListener('touchstart', onTouchStart, false);
this.domElement.removeEventListener('touchend', onTouchEnd, false);
this.domElement.removeEventListener('touchmove', onTouchMove, false);
document.removeEventListener('mousemove', onMouseMove, false);
document.removeEventListener('mouseup', onMouseUp, false);
window.removeEventListener('keydown', onKeyDown, false);
// this.dispatchEvent( { type: 'dispose' } ); // should this be added here?
};
//
// internals
//
const changeEvent = {type: 'change'};
const startEvent = {type: 'start'};
const endEvent = {type: 'end'};
const STATE = {NONE: -1, ROTATE: 0, DOLLY: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_DOLLY: 4, TOUCH_PAN: 5};
let state = STATE.NONE;
const EPS = 0.000001;
// current position in spherical coordinates
const spherical = new Spherical();
const sphericalDelta = new Spherical();
let scale = 1;
const panOffset = new Vector3();
let zoomChanged = false;
const rotateStart = new Vector2();
const rotateEnd = new Vector2();
const rotateDelta = new Vector2();
const panStart = new Vector2();
const panEnd = new Vector2();
const panDelta = new Vector2();
const dollyStart = new Vector2();
const dollyEnd = new Vector2();
const dollyDelta = new Vector2();
const getAutoRotationAngle = () => {
return 2 * Math.PI / 60 / 60 * this.autoRotateSpeed;
};
const getZoomScale = () => {
return Math.pow(0.95, this.zoomSpeed);
};
const rotateLeft = angle => {
sphericalDelta.theta -= angle;
};
const rotateUp = angle => {
sphericalDelta.phi -= angle;
};
const panLeft = (() => {
const v = new Vector3();
return (distance, objectMatrix) => {
v.setFromMatrixColumn(objectMatrix, 0); // get X column of objectMatrix
v.multiplyScalar(-distance);
panOffset.add(v);
};
})();
const panUp = (() => {
const v = new Vector3();
return (distance, objectMatrix) => {
v.setFromMatrixColumn(objectMatrix, 1); // get Y column of objectMatrix
v.multiplyScalar(distance);
panOffset.add(v);
};
})();
// deltaX and deltaY are in pixels; right and down are positive
const pan = (() => {
const offset = new Vector3();
return (deltaX, deltaY) => {
const element = this.domElement === document ? this.domElement.body : this.domElement;
if (this.object instanceof PerspectiveCamera) {
// perspective
const position = this.object.position;
offset.copy(position).sub(this.target);
let targetDistance = offset.length();
// half of the fov is center to top of screen
targetDistance *= Math.tan((this.object.fov / 2) * Math.PI / 180.0);
// we actually don't use screenWidth, since perspective camera is fixed to screen height
panLeft(2 * deltaX * targetDistance / element.clientHeight, this.object.matrix);
panUp(2 * deltaY * targetDistance / element.clientHeight, this.object.matrix);
} else if (this.object instanceof OrthographicCamera) {
// orthographic
panLeft(deltaX * (this.object.right - this.object.left) / this.object.zoom / element.clientWidth, this.object.matrix);
panUp(deltaY * (this.object.top - this.object.bottom) / this.object.zoom / element.clientHeight, this.object.matrix);
} else {
// camera neither orthographic nor perspective
console.warn('WARNING: OrbitControlsModule.js encountered an unknown camera type - pan disabled.');
this.enablePan = false;
}
};
})();
const dollyIn = dollyScale => {
if (this.object instanceof PerspectiveCamera)
scale /= dollyScale;
else if (this.object instanceof OrthographicCamera) {
this.object.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.object.zoom * dollyScale));
this.object.updateProjectionMatrix();
zoomChanged = true;
} else {
console.warn('WARNING: OrbitControlsModule.js encountered an unknown camera type - dolly/zoom disabled.');
this.enableZoom = false;
}
};
const dollyOut = dollyScale => {
if (this.object instanceof PerspectiveCamera)
scale *= dollyScale;
else if (this.object instanceof OrthographicCamera) {
this.object.zoom = Math.max(this.minZoom, Math.min(this.maxZoom, this.object.zoom / dollyScale));
this.object.updateProjectionMatrix();
zoomChanged = true;
} else {
console.warn('WARNING: OrbitControlsModule.js encountered an unknown camera type - dolly/zoom disabled.');
this.enableZoom = false;
}
};
//
// event callbacks - update the object state
//
const handleMouseDownRotate = event => {
// console.log( 'handleMouseDownRotate' );
rotateStart.set(event.clientX, event.clientY);
};
const handleMouseDownDolly = event => {
// console.log( 'handleMouseDownDolly' );
dollyStart.set(event.clientX, event.clientY);
};
const handleMouseDownPan = event => {
// console.log( 'handleMouseDownPan' );
panStart.set(event.clientX, event.clientY);
};
const handleMouseMoveRotate = event => {
// console.log( 'handleMouseMoveRotate' );
rotateEnd.set(event.clientX, event.clientY);
rotateDelta.subVectors(rotateEnd, rotateStart);
const element = this.domElement === document ? this.domElement.body : this.domElement;
// rotating across whole screen goes 360 degrees around
rotateLeft(2 * Math.PI * rotateDelta.x / element.clientWidth * this.rotateSpeed);
// rotating up and down along whole screen attempts to go 360, but limited to 180
rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight * this.rotateSpeed);
rotateStart.copy(rotateEnd);
this.update();
};
const handleMouseMoveDolly = event => {
// console.log( 'handleMouseMoveDolly' );
dollyEnd.set(event.clientX, event.clientY);
dollyDelta.subVectors(dollyEnd, dollyStart);
if (dollyDelta.y > 0)
dollyIn(getZoomScale());
else if (dollyDelta.y < 0)
dollyOut(getZoomScale());
dollyStart.copy(dollyEnd);
this.update();
};
const handleMouseMovePan = event => {
// console.log( 'handleMouseMovePan' );
panEnd.set(event.clientX, event.clientY);
panDelta.subVectors(panEnd, panStart);
pan(panDelta.x, panDelta.y);
panStart.copy(panEnd);
this.update();
};
const handleMouseUp = event => {
// console.log( 'handleMouseUp' );
};
const handleMouseWheel = event => {
// console.log( 'handleMouseWheel' );
if (event.deltaY < 0)
dollyOut(getZoomScale());
else if (event.deltaY > 0)
dollyIn(getZoomScale());
this.update();
};
const handleKeyDown = event => {
// console.log( 'handleKeyDown' );
switch (event.keyCode) {
case this.keys.UP:
pan(0, this.keyPanSpeed);
this.update();
break;
case this.keys.BOTTOM:
pan(0, -this.keyPanSpeed);
this.update();
break;
case this.keys.LEFT:
pan(this.keyPanSpeed, 0);
this.update();
break;
case this.keys.RIGHT:
pan(-this.keyPanSpeed, 0);
this.update();
break;
}
};
const handleTouchStartRotate = event => {
// console.log( 'handleTouchStartRotate' );
rotateStart.set(event.touches[0].pageX, event.touches[0].pageY);
};
const handleTouchStartDolly = event => {
// console.log( 'handleTouchStartDolly' );
const dx = event.touches[0].pageX - event.touches[1].pageX;
const dy = event.touches[0].pageY - event.touches[1].pageY;
const distance = Math.sqrt(dx * dx + dy * dy);
dollyStart.set(0, distance);
};
const handleTouchStartPan = event => {
// console.log( 'handleTouchStartPan' );
panStart.set(event.touches[0].pageX, event.touches[0].pageY);
};
const handleTouchMoveRotate = event => {
// console.log( 'handleTouchMoveRotate' );
rotateEnd.set(event.touches[0].pageX, event.touches[0].pageY);
rotateDelta.subVectors(rotateEnd, rotateStart);
const element = this.domElement === document ? this.domElement.body : this.domElement;
// rotating across whole screen goes 360 degrees around
rotateLeft(2 * Math.PI * rotateDelta.x / element.clientWidth * this.rotateSpeed);
// rotating up and down along whole screen attempts to go 360, but limited to 180
rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight * this.rotateSpeed);
rotateStart.copy(rotateEnd);
this.update();
};
const handleTouchMoveDolly = event => {
// console.log( 'handleTouchMoveDolly' );
const dx = event.touches[0].pageX - event.touches[1].pageX;
const dy = event.touches[0].pageY - event.touches[1].pageY;
const distance = Math.sqrt(dx * dx + dy * dy);
dollyEnd.set(0, distance);
dollyDelta.subVectors(dollyEnd, dollyStart);
if (dollyDelta.y > 0)
dollyOut(getZoomScale());
else if (dollyDelta.y < 0)
dollyIn(getZoomScale());
dollyStart.copy(dollyEnd);
this.update();
};
const handleTouchMovePan = event => {
// console.log( 'handleTouchMovePan' );
panEnd.set(event.touches[0].pageX, event.touches[0].pageY);
panDelta.subVectors(panEnd, panStart);
pan(panDelta.x, panDelta.y);
panStart.copy(panEnd);
this.update();
};
const handleTouchEnd = () => {
// console.log( 'handleTouchEnd' );
};
//
// event handlers - FSM: listen for events and reset state
//
const onMouseDown = event => {
if (this.enabled === false) return;
event.preventDefault();
if (event.button === this.mouseButtons.ORBIT) {
if (this.enableRotate === false) return;
handleMouseDownRotate(event);
state = STATE.ROTATE;
} else if (event.button === this.mouseButtons.ZOOM) {
if (this.enableZoom === false) return;
handleMouseDownDolly(event);
state = STATE.DOLLY;
} else if (event.button === this.mouseButtons.PAN) {
if (this.enablePan === false) return;
handleMouseDownPan(event);
state = STATE.PAN;
}
if (state !== STATE.NONE) {
this.eventHandler.on('mousemove', onMouseMove, false);
this.eventHandler.on('mouseup', onMouseUp, false);
this.dispatchEvent(startEvent);
}
};
const onMouseMove = event => {
if (this.enabled === false) return;
event.preventDefault();
if (state === STATE.ROTATE) {
if (this.enableRotate === false) return;
handleMouseMoveRotate(event);
} else if (state === STATE.DOLLY) {
if (this.enableZoom === false) return;
handleMouseMoveDolly(event);
} else if (state === STATE.PAN) {
if (this.enablePan === false) return;
handleMouseMovePan(event);
}
};
const onMouseUp = event => {
if (this.enabled === false) return;
handleMouseUp(event);
document.removeEventListener('mousemove', onMouseMove, false);
document.removeEventListener('mouseup', onMouseUp, false);
this.dispatchEvent(endEvent);
state = STATE.NONE;
};
const onMouseWheel = event => {
if (this.enabled === false || this.enableZoom === false || (state !== STATE.NONE && state !== STATE.ROTATE)) return;
event.preventDefault();
event.stopPropagation();
handleMouseWheel(event);
this.dispatchEvent(startEvent); // not sure why these are here...
this.dispatchEvent(endEvent);
};
const onKeyDown = event => {
if (this.enabled === false || this.enableKeys === false || this.enablePan === false) return;
handleKeyDown(event);
};
const onTouchStart = event => {
if (this.enabled === false) return;
switch (event.touches.length) {
case 1: // one-fingered touch: rotate
if (this.enableRotate === false) return;
handleTouchStartRotate(event);
state = STATE.TOUCH_ROTATE;
break;
case 2: // two-fingered touch: dolly
if (this.enableZoom === false) return;
handleTouchStartDolly(event);
state = STATE.TOUCH_DOLLY;
break;
case 3: // three-fingered touch: pan
if (this.enablePan === false) return;
handleTouchStartPan(event);
state = STATE.TOUCH_PAN;
break;
default:
state = STATE.NONE;
}
if (state !== STATE.NONE)
this.dispatchEvent(startEvent);
};
const onTouchMove = event => {
if (this.enabled === false) return;
event.preventDefault();
event.stopPropagation();
switch (event.touches.length) {
case 1: // one-fingered touch: rotate
if (this.enableRotate === false) return;
if (state !== STATE.TOUCH_ROTATE) return; // is this needed?...
handleTouchMoveRotate(event);
break;
case 2: // two-fingered touch: dolly
if (this.enableZoom === false) return;
if (state !== STATE.TOUCH_DOLLY) return; // is this needed?...
handleTouchMoveDolly(event);
break;
case 3: // three-fingered touch: pan
if (this.enablePan === false) return;
if (state !== STATE.TOUCH_PAN) return; // is this needed?...
handleTouchMovePan(event);
break;
default:
state = STATE.NONE;
}
};
const onTouchEnd = event => {
if (this.enabled === false) return;
handleTouchEnd(event);
this.dispatchEvent(endEvent);
state = STATE.NONE;
};
const onContextMenu = event => {
event.preventDefault();
};
//
this.eventHandler.on('contextmenu', onContextMenu, false);
this.eventHandler.on('mousedown', onMouseDown, false);
this.eventHandler.on('wheel', onMouseWheel, false);
this.eventHandler.on('touchstart', onTouchStart, false);
this.eventHandler.on('touchend', onTouchEnd, false);
this.eventHandler.on('touchmove', onTouchMove, false);
this.eventHandler.on('keydown', onKeyDown, false);
// force an update at start
this.update();
}
get center() {
console.warn('OrbitControls: .center has been renamed to .target');
return this.target;
}
get noZoom() {
console.warn('OrbitControls: .noZoom has been deprecated. Use .enableZoom instead.');
return !this.enableZoom;
}
set noZoom(value) {
console.warn('OrbitControls: .noZoom has been deprecated. Use .enableZoom instead.');
this.enableZoom = !value;
}
get noRotate() {
console.warn('OrbitControls: .noRotate has been deprecated. Use .enableRotate instead.');
return !this.enableRotate;
}
set noRotate(value) {
console.warn('OrbitControls: .noRotate has been deprecated. Use .enableRotate instead.');
this.enableRotate = !value;
}
get noPan() {
console.warn('OrbitControls: .noPan has been deprecated. Use .enablePan instead.');
return !this.enablePan;
}
set noPan(value) {
console.warn('OrbitControls: .noPan has been deprecated. Use .enablePan instead.');
this.enablePan = !value;
}
get noKeys() {
console.warn('OrbitControls: .noKeys has been deprecated. Use .enableKeys instead.');
return !this.enableKeys;
}
set noKeys(value) {
console.warn('OrbitControls: .noKeys has been deprecated. Use .enableKeys instead.');
this.enableKeys = !value;
}
get staticMoving() {
console.warn('OrbitControls: .staticMoving has been deprecated. Use .enableDamping instead.');
return !this.enableDamping;
}
set staticMoving(value) {
console.warn('OrbitControls: .staticMoving has been deprecated. Use .enableDamping instead.');
this.enableDamping = !value;
}
get dynamicDampingFactor() {
console.warn('OrbitControls: .dynamicDampingFactor has been renamed. Use .dampingFactor instead.');
return this.dampingFactor;
}
set dynamicDampingFactor(value) {
console.warn('OrbitControls: .dynamicDampingFactor has been renamed. Use .dampingFactor instead.');
this.dampingFactor = value;
}
}