@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
524 lines • 22.1 kB
JavaScript
/*
* 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.
*/
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
import { EventDispatcher, Quaternion, Spherical, Vector2, Vector3 } from 'three';
import { clamp } from '../utilities.js';
export const DEFAULT_OPTIONS = Object.freeze({
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 const ChangeSource = {
USER_INTERACTION: 'user-interaction',
NONE: 'none'
};
/**
* 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 {
constructor() {
this[_a] = 0;
}
update(x, xGoal, timeStepMilliseconds, xNormalization) {
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;
}
}
}
_a = $velocity;
/**
* 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[_b] = false;
this[_c] = new Quaternion();
this[_d] = new Quaternion();
this[_e] = false;
this[_f] = new Spherical();
this[_g] = new Spherical();
this[_h] = new Damper();
this[_j] = new Damper();
this[_k] = new Damper();
this[_l] = new Damper();
this[_m] = new Vector3();
this[_o] = false;
this[_p] = new Vector2();
this[_q] = 1;
this[$upQuaternion].setFromUnitVectors(camera.up, UP);
this[$upQuaternionInverse].copy(this[$upQuaternion]).inverse();
this[$onMouseMove] = (event) => this[$handlePointerMove](event);
this[$onMouseDown] = (event) => this[$handlePointerDown](event);
this[$onMouseUp] = (event) => this[$handlePointerUp](event);
this[$onWheel] = (event) => this[$handleWheel](event);
this[$onKeyDown] = (event) => this[$handleKey](event);
this[$onTouchStart] = (event) => this[$handlePointerDown](event);
this[$onTouchEnd] = (event) => this[$handlePointerUp](event);
this[$onTouchMove] = (event) => this[$handlePointerMove](event);
this[$options] = Object.assign({}, DEFAULT_OPTIONS);
this.setOrbit(0, Math.PI / 2, 1);
this.setFov(100);
this.jumpToGoal();
}
get interactionEnabled() {
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 = 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();
// 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, farPlane, aspect, zoomSensitivity) {
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 = 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);
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
*/
setFov(fov) {
const { minimumFov, maximumFov } = this[$options];
this[$goalFov] = clamp(fov, minimumFov, maximumFov);
}
/**
* Sets the target the camera is pointing toward
*/
setTarget(target) {
if (!this[$target].equals(target)) {
this[$target].copy(target);
this[$moveCamera]();
}
}
/**
* Returns a copy of the target position the camera is pointed toward
*/
getTarget() {
return this[$target].clone();
}
/**
* Adjust the orbital position of the camera relative to its current orbital
* position.
*/
adjustOrbit(deltaTheta, deltaPhi, deltaRadius) {
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, delta) {
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]();
}
[(_b = $interactionEnabled, _c = $upQuaternion, _d = $upQuaternionInverse, _e = $isUserChange, _f = $spherical, _g = $goalSpherical, _h = $thetaDamper, _j = $phiDamper, _k = $radiusDamper, _l = $fovDamper, _m = $target, _o = $pointerIsDown, _p = $lastPointerPosition, _q = $zoomMeters, $isStationary)]() {
return this[$goalSpherical].theta === this[$spherical].theta &&
this[$goalSpherical].phi === this[$spherical].phi &&
this[$goalSpherical].radius === this[$spherical].radius &&
this[$goalFov] === this[$fov];
}
[$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 });
}
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, deltaRadius) {
const handled = this.adjustOrbit(deltaTheta, deltaPhi, deltaRadius);
this[$isUserChange] = true;
return handled;
}
[$pixelLengthToSphericalAngle](pixelLength) {
return TAU * pixelLength / this.element.clientHeight;
}
[$sphericalToPosition](spherical, position) {
position.setFromSpherical(spherical);
position.applyQuaternion(this[$upQuaternionInverse]);
position.add(this[$target]);
}
[$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);
}
[$handlePointerMove](event) {
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;
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;
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();
}
;
}
[$handlePointerDown](event) {
this[$pointerIsDown] = true;
if (TOUCH_EVENT_RE.test(event.type)) {
const { touches } = event;
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;
this[$lastPointerPosition].set(x, y);
this.element.style.cursor = 'grabbing';
}
}
[$handlePointerUp](_event) {
this.element.style.cursor = 'grab';
this[$pointerIsDown] = false;
}
[$handleWheel](event) {
if (!this[$canInteract]) {
return;
}
const deltaRadius = event.deltaY * this[$zoomMeters] / 10.0;
if ((this[$userAdjustOrbit](0, 0, deltaRadius) ||
this[$options].eventHandlingBehavior === 'prevent-all') &&
event.cancelable) {
event.preventDefault();
}
}
[$handleKey](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 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();
}
}
}
//# sourceMappingURL=SmoothControls.js.map