UNPKG

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
/*! * 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