UNPKG

@google/model-viewer

Version:

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

707 lines (604 loc) 23.4 kB
/* * Copyright 2018 Google Inc. All Rights Reserved. * 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 {Event, EventDispatcher, PerspectiveCamera, Quaternion, Spherical, Vector2, Vector3} from 'three'; import {clamp} from '../utilities.js'; export type EventHandlingBehavior = 'prevent-all'|'prevent-handled'; export type InteractionPolicy = 'always-allow'|'allow-when-focused'; export type TouchMode = 'rotate'|'zoom'; export interface SmoothControlsOptions { // The closest the camera can be to the target minimumRadius?: number; // The farthest the camera can be from the target maximumRadius?: number; // The minimum angle between model-up and the camera polar position minimumPolarAngle?: number; // The maximum angle between model-up and the camera polar position maximumPolarAngle?: number; // The minimum angle between model-forward and the camera azimuthal position minimumAzimuthalAngle?: number; // The maximum angle between model-forward and the camera azimuthal position maximumAzimuthalAngle?: number; // The minimum camera field of view in degrees minimumFov?: number; // The maximum camera field of view in degrees maximumFov?: number; // Controls when events will be cancelled (always, or only when handled) eventHandlingBehavior?: EventHandlingBehavior; // Controls when interaction is allowed (always, or only when focused) interactionPolicy?: InteractionPolicy; } export const DEFAULT_OPTIONS = Object.freeze<SmoothControlsOptions>({ minimumRadius: 1, maximumRadius: 2, minimumPolarAngle: Math.PI / 8, maximumPolarAngle: Math.PI - Math.PI / 8, minimumAzimuthalAngle: -Infinity, maximumAzimuthalAngle: Infinity, minimumFov: 20, maximumFov: 45, eventHandlingBehavior: 'prevent-all', interactionPolicy: 'always-allow' }); const $velocity = Symbol('v'); // Internal orbital position state const $spherical = Symbol('spherical'); const $goalSpherical = Symbol('goalSpherical'); const $thetaDamper = Symbol('thetaDamper'); const $phiDamper = Symbol('phiDamper'); const $radiusDamper = Symbol('radiusDamper'); const $fov = Symbol('fov'); const $goalFov = Symbol('goalFov'); const $fovDamper = Symbol('fovDamper'); const $target = Symbol('target'); const $options = Symbol('options'); const $upQuaternion = Symbol('upQuaternion'); const $upQuaternionInverse = Symbol('upQuaternionInverse'); const $touchMode = Symbol('touchMode'); const $canInteract = Symbol('canInteract'); const $interactionEnabled = Symbol('interactionEnabled'); const $zoomMeters = Symbol('zoomMeters'); const $userAdjustOrbit = Symbol('userAdjustOrbit'); const $isUserChange = Symbol('isUserChange'); const $isStationary = Symbol('isMoving'); const $moveCamera = Symbol('moveCamera'); // Pointer state const $pointerIsDown = Symbol('pointerIsDown'); const $lastPointerPosition = Symbol('lastPointerPosition'); const $lastTouches = Symbol('lastTouches'); // Value conversion methods const $pixelLengthToSphericalAngle = Symbol('pixelLengthToSphericalAngle'); const $sphericalToPosition = Symbol('sphericalToPosition'); const $twoTouchDistance = Symbol('twoTouchDistance'); // Event handlers const $onMouseMove = Symbol('onMouseMove'); const $onMouseDown = Symbol('onMouseDown'); const $onMouseUp = Symbol('onMouseUp'); const $onTouchStart = Symbol('onTouchStart'); const $onTouchEnd = Symbol('onTouchEnd'); const $onTouchMove = Symbol('onTouchMove'); const $onWheel = Symbol('onWheel'); const $onKeyDown = Symbol('onKeyDown'); const $handlePointerMove = Symbol('handlePointerMove'); const $handlePointerDown = Symbol('handlePointerDown'); const $handlePointerUp = Symbol('handlePointerUp'); const $handleWheel = Symbol('handleWheel'); const $handleKey = Symbol('handleKey'); // Constants const TOUCH_EVENT_RE = /^touch(start|end|move)$/; const KEYBOARD_ORBIT_INCREMENT = Math.PI / 8; const DECAY_MILLISECONDS = 50; const NATURAL_FREQUENCY = 1 / DECAY_MILLISECONDS; const NIL_SPEED = 0.0002 * NATURAL_FREQUENCY; const TAU = 2 * Math.PI; const UP = new Vector3(0, 1, 0); export const KeyCode = { PAGE_UP: 33, PAGE_DOWN: 34, LEFT: 37, UP: 38, RIGHT: 39, DOWN: 40 }; export type ChangeSource = 'user-interaction'|'none'; export const ChangeSource: {[index: string]: ChangeSource} = { USER_INTERACTION: 'user-interaction', NONE: 'none' }; /** * ChangEvents are dispatched whenever the camera position or orientation has * changed */ export interface ChangeEvent extends Event { /** * determines what was the originating reason for the change event eg user or * none */ source: ChangeSource, } /** * The Damper class is a generic second-order critically damped system that does * one linear step of the desired length of time. The only parameter is * DECAY_MILLISECONDS, which should be adjustable: TODO(#580). This common * parameter makes all states converge at the same rate regardless of scale. * xNormalization is a number to provide the rough scale of x, such that * NIL_SPEED clamping also happens at roughly the same convergence for all * states. */ export class Damper { private[$velocity]: number = 0; update( x: number, xGoal: number, timeStepMilliseconds: number, xNormalization: number): number { if (x == null) { return xGoal; } if (timeStepMilliseconds < 0) { return x; } // Exact solution to a critically damped second-order system, where: // acceleration = NATURAL_FREQUENCY * NATURAL_FREQUENCY * (xGoal - x) - // 2 * NATURAL_FREQUENCY * this[$velocity]; const deltaX = (x - xGoal); const intermediateVelocity = this[$velocity] + NATURAL_FREQUENCY * deltaX; const intermediateX = deltaX + timeStepMilliseconds * intermediateVelocity; const decay = Math.exp(-NATURAL_FREQUENCY * timeStepMilliseconds); const newVelocity = (intermediateVelocity - NATURAL_FREQUENCY * intermediateX) * decay; const acceleration = -NATURAL_FREQUENCY * (newVelocity + intermediateVelocity * decay); if (Math.abs(newVelocity) < NIL_SPEED * xNormalization && acceleration * deltaX >= 0) { // This ensures the controls settle and stop calling this function instead // of asymptotically approaching their goal. this[$velocity] = 0; return xGoal; } else { this[$velocity] = newVelocity; return xGoal + intermediateX * decay; } } } /** * 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 { private[$interactionEnabled]: boolean = false; private[$options]: SmoothControlsOptions; private[$upQuaternion] = new Quaternion(); private[$upQuaternionInverse] = new Quaternion(); private[$isUserChange] = false; private[$spherical] = new Spherical(); private[$goalSpherical] = new Spherical(); private[$thetaDamper] = new Damper(); private[$phiDamper] = new Damper(); private[$radiusDamper] = new Damper(); private[$fov]: number; private[$goalFov]: number; private[$fovDamper] = new Damper(); private[$target] = new Vector3(); private[$pointerIsDown] = false; private[$lastPointerPosition] = new Vector2(); private[$lastTouches]: TouchList; private[$touchMode]: TouchMode; private[$onMouseMove]: (event: Event) => void; private[$onMouseDown]: (event: Event) => void; private[$onMouseUp]: (event: Event) => void; private[$onWheel]: (event: Event) => void; private[$onKeyDown]: (event: Event) => void; private[$onTouchStart]: (event: Event) => void; private[$onTouchEnd]: (event: Event) => void; private[$onTouchMove]: (event: Event) => void; private[$zoomMeters] = 1; constructor( readonly camera: PerspectiveCamera, readonly element: HTMLElement) { super(); this[$upQuaternion].setFromUnitVectors(camera.up, UP); this[$upQuaternionInverse].copy(this[$upQuaternion]).inverse(); this[$onMouseMove] = (event: Event) => this[$handlePointerMove](event as MouseEvent); this[$onMouseDown] = (event: Event) => this[$handlePointerDown](event as MouseEvent); this[$onMouseUp] = (event: Event) => this[$handlePointerUp](event as MouseEvent); this[$onWheel] = (event: Event) => this[$handleWheel](event as WheelEvent); this[$onKeyDown] = (event: Event) => this[$handleKey](event as KeyboardEvent); this[$onTouchStart] = (event: Event) => this[$handlePointerDown](event as TouchEvent); this[$onTouchEnd] = (event: Event) => this[$handlePointerUp](event as TouchEvent); this[$onTouchMove] = (event: Event) => this[$handlePointerMove](event as TouchEvent); this[$options] = Object.assign({}, DEFAULT_OPTIONS); this.setOrbit(0, Math.PI / 2, 1); this.setFov(100); this.jumpToGoal(); } get interactionEnabled(): boolean { return this[$interactionEnabled]; } enableInteraction() { if (this[$interactionEnabled] === false) { const {element} = this; element.addEventListener('mousemove', this[$onMouseMove]); element.addEventListener('mousedown', this[$onMouseDown]); element.addEventListener('wheel', this[$onWheel]); element.addEventListener('keydown', this[$onKeyDown]); element.addEventListener('touchstart', this[$onTouchStart]); element.addEventListener('touchmove', this[$onTouchMove]); self.addEventListener('mouseup', this[$onMouseUp]); self.addEventListener('touchend', this[$onTouchEnd]); this.element.style.cursor = 'grab'; this[$interactionEnabled] = true; } } disableInteraction() { if (this[$interactionEnabled] === true) { const {element} = this; element.removeEventListener('mousemove', this[$onMouseMove]); element.removeEventListener('mousedown', this[$onMouseDown]); element.removeEventListener('wheel', this[$onWheel]); element.removeEventListener('keydown', this[$onKeyDown]); element.removeEventListener('touchstart', this[$onTouchStart]); element.removeEventListener('touchmove', this[$onTouchMove]); self.removeEventListener('mouseup', this[$onMouseUp]); self.removeEventListener('touchend', this[$onTouchEnd]); element.style.cursor = ''; this[$interactionEnabled] = false; } } /** * The options that are currently configured for the controls instance. */ get options() { return this[$options]; } /** * 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: Spherical = new Spherical()) { return target.copy(this[$spherical]); } /** * Returns the camera's current vertical field of view in degrees. */ getFieldOfView(): number { 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: SmoothControlsOptions) { Object.assign(this[$options], options); // Re-evaluates clamping based on potentially new values for min/max // polar, azimuth and radius: this.setOrbit(); // Prevent interpolation in the case that any target spherical values // changed (preserving OrbitalControls behavior): if (this[$isStationary]()) { return; } this[$spherical].copy(this[$goalSpherical]); this[$moveCamera](); } /** * Sets the non-interpolated camera parameters */ updateIntrinsics( nearPlane: number, farPlane: number, aspect: number, zoomSensitivity: number) { this[$zoomMeters] = zoomSensitivity; this.camera.near = nearPlane; this.camera.far = farPlane; 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: number = this[$goalSpherical].theta, goalPhi: number = this[$goalSpherical].phi, goalRadius: number = this[$goalSpherical].radius): boolean { const { minimumAzimuthalAngle, maximumAzimuthalAngle, minimumPolarAngle, maximumPolarAngle, minimumRadius, maximumRadius } = this[$options]; const {theta, phi, radius} = this[$goalSpherical]; const nextTheta = clamp(goalTheta, minimumAzimuthalAngle!, maximumAzimuthalAngle!); 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: number) { this[$goalSpherical].radius = radius; this.setOrbit(); } /** * Sets the goal field of view for the camera */ setFov(fov: number) { const {minimumFov, maximumFov} = this[$options]; this[$goalFov] = clamp(fov, minimumFov!, maximumFov!); } /** * Sets the target the camera is pointing toward */ setTarget(target: Vector3) { if (!this[$target].equals(target)) { this[$target].copy(target); this[$moveCamera](); } } /** * Returns a copy of the target position the camera is pointed toward */ getTarget(): Vector3 { return this[$target].clone(); } /** * Adjust the orbital position of the camera relative to its current orbital * position. */ adjustOrbit(deltaTheta: number, deltaPhi: number, deltaRadius: number): boolean { const {theta, phi, radius} = this[$goalSpherical]; const goalTheta = theta - deltaTheta; const goalPhi = phi - deltaPhi; const goalRadius = radius + deltaRadius; return this.setOrbit(goalTheta, goalPhi, goalRadius); } /** * Move the camera instantly instead of accelerating toward the goal * parameters. */ jumpToGoal() { this.update(0, 100 * DECAY_MILLISECONDS); } /** * 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: number, delta: number) { if (this[$isStationary]()) { return; } const {maximumPolarAngle, maximumRadius, maximumFov} = this[$options]; 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[$fov] = this[$fovDamper].update(this[$fov], this[$goalFov], delta, maximumFov!); this[$moveCamera](); } private[$isStationary](): boolean { return this[$goalSpherical].theta === this[$spherical].theta && this[$goalSpherical].phi === this[$spherical].phi && this[$goalSpherical].radius === this[$spherical].radius && this[$goalFov] === this[$fov]; } private[$moveCamera]() { // Derive the new camera position from the updated spherical: this[$spherical].makeSafe(); this[$sphericalToPosition](this[$spherical], this.camera.position); this.camera.lookAt(this[$target]); if (this.camera.fov !== this[$fov]) { this.camera.fov = this[$fov]; this.camera.updateProjectionMatrix(); } const source = this[$isUserChange] ? ChangeSource.USER_INTERACTION : ChangeSource.NONE; this.dispatchEvent({type: 'change', source}); } private get[$canInteract](): boolean { if (this[$options].interactionPolicy == 'allow-when-focused') { const rootNode = this.element.getRootNode() as Document | ShadowRoot; return rootNode.activeElement === this.element; } return this[$options].interactionPolicy === 'always-allow'; } private[$userAdjustOrbit]( deltaTheta: number, deltaPhi: number, deltaRadius: number): boolean { const handled = this.adjustOrbit(deltaTheta, deltaPhi, deltaRadius); this[$isUserChange] = true; return handled; } private[$pixelLengthToSphericalAngle](pixelLength: number): number { return TAU * pixelLength / this.element.clientHeight; } private[$sphericalToPosition](spherical: Spherical, position: Vector3) { position.setFromSpherical(spherical); position.applyQuaternion(this[$upQuaternionInverse]); position.add(this[$target]); } private[$twoTouchDistance](touchOne: Touch, touchTwo: Touch): number { 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); } private[$handlePointerMove](event: MouseEvent|TouchEvent) { if (!this[$pointerIsDown] || !this[$canInteract]) { return; } let handled = false; // 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 as TouchEvent; 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 radiusDelta = -1 * this[$zoomMeters] * (touchDistance - lastTouchDistance) / 10.0; handled = this[$userAdjustOrbit](0, 0, radiusDelta); } break; case 'rotate': const {clientX: xOne, clientY: yOne} = this[$lastTouches][0]; const {clientX: xTwo, clientY: yTwo} = touches[0]; const deltaTheta = this[$pixelLengthToSphericalAngle](xTwo - xOne); const deltaPhi = this[$pixelLengthToSphericalAngle](yTwo - yOne); handled = this[$userAdjustOrbit](deltaTheta, deltaPhi, 0); break; } this[$lastTouches] = touches; } else { const {clientX: x, clientY: y} = event as MouseEvent; const deltaTheta = this[$pixelLengthToSphericalAngle](x - this[$lastPointerPosition].x); const deltaPhi = this[$pixelLengthToSphericalAngle](y - this[$lastPointerPosition].y); handled = this[$userAdjustOrbit](deltaTheta, deltaPhi, 0.0); this[$lastPointerPosition].set(x, y); } if ((handled || this[$options].eventHandlingBehavior === 'prevent-all') && event.cancelable) { event.preventDefault(); }; } private[$handlePointerDown](event: MouseEvent|TouchEvent) { this[$pointerIsDown] = true; if (TOUCH_EVENT_RE.test(event.type)) { const {touches} = event as TouchEvent; switch (touches.length) { default: case 1: this[$touchMode] = 'rotate'; break; case 2: this[$touchMode] = 'zoom'; break; } this[$lastTouches] = touches; } else { const {clientX: x, clientY: y} = event as MouseEvent; this[$lastPointerPosition].set(x, y); this.element.style.cursor = 'grabbing'; } } private[$handlePointerUp](_event: MouseEvent|TouchEvent) { this.element.style.cursor = 'grab'; this[$pointerIsDown] = false; } private[$handleWheel](event: Event) { if (!this[$canInteract]) { return; } const deltaRadius = (event as WheelEvent).deltaY * this[$zoomMeters] / 10.0; if ((this[$userAdjustOrbit](0, 0, deltaRadius) || this[$options].eventHandlingBehavior === 'prevent-all') && event.cancelable) { event.preventDefault(); } } private[$handleKey](event: KeyboardEvent) { // 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 and eventHandlingBehavior is set to 'prevent-all'. let relevantKey = false; let handled = false; switch (event.keyCode) { case KeyCode.PAGE_UP: relevantKey = true; handled = this[$userAdjustOrbit](0, 0, this[$zoomMeters]); break; case KeyCode.PAGE_DOWN: relevantKey = true; handled = this[$userAdjustOrbit](0, 0, -1 * this[$zoomMeters]); break; case KeyCode.UP: relevantKey = true; handled = this[$userAdjustOrbit](0, -KEYBOARD_ORBIT_INCREMENT, 0); break; case KeyCode.DOWN: relevantKey = true; handled = this[$userAdjustOrbit](0, KEYBOARD_ORBIT_INCREMENT, 0); break; case KeyCode.LEFT: relevantKey = true; handled = this[$userAdjustOrbit](-KEYBOARD_ORBIT_INCREMENT, 0, 0); break; case KeyCode.RIGHT: relevantKey = true; handled = this[$userAdjustOrbit](KEYBOARD_ORBIT_INCREMENT, 0, 0); break; } if (relevantKey && (handled || this[$options].eventHandlingBehavior === 'prevent-all') && event.cancelable) { event.preventDefault(); } } }