@abernier/camera-controls
Version:
[](https://www.npmjs.com/package/@abernier/camera-controls) [](https://dev--68888af2a4f99a6
1,151 lines (1,142 loc) • 120 kB
JavaScript
/*!
* @abernier/camera-controls
* https://github.com/abernier/camera-controls
* (c) 2017 @yomotsu
* Released under the MIT License.
*/
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
// 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 {
_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);
}
}
}
}
class CameraControls extends EventDispatcher {
static VERSION = '1.1.0'; // will be replaced with `version` in package.json during the build process.
static TOUCH_DOLLY_FACTOR = 1 / 8;
static isMac = /Mac/.test(globalThis?.navigator?.platform);
static THREE;
static _ORIGIN;
static _AXIS_Y;
static _AXIS_Z;
static _v2;
static _v3A;
static _v3B;
static _v3C;
static _cameraDirection;
static _xColumn;
static _yColumn;
static _zColumn;
static _deltaTarget;
static _deltaOffset;
static _sphericalA;
static _sphericalB;
static _box3A;
static _box3B;
static _sphere;
static _quaternionA;
static _quaternionB;
static _rotationMatrix;
static _raycaster;
get ctor() {
return this.constructor;
}
/**
* 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) {
const THREE = libs.THREE;
this.THREE = THREE;
this._ORIGIN = Object.freeze(new THREE.Vector3(0, 0, 0));
this._AXIS_Y = Object.freeze(new THREE.Vector3(0, 1, 0));
this._AXIS_Z = Object.freeze(new THREE.Vector3(0, 0, 1));
this._v2 = new THREE.Vector2();
this._v3A = new THREE.Vector3();
this._v3B = new THREE.Vector3();
this._v3C = new THREE.Vector3();
this._cameraDirection = new THREE.Vector3();
this._xColumn = new THREE.Vector3();
this._yColumn = new THREE.Vector3();
this._zColumn = new THREE.Vector3();
this._deltaTarget = new THREE.Vector3();
this._deltaOffset = new THREE.Vector3();
this._sphericalA = new THREE.Spherical();
this._sphericalB = new THREE.Spherical();
this._box3A = new THREE.Box3();
this._box3B = new THREE.Box3();
this._sphere = new THREE.Sphere();
this._quaternionA = new THREE.Quaternion();
this._quaternionB = new THREE.Quaternion();
this._rotationMatrix = new THREE.Matrix4();
this._raycaster = new THREE.Raycaster();
}
/**
* list all ACTIONs
* @category Statics
*/
static get ACTION() {
return ACTION;
}
/**
* 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
*/
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
*/
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
*/
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
*/
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
*/
minDistance = Number.EPSILON;
/**
* Maximum distance for dolly. The value must be higher than `minDistance`. Default is `Infinity`.
* PerspectiveCamera only.
* @category Properties
*/
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
*/
infinityDolly = false;
/**
* Minimum camera zoom.
* @category Properties
*/
minZoom = 0.01;
/**
* Maximum camera zoom.
* @category Properties
*/
maxZoom = Infinity;
/**
* Approximate time in seconds to reach the target. A smaller value will reach the target faster.
* @category Properties
*/
smoothTime = 0.25;
/**
* the smoothTime while dragging
* @category Properties
*/
draggingSmoothTime = 0.125;
/**
* Max transition speed in unit-per-seconds
* @category Properties
*/
maxSpeed = Infinity;
/**
* Speed of azimuth (horizontal) rotation.
* @category Properties
*/
azimuthRotateSpeed = 1.0;
/**
* Speed of polar (vertical) rotation.
* @category Properties
*/
polarRotateSpeed = 1.0;
/**
* Speed of mouse-wheel dollying.
* @category Properties
*/
dollySpeed = 1.0;
/**
* `true` to invert direction when dollying or zooming via drag
* @category Properties
*/
dollyDragInverted = false;
/**
* Speed of drag for truck and pedestal.
* @category Properties
*/
truckSpeed = 2.0;
/**
* `true` to enable Dolly-in to the mouse cursor coords.
* @category Properties
*/
dollyToCursor = false;
/**
* @category Properties
*/
dragToOffset = false;
/**
* Friction ratio of the boundary.
* @category Properties
*/
boundaryFriction = 0.0;
/**
* Controls how soon the `rest` event fires as the camera slows.
* @category Properties
*/
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
*/
colliderMeshes = [];
// button configs
/**
* User's mouse input config.
*
* | button to assign | behavior |
* | --------------------- | -------- |
* | `mouseButtons.left` | `CameraControls.ACTION.ROTATE`* \| `CameraControls.ACTION.TRUCK` \| `CameraControls.ACTION.OFFSET` \| `CameraControls.ACTION.DOLLY` \| `CameraControls.ACTION.ZOOM` \| `CameraControls.ACTION.NONE` |
* | `mouseButtons.right` | `CameraControls.ACTION.ROTATE` \| `CameraControls.ACTION.TRUCK`* \| `CameraControls.ACTION.OFFSET` \| `CameraControls.ACTION.DOLLY` \| `CameraControls.ACTION.ZOOM` \| `CameraControls.ACTION.NONE` |
* | `mouseButtons.wheel` ¹ | `CameraControls.ACTION.ROTATE` \| `CameraControls.ACTION.TRUCK` \| `CameraControls.ACTION.OFFSET` \| `CameraControls.ACTION.DOLLY` \| `CameraControls.ACTION.ZOOM` \| `CameraControls.ACTION.NONE` |
* | `mouseButtons.middle` ² | `CameraControls.ACTION.ROTATE` \| `CameraControls.ACTION.TRUCK` \| `CameraControls.ACTION.OFFSET` \| `CameraControls.ACTION.DOLLY`* \| `CameraControls.ACTION.ZOOM` \| `CameraControls.ACTION.NONE` |
*
* 1. Mouse wheel event for scroll "up/down" on mac "up/down/left/right"
* 2. Mouse click on wheel event "button"
* - \* is the default.
* - The default of `mouseButtons.wheel` is:
* - `DOLLY` for Perspective camera.
* - `ZOOM` for Orthographic camera, and can't set `DOLLY`.
* @category Properties
*/
mouseButtons;
/**
* User's touch input config.
*
* | fingers to assign | behavior |
* | --------------------- | -------- |
* | `touches.one` | `CameraControls.ACTION.TOUCH_ROTATE`* \| `CameraControls.ACTION.TOUCH_TRUCK` \| `CameraControls.ACTION.TOUCH_OFFSET` \| `CameraControls.ACTION.DOLLY` | `CameraControls.ACTION.ZOOM` | `CameraControls.ACTION.NONE` |
* | `touches.two` | `ACTION.TOUCH_DOLLY_TRUCK` \| `ACTION.TOUCH_DOLLY_OFFSET` \| `ACTION.TOUCH_DOLLY_ROTATE` \| `ACTION.TOUCH_ZOOM_TRUCK` \| `ACTION.TOUCH_ZOOM_OFFSET` \| `ACTION.TOUCH_ZOOM_ROTATE` \| `ACTION.TOUCH_DOLLY` \| `ACTION.TOUCH_ZOOM` \| `CameraControls.ACTION.TOUCH_ROTATE` \| `CameraControls.ACTION.TOUCH_TRUCK` \| `CameraControls.ACTION.TOUCH_OFFSET` \| `CameraControls.ACTION.NONE` |
* | `touches.three` | `ACTION.TOUCH_DOLLY_TRUCK` \| `ACTION.TOUCH_DOLLY_OFFSET` \| `ACTION.TOUCH_DOLLY_ROTATE` \| `ACTION.TOUCH_ZOOM_TRUCK` \| `ACTION.TOUCH_ZOOM_OFFSET` \| `ACTION.TOUCH_ZOOM_ROTATE` \| `CameraControls.ACTION.TOUCH_ROTATE` \| `CameraControls.ACTION.TOUCH_TRUCK` \| `CameraControls.ACTION.TOUCH_OFFSET` \| `CameraControls.ACTION.NONE` |
*
* - \* is the default.
* - The default of `touches.two` and `touches.three` is:
* - `TOUCH_DOLLY_TRUCK` for Perspective camera.
* - `TOUCH_ZOOM_TRUCK` for Orthographic camera, and can't set `TOUCH_DOLLY_TRUCK` and `TOUCH_DOLLY`.
* @category Properties
*/
touches;
/**
* Force cancel user dragging.
* @category Methods
*/
// cancel will be overwritten in the constructor.
cancel = () => { };
/**
* Still an experimental feature.
* This could change at any time.
* @category Methods
*/
lockPointer;
/**
* Still an experimental feature.
* This could change at any time.
* @category Methods
*/
unlockPointer;
_enabled = true;
_camera;
_yAxisUpSpace;
_yAxisUpSpaceInverse;
_state = ACTION.NONE;
_domElement;
_viewport = null;
// the location of focus, where the object orbits around
_target;
_targetEnd;
_focalOffset;
_focalOffsetEnd;
// rotation and dolly distance
_spherical;
_sphericalEnd;
_lastDistance;
_zoom;
_zoomEnd;
_lastZoom;
// reset
_cameraUp0;
_target0;
_position0;
_zoom0;
_focalOffset0;
_dollyControlCoord;
_changedDolly = 0;
_changedZoom = 0;
// collisionTest uses nearPlane. ( PerspectiveCamera only )
_nearPlaneCorners;
_hasRested = true;
_boundary;
_boundaryEnclosesCamera = false;
_needsUpdate = true;
_updatedLastTime = false;
_elementRect = new DOMRect();
_isDragging = false;
_dragNeedsUpdate = true;
_activePointers = [];
_lockedPointer = null;
_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.
_isUserControllingRotate = false;
_isUserControllingDolly = false;
_isUserControllingTruck = false;
_isUserControllingOffset = false;
_isUserControllingZoom = false;
_lastDollyDirection = DOLLY_DIRECTION.NONE;
// velocities for smoothDamp
_thetaVelocity = { value: 0 };
_phiVelocity = { value: 0 };
_radiusVelocity = { value: 0 };
_targetVelocity = new this.ctor.THREE.Vector3();
_focalOffsetVelocity = new this.ctor.THREE.Vector3();
_zoomVelocity = { value: 0 };
/**
* @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();
// Check if the user has installed THREE
if (typeof this.ctor.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 this.ctor.THREE.Quaternion().setFromUnitVectors(this._camera.up, this.ctor._AXIS_Y);
this._yAxisUpSpaceInverse = this._yAxisUpSpace.clone().invert();
this._state = ACTION.NONE;
// the location
this._target = new this.ctor.THREE.Vector3();
this._targetEnd = this._target.clone();
this._focalOffset = new this.ctor.THREE.Vector3();
this._focalOffsetEnd = this._focalOffset.clone();
// rotation
this._spherical = new this.ctor.THREE.Spherical().setFromVector3(this.ctor._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 this.ctor.THREE.Vector3(),
new this.ctor.THREE.Vector3(),
new this.ctor.THREE.Vector3(),
new this.ctor.THREE.Vector3(),
];
this._updateNearPlaneCorners();
// Target cannot move outside of this box
this._boundary = new this.ctor.THREE.Box3(new this.ctor.THREE.Vector3(-Infinity, -Infinity, -Infinity), new this.ctor.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 this.ctor.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 this.ctor.THREE.Vector2();
const lastDragPosition = new this.ctor.THREE.Vector2();
const dollyStart = new this.ctor.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 = this.ctor.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, this.ctor._v2);
this._getClientRect(this._elementRect);
dragStartPosition.copy(this.ctor._v2);
lastDragPosition.copy(this.ctor._v2);
const isMultiTouch = this._activePointers.length >= 2;
if (isMultiTouch) {
// 2 finger pinch
const dx = this.ctor._v2.x - this._activePointers[1].clientX;
const dy = this.ctor._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, this.ctor._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 - this.ctor._v2.x;
const deltaY = lockedPointer ? -lockedPointer.deltaY : lastDragPosition.y - this.ctor._v2.y;
lastDragPosition.copy(this.ctor._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 * this.ctor.TOUCH_DOLLY_FACTOR, dollyX, dollyY);
this._isUserControllingDolly = true;
}
else {
this._zoomInternal(dollyDirection * deltaY * this.ctor.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 = this.ctor._v2.x - this._activePointers[1].clientX;
const dy = this.ctor._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 * this.ctor.TOUCH_DOLLY_FACTOR, dollyX, dollyY);
this._isUserControllingDolly = true;
}
else {
this._zoomInternal(dollyDelta * this.ctor.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, this.ctor._v2);
lastDragPosition.copy(this.ctor._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 = () => {
if (this._lockedPointer !== null) {
this._disposePointer(this._lockedPointer);
this._lockedPointer = null;
}
this._domElement?.ownerDocument.exitPointerLock();
this._domElement?.ownerDocument.removeEventListener('pointerlockchange', onPointerLockChange);
this._domElement?.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.addEventListener('wheel', onMouseWheel, { passive: false });
this._domElement.addEventListener('contextmenu', onContextMenu);
};
this._removeAllEventListeners = () => {
if (!this._domElement)
return;
this._domElement.style.touchAction = '';
this._domElement