camera-controls
Version:
A camera control for three.js, similar to THREE.OrbitControls yet supports smooth transitions and more features.
1,135 lines (1,128 loc) • 115 kB
JavaScript
/*!
* camera-controls
* https://github.com/yomotsu/camera-controls
* (c) 2017 @yomotsu
* Released under the MIT License.
*/
// see https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons#value
const MOUSE_BUTTON = {
LEFT: 1,
RIGHT: 2,
MIDDLE: 4,
};
const ACTION = Object.freeze({
NONE: 0b0,
ROTATE: 0b1,
TRUCK: 0b10,
SCREEN_PAN: 0b100,
OFFSET: 0b1000,
DOLLY: 0b10000,
ZOOM: 0b100000,
TOUCH_ROTATE: 0b1000000,
TOUCH_TRUCK: 0b10000000,
TOUCH_SCREEN_PAN: 0b100000000,
TOUCH_OFFSET: 0b1000000000,
TOUCH_DOLLY: 0b10000000000,
TOUCH_ZOOM: 0b100000000000,
TOUCH_DOLLY_TRUCK: 0b1000000000000,
TOUCH_DOLLY_SCREEN_PAN: 0b10000000000000,
TOUCH_DOLLY_OFFSET: 0b100000000000000,
TOUCH_DOLLY_ROTATE: 0b1000000000000000,
TOUCH_ZOOM_TRUCK: 0b10000000000000000,
TOUCH_ZOOM_OFFSET: 0b100000000000000000,
TOUCH_ZOOM_SCREEN_PAN: 0b1000000000000000000,
TOUCH_ZOOM_ROTATE: 0b10000000000000000000,
});
const DOLLY_DIRECTION = {
NONE: 0,
IN: 1,
OUT: -1,
};
function isPerspectiveCamera(camera) {
return camera.isPerspectiveCamera;
}
function isOrthographicCamera(camera) {
return camera.isOrthographicCamera;
}
const PI_2 = Math.PI * 2;
const PI_HALF = Math.PI / 2;
const EPSILON = 1e-5;
const DEG2RAD = Math.PI / 180;
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function approxZero(number, error = EPSILON) {
return Math.abs(number) < error;
}
function approxEquals(a, b, error = EPSILON) {
return approxZero(a - b, error);
}
function roundToStep(value, step) {
return Math.round(value / step) * step;
}
function infinityToMaxNumber(value) {
if (isFinite(value))
return value;
if (value < 0)
return -Number.MAX_VALUE;
return Number.MAX_VALUE;
}
function maxNumberToInfinity(value) {
if (Math.abs(value) < Number.MAX_VALUE)
return value;
return value * Infinity;
}
// https://docs.unity3d.com/ScriptReference/Mathf.SmoothDamp.html
// https://github.com/Unity-Technologies/UnityCsReference/blob/a2bdfe9b3c4cd4476f44bf52f848063bfaf7b6b9/Runtime/Export/Math/Mathf.cs#L308
function smoothDamp(current, target, currentVelocityRef, smoothTime, maxSpeed = Infinity, deltaTime) {
// Based on Game Programming Gems 4 Chapter 1.10
smoothTime = Math.max(0.0001, smoothTime);
const omega = 2 / smoothTime;
const x = omega * deltaTime;
const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
let change = current - target;
const originalTo = target;
// Clamp maximum speed
const maxChange = maxSpeed * smoothTime;
change = clamp(change, -maxChange, maxChange);
target = current - change;
const temp = (currentVelocityRef.value + omega * change) * deltaTime;
currentVelocityRef.value = (currentVelocityRef.value - omega * temp) * exp;
let output = target + (change + temp) * exp;
// Prevent overshooting
if (originalTo - current > 0.0 === output > originalTo) {
output = originalTo;
currentVelocityRef.value = (output - originalTo) / deltaTime;
}
return output;
}
// https://docs.unity3d.com/ScriptReference/Vector3.SmoothDamp.html
// https://github.com/Unity-Technologies/UnityCsReference/blob/a2bdfe9b3c4cd4476f44bf52f848063bfaf7b6b9/Runtime/Export/Math/Vector3.cs#L97
function smoothDampVec3(current, target, currentVelocityRef, smoothTime, maxSpeed = Infinity, deltaTime, out) {
// Based on Game Programming Gems 4 Chapter 1.10
smoothTime = Math.max(0.0001, smoothTime);
const omega = 2 / smoothTime;
const x = omega * deltaTime;
const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x);
let targetX = target.x;
let targetY = target.y;
let targetZ = target.z;
let changeX = current.x - targetX;
let changeY = current.y - targetY;
let changeZ = current.z - targetZ;
const originalToX = targetX;
const originalToY = targetY;
const originalToZ = targetZ;
// Clamp maximum speed
const maxChange = maxSpeed * smoothTime;
const maxChangeSq = maxChange * maxChange;
const magnitudeSq = changeX * changeX + changeY * changeY + changeZ * changeZ;
if (magnitudeSq > maxChangeSq) {
const magnitude = Math.sqrt(magnitudeSq);
changeX = changeX / magnitude * maxChange;
changeY = changeY / magnitude * maxChange;
changeZ = changeZ / magnitude * maxChange;
}
targetX = current.x - changeX;
targetY = current.y - changeY;
targetZ = current.z - changeZ;
const tempX = (currentVelocityRef.x + omega * changeX) * deltaTime;
const tempY = (currentVelocityRef.y + omega * changeY) * deltaTime;
const tempZ = (currentVelocityRef.z + omega * changeZ) * deltaTime;
currentVelocityRef.x = (currentVelocityRef.x - omega * tempX) * exp;
currentVelocityRef.y = (currentVelocityRef.y - omega * tempY) * exp;
currentVelocityRef.z = (currentVelocityRef.z - omega * tempZ) * exp;
out.x = targetX + (changeX + tempX) * exp;
out.y = targetY + (changeY + tempY) * exp;
out.z = targetZ + (changeZ + tempZ) * exp;
// Prevent overshooting
const origMinusCurrentX = originalToX - current.x;
const origMinusCurrentY = originalToY - current.y;
const origMinusCurrentZ = originalToZ - current.z;
const outMinusOrigX = out.x - originalToX;
const outMinusOrigY = out.y - originalToY;
const outMinusOrigZ = out.z - originalToZ;
if (origMinusCurrentX * outMinusOrigX + origMinusCurrentY * outMinusOrigY + origMinusCurrentZ * outMinusOrigZ > 0) {
out.x = originalToX;
out.y = originalToY;
out.z = originalToZ;
currentVelocityRef.x = (out.x - originalToX) / deltaTime;
currentVelocityRef.y = (out.y - originalToY) / deltaTime;
currentVelocityRef.z = (out.z - originalToZ) / deltaTime;
}
return out;
}
function extractClientCoordFromEvent(pointers, out) {
out.set(0, 0);
pointers.forEach((pointer) => {
out.x += pointer.clientX;
out.y += pointer.clientY;
});
out.x /= pointers.length;
out.y /= pointers.length;
}
function notSupportedInOrthographicCamera(camera, message) {
if (isOrthographicCamera(camera)) {
console.warn(`${message} is not supported in OrthographicCamera`);
return true;
}
return false;
}
class EventDispatcher {
constructor() {
this._listeners = {};
}
/**
* Adds the specified event listener.
* @param type event name
* @param listener handler function
* @category Methods
*/
addEventListener(type, listener) {
const listeners = this._listeners;
if (listeners[type] === undefined)
listeners[type] = [];
if (listeners[type].indexOf(listener) === -1)
listeners[type].push(listener);
}
/**
* Presence of the specified event listener.
* @param type event name
* @param listener handler function
* @category Methods
*/
hasEventListener(type, listener) {
const listeners = this._listeners;
return listeners[type] !== undefined && listeners[type].indexOf(listener) !== -1;
}
/**
* Removes the specified event listener
* @param type event name
* @param listener handler function
* @category Methods
*/
removeEventListener(type, listener) {
const listeners = this._listeners;
const listenerArray = listeners[type];
if (listenerArray !== undefined) {
const index = listenerArray.indexOf(listener);
if (index !== -1)
listenerArray.splice(index, 1);
}
}
/**
* Removes all event listeners
* @param type event name
* @category Methods
*/
removeAllEventListeners(type) {
if (!type) {
this._listeners = {};
return;
}
if (Array.isArray(this._listeners[type]))
this._listeners[type].length = 0;
}
/**
* Fire an event type.
* @param event DispatcherEvent
* @category Methods
*/
dispatchEvent(event) {
const listeners = this._listeners;
const listenerArray = listeners[event.type];
if (listenerArray !== undefined) {
event.target = this;
const array = listenerArray.slice(0);
for (let i = 0, l = array.length; i < l; i++) {
array[i].call(this, event);
}
}
}
}
var _a;
const VERSION = '3.1.0'; // will be replaced with `version` in package.json during the build process.
const TOUCH_DOLLY_FACTOR = 1 / 8;
const isMac = /Mac/.test((_a = globalThis === null || globalThis === void 0 ? void 0 : globalThis.navigator) === null || _a === void 0 ? void 0 : _a.platform);
let THREE;
let _ORIGIN;
let _AXIS_Y;
let _AXIS_Z;
let _v2;
let _v3A;
let _v3B;
let _v3C;
let _cameraDirection;
let _xColumn;
let _yColumn;
let _zColumn;
let _deltaTarget;
let _deltaOffset;
let _sphericalA;
let _sphericalB;
let _box3A;
let _box3B;
let _sphere;
let _quaternionA;
let _quaternionB;
let _rotationMatrix;
let _raycaster;
class CameraControls extends EventDispatcher {
/**
* Injects THREE as the dependency. You can then proceed to use CameraControls.
*
* e.g
* ```javascript
* CameraControls.install( { THREE: THREE } );
* ```
*
* Note: If you do not wish to use enter three.js to reduce file size(tree-shaking for example), make a subset to install.
*
* ```js
* import {
* Vector2,
* Vector3,
* Vector4,
* Quaternion,
* Matrix4,
* Spherical,
* Box3,
* Sphere,
* Raycaster,
* MathUtils,
* } from 'three';
*
* const subsetOfTHREE = {
* Vector2 : Vector2,
* Vector3 : Vector3,
* Vector4 : Vector4,
* Quaternion: Quaternion,
* Matrix4 : Matrix4,
* Spherical : Spherical,
* Box3 : Box3,
* Sphere : Sphere,
* Raycaster : Raycaster,
* };
* CameraControls.install( { THREE: subsetOfTHREE } );
* ```
* @category Statics
*/
static install(libs) {
THREE = libs.THREE;
_ORIGIN = Object.freeze(new THREE.Vector3(0, 0, 0));
_AXIS_Y = Object.freeze(new THREE.Vector3(0, 1, 0));
_AXIS_Z = Object.freeze(new THREE.Vector3(0, 0, 1));
_v2 = new THREE.Vector2();
_v3A = new THREE.Vector3();
_v3B = new THREE.Vector3();
_v3C = new THREE.Vector3();
_cameraDirection = new THREE.Vector3();
_xColumn = new THREE.Vector3();
_yColumn = new THREE.Vector3();
_zColumn = new THREE.Vector3();
_deltaTarget = new THREE.Vector3();
_deltaOffset = new THREE.Vector3();
_sphericalA = new THREE.Spherical();
_sphericalB = new THREE.Spherical();
_box3A = new THREE.Box3();
_box3B = new THREE.Box3();
_sphere = new THREE.Sphere();
_quaternionA = new THREE.Quaternion();
_quaternionB = new THREE.Quaternion();
_rotationMatrix = new THREE.Matrix4();
_raycaster = new THREE.Raycaster();
}
/**
* list all ACTIONs
* @category Statics
*/
static get ACTION() {
return ACTION;
}
/**
* @deprecated Use `cameraControls.mouseButtons.left = CameraControls.ACTION.SCREEN_PAN` instead.
*/
set verticalDragToForward(_) {
console.warn('camera-controls: `verticalDragToForward` was removed. Use `mouseButtons.left = CameraControls.ACTION.SCREEN_PAN` instead.');
}
/**
* Creates a `CameraControls` instance.
*
* Note:
* You **must install** three.js before using camera-controls. see [#install](#install)
* Not doing so will lead to runtime errors (`undefined` references to THREE).
*
* e.g.
* ```
* CameraControls.install( { THREE } );
* const cameraControls = new CameraControls( camera, domElement );
* ```
*
* @param camera A `THREE.PerspectiveCamera` or `THREE.OrthographicCamera` to be controlled.
* @param domElement A `HTMLElement` for the draggable area, usually `renderer.domElement`.
* @category Constructor
*/
constructor(camera, domElement) {
super();
/**
* Minimum vertical angle in radians.
* The angle has to be between `0` and `.maxPolarAngle` inclusive.
* The default value is `0`.
*
* e.g.
* ```
* cameraControls.maxPolarAngle = 0;
* ```
* @category Properties
*/
this.minPolarAngle = 0; // radians
/**
* Maximum vertical angle in radians.
* The angle has to be between `.maxPolarAngle` and `Math.PI` inclusive.
* The default value is `Math.PI`.
*
* e.g.
* ```
* cameraControls.maxPolarAngle = Math.PI;
* ```
* @category Properties
*/
this.maxPolarAngle = Math.PI; // radians
/**
* Minimum horizontal angle in radians.
* The angle has to be less than `.maxAzimuthAngle`.
* The default value is `- Infinity`.
*
* e.g.
* ```
* cameraControls.minAzimuthAngle = - Infinity;
* ```
* @category Properties
*/
this.minAzimuthAngle = -Infinity; // radians
/**
* Maximum horizontal angle in radians.
* The angle has to be greater than `.minAzimuthAngle`.
* The default value is `Infinity`.
*
* e.g.
* ```
* cameraControls.maxAzimuthAngle = Infinity;
* ```
* @category Properties
*/
this.maxAzimuthAngle = Infinity; // radians
// How far you can dolly in and out ( PerspectiveCamera only )
/**
* Minimum distance for dolly. The value must be higher than `0`. Default is `Number.EPSILON`.
* PerspectiveCamera only.
* @category Properties
*/
this.minDistance = Number.EPSILON;
/**
* Maximum distance for dolly. The value must be higher than `minDistance`. Default is `Infinity`.
* PerspectiveCamera only.
* @category Properties
*/
this.maxDistance = Infinity;
/**
* `true` to enable Infinity Dolly for wheel and pinch. Use this with `minDistance` and `maxDistance`
* If the Dolly distance is less (or over) than the `minDistance` (or `maxDistance`), `infinityDolly` will keep the distance and pushes the target position instead.
* @category Properties
*/
this.infinityDolly = false;
/**
* Minimum camera zoom.
* @category Properties
*/
this.minZoom = 0.01;
/**
* Maximum camera zoom.
* @category Properties
*/
this.maxZoom = Infinity;
/**
* Approximate time in seconds to reach the target. A smaller value will reach the target faster.
* @category Properties
*/
this.smoothTime = 0.25;
/**
* the smoothTime while dragging
* @category Properties
*/
this.draggingSmoothTime = 0.125;
/**
* Max transition speed in unit-per-seconds
* @category Properties
*/
this.maxSpeed = Infinity;
/**
* Speed of azimuth (horizontal) rotation.
* @category Properties
*/
this.azimuthRotateSpeed = 1.0;
/**
* Speed of polar (vertical) rotation.
* @category Properties
*/
this.polarRotateSpeed = 1.0;
/**
* Speed of mouse-wheel dollying.
* @category Properties
*/
this.dollySpeed = 1.0;
/**
* `true` to invert direction when dollying or zooming via drag
* @category Properties
*/
this.dollyDragInverted = false;
/**
* Speed of drag for truck and pedestal.
* @category Properties
*/
this.truckSpeed = 2.0;
/**
* `true` to enable Dolly-in to the mouse cursor coords.
* @category Properties
*/
this.dollyToCursor = false;
/**
* @category Properties
*/
this.dragToOffset = false;
/**
* Friction ratio of the boundary.
* @category Properties
*/
this.boundaryFriction = 0.0;
/**
* Controls how soon the `rest` event fires as the camera slows.
* @category Properties
*/
this.restThreshold = 0.01;
/**
* An array of Meshes to collide with camera.
* Be aware colliderMeshes may decrease performance. The collision test uses 4 raycasters from the camera since the near plane has 4 corners.
* @category Properties
*/
this.colliderMeshes = [];
/**
* Force cancel user dragging.
* @category Methods
*/
// cancel will be overwritten in the constructor.
this.cancel = () => { };
this._enabled = true;
this._state = ACTION.NONE;
this._viewport = null;
this._changedDolly = 0;
this._changedZoom = 0;
this._hasRested = true;
this._boundaryEnclosesCamera = false;
this._needsUpdate = true;
this._updatedLastTime = false;
this._elementRect = new DOMRect();
this._isDragging = false;
this._dragNeedsUpdate = true;
this._activePointers = [];
this._lockedPointer = null;
this._interactiveArea = new DOMRect(0, 0, 1, 1);
// Use draggingSmoothTime over smoothTime while true.
// set automatically true on user-dragging start.
// set automatically false on programmable methods call.
this._isUserControllingRotate = false;
this._isUserControllingDolly = false;
this._isUserControllingTruck = false;
this._isUserControllingOffset = false;
this._isUserControllingZoom = false;
this._lastDollyDirection = DOLLY_DIRECTION.NONE;
// velocities for smoothDamp
this._thetaVelocity = { value: 0 };
this._phiVelocity = { value: 0 };
this._radiusVelocity = { value: 0 };
this._targetVelocity = new THREE.Vector3();
this._focalOffsetVelocity = new THREE.Vector3();
this._zoomVelocity = { value: 0 };
this._truckInternal = (deltaX, deltaY, dragToOffset, screenSpacePanning) => {
let truckX;
let pedestalY;
if (isPerspectiveCamera(this._camera)) {
const offset = _v3A.copy(this._camera.position).sub(this._target);
// half of the fov is center to top of screen
const fov = this._camera.getEffectiveFOV() * DEG2RAD;
const targetDistance = offset.length() * Math.tan(fov * 0.5);
truckX = (this.truckSpeed * deltaX * targetDistance / this._elementRect.height);
pedestalY = (this.truckSpeed * deltaY * targetDistance / this._elementRect.height);
}
else if (isOrthographicCamera(this._camera)) {
const camera = this._camera;
truckX = this.truckSpeed * deltaX * (camera.right - camera.left) / camera.zoom / this._elementRect.width;
pedestalY = this.truckSpeed * deltaY * (camera.top - camera.bottom) / camera.zoom / this._elementRect.height;
}
else {
return;
}
if (screenSpacePanning) {
dragToOffset ?
this.setFocalOffset(this._focalOffsetEnd.x + truckX, this._focalOffsetEnd.y, this._focalOffsetEnd.z, true) :
this.truck(truckX, 0, true);
this.forward(-pedestalY, true);
}
else {
dragToOffset ?
this.setFocalOffset(this._focalOffsetEnd.x + truckX, this._focalOffsetEnd.y + pedestalY, this._focalOffsetEnd.z, true) :
this.truck(truckX, pedestalY, true);
}
};
this._rotateInternal = (deltaX, deltaY) => {
const theta = PI_2 * this.azimuthRotateSpeed * deltaX / this._elementRect.height; // divide by *height* to refer the resolution
const phi = PI_2 * this.polarRotateSpeed * deltaY / this._elementRect.height;
this.rotate(theta, phi, true);
};
this._dollyInternal = (delta, x, y) => {
const dollyScale = Math.pow(0.95, -delta * this.dollySpeed);
const lastDistance = this._sphericalEnd.radius;
const distance = this._sphericalEnd.radius * dollyScale;
const clampedDistance = clamp(distance, this.minDistance, this.maxDistance);
const overflowedDistance = clampedDistance - distance;
if (this.infinityDolly && this.dollyToCursor) {
this._dollyToNoClamp(distance, true);
}
else if (this.infinityDolly && !this.dollyToCursor) {
this.dollyInFixed(overflowedDistance, true);
this._dollyToNoClamp(clampedDistance, true);
}
else {
this._dollyToNoClamp(clampedDistance, true);
}
if (this.dollyToCursor) {
this._changedDolly += (this.infinityDolly ? distance : clampedDistance) - lastDistance;
this._dollyControlCoord.set(x, y);
}
this._lastDollyDirection = Math.sign(-delta);
};
this._zoomInternal = (delta, x, y) => {
const zoomScale = Math.pow(0.95, delta * this.dollySpeed);
const lastZoom = this._zoom;
const zoom = this._zoom * zoomScale;
// for both PerspectiveCamera and OrthographicCamera
this.zoomTo(zoom, true);
if (this.dollyToCursor) {
this._changedZoom += zoom - lastZoom;
this._dollyControlCoord.set(x, y);
}
};
// Check if the user has installed THREE
if (typeof THREE === 'undefined') {
console.error('camera-controls: `THREE` is undefined. You must first run `CameraControls.install( { THREE: THREE } )`. Check the docs for further information.');
}
this._camera = camera;
this._yAxisUpSpace = new THREE.Quaternion().setFromUnitVectors(this._camera.up, _AXIS_Y);
this._yAxisUpSpaceInverse = this._yAxisUpSpace.clone().invert();
this._state = ACTION.NONE;
// the location
this._target = new THREE.Vector3();
this._targetEnd = this._target.clone();
this._focalOffset = new THREE.Vector3();
this._focalOffsetEnd = this._focalOffset.clone();
// rotation
this._spherical = new THREE.Spherical().setFromVector3(_v3A.copy(this._camera.position).applyQuaternion(this._yAxisUpSpace));
this._sphericalEnd = this._spherical.clone();
this._lastDistance = this._spherical.radius;
this._zoom = this._camera.zoom;
this._zoomEnd = this._zoom;
this._lastZoom = this._zoom;
// collisionTest uses nearPlane.s
this._nearPlaneCorners = [
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
];
this._updateNearPlaneCorners();
// Target cannot move outside of this box
this._boundary = new THREE.Box3(new THREE.Vector3(-Infinity, -Infinity, -Infinity), new THREE.Vector3(Infinity, Infinity, Infinity));
// reset
this._cameraUp0 = this._camera.up.clone();
this._target0 = this._target.clone();
this._position0 = this._camera.position.clone();
this._zoom0 = this._zoom;
this._focalOffset0 = this._focalOffset.clone();
this._dollyControlCoord = new THREE.Vector2();
// configs
this.mouseButtons = {
left: ACTION.ROTATE,
middle: ACTION.DOLLY,
right: ACTION.TRUCK,
wheel: isPerspectiveCamera(this._camera) ? ACTION.DOLLY :
isOrthographicCamera(this._camera) ? ACTION.ZOOM :
ACTION.NONE,
};
this.touches = {
one: ACTION.TOUCH_ROTATE,
two: isPerspectiveCamera(this._camera) ? ACTION.TOUCH_DOLLY_TRUCK :
isOrthographicCamera(this._camera) ? ACTION.TOUCH_ZOOM_TRUCK :
ACTION.NONE,
three: ACTION.TOUCH_TRUCK,
};
const dragStartPosition = new THREE.Vector2();
const lastDragPosition = new THREE.Vector2();
const dollyStart = new THREE.Vector2();
const onPointerDown = (event) => {
if (!this._enabled || !this._domElement)
return;
if (this._interactiveArea.left !== 0 ||
this._interactiveArea.top !== 0 ||
this._interactiveArea.width !== 1 ||
this._interactiveArea.height !== 1) {
const elRect = this._domElement.getBoundingClientRect();
const left = event.clientX / elRect.width;
const top = event.clientY / elRect.height;
// check if the interactiveArea contains the drag start position.
if (left < this._interactiveArea.left ||
left > this._interactiveArea.right ||
top < this._interactiveArea.top ||
top > this._interactiveArea.bottom)
return;
}
// Don't call `event.preventDefault()` on the pointerdown event
// to keep receiving pointermove evens outside dragging iframe
// https://taye.me/blog/tips/2015/11/16/mouse-drag-outside-iframe/
const mouseButton = event.pointerType !== 'mouse' ? null :
(event.buttons & MOUSE_BUTTON.LEFT) === MOUSE_BUTTON.LEFT ? MOUSE_BUTTON.LEFT :
(event.buttons & MOUSE_BUTTON.MIDDLE) === MOUSE_BUTTON.MIDDLE ? MOUSE_BUTTON.MIDDLE :
(event.buttons & MOUSE_BUTTON.RIGHT) === MOUSE_BUTTON.RIGHT ? MOUSE_BUTTON.RIGHT :
null;
if (mouseButton !== null) {
const zombiePointer = this._findPointerByMouseButton(mouseButton);
zombiePointer && this._disposePointer(zombiePointer);
}
if ((event.buttons & MOUSE_BUTTON.LEFT) === MOUSE_BUTTON.LEFT && this._lockedPointer)
return;
const pointer = {
pointerId: event.pointerId,
clientX: event.clientX,
clientY: event.clientY,
deltaX: 0,
deltaY: 0,
mouseButton,
};
this._activePointers.push(pointer);
// eslint-disable-next-line no-undef
this._domElement.ownerDocument.removeEventListener('pointermove', onPointerMove, { passive: false });
this._domElement.ownerDocument.removeEventListener('pointerup', onPointerUp);
this._domElement.ownerDocument.addEventListener('pointermove', onPointerMove, { passive: false });
this._domElement.ownerDocument.addEventListener('pointerup', onPointerUp);
this._isDragging = true;
startDragging(event);
};
const onPointerMove = (event) => {
if (event.cancelable)
event.preventDefault();
const pointerId = event.pointerId;
const pointer = this._lockedPointer || this._findPointerById(pointerId);
if (!pointer)
return;
pointer.clientX = event.clientX;
pointer.clientY = event.clientY;
pointer.deltaX = event.movementX;
pointer.deltaY = event.movementY;
this._state = 0;
if (event.pointerType === 'touch') {
switch (this._activePointers.length) {
case 1:
this._state = this.touches.one;
break;
case 2:
this._state = this.touches.two;
break;
case 3:
this._state = this.touches.three;
break;
}
}
else {
if ((!this._isDragging && this._lockedPointer) ||
this._isDragging && (event.buttons & MOUSE_BUTTON.LEFT) === MOUSE_BUTTON.LEFT) {
this._state = this._state | this.mouseButtons.left;
}
if (this._isDragging && (event.buttons & MOUSE_BUTTON.MIDDLE) === MOUSE_BUTTON.MIDDLE) {
this._state = this._state | this.mouseButtons.middle;
}
if (this._isDragging && (event.buttons & MOUSE_BUTTON.RIGHT) === MOUSE_BUTTON.RIGHT) {
this._state = this._state | this.mouseButtons.right;
}
}
dragging();
};
const onPointerUp = (event) => {
const pointer = this._findPointerById(event.pointerId);
if (pointer && pointer === this._lockedPointer)
return;
pointer && this._disposePointer(pointer);
if (event.pointerType === 'touch') {
switch (this._activePointers.length) {
case 0:
this._state = ACTION.NONE;
break;
case 1:
this._state = this.touches.one;
break;
case 2:
this._state = this.touches.two;
break;
case 3:
this._state = this.touches.three;
break;
}
}
else {
this._state = ACTION.NONE;
}
endDragging();
};
let lastScrollTimeStamp = -1;
const onMouseWheel = (event) => {
if (!this._domElement)
return;
if (!this._enabled || this.mouseButtons.wheel === ACTION.NONE)
return;
if (this._interactiveArea.left !== 0 ||
this._interactiveArea.top !== 0 ||
this._interactiveArea.width !== 1 ||
this._interactiveArea.height !== 1) {
const elRect = this._domElement.getBoundingClientRect();
const left = event.clientX / elRect.width;
const top = event.clientY / elRect.height;
// check if the interactiveArea contains the drag start position.
if (left < this._interactiveArea.left ||
left > this._interactiveArea.right ||
top < this._interactiveArea.top ||
top > this._interactiveArea.bottom)
return;
}
event.preventDefault();
if (this.dollyToCursor ||
this.mouseButtons.wheel === ACTION.ROTATE ||
this.mouseButtons.wheel === ACTION.TRUCK) {
const now = performance.now();
// only need to fire this at scroll start.
if (lastScrollTimeStamp - now < 1000)
this._getClientRect(this._elementRect);
lastScrollTimeStamp = now;
}
// Ref: https://github.com/cedricpinson/osgjs/blob/00e5a7e9d9206c06fdde0436e1d62ab7cb5ce853/sources/osgViewer/input/source/InputSourceMouse.js#L89-L103
const deltaYFactor = isMac ? -1 : -3;
// Checks event.ctrlKey to detect multi-touch gestures on a trackpad.
const delta = (event.deltaMode === 1 || event.ctrlKey) ? event.deltaY / deltaYFactor : event.deltaY / (deltaYFactor * 10);
const x = this.dollyToCursor ? (event.clientX - this._elementRect.x) / this._elementRect.width * 2 - 1 : 0;
const y = this.dollyToCursor ? (event.clientY - this._elementRect.y) / this._elementRect.height * -2 + 1 : 0;
switch (this.mouseButtons.wheel) {
case ACTION.ROTATE: {
this._rotateInternal(event.deltaX, event.deltaY);
this._isUserControllingRotate = true;
break;
}
case ACTION.TRUCK: {
this._truckInternal(event.deltaX, event.deltaY, false, false);
this._isUserControllingTruck = true;
break;
}
case ACTION.SCREEN_PAN: {
this._truckInternal(event.deltaX, event.deltaY, false, true);
this._isUserControllingTruck = true;
break;
}
case ACTION.OFFSET: {
this._truckInternal(event.deltaX, event.deltaY, true, false);
this._isUserControllingOffset = true;
break;
}
case ACTION.DOLLY: {
this._dollyInternal(-delta, x, y);
this._isUserControllingDolly = true;
break;
}
case ACTION.ZOOM: {
this._zoomInternal(-delta, x, y);
this._isUserControllingZoom = true;
break;
}
}
this.dispatchEvent({ type: 'control' });
};
const onContextMenu = (event) => {
if (!this._domElement || !this._enabled)
return;
// contextmenu event is fired right after pointerdown
// remove attached handlers and active pointer, if interrupted by contextmenu.
if (this.mouseButtons.right === CameraControls.ACTION.NONE) {
const pointerId = event instanceof PointerEvent ? event.pointerId : 0;
const pointer = this._findPointerById(pointerId);
pointer && this._disposePointer(pointer);
// eslint-disable-next-line no-undef
this._domElement.ownerDocument.removeEventListener('pointermove', onPointerMove, { passive: false });
this._domElement.ownerDocument.removeEventListener('pointerup', onPointerUp);
return;
}
event.preventDefault();
};
const startDragging = (event) => {
if (!this._enabled)
return;
extractClientCoordFromEvent(this._activePointers, _v2);
this._getClientRect(this._elementRect);
dragStartPosition.copy(_v2);
lastDragPosition.copy(_v2);
const isMultiTouch = this._activePointers.length >= 2;
if (isMultiTouch) {
// 2 finger pinch
const dx = _v2.x - this._activePointers[1].clientX;
const dy = _v2.y - this._activePointers[1].clientY;
const distance = Math.sqrt(dx * dx + dy * dy);
dollyStart.set(0, distance);
// center coords of 2 finger truck
const x = (this._activePointers[0].clientX + this._activePointers[1].clientX) * 0.5;
const y = (this._activePointers[0].clientY + this._activePointers[1].clientY) * 0.5;
lastDragPosition.set(x, y);
}
this._state = 0;
if (!event) {
if (this._lockedPointer)
this._state = this._state | this.mouseButtons.left;
}
else if ('pointerType' in event && event.pointerType === 'touch') {
switch (this._activePointers.length) {
case 1:
this._state = this.touches.one;
break;
case 2:
this._state = this.touches.two;
break;
case 3:
this._state = this.touches.three;
break;
}
}
else {
if (!this._lockedPointer && (event.buttons & MOUSE_BUTTON.LEFT) === MOUSE_BUTTON.LEFT) {
this._state = this._state | this.mouseButtons.left;
}
if ((event.buttons & MOUSE_BUTTON.MIDDLE) === MOUSE_BUTTON.MIDDLE) {
this._state = this._state | this.mouseButtons.middle;
}
if ((event.buttons & MOUSE_BUTTON.RIGHT) === MOUSE_BUTTON.RIGHT) {
this._state = this._state | this.mouseButtons.right;
}
}
// stop current movement on drag start
// - rotate
if ((this._state & ACTION.ROTATE) === ACTION.ROTATE ||
(this._state & ACTION.TOUCH_ROTATE) === ACTION.TOUCH_ROTATE ||
(this._state & ACTION.TOUCH_DOLLY_ROTATE) === ACTION.TOUCH_DOLLY_ROTATE ||
(this._state & ACTION.TOUCH_ZOOM_ROTATE) === ACTION.TOUCH_ZOOM_ROTATE) {
this._sphericalEnd.theta = this._spherical.theta;
this._sphericalEnd.phi = this._spherical.phi;
this._thetaVelocity.value = 0;
this._phiVelocity.value = 0;
}
// - truck and screen-pan
if ((this._state & ACTION.TRUCK) === ACTION.TRUCK ||
(this._state & ACTION.SCREEN_PAN) === ACTION.SCREEN_PAN ||
(this._state & ACTION.TOUCH_TRUCK) === ACTION.TOUCH_TRUCK ||
(this._state & ACTION.TOUCH_SCREEN_PAN) === ACTION.TOUCH_SCREEN_PAN ||
(this._state & ACTION.TOUCH_DOLLY_TRUCK) === ACTION.TOUCH_DOLLY_TRUCK ||
(this._state & ACTION.TOUCH_DOLLY_SCREEN_PAN) === ACTION.TOUCH_DOLLY_SCREEN_PAN ||
(this._state & ACTION.TOUCH_ZOOM_TRUCK) === ACTION.TOUCH_ZOOM_TRUCK ||
(this._state & ACTION.TOUCH_ZOOM_SCREEN_PAN) === ACTION.TOUCH_DOLLY_SCREEN_PAN) {
this._targetEnd.copy(this._target);
this._targetVelocity.set(0, 0, 0);
}
// - dolly
if ((this._state & ACTION.DOLLY) === ACTION.DOLLY ||
(this._state & ACTION.TOUCH_DOLLY) === ACTION.TOUCH_DOLLY ||
(this._state & ACTION.TOUCH_DOLLY_TRUCK) === ACTION.TOUCH_DOLLY_TRUCK ||
(this._state & ACTION.TOUCH_DOLLY_SCREEN_PAN) === ACTION.TOUCH_DOLLY_SCREEN_PAN ||
(this._state & ACTION.TOUCH_DOLLY_OFFSET) === ACTION.TOUCH_DOLLY_OFFSET ||
(this._state & ACTION.TOUCH_DOLLY_ROTATE) === ACTION.TOUCH_DOLLY_ROTATE) {
this._sphericalEnd.radius = this._spherical.radius;
this._radiusVelocity.value = 0;
}
// - zoom
if ((this._state & ACTION.ZOOM) === ACTION.ZOOM ||
(this._state & ACTION.TOUCH_ZOOM) === ACTION.TOUCH_ZOOM ||
(this._state & ACTION.TOUCH_ZOOM_TRUCK) === ACTION.TOUCH_ZOOM_TRUCK ||
(this._state & ACTION.TOUCH_ZOOM_SCREEN_PAN) === ACTION.TOUCH_ZOOM_SCREEN_PAN ||
(this._state & ACTION.TOUCH_ZOOM_OFFSET) === ACTION.TOUCH_ZOOM_OFFSET ||
(this._state & ACTION.TOUCH_ZOOM_ROTATE) === ACTION.TOUCH_ZOOM_ROTATE) {
this._zoomEnd = this._zoom;
this._zoomVelocity.value = 0;
}
// - offset
if ((this._state & ACTION.OFFSET) === ACTION.OFFSET ||
(this._state & ACTION.TOUCH_OFFSET) === ACTION.TOUCH_OFFSET ||
(this._state & ACTION.TOUCH_DOLLY_OFFSET) === ACTION.TOUCH_DOLLY_OFFSET ||
(this._state & ACTION.TOUCH_ZOOM_OFFSET) === ACTION.TOUCH_ZOOM_OFFSET) {
this._focalOffsetEnd.copy(this._focalOffset);
this._focalOffsetVelocity.set(0, 0, 0);
}
this.dispatchEvent({ type: 'controlstart' });
};
const dragging = () => {
if (!this._enabled || !this._dragNeedsUpdate)
return;
this._dragNeedsUpdate = false;
extractClientCoordFromEvent(this._activePointers, _v2);
// When pointer lock is enabled clientX, clientY, screenX, and screenY remain 0.
// If pointer lock is enabled, use the Delta directory, and assume active-pointer is not multiple.
const isPointerLockActive = this._domElement && this._domElement.ownerDocument.pointerLockElement === this._domElement;
const lockedPointer = isPointerLockActive ? this._lockedPointer || this._activePointers[0] : null;
const deltaX = lockedPointer ? -lockedPointer.deltaX : lastDragPosition.x - _v2.x;
const deltaY = lockedPointer ? -lockedPointer.deltaY : lastDragPosition.y - _v2.y;
lastDragPosition.copy(_v2);
// rotate
if ((this._state & ACTION.ROTATE) === ACTION.ROTATE ||
(this._state & ACTION.TOUCH_ROTATE) === ACTION.TOUCH_ROTATE ||
(this._state & ACTION.TOUCH_DOLLY_ROTATE) === ACTION.TOUCH_DOLLY_ROTATE ||
(this._state & ACTION.TOUCH_ZOOM_ROTATE) === ACTION.TOUCH_ZOOM_ROTATE) {
this._rotateInternal(deltaX, deltaY);
this._isUserControllingRotate = true;
}
// mouse dolly or zoom
if ((this._state & ACTION.DOLLY) === ACTION.DOLLY ||
(this._state & ACTION.ZOOM) === ACTION.ZOOM) {
const dollyX = this.dollyToCursor ? (dragStartPosition.x - this._elementRect.x) / this._elementRect.width * 2 - 1 : 0;
const dollyY = this.dollyToCursor ? (dragStartPosition.y - this._elementRect.y) / this._elementRect.height * -2 + 1 : 0;
const dollyDirection = this.dollyDragInverted ? -1 : 1;
if ((this._state & ACTION.DOLLY) === ACTION.DOLLY) {
this._dollyInternal(dollyDirection * deltaY * TOUCH_DOLLY_FACTOR, dollyX, dollyY);
this._isUserControllingDolly = true;
}
else {
this._zoomInternal(dollyDirection * deltaY * TOUCH_DOLLY_FACTOR, dollyX, dollyY);
this._isUserControllingZoom = true;
}
}
// touch dolly or zoom
if ((this._state & ACTION.TOUCH_DOLLY) === ACTION.TOUCH_DOLLY ||
(this._state & ACTION.TOUCH_ZOOM) === ACTION.TOUCH_ZOOM ||
(this._state & ACTION.TOUCH_DOLLY_TRUCK) === ACTION.TOUCH_DOLLY_TRUCK ||
(this._state & ACTION.TOUCH_ZOOM_TRUCK) === ACTION.TOUCH_ZOOM_TRUCK ||
(this._state & ACTION.TOUCH_DOLLY_SCREEN_PAN) === ACTION.TOUCH_DOLLY_SCREEN_PAN ||
(this._state & ACTION.TOUCH_ZOOM_SCREEN_PAN) === ACTION.TOUCH_ZOOM_SCREEN_PAN ||
(this._state & ACTION.TOUCH_DOLLY_OFFSET) === ACTION.TOUCH_DOLLY_OFFSET ||
(this._state & ACTION.TOUCH_ZOOM_OFFSET) === ACTION.TOUCH_ZOOM_OFFSET ||
(this._state & ACTION.TOUCH_DOLLY_ROTATE) === ACTION.TOUCH_DOLLY_ROTATE ||
(this._state & ACTION.TOUCH_ZOOM_ROTATE) === ACTION.TOUCH_ZOOM_ROTATE) {
const dx = _v2.x - this._activePointers[1].clientX;
const dy = _v2.y - this._activePointers[1].clientY;
const distance = Math.sqrt(dx * dx + dy * dy);
const dollyDelta = dollyStart.y - distance;
dollyStart.set(0, distance);
const dollyX = this.dollyToCursor ? (lastDragPosition.x - this._elementRect.x) / this._elementRect.width * 2 - 1 : 0;
const dollyY = this.dollyToCursor ? (lastDragPosition.y - this._elementRect.y) / this._elementRect.height * -2 + 1 : 0;
if ((this._state & ACTION.TOUCH_DOLLY) === ACTION.TOUCH_DOLLY ||
(this._state & ACTION.TOUCH_DOLLY_ROTATE) === ACTION.TOUCH_DOLLY_ROTATE ||
(this._state & ACTION.TOUCH_DOLLY_TRUCK) === ACTION.TOUCH_DOLLY_TRUCK ||
(this._state & ACTION.TOUCH_DOLLY_SCREEN_PAN) === ACTION.TOUCH_DOLLY_SCREEN_PAN ||
(this._state & ACTION.TOUCH_DOLLY_OFFSET) === ACTION.TOUCH_DOLLY_OFFSET) {
this._dollyInternal(dollyDelta * TOUCH_DOLLY_FACTOR, dollyX, dollyY);
this._isUserControllingDolly = true;
}
else {
this._zoomInternal(dollyDelta * TOUCH_DOLLY_FACTOR, dollyX, dollyY);
this._isUserControllingZoom = true;
}
}
// truck
if ((this._state & ACTION.TRUCK) === ACTION.TRUCK ||
(this._state & ACTION.TOUCH_TRUCK) === ACTION.TOUCH_TRUCK ||
(this._state & ACTION.TOUCH_DOLLY_TRUCK) === ACTION.TOUCH_DOLLY_TRUCK ||
(this._state & ACTION.TOUCH_ZOOM_TRUCK) === ACTION.TOUCH_ZOOM_TRUCK) {
this._truckInternal(deltaX, deltaY, false, false);
this._isUserControllingTruck = true;
}
// screen-pan
if ((this._state & ACTION.SCREEN_PAN) === ACTION.SCREEN_PAN ||
(this._state & ACTION.TOUCH_SCREEN_PAN) === ACTION.TOUCH_SCREEN_PAN ||
(this._state & ACTION.TOUCH_DOLLY_SCREEN_PAN) === ACTION.TOUCH_DOLLY_SCREEN_PAN ||
(this._state & ACTION.TOUCH_ZOOM_SCREEN_PAN) === ACTION.TOUCH_ZOOM_SCREEN_PAN) {
this._truckInternal(deltaX, deltaY, false, true);
this._isUserControllingTruck = true;
}
// offset
if ((this._state & ACTION.OFFSET) === ACTION.OFFSET ||
(this._state & ACTION.TOUCH_OFFSET) === ACTION.TOUCH_OFFSET ||
(this._state & ACTION.TOUCH_DOLLY_OFFSET) === ACTION.TOUCH_DOLLY_OFFSET ||
(this._state & ACTION.TOUCH_ZOOM_OFFSET) === ACTION.TOUCH_ZOOM_OFFSET) {
this._truckInternal(deltaX, deltaY, true, false);
this._isUserControllingOffset = true;
}
this.dispatchEvent({ type: 'control' });
};
const endDragging = () => {
extractClientCoordFromEvent(this._activePointers, _v2);
lastDragPosition.copy(_v2);
this._dragNeedsUpdate = false;
if (this._activePointers.length === 0 ||
(this._activePointers.length === 1 && this._activePointers[0] === this._lockedPointer)) {
this._isDragging = false;
}
if (this._activePointers.length === 0 && this._domElement) {
// eslint-disable-next-line no-undef
this._domElement.ownerDocument.removeEventListener('pointermove', onPointerMove, { passive: false });
this._domElement.ownerDocument.removeEventListener('pointerup', onPointerUp);
this.dispatchEvent({ type: 'controlend' });
}
};
this.lockPointer = () => {
if (!this._enabled || !this._domElement)
return;
this.cancel();
// Element.requestPointerLock is allowed to happen without any pointer active - create a faux one for compatibility with controls
this._lockedPointer = {
pointerId: -1,
clientX: 0,
clientY: 0,
deltaX: 0,
deltaY: 0,
mouseButton: null,
};
this._activePointers.push(this._lockedPointer);
// eslint-disable-next-line no-undef
this._domElement.ownerDocument.removeEventListener('pointermove', onPointerMove, { passive: false });
this._domElement.ownerDocument.removeEventListener('pointerup', onPointerUp);
this._domElement.requestPointerLock();
this._domElement.ownerDocument.addEventListener('pointerlockchange', onPointerLockChange);
this._domElement.ownerDocument.addEventListener('pointerlockerror', onPointerLockError);
this._domElement.ownerDocument.addEventListener('pointermove', onPointerMove, { passive: false });
this._domElement.ownerDocument.addEventListener('pointerup', onPointerUp);
startDragging();
};
this.unlockPointer = () => {
var _a, _b, _c;
if (this._lockedPointer !== null) {
this._disposePointer(this._lockedPointer);
this._lockedPointer = null;
}
(_a = this._domElement) === null || _a === void 0 ? void 0 : _a.ownerDocument.exitPointerLock();
(_b = this._domElement) === null || _b === void 0 ? void 0 : _b.ownerDocument.removeEventListener('pointerlockchange', onPointerLockChange);
(_c = this._domElement) === null || _c === void 0 ? void 0 : _c.ownerDocument.removeEventListener('pointerlockerror', onPointerLockError);
this.cancel();
};
const onPointerLockChange = () => {
const isPointerLockActive = this._domElement && this._domElement.ownerDocument.pointerLockElement === this._domElement;
if (!isPointerLockActive)
this.unlockPointer();
};
const onPointerLockError = () => {
this.unlockPointer();
};
this._addAllEventListeners = (domElement) => {
this._domElement = domElement;
this._domElement.style.touchAction = 'none';
this._domElement.style.userSelect = 'none';
this._domElement.style.webkitUserSelect = 'none';
this._domElement.addEventListener('pointerdown', onPointerDown);
this._domElement.addEventListener('pointercancel', onPointerUp);
this._domElement.addEv