UNPKG

@google/model-viewer

Version:

Easily display interactive 3D models on the web and in AR!

485 lines 20.8 kB
/* @license * Licensed under the Apache License, Version 2.0 (the 'License'); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an 'AS IS' BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Euler, EventDispatcher, Spherical } from 'three'; import { clamp } from '../utilities.js'; import { Damper, SETTLING_TIME } from './Damper.js'; export const DEFAULT_OPTIONS = Object.freeze({ minimumRadius: 0, maximumRadius: Infinity, minimumPolarAngle: Math.PI / 8, maximumPolarAngle: Math.PI - Math.PI / 8, minimumAzimuthalAngle: -Infinity, maximumAzimuthalAngle: Infinity, minimumFieldOfView: 10, maximumFieldOfView: 45, interactionPolicy: 'always-allow', touchAction: 'pan-y' }); // Constants const TOUCH_EVENT_RE = /^touch(start|end|move)$/; const KEYBOARD_ORBIT_INCREMENT = Math.PI / 8; const ZOOM_SENSITIVITY = 0.04; export const KeyCode = { PAGE_UP: 33, PAGE_DOWN: 34, LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40 }; export const ChangeSource = { USER_INTERACTION: 'user-interaction', NONE: 'none' }; /** * SmoothControls is a Three.js helper for adding delightful pointer and * keyboard-based input to a staged Three.js scene. Its API is very similar to * OrbitControls, but it offers more opinionated (subjectively more delightful) * defaults, easy extensibility and subjectively better out-of-the-box keyboard * support. * * One important change compared to OrbitControls is that the `update` method * of SmoothControls must be invoked on every frame, otherwise the controls * will not have an effect. * * Another notable difference compared to OrbitControls is that SmoothControls * does not currently support panning (but probably will in a future revision). * * Like OrbitControls, SmoothControls assumes that the orientation of the camera * has been set in terms of position, rotation and scale, so it is important to * ensure that the camera's matrixWorld is in sync before using SmoothControls. */ export class SmoothControls extends EventDispatcher { constructor(camera, element) { super(); this.camera = camera; this.element = element; this.sensitivity = 1; this._interactionEnabled = false; this._disableZoom = false; this.isUserChange = false; this.isUserPointing = false; // Internal orbital position state this.spherical = new Spherical(); this.goalSpherical = new Spherical(); this.thetaDamper = new Damper(); this.phiDamper = new Damper(); this.radiusDamper = new Damper(); this.logFov = Math.log(DEFAULT_OPTIONS.maximumFieldOfView); this.goalLogFov = this.logFov; this.fovDamper = new Damper(); // Pointer state this.pointerIsDown = false; this.lastPointerPosition = { clientX: 0, clientY: 0, }; this.touchMode = 'rotate'; this.touchDecided = false; this.onPointerMove = (event) => { if (!this.pointerIsDown || !this.canInteract) { return; } // NOTE(cdata): We test event.type as some browsers do not have a global // TouchEvent contructor. if (TOUCH_EVENT_RE.test(event.type)) { const { touches } = event; switch (this.touchMode) { case 'zoom': if (this.lastTouches.length > 1 && touches.length > 1) { const lastTouchDistance = this.twoTouchDistance(this.lastTouches[0], this.lastTouches[1]); const touchDistance = this.twoTouchDistance(touches[0], touches[1]); const deltaZoom = ZOOM_SENSITIVITY * (lastTouchDistance - touchDistance) / 10.0; this.userAdjustOrbit(0, 0, deltaZoom); } break; case 'rotate': const { touchAction } = this._options; if (!this.touchDecided && touchAction !== 'none') { this.touchDecided = true; const { clientX, clientY } = touches[0]; const dx = Math.abs(clientX - this.lastPointerPosition.clientX); const dy = Math.abs(clientY - this.lastPointerPosition.clientY); // If motion is mostly vertical, assume scrolling is the intent. if ((touchAction === 'pan-y' && dy > dx) || (touchAction === 'pan-x' && dx > dy)) { this.touchMode = 'scroll'; return; } } this.handleSinglePointerMove(touches[0]); break; case 'scroll': return; } this.lastTouches = touches; } else { this.handleSinglePointerMove(event); } if (event.cancelable) { event.preventDefault(); } ; }; this.onPointerDown = (event) => { this.pointerIsDown = true; this.isUserPointing = false; if (TOUCH_EVENT_RE.test(event.type)) { const { touches } = event; this.touchDecided = false; switch (touches.length) { default: case 1: this.touchMode = 'rotate'; this.handleSinglePointerDown(touches[0]); break; case 2: this.touchMode = this._disableZoom ? 'scroll' : 'zoom'; break; } this.lastTouches = touches; } else { this.handleSinglePointerDown(event); } }; this.onPointerUp = (_event) => { this.element.style.cursor = 'grab'; this.pointerIsDown = false; if (this.isUserPointing) { this.dispatchEvent({ type: 'pointer-change-end', pointer: Object.assign({}, this.lastPointerPosition) }); } }; this.onWheel = (event) => { if (!this.canInteract) { return; } const deltaZoom = event.deltaY * (event.deltaMode == 1 ? 18 : 1) * ZOOM_SENSITIVITY / 30; this.userAdjustOrbit(0, 0, deltaZoom); if (event.cancelable) { event.preventDefault(); } }; this.onKeyDown = (event) => { // We track if the key is actually one we respond to, so as not to // accidentally clober unrelated key inputs when the <model-viewer> has // focus. let relevantKey = false; switch (event.keyCode) { case KeyCode.PAGE_UP: relevantKey = true; this.userAdjustOrbit(0, 0, ZOOM_SENSITIVITY); break; case KeyCode.PAGE_DOWN: relevantKey = true; this.userAdjustOrbit(0, 0, -1 * ZOOM_SENSITIVITY); break; case KeyCode.UP: relevantKey = true; this.userAdjustOrbit(0, -KEYBOARD_ORBIT_INCREMENT, 0); break; case KeyCode.DOWN: relevantKey = true; this.userAdjustOrbit(0, KEYBOARD_ORBIT_INCREMENT, 0); break; case KeyCode.LEFT: relevantKey = true; this.userAdjustOrbit(-KEYBOARD_ORBIT_INCREMENT, 0, 0); break; case KeyCode.RIGHT: relevantKey = true; this.userAdjustOrbit(KEYBOARD_ORBIT_INCREMENT, 0, 0); break; } if (relevantKey && event.cancelable) { event.preventDefault(); } }; this._options = Object.assign({}, DEFAULT_OPTIONS); this.setOrbit(0, Math.PI / 2, 1); this.setFieldOfView(100); this.jumpToGoal(); } get interactionEnabled() { return this._interactionEnabled; } enableInteraction() { if (this._interactionEnabled === false) { const { element } = this; element.addEventListener('mousemove', this.onPointerMove); element.addEventListener('mousedown', this.onPointerDown); if (!this._disableZoom) { element.addEventListener('wheel', this.onWheel); } element.addEventListener('keydown', this.onKeyDown); element.addEventListener('touchstart', this.onPointerDown, { passive: true }); element.addEventListener('touchmove', this.onPointerMove); self.addEventListener('mouseup', this.onPointerUp); self.addEventListener('touchend', this.onPointerUp); this.element.style.cursor = 'grab'; this._interactionEnabled = true; } } disableInteraction() { if (this._interactionEnabled === true) { const { element } = this; element.removeEventListener('mousemove', this.onPointerMove); element.removeEventListener('mousedown', this.onPointerDown); if (!this._disableZoom) { element.removeEventListener('wheel', this.onWheel); } element.removeEventListener('keydown', this.onKeyDown); element.removeEventListener('touchstart', this.onPointerDown); element.removeEventListener('touchmove', this.onPointerMove); self.removeEventListener('mouseup', this.onPointerUp); self.removeEventListener('touchend', this.onPointerUp); element.style.cursor = ''; this._interactionEnabled = false; } } /** * The options that are currently configured for the controls instance. */ get options() { return this._options; } set disableZoom(disable) { if (this._disableZoom != disable) { this._disableZoom = disable; if (disable === true) { this.element.removeEventListener('wheel', this.onWheel); } else { this.element.addEventListener('wheel', this.onWheel); } } } /** * Copy the spherical values that represent the current camera orbital * position relative to the configured target into a provided Spherical * instance. If no Spherical is provided, a new Spherical will be allocated * to copy the values into. The Spherical that values are copied into is * returned. */ getCameraSpherical(target = new Spherical()) { return target.copy(this.spherical); } /** * Returns the camera's current vertical field of view in degrees. */ getFieldOfView() { return this.camera.fov; } /** * Configure the _options of the controls. Configured _options will be * merged with whatever _options have already been configured for this * controls instance. */ applyOptions(_options) { Object.assign(this._options, _options); // Re-evaluates clamping based on potentially new values for min/max // polar, azimuth and radius: this.setOrbit(); this.setFieldOfView(Math.exp(this.goalLogFov)); } /** * Sets the near and far planes of the camera. */ updateNearFar(nearPlane, farPlane) { this.camera.near = Math.max(nearPlane, farPlane / 1000); this.camera.far = farPlane; this.camera.updateProjectionMatrix(); } /** * Sets the aspect ratio of the camera */ updateAspect(aspect) { this.camera.aspect = aspect; this.camera.updateProjectionMatrix(); } /** * Set the absolute orbital goal of the camera. The change will be * applied over a number of frames depending on configured acceleration and * dampening _options. * * Returns true if invoking the method will result in the camera changing * position and/or rotation, otherwise false. */ setOrbit(goalTheta = this.goalSpherical.theta, goalPhi = this.goalSpherical.phi, goalRadius = this.goalSpherical.radius) { const { minimumAzimuthalAngle, maximumAzimuthalAngle, minimumPolarAngle, maximumPolarAngle, minimumRadius, maximumRadius } = this._options; const { theta, phi, radius } = this.goalSpherical; const nextTheta = clamp(goalTheta, minimumAzimuthalAngle, maximumAzimuthalAngle); if (!isFinite(minimumAzimuthalAngle) && !isFinite(maximumAzimuthalAngle)) { this.spherical.theta = this.wrapAngle(this.spherical.theta - nextTheta) + nextTheta; } const nextPhi = clamp(goalPhi, minimumPolarAngle, maximumPolarAngle); const nextRadius = clamp(goalRadius, minimumRadius, maximumRadius); if (nextTheta === theta && nextPhi === phi && nextRadius === radius) { return false; } this.goalSpherical.theta = nextTheta; this.goalSpherical.phi = nextPhi; this.goalSpherical.radius = nextRadius; this.goalSpherical.makeSafe(); this.isUserChange = false; return true; } /** * Subset of setOrbit() above, which only sets the camera's radius. */ setRadius(radius) { this.goalSpherical.radius = radius; this.setOrbit(); } /** * Sets the goal field of view for the camera */ setFieldOfView(fov) { const { minimumFieldOfView, maximumFieldOfView } = this._options; fov = clamp(fov, minimumFieldOfView, maximumFieldOfView); this.goalLogFov = Math.log(fov); } /** * Adjust the orbital position of the camera relative to its current orbital * position. Does not let the theta goal get more than pi ahead of the current * theta, which ensures interpolation continues in the direction of the delta. * The deltaZoom parameter adjusts both the field of view and the orbit radius * such that they progress across their allowed ranges in sync. */ adjustOrbit(deltaTheta, deltaPhi, deltaZoom) { const { theta, phi, radius } = this.goalSpherical; const { minimumRadius, maximumRadius, minimumFieldOfView, maximumFieldOfView } = this._options; const dTheta = this.spherical.theta - theta; const dThetaLimit = Math.PI - 0.001; const goalTheta = theta - clamp(deltaTheta, -dThetaLimit - dTheta, dThetaLimit - dTheta); const goalPhi = phi - deltaPhi; const deltaRatio = deltaZoom === 0 ? 0 : deltaZoom > 0 ? (maximumRadius - radius) / (Math.log(maximumFieldOfView) - this.goalLogFov) : (radius - minimumRadius) / (this.goalLogFov - Math.log(minimumFieldOfView)); const goalRadius = radius + deltaZoom * Math.min(isFinite(deltaRatio) ? deltaRatio : Infinity, maximumRadius - minimumRadius); this.setOrbit(goalTheta, goalPhi, goalRadius); if (deltaZoom !== 0) { const goalLogFov = this.goalLogFov + deltaZoom; this.setFieldOfView(Math.exp(goalLogFov)); } } /** * Move the camera instantly instead of accelerating toward the goal * parameters. */ jumpToGoal() { this.update(0, SETTLING_TIME); } /** * Update controls. In most cases, this will result in the camera * interpolating its position and rotation until it lines up with the * designated goal orbital position. * * Time and delta are measured in milliseconds. */ update(_time, delta) { if (this.isStationary()) { return; } const { maximumPolarAngle, maximumRadius } = this._options; const dTheta = this.spherical.theta - this.goalSpherical.theta; if (Math.abs(dTheta) > Math.PI && !isFinite(this._options.minimumAzimuthalAngle) && !isFinite(this._options.maximumAzimuthalAngle)) { this.spherical.theta -= Math.sign(dTheta) * 2 * Math.PI; } this.spherical.theta = this.thetaDamper.update(this.spherical.theta, this.goalSpherical.theta, delta, Math.PI); this.spherical.phi = this.phiDamper.update(this.spherical.phi, this.goalSpherical.phi, delta, maximumPolarAngle); this.spherical.radius = this.radiusDamper.update(this.spherical.radius, this.goalSpherical.radius, delta, maximumRadius); this.logFov = this.fovDamper.update(this.logFov, this.goalLogFov, delta, 1); this.moveCamera(); } isStationary() { return this.goalSpherical.theta === this.spherical.theta && this.goalSpherical.phi === this.spherical.phi && this.goalSpherical.radius === this.spherical.radius && this.goalLogFov === this.logFov; } moveCamera() { // Derive the new camera position from the updated spherical: this.spherical.makeSafe(); this.camera.position.setFromSpherical(this.spherical); this.camera.setRotationFromEuler(new Euler(this.spherical.phi - Math.PI / 2, this.spherical.theta, 0, 'YXZ')); if (this.camera.fov !== Math.exp(this.logFov)) { this.camera.fov = Math.exp(this.logFov); this.camera.updateProjectionMatrix(); } const source = this.isUserChange ? ChangeSource.USER_INTERACTION : ChangeSource.NONE; this.dispatchEvent({ type: 'change', source }); } get canInteract() { if (this._options.interactionPolicy == 'allow-when-focused') { const rootNode = this.element.getRootNode(); return rootNode.activeElement === this.element; } return this._options.interactionPolicy === 'always-allow'; } userAdjustOrbit(deltaTheta, deltaPhi, deltaZoom) { this.adjustOrbit(deltaTheta * this.sensitivity, deltaPhi * this.sensitivity, deltaZoom); this.isUserChange = true; // Always make sure that an initial event is triggered in case there is // contention between user interaction and imperative changes. This initial // event will give external observers that chance to observe that // interaction occurred at all: this.dispatchEvent({ type: 'change', source: ChangeSource.USER_INTERACTION }); } // Wraps to bewteen -pi and pi wrapAngle(radians) { const normalized = (radians + Math.PI) / (2 * Math.PI); const wrapped = normalized - Math.floor(normalized); return wrapped * 2 * Math.PI - Math.PI; } pixelLengthToSphericalAngle(pixelLength) { return 2 * Math.PI * pixelLength / this.element.clientHeight; } twoTouchDistance(touchOne, touchTwo) { const { clientX: xOne, clientY: yOne } = touchOne; const { clientX: xTwo, clientY: yTwo } = touchTwo; const xDelta = xTwo - xOne; const yDelta = yTwo - yOne; return Math.sqrt(xDelta * xDelta + yDelta * yDelta); } handleSinglePointerMove(pointer) { const { clientX, clientY } = pointer; const deltaTheta = this.pixelLengthToSphericalAngle(clientX - this.lastPointerPosition.clientX); const deltaPhi = this.pixelLengthToSphericalAngle(clientY - this.lastPointerPosition.clientY); this.lastPointerPosition.clientX = clientX; this.lastPointerPosition.clientY = clientY; if (this.isUserPointing === false) { this.isUserPointing = true; this.dispatchEvent({ type: 'pointer-change-start', pointer: Object.assign({}, pointer) }); } this.userAdjustOrbit(deltaTheta, deltaPhi, 0); } handleSinglePointerDown(pointer) { this.lastPointerPosition.clientX = pointer.clientX; this.lastPointerPosition.clientY = pointer.clientY; this.element.style.cursor = 'grabbing'; } } //# sourceMappingURL=SmoothControls.js.map