UNPKG

@google/model-viewer

Version:

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

448 lines 21.9 kB
/* @license * Copyright 2019 Google LLC. 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 __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; import { property } from 'lit-element'; import { Spherical, Vector3 } from 'three'; import { style } from '../decorators.js'; import { $ariaLabel, $loadedTime, $needsRender, $onModelLoad, $onResize, $scene, $tick } from '../model-viewer-base.js'; import { normalizeUnit } from '../styles/conversions.js'; import { numberNode, parseExpressions } from '../styles/parsers.js'; import { DEFAULT_FOV_DEG } from '../three-components/Model.js'; import { ChangeSource, SmoothControls } from '../three-components/SmoothControls.js'; import { timeline } from '../utilities/animation.js'; // NOTE(cdata): The following "animation" timing functions are deliberately // being used in favor of CSS animations. In Safari 12.1 and 13, CSS animations // would cause the interaction prompt to glitch unexpectedly // @see https://github.com/GoogleWebComponents/model-viewer/issues/839 const PROMPT_ANIMATION_TIME = 5000; // For timing purposes, a "frame" is a timing agnostic relative unit of time // and a "value" is a target value for the keyframe. const wiggle = timeline(0, [ { frames: 6, value: 0 }, { frames: 5, value: -1 }, { frames: 1, value: -1 }, { frames: 8, value: 1 }, { frames: 1, value: 1 }, { frames: 5, value: 0 }, { frames: 12, value: 0 } ]); const fade = timeline(0, [ { frames: 2, value: 0 }, { frames: 1, value: 1 }, { frames: 5, value: 1 }, { frames: 1, value: 0 }, { frames: 4, value: 0 } ]); export const InteractionPromptStrategy = { AUTO: 'auto', WHEN_FOCUSED: 'when-focused', NONE: 'none' }; export const InteractionPromptStyle = { BASIC: 'basic', WIGGLE: 'wiggle' }; export const InteractionPolicy = { ALWAYS_ALLOW: 'always-allow', WHEN_FOCUSED: 'allow-when-focused' }; export const DEFAULT_CAMERA_ORBIT = '0deg 75deg 105%'; const DEFAULT_CAMERA_TARGET = 'auto auto auto'; const DEFAULT_FIELD_OF_VIEW = 'auto'; export const fieldOfViewIntrinsics = (element) => { return { basis: [numberNode(element[$zoomAdjustedFieldOfView] * Math.PI / 180, 'rad')], keywords: { auto: [null] } }; }; export const cameraOrbitIntrinsics = (() => { const defaultTerms = parseExpressions(DEFAULT_CAMERA_ORBIT)[0] .terms; const theta = normalizeUnit(defaultTerms[0]); const phi = normalizeUnit(defaultTerms[1]); return (element) => { const radius = element[$scene].model.idealCameraDistance; return { basis: [theta, phi, numberNode(radius, 'm')], keywords: { auto: [null, null, numberNode(105, '%')] } }; }; })(); export const cameraTargetIntrinsics = (element) => { const center = element[$scene].model.boundingBox.getCenter(new Vector3); return { basis: [ numberNode(center.x, 'm'), numberNode(center.y, 'm'), numberNode(center.z, 'm') ], keywords: { auto: [null, null, null] } }; }; const HALF_FIELD_OF_VIEW_RADIANS = (DEFAULT_FOV_DEG / 2) * Math.PI / 180; const HALF_PI = Math.PI / 2.0; const THIRD_PI = Math.PI / 3.0; const QUARTER_PI = HALF_PI / 2.0; const PHI = 2.0 * Math.PI; const AZIMUTHAL_QUADRANT_LABELS = ['front', 'right', 'back', 'left']; const POLAR_TRIENT_LABELS = ['upper-', '', 'lower-']; export const DEFAULT_INTERACTION_PROMPT_THRESHOLD = 3000; export const INTERACTION_PROMPT = 'Use mouse, touch or arrow keys to control the camera!'; export const $controls = Symbol('controls'); export const $promptElement = Symbol('promptElement'); export const $promptAnimatedContainer = Symbol('promptAnimatedContainer'); export const $idealCameraDistance = Symbol('idealCameraDistance'); const $framedFieldOfView = Symbol('framedFieldOfView'); const $deferInteractionPrompt = Symbol('deferInteractionPrompt'); const $updateAria = Symbol('updateAria'); const $updateCamera = Symbol('updateCamera'); const $blurHandler = Symbol('blurHandler'); const $focusHandler = Symbol('focusHandler'); const $changeHandler = Symbol('changeHandler'); const $onBlur = Symbol('onBlur'); const $onFocus = Symbol('onFocus'); const $onChange = Symbol('onChange'); const $shouldPromptUserToInteract = Symbol('shouldPromptUserToInteract'); const $waitingToPromptUser = Symbol('waitingToPromptUser'); const $userPromptedOnce = Symbol('userPromptedOnce'); const $promptElementVisibleTime = Symbol('promptElementVisibleTime'); const $lastPromptOffset = Symbol('lastPromptOffset'); const $focusedTime = Symbol('focusedTime'); const $zoomAdjustedFieldOfView = Symbol('zoomAdjustedFieldOfView'); const $lastSpherical = Symbol('lastSpherical'); const $jumpCamera = Symbol('jumpCamera'); const $syncCameraOrbit = Symbol('syncCameraOrbit'); const $syncFieldOfView = Symbol('syncFieldOfView'); const $syncCameraTarget = Symbol('syncCameraTarget'); export const ControlsMixin = (ModelViewerElement) => { var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r; class ControlsModelViewerElement extends ModelViewerElement { constructor() { super(...arguments); this.cameraControls = false; this.cameraOrbit = DEFAULT_CAMERA_ORBIT; this.cameraTarget = DEFAULT_CAMERA_TARGET; this.fieldOfView = DEFAULT_FIELD_OF_VIEW; this.interactionPromptThreshold = DEFAULT_INTERACTION_PROMPT_THRESHOLD; this.interactionPromptStyle = InteractionPromptStyle.WIGGLE; this.interactionPrompt = InteractionPromptStrategy.AUTO; this.interactionPolicy = InteractionPolicy.ALWAYS_ALLOW; this[_a] = this.shadowRoot.querySelector('.interaction-prompt'); this[_b] = this.shadowRoot.querySelector('.interaction-prompt > .animated-container'); this[_c] = Infinity; this[_d] = 0; this[_e] = Infinity; this[_f] = false; this[_g] = false; this[_h] = true; this[_j] = new SmoothControls(this[$scene].getCamera(), this[$scene].canvas); this[_k] = null; this[_l] = DEFAULT_FOV_DEG; this[_m] = new Spherical(); this[_o] = false; this[_p] = (event) => this[$onChange](event); this[_q] = () => this[$onFocus](); this[_r] = () => this[$onBlur](); } getCameraOrbit() { const { theta, phi, radius } = this[$lastSpherical]; return { theta, phi, radius }; } getCameraTarget() { return this[$controls].getTarget(); } getFieldOfView() { return this[$controls].getFieldOfView(); } jumpCameraToGoal() { this[$jumpCamera] = true; this.requestUpdate($jumpCamera, false); } connectedCallback() { super.connectedCallback(); this[$controls].addEventListener('change', this[$changeHandler]); } disconnectedCallback() { super.disconnectedCallback(); this[$controls].removeEventListener('change', this[$changeHandler]); } updated(changedProperties) { super.updated(changedProperties); const controls = this[$controls]; const scene = this[$scene]; if (changedProperties.has('cameraControls')) { if (this.cameraControls) { controls.enableInteraction(); if (this.interactionPrompt === InteractionPromptStrategy.AUTO) { this[$waitingToPromptUser] = true; } scene.canvas.addEventListener('focus', this[$focusHandler]); scene.canvas.addEventListener('blur', this[$blurHandler]); } else { scene.canvas.removeEventListener('focus', this[$focusHandler]); scene.canvas.removeEventListener('blur', this[$blurHandler]); controls.disableInteraction(); this[$deferInteractionPrompt](); } } if (changedProperties.has('interactionPrompt') || changedProperties.has('cameraControls') || changedProperties.has('src')) { if (this.interactionPrompt === InteractionPromptStrategy.AUTO && this.cameraControls) { this[$waitingToPromptUser] = true; } else { this[$deferInteractionPrompt](); } } if (changedProperties.has('interactionPromptStyle')) { this[$promptElement].classList.toggle('wiggle', this.interactionPromptStyle === InteractionPromptStyle.WIGGLE); } if (changedProperties.has('interactionPolicy')) { const interactionPolicy = this.interactionPolicy; controls.applyOptions({ interactionPolicy }); } if (this[$jumpCamera] === true) { Promise.resolve().then(() => { this[$controls].jumpToGoal(); this[$jumpCamera] = false; }); } } [(_a = $promptElement, _b = $promptAnimatedContainer, _c = $focusedTime, _d = $lastPromptOffset, _e = $promptElementVisibleTime, _f = $userPromptedOnce, _g = $waitingToPromptUser, _h = $shouldPromptUserToInteract, _j = $controls, _k = $framedFieldOfView, _l = $zoomAdjustedFieldOfView, _m = $lastSpherical, _o = $jumpCamera, _p = $changeHandler, _q = $focusHandler, _r = $blurHandler, $syncFieldOfView)](style) { this[$controls].setFieldOfView(style[0] * 180 / Math.PI); } [$syncCameraOrbit](style) { this[$controls].setOrbit(style[0], style[1], style[2]); } [$syncCameraTarget](style) { const [x, y, z] = style; const scene = this[$scene]; this[$controls].setTarget(x, y, z); // TODO(#837): Mutating scene.pivotCenter should automatically adjust // pivot rotation scene.pivotCenter.set(x, y, z); scene.setPivotRotation(scene.getPivotRotation()); } [$tick](time, delta) { super[$tick](time, delta); if (this[$waitingToPromptUser] && this.interactionPrompt !== InteractionPromptStrategy.NONE) { const thresholdTime = this.interactionPrompt === InteractionPromptStrategy.AUTO ? this[$loadedTime] : this[$focusedTime]; if (this.loaded && time > thresholdTime + this.interactionPromptThreshold) { this[$scene].canvas.setAttribute('aria-label', INTERACTION_PROMPT); // NOTE(cdata): After notifying users that the controls are // available, we flag that the user has been prompted at least // once, and then effectively stop the idle timer. If the camera // orbit changes after this point, the user will never be prompted // again for this particular <model-element> instance: this[$userPromptedOnce] = true; this[$waitingToPromptUser] = false; this[$promptElementVisibleTime] = time; this[$promptElement].classList.add('visible'); } } if (isFinite(this[$promptElementVisibleTime]) && this.interactionPromptStyle === InteractionPromptStyle.WIGGLE) { const scene = this[$scene]; const animationTime = ((time - this[$promptElementVisibleTime]) / PROMPT_ANIMATION_TIME) % 1; const offset = wiggle(animationTime); const opacity = fade(animationTime); const xOffset = offset * scene.width * 0.05; const deltaTheta = (offset - this[$lastPromptOffset]) * Math.PI / 16; this[$promptAnimatedContainer].style.transform = `translateX(${xOffset}px)`; this[$promptAnimatedContainer].style.opacity = `${opacity}`; this[$controls].adjustOrbit(deltaTheta, 0, 0, 0); this[$lastPromptOffset] = offset; this[$needsRender](); } this[$controls].update(time, delta); const target = this.getCameraTarget(); if (!this[$scene].pivotCenter.equals(target)) { this[$scene].pivotCenter.copy(target); this[$scene].setPivotRotation(this[$scene].getPivotRotation()); } } [$deferInteractionPrompt]() { // Effectively cancel the timer waiting for user interaction: this[$waitingToPromptUser] = false; this[$promptElement].classList.remove('visible'); this[$promptElementVisibleTime] = Infinity; // Implicitly there was some reason to defer the prompt. If the user // has been prompted at least once already, we no longer need to // prompt the user, although if they have never been prompted we // should probably prompt them at least once just in case. if (this[$userPromptedOnce]) { this[$shouldPromptUserToInteract] = false; } } /** * Set the camera's radius and field of view to properly frame the scene * based on changes to the model or aspect ratio, and maintains the * relative camera zoom state. */ [$updateCamera]() { const controls = this[$controls]; const { aspect } = this[$scene]; const { idealCameraDistance, fieldOfViewAspect } = this[$scene].model; const maximumRadius = idealCameraDistance * 2; controls.applyOptions({ maximumRadius }); const modelRadius = idealCameraDistance * Math.sin(HALF_FIELD_OF_VIEW_RADIANS); const near = 0; const far = maximumRadius + modelRadius; controls.updateIntrinsics(near, far, aspect); if (this.fieldOfView === DEFAULT_FIELD_OF_VIEW) { const zoom = (this[$framedFieldOfView] != null) ? controls.getFieldOfView() / this[$framedFieldOfView] : 1; const vertical = Math.tan(HALF_FIELD_OF_VIEW_RADIANS) * Math.max(1, fieldOfViewAspect / aspect); this[$framedFieldOfView] = 2 * Math.atan(vertical) * 180 / Math.PI; const maximumFieldOfView = this[$framedFieldOfView]; controls.applyOptions({ maximumFieldOfView }); // TODO(#835): Move computation of this value to Model or ModelScene this[$zoomAdjustedFieldOfView] = this[$framedFieldOfView] * zoom; this.requestUpdate('fieldOfView', this.fieldOfView); } controls.jumpToGoal(); } [$updateAria]() { // NOTE(cdata): It is possible that we might want to record the // last spherical when the label actually changed. Right now, the // side-effect the current implementation is that we will only // announce the first view change that occurs after the element // becomes focused. const { theta: lastTheta, phi: lastPhi } = this[$lastSpherical]; const { theta, phi } = this[$controls].getCameraSpherical(this[$lastSpherical]); const rootNode = this.getRootNode(); // Only change the aria-label if <model-viewer> is currently focused: if (rootNode != null && rootNode.activeElement === this) { const lastAzimuthalQuadrant = (4 + Math.floor(((lastTheta % PHI) + QUARTER_PI) / HALF_PI)) % 4; const azimuthalQuadrant = (4 + Math.floor(((theta % PHI) + QUARTER_PI) / HALF_PI)) % 4; const lastPolarTrient = Math.floor(lastPhi / THIRD_PI); const polarTrient = Math.floor(phi / THIRD_PI); if (azimuthalQuadrant !== lastAzimuthalQuadrant || polarTrient !== lastPolarTrient) { const { canvas } = this[$scene]; const azimuthalQuadrantLabel = AZIMUTHAL_QUADRANT_LABELS[azimuthalQuadrant]; const polarTrientLabel = POLAR_TRIENT_LABELS[polarTrient]; const ariaLabel = `View from stage ${polarTrientLabel}${azimuthalQuadrantLabel}`; canvas.setAttribute('aria-label', ariaLabel); } } } [$onResize](event) { super[$onResize](event); this[$updateCamera](); } [$onModelLoad](event) { super[$onModelLoad](event); this[$updateCamera](); this.requestUpdate('cameraOrbit', this.cameraOrbit); this.requestUpdate('cameraTarget', this.cameraTarget); this[$controls].jumpToGoal(); } [$onFocus]() { const { canvas } = this[$scene]; if (!isFinite(this[$focusedTime])) { this[$focusedTime] = performance.now(); } // NOTE(cdata): On every re-focus, we switch the aria-label back to // the original, non-prompt label if appropriate. If the user has // already interacted, they no longer need to hear the prompt. // Otherwise, they will hear it again after the idle prompt threshold // has been crossed. const ariaLabel = this[$ariaLabel]; if (canvas.getAttribute('aria-label') !== ariaLabel) { canvas.setAttribute('aria-label', ariaLabel); } // NOTE(cdata): When focused, if the user has yet to interact with the // camera controls (that is, we "should" prompt the user), we begin // the idle timer and indicate that we are waiting for it to cross the // prompt threshold: if (!isFinite(this[$promptElementVisibleTime]) && this[$shouldPromptUserToInteract]) { this[$waitingToPromptUser] = true; } } [$onBlur]() { this[$waitingToPromptUser] = false; this[$promptElement].classList.remove('visible'); this[$promptElementVisibleTime] = Infinity; this[$focusedTime] = Infinity; } [$onChange]({ source }) { this[$updateAria](); this[$needsRender](); if (source === ChangeSource.USER_INTERACTION) { this[$deferInteractionPrompt](); } this.dispatchEvent(new CustomEvent('camera-change', { detail: { source } })); } } __decorate([ property({ type: Boolean, attribute: 'camera-controls' }) ], ControlsModelViewerElement.prototype, "cameraControls", void 0); __decorate([ style({ intrinsics: cameraOrbitIntrinsics, observeEffects: true, updateHandler: $syncCameraOrbit }), property({ type: String, attribute: 'camera-orbit', hasChanged: () => true }) ], ControlsModelViewerElement.prototype, "cameraOrbit", void 0); __decorate([ style({ intrinsics: cameraTargetIntrinsics, observeEffects: true, updateHandler: $syncCameraTarget }), property({ type: String, attribute: 'camera-target', hasChanged: () => true }) ], ControlsModelViewerElement.prototype, "cameraTarget", void 0); __decorate([ style({ intrinsics: fieldOfViewIntrinsics, observeEffects: true, updateHandler: $syncFieldOfView }), property({ type: String, attribute: 'field-of-view', hasChanged: () => true }) ], ControlsModelViewerElement.prototype, "fieldOfView", void 0); __decorate([ property({ type: Number, attribute: 'interaction-prompt-threshold' }) ], ControlsModelViewerElement.prototype, "interactionPromptThreshold", void 0); __decorate([ property({ type: String, attribute: 'interaction-prompt-style' }) ], ControlsModelViewerElement.prototype, "interactionPromptStyle", void 0); __decorate([ property({ type: String, attribute: 'interaction-prompt' }) ], ControlsModelViewerElement.prototype, "interactionPrompt", void 0); __decorate([ property({ type: String, attribute: 'interaction-policy' }) ], ControlsModelViewerElement.prototype, "interactionPolicy", void 0); return ControlsModelViewerElement; }; //# sourceMappingURL=controls.js.map