@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
924 lines (803 loc) • 29.6 kB
text/typescript
/* @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, Event as ThreeEvent, EventDispatcher, Matrix3, PerspectiveCamera, Spherical, Vector2, Vector3} from 'three';
import {$panElement, TouchAction} from '../features/controls.js';
import {clamp} from '../utilities.js';
import {Damper, SETTLING_TIME} from './Damper.js';
import {ModelScene} from './ModelScene.js';
const PAN_SENSITIVITY = 0.018;
const TAP_DISTANCE = 2;
const TAP_MS = 300;
const vector2 = new Vector2();
const vector3 = new Vector3();
export type TouchMode = null|((dx: number, dy: number) => void);
export interface Pointer {
clientX: number;
clientY: number;
id: number;
}
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
minimumFieldOfView?: number;
// The maximum camera field of view in degrees
maximumFieldOfView?: number;
// Controls scrolling behavior
touchAction?: TouchAction;
}
export const DEFAULT_OPTIONS = Object.freeze<SmoothControlsOptions>({
minimumRadius: 0,
maximumRadius: Infinity,
minimumPolarAngle: 0,
maximumPolarAngle: Math.PI,
minimumAzimuthalAngle: -Infinity,
maximumAzimuthalAngle: Infinity,
minimumFieldOfView: 10,
maximumFieldOfView: 45,
touchAction: 'none'
});
// Constants
const KEYBOARD_ORBIT_INCREMENT = Math.PI / 8;
const ZOOM_SENSITIVITY = 0.04;
// The move size on pan key event
const PAN_KEY_INCREMENT = 10;
export const KeyCode = {
PAGE_UP: 33,
PAGE_DOWN: 34,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40
};
export type ChangeSource = 'user-interaction'|'none'|'automatic';
export const ChangeSource: {[index: string]: ChangeSource} = {
USER_INTERACTION: 'user-interaction',
NONE: 'none',
AUTOMATIC: 'automatic'
};
/**
* ChangEvents are dispatched whenever the camera position or orientation has
* changed
*/
export interface ChangeEvent extends ThreeEvent {
/**
* determines what was the originating reason for the change event eg user or
* none
*/
source: ChangeSource,
}
export interface PointerChangeEvent extends ThreeEvent {
type: 'pointer-change-start'|'pointer-change-end';
}
/**
* 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<{
'user-interaction': {},
'pointer-change-start': {},
'pointer-change-end': {}
}> {
public orbitSensitivity = 1;
public zoomSensitivity = 1;
public panSensitivity = 1;
public inputSensitivity = 1;
public changeSource = ChangeSource.NONE;
private _interactionEnabled: boolean = false;
private _options: SmoothControlsOptions;
private _disableZoom = false;
private isUserPointing = false;
// Pan state
public enablePan = true;
public enableTap = true;
private panProjection = new Matrix3();
private panPerPixel = 0;
// Internal orbital position state
private spherical = new Spherical();
private goalSpherical = new Spherical();
private thetaDamper = new Damper();
private phiDamper = new Damper();
private radiusDamper = new Damper();
private logFov = Math.log(DEFAULT_OPTIONS.maximumFieldOfView!);
private goalLogFov = this.logFov;
private fovDamper = new Damper();
// Pointer state
private touchMode: TouchMode = null;
private pointers: Pointer[] = [];
private startTime = 0;
private startPointerPosition = {clientX: 0, clientY: 0};
private lastSeparation = 0;
private touchDecided = false;
constructor(
readonly camera: PerspectiveCamera, readonly element: HTMLElement,
readonly scene: ModelScene) {
super();
this._options = Object.assign({}, DEFAULT_OPTIONS);
this.setOrbit(0, Math.PI / 2, 1);
this.setFieldOfView(100);
this.jumpToGoal();
}
get interactionEnabled(): boolean {
return this._interactionEnabled;
}
enableInteraction() {
if (this._interactionEnabled === false) {
const {element} = this;
element.addEventListener('pointerdown', this.onPointerDown);
element.addEventListener('pointercancel', this.onPointerUp);
if (!this._disableZoom) {
element.addEventListener('wheel', this.onWheel);
}
element.addEventListener('keydown', this.onKeyDown);
// This little beauty is to work around a WebKit bug that otherwise makes
// touch events randomly not cancelable.
element.addEventListener('touchmove', () => {}, {passive: false});
element.addEventListener('contextmenu', this.onContext);
this.element.style.cursor = 'grab';
this._interactionEnabled = true;
this.updateTouchActionStyle();
}
}
disableInteraction() {
if (this._interactionEnabled === true) {
const {element} = this;
element.removeEventListener('pointerdown', this.onPointerDown);
element.removeEventListener('pointermove', this.onPointerMove);
element.removeEventListener('pointerup', this.onPointerUp);
element.removeEventListener('pointercancel', this.onPointerUp);
element.removeEventListener('wheel', this.onWheel);
element.removeEventListener('keydown', this.onKeyDown);
element.removeEventListener('contextmenu', this.onContext);
element.style.cursor = '';
this.touchMode = null;
this._interactionEnabled = false;
this.updateTouchActionStyle();
}
}
/**
* The options that are currently configured for the controls instance.
*/
get options() {
return this._options;
}
onContext = (event: MouseEvent) => {
if (this.enablePan) {
event.preventDefault();
} else {
for (const pointer of this.pointers) {
// Required because of a common browser bug where the context menu never
// fires a pointercancel event.
this.onPointerUp(new PointerEvent(
'pointercancel',
{...this.startPointerPosition, pointerId: pointer.id}));
}
}
};
set disableZoom(disable: boolean) {
if (this._disableZoom != disable) {
this._disableZoom = disable;
if (disable === true) {
this.element.removeEventListener('wheel', this.onWheel);
} else {
this.element.addEventListener('wheel', this.onWheel);
}
this.updateTouchActionStyle();
}
}
/**
* 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();
this.setFieldOfView(Math.exp(this.goalLogFov));
}
/**
* Sets the near and far planes of the camera.
*/
updateNearFar(nearPlane: number, farPlane: number) {
this.camera.far = farPlane === 0 ? 2 : farPlane;
this.camera.near = Math.max(nearPlane, this.camera.far / 1000);
this.camera.updateProjectionMatrix();
}
/**
* Sets the aspect ratio of the camera
*/
updateAspect(aspect: number) {
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!);
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;
}
if (!isFinite(nextTheta) || !isFinite(nextPhi) || !isFinite(nextRadius)) {
return false;
}
this.goalSpherical.theta = nextTheta;
this.goalSpherical.phi = nextPhi;
this.goalSpherical.radius = nextRadius;
this.goalSpherical.makeSafe();
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
*/
setFieldOfView(fov: number) {
const {minimumFieldOfView, maximumFieldOfView} = this._options;
fov = clamp(fov, minimumFieldOfView!, maximumFieldOfView!);
this.goalLogFov = Math.log(fov);
}
/**
* Sets the smoothing decay time.
*/
setDamperDecayTime(decayMilliseconds: number) {
this.thetaDamper.setDecayTime(decayMilliseconds);
this.phiDamper.setDecayTime(decayMilliseconds);
this.radiusDamper.setDecayTime(decayMilliseconds);
this.fovDamper.setDecayTime(decayMilliseconds);
}
/**
* 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: number, deltaPhi: number, deltaZoom: number) {
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! : minimumRadius!) - radius) /
(Math.log(
deltaZoom > 0 ? maximumFieldOfView! : minimumFieldOfView!) -
this.goalLogFov);
const goalRadius = radius +
deltaZoom *
(isFinite(deltaRatio) ? deltaRatio :
(maximumRadius! - minimumRadius!) * 2);
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. Returns false if the camera did not move.
*
* Time and delta are measured in milliseconds.
*/
update(_time: number, delta: number): boolean {
if (this.isStationary()) {
return false;
}
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();
return true;
}
updateTouchActionStyle() {
const {style} = this.element;
if (this._interactionEnabled) {
const {touchAction} = this._options;
if (this._disableZoom && touchAction !== 'none') {
style.touchAction = 'manipulation';
} else {
style.touchAction = touchAction!;
}
} else {
style.touchAction = '';
}
}
private isStationary(): boolean {
return this.goalSpherical.theta === this.spherical.theta &&
this.goalSpherical.phi === this.spherical.phi &&
this.goalSpherical.radius === this.spherical.radius &&
this.goalLogFov === this.logFov;
}
private 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();
}
}
private userAdjustOrbit(
deltaTheta: number, deltaPhi: number, deltaZoom: number) {
this.adjustOrbit(
deltaTheta * this.orbitSensitivity * this.inputSensitivity,
deltaPhi * this.orbitSensitivity * this.inputSensitivity,
deltaZoom * this.inputSensitivity);
}
// Wraps to between -pi and pi
private wrapAngle(radians: number): number {
const normalized = (radians + Math.PI) / (2 * Math.PI);
const wrapped = normalized - Math.floor(normalized);
return wrapped * 2 * Math.PI - Math.PI;
}
private pixelLengthToSphericalAngle(pixelLength: number): number {
return 2 * Math.PI * pixelLength / this.scene.height;
}
private twoTouchDistance(touchOne: Pointer, touchTwo: Pointer): 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 touchModeZoom: TouchMode = (dx: number, dy: number) => {
if (!this._disableZoom) {
const touchDistance =
this.twoTouchDistance(this.pointers[0], this.pointers[1]);
const deltaZoom = ZOOM_SENSITIVITY * this.zoomSensitivity *
(this.lastSeparation - touchDistance) * 50 / this.scene.height;
this.lastSeparation = touchDistance;
this.userAdjustOrbit(0, 0, deltaZoom);
}
if (this.panPerPixel > 0) {
this.movePan(dx, dy);
}
};
// We implement our own version of the browser's CSS touch-action, enforced by
// this function, because the iOS implementation of pan-y is bad and doesn't
// match Android. Specifically, even if a touch gesture begins by panning X,
// iOS will switch to scrolling as soon as the gesture moves in the Y, rather
// than staying in the same mode until the end of the gesture.
private disableScroll = (event: TouchEvent) => {
event.preventDefault();
};
private touchModeRotate: TouchMode = (dx: number, dy: number) => {
const {touchAction} = this._options;
if (!this.touchDecided && touchAction !== 'none') {
this.touchDecided = true;
const dxMag = Math.abs(dx);
const dyMag = Math.abs(dy);
// If motion is mostly vertical, assume scrolling is the intent.
if (this.changeSource === ChangeSource.USER_INTERACTION &&
((touchAction === 'pan-y' && dyMag > dxMag) ||
(touchAction === 'pan-x' && dxMag > dyMag))) {
this.touchMode = null;
return;
} else {
this.element.addEventListener(
'touchmove', this.disableScroll, {passive: false});
}
}
this.handleSinglePointerMove(dx, dy);
};
private handleSinglePointerMove(dx: number, dy: number) {
const deltaTheta = this.pixelLengthToSphericalAngle(dx);
const deltaPhi = this.pixelLengthToSphericalAngle(dy);
if (this.isUserPointing === false) {
this.isUserPointing = true;
this.dispatchEvent({type: 'pointer-change-start'});
}
this.userAdjustOrbit(deltaTheta, deltaPhi, 0);
}
private initializePan() {
const {theta, phi} = this.spherical;
const psi = theta - this.scene.yaw;
this.panPerPixel =
PAN_SENSITIVITY * this.panSensitivity / this.scene.height;
this.panProjection.set(
-Math.cos(psi),
-Math.cos(phi) * Math.sin(psi),
0,
0,
Math.sin(phi),
0,
Math.sin(psi),
-Math.cos(phi) * Math.cos(psi),
0);
}
private movePan(dx: number, dy: number) {
const {scene} = this;
const dxy = vector3.set(dx, dy, 0).multiplyScalar(this.inputSensitivity);
const metersPerPixel =
this.spherical.radius * Math.exp(this.logFov) * this.panPerPixel;
dxy.multiplyScalar(metersPerPixel);
const target = scene.getTarget();
target.add(dxy.applyMatrix3(this.panProjection));
scene.boundingSphere.clampPoint(target, target);
scene.setTarget(target.x, target.y, target.z);
}
private recenter(pointer: PointerEvent) {
if (performance.now() > this.startTime + TAP_MS ||
Math.abs(pointer.clientX - this.startPointerPosition.clientX) >
TAP_DISTANCE ||
Math.abs(pointer.clientY - this.startPointerPosition.clientY) >
TAP_DISTANCE) {
return;
}
const {scene} = this;
const hit = scene.positionAndNormalFromPoint(
scene.getNDC(pointer.clientX, pointer.clientY));
if (hit == null) {
const {cameraTarget} = scene.element;
scene.element.cameraTarget = '';
scene.element.cameraTarget = cameraTarget;
// Zoom all the way out.
this.userAdjustOrbit(0, 0, 1);
} else {
scene.target.worldToLocal(hit.position);
scene.setTarget(hit.position.x, hit.position.y, hit.position.z);
}
}
private resetRadius() {
const {scene} = this;
const hit = scene.positionAndNormalFromPoint(vector2.set(0, 0));
if (hit == null) {
return;
}
scene.target.worldToLocal(hit.position);
const goalTarget = scene.getTarget();
const {theta, phi} = this.spherical;
// Set target to surface hit point, except the target is still settling,
// so offset the goal accordingly so the transition is smooth even though
// this will drift the target slightly away from the hit point.
const psi = theta - scene.yaw;
const n = vector3.set(
Math.sin(phi) * Math.sin(psi),
Math.cos(phi),
Math.sin(phi) * Math.cos(psi));
const dr = n.dot(hit.position.sub(goalTarget));
goalTarget.add(n.multiplyScalar(dr));
scene.setTarget(goalTarget.x, goalTarget.y, goalTarget.z);
// Change the camera radius to match the change in target so that the
// camera itself does not move, unless it hits a radius bound.
this.setOrbit(undefined, undefined, this.goalSpherical.radius - dr);
}
private onPointerDown = (event: PointerEvent) => {
if (this.pointers.length > 2) {
return;
}
const {element} = this;
if (this.pointers.length === 0) {
element.addEventListener('pointermove', this.onPointerMove);
element.addEventListener('pointerup', this.onPointerUp);
this.touchMode = null;
this.touchDecided = false;
this.startPointerPosition.clientX = event.clientX;
this.startPointerPosition.clientY = event.clientY;
this.startTime = performance.now();
}
try {
element.setPointerCapture(event.pointerId);
} catch {
}
this.pointers.push(
{clientX: event.clientX, clientY: event.clientY, id: event.pointerId});
this.isUserPointing = false;
if (event.pointerType === 'touch') {
this.changeSource = event.altKey ? // set by interact() in controls.ts
ChangeSource.AUTOMATIC :
ChangeSource.USER_INTERACTION;
this.onTouchChange(event);
} else {
this.changeSource = ChangeSource.USER_INTERACTION;
this.onMouseDown(event);
}
if (this.changeSource === ChangeSource.USER_INTERACTION) {
this.dispatchEvent({type: 'user-interaction'});
}
};
private onPointerMove = (event: PointerEvent) => {
const pointer =
this.pointers.find((pointer) => pointer.id === event.pointerId);
if (pointer == null) {
return;
}
// In case no one gave us a pointerup or pointercancel event.
if (event.pointerType === 'mouse' && event.buttons === 0) {
this.onPointerUp(event);
return;
}
const numTouches = this.pointers.length;
const dx = (event.clientX - pointer.clientX) / numTouches;
const dy = (event.clientY - pointer.clientY) / numTouches;
if (dx === 0 && dy === 0) {
return;
}
pointer.clientX = event.clientX;
pointer.clientY = event.clientY;
if (event.pointerType === 'touch') {
this.changeSource = event.altKey ? // set by interact() in controls.ts
ChangeSource.AUTOMATIC :
ChangeSource.USER_INTERACTION;
if (this.touchMode !== null) {
this.touchMode(dx, dy);
}
} else {
this.changeSource = ChangeSource.USER_INTERACTION;
if (this.panPerPixel > 0) {
this.movePan(dx, dy);
} else {
this.handleSinglePointerMove(dx, dy);
}
}
};
private onPointerUp = (event: PointerEvent) => {
const {element} = this;
const index =
this.pointers.findIndex((pointer) => pointer.id === event.pointerId);
if (index !== -1) {
this.pointers.splice(index, 1);
}
// altKey indicates an interaction prompt; don't reset radius in this case
// as it will cause the camera to drift.
if (this.panPerPixel > 0 && !event.altKey) {
this.resetRadius();
}
if (this.pointers.length === 0) {
element.removeEventListener('pointermove', this.onPointerMove);
element.removeEventListener('pointerup', this.onPointerUp);
element.removeEventListener('touchmove', this.disableScroll);
if (this.enablePan && this.enableTap) {
this.recenter(event);
}
} else if (this.touchMode !== null) {
this.onTouchChange(event);
}
(this.scene.element as any)[$panElement].style.opacity = 0;
element.style.cursor = 'grab';
this.panPerPixel = 0;
if (this.isUserPointing) {
this.dispatchEvent({type: 'pointer-change-end'});
}
};
private onTouchChange(event: PointerEvent) {
if (this.pointers.length === 1) {
this.touchMode = this.touchModeRotate;
} else {
if (this._disableZoom) {
this.touchMode = null;
this.element.removeEventListener('touchmove', this.disableScroll);
return;
}
this.touchMode = (this.touchDecided && this.touchMode === null) ?
null :
this.touchModeZoom;
this.touchDecided = true;
this.element.addEventListener(
'touchmove', this.disableScroll, {passive: false});
this.lastSeparation =
this.twoTouchDistance(this.pointers[0], this.pointers[1]);
if (this.enablePan && this.touchMode != null) {
this.initializePan();
if (!event.altKey) { // user interaction, not prompt
(this.scene.element as any)[$panElement].style.opacity = 1;
}
}
}
}
private onMouseDown(event: MouseEvent) {
this.panPerPixel = 0;
if (this.enablePan &&
(event.button === 2 || event.ctrlKey || event.metaKey ||
event.shiftKey)) {
this.initializePan();
(this.scene.element as any)[$panElement].style.opacity = 1;
}
this.element.style.cursor = 'grabbing';
}
private onWheel = (event: Event) => {
this.changeSource = ChangeSource.USER_INTERACTION;
const deltaZoom = (event as WheelEvent).deltaY *
((event as WheelEvent).deltaMode == 1 ? 18 : 1) * ZOOM_SENSITIVITY *
this.zoomSensitivity / 30;
this.userAdjustOrbit(0, 0, deltaZoom);
event.preventDefault();
this.dispatchEvent({type: 'user-interaction'});
};
private onKeyDown = (event: KeyboardEvent) => {
// We track if the key is actually one we respond to, so as not to
// accidentally clobber unrelated key inputs when the <model-viewer> has
// focus.
const {changeSource} = this;
this.changeSource = ChangeSource.USER_INTERACTION;
const relevantKey = (event.shiftKey && this.enablePan) ?
this.panKeyCodeHandler(event) :
this.orbitZoomKeyCodeHandler(event);
if (relevantKey) {
event.preventDefault();
this.dispatchEvent({type: 'user-interaction'});
} else {
this.changeSource = changeSource;
}
};
/**
* Handles the orbit and Zoom key presses
* Uses constants for the increment.
* @param event The keyboard event for the .key value
* @returns boolean to indicate if the key event has been handled
*/
private orbitZoomKeyCodeHandler(event: KeyboardEvent) {
let relevantKey = true;
switch (event.key) {
case 'PageUp':
this.userAdjustOrbit(0, 0, ZOOM_SENSITIVITY * this.zoomSensitivity);
break;
case 'PageDown':
this.userAdjustOrbit(
0, 0, -1 * ZOOM_SENSITIVITY * this.zoomSensitivity);
break;
case 'ArrowUp':
this.userAdjustOrbit(0, -KEYBOARD_ORBIT_INCREMENT, 0);
break;
case 'ArrowDown':
this.userAdjustOrbit(0, KEYBOARD_ORBIT_INCREMENT, 0);
break;
case 'ArrowLeft':
this.userAdjustOrbit(-KEYBOARD_ORBIT_INCREMENT, 0, 0);
break;
case 'ArrowRight':
this.userAdjustOrbit(KEYBOARD_ORBIT_INCREMENT, 0, 0);
break;
default:
relevantKey = false;
break;
}
return relevantKey;
}
/**
* Handles the Pan key presses
* Uses constants for the increment.
* @param event The keyboard event for the .key value
* @returns boolean to indicate if the key event has been handled
*/
private panKeyCodeHandler(event: KeyboardEvent) {
this.initializePan();
let relevantKey = true;
switch (event.key) {
case 'ArrowUp':
this.movePan(
0,
-1 * PAN_KEY_INCREMENT); // This is the negative one so that the
// model appears to move as the arrow
// direction rather than the view moving
break;
case 'ArrowDown':
this.movePan(0, PAN_KEY_INCREMENT);
break;
case 'ArrowLeft':
this.movePan(-1 * PAN_KEY_INCREMENT, 0);
break;
case 'ArrowRight':
this.movePan(PAN_KEY_INCREMENT, 0);
break;
default:
relevantKey = false;
break;
}
return relevantKey;
}
}