UNPKG

@abernier/camera-controls

Version:

[![Latest NPM release](https://img.shields.io/npm/v/@abernier/camera-controls.svg)](https://www.npmjs.com/package/@abernier/camera-controls) [![Storybook dev branch](https://img.shields.io/badge/dev-f7f9fc.svg?logo=storybook)](https://dev--68888af2a4f99a6

1,151 lines (1,142 loc) 120 kB
/*! * @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