@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
483 lines (404 loc) • 17.5 kB
text/typescript
/* @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.
*/
import {property} from 'lit-element';
import ModelViewerElementBase, {$announceModelVisibility, $ariaLabel, $getModelIsVisible, $hasTransitioned, $isElementInViewport, $progressTracker, $scene, $sceneIsReady, $shouldAttemptPreload, $updateSource, $userInputElement, toVector3D, Vector3D} from '../model-viewer-base.js';
import {$loader, CachingGLTFLoader} from '../three-components/CachingGLTFLoader.js';
import {Renderer} from '../three-components/Renderer.js';
import {Constructor, throttle} from '../utilities.js';
import {LoadingStatusAnnouncer} from './loading/status-announcer.js';
export type RevealAttributeValue = 'auto'|'interaction'|'manual';
export type LoadingAttributeValue = 'auto'|'lazy'|'eager';
type DismissalSource = 'interaction';
export const POSTER_TRANSITION_TIME = 300;
export const PROGRESS_BAR_UPDATE_THRESHOLD = 100;
const PROGRESS_MASK_BASE_OPACITY = 0.2;
const DEFAULT_DRACO_DECODER_LOCATION =
'https://www.gstatic.com/draco/versioned/decoders/1.3.6/';
const DEFAULT_KTX2_TRANSCODER_LOCATION =
'https://www.gstatic.com/basis-universal/versioned/2020-12-21-ef70ddd/';
const SPACE_KEY = 32;
const ENTER_KEY = 13;
const RevealStrategy: {[index: string]: RevealAttributeValue} = {
AUTO: 'auto',
INTERACTION: 'interaction',
MANUAL: 'manual'
};
const LoadingStrategy: {[index: string]: LoadingAttributeValue} = {
AUTO: 'auto',
LAZY: 'lazy',
EAGER: 'eager'
};
const PosterDismissalSource: {[index: string]: DismissalSource} = {
INTERACTION: 'interaction'
};
const loadingStatusAnnouncer = new LoadingStatusAnnouncer();
export const $defaultProgressBarElement = Symbol('defaultProgressBarElement');
export const $defaultProgressMaskElement = Symbol('defaultProgressMaskElement');
export const $posterContainerElement = Symbol('posterContainerElement');
export const $defaultPosterElement = Symbol('defaultPosterElement');
const $posterDismissalSource = Symbol('posterDismissalSource');
const $hidePoster = Symbol('hidePoster');
const $modelIsRevealed = Symbol('modelIsRevealed');
const $updateProgressBar = Symbol('updateProgressBar');
const $lastReportedProgress = Symbol('lastReportedProgress');
const $transitioned = Symbol('transitioned');
const $ariaLabelCallToAction = Symbol('ariaLabelCallToAction');
const $onClick = Symbol('onClick');
const $onKeydown = Symbol('onKeydown');
const $onProgress = Symbol('onProgress');
export declare interface LoadingInterface {
poster: string|null;
reveal: RevealAttributeValue;
loading: LoadingAttributeValue;
readonly loaded: boolean;
readonly modelIsVisible: boolean;
dismissPoster(): void;
showPoster(): void;
getDimensions(): Vector3D;
}
export declare interface LoadingStaticInterface {
dracoDecoderLocation: string;
ktx2TranscoderLocation: string;
mapURLs(callback: (url: string) => string): void;
}
interface ModelViewerGlobalConfig {
dracoDecoderLocation?: string;
ktx2TranscoderLocation?: string;
}
/**
* LoadingMixin implements features related to lazy loading, as well as
* presentation details related to the pre-load / pre-render presentation of a
* <model-viewer>
*
* This mixin implements support for models with DRACO-compressed meshes.
* The DRACO decoder will be loaded on-demand if a glTF that uses the DRACO mesh
* compression extension is encountered.
*
* By default, the DRACO decoder will be loaded from a Google CDN. It is
* possible to customize where the decoder is loaded from by defining a global
* configuration option for `<model-viewer>` like so:
*
* ```html
* <script>
* ModelViewerElement = self.ModelViewerElement || {};
* ModelViewerElement.dracoDecoderLocation =
* 'http://example.com/location/of/draco/decoder/files/';
* </script>
* ```
*
* Note that the above configuration strategy must be performed *before* the
* first `<model-viewer>` element is created in the browser. The configuration
* can be done anywhere, but the easiest way to ensure it is done at the right
* time is to do it in the `<head>` of the HTML document. This is the
* recommended way to set the location because it is most compatible with
* scenarios where the `<model-viewer>` library is lazily loaded.
*
* If you absolutely have to set the DRACO decoder location *after* the first
* `<model-viewer>` element is created, you can do it this way:
*
* ```html
* <script>
* const ModelViewerElement = customElements.get('model-viewer');
* ModelViewerElement.dracoDecoderLocation =
* 'http://example.com/location/of/draco/decoder/files/';
* </script>
* ```
*
* Note that the above configuration approach will not work until *after*
* `<model-viewer>` is defined in the browser. Also note that this configuration
* *must* be set *before* the first DRACO model is fully loaded.
*
* It is recommended that users who intend to take advantage of DRACO mesh
* compression consider whether or not it is acceptable for their use case to
* have code side-loaded from a Google CDN. If it is not acceptable, then the
* location must be customized before loading any DRACO models in order to cause
* the decoder to be loaded from an alternative, acceptable location.
*/
export const LoadingMixin = <T extends Constructor<ModelViewerElementBase>>(
ModelViewerElement:
T): Constructor<LoadingInterface, LoadingStaticInterface>&T => {
class LoadingModelViewerElement extends ModelViewerElement {
static set dracoDecoderLocation(value: string) {
CachingGLTFLoader.setDRACODecoderLocation(value);
}
static get dracoDecoderLocation() {
return CachingGLTFLoader.getDRACODecoderLocation();
}
static set ktx2TranscoderLocation(value: string) {
CachingGLTFLoader.setKTX2TranscoderLocation(value);
}
static get ktx2TranscoderLocation() {
return CachingGLTFLoader.getKTX2TranscoderLocation();
}
/**
* If provided, the callback will be passed each resource URL before a
* request is sent. The callback may return the original URL, or a new URL
* to override loading behavior. This behavior can be used to load assets
* from .ZIP files, drag-and-drop APIs, and Data URIs.
*/
static mapURLs(callback: (url: string) => string) {
Renderer.singleton.loader[$loader].manager.setURLModifier(callback);
}
/**
* A URL pointing to the image to use as a poster in scenarios where the
* <model-viewer> is not ready to reveal a rendered model to the viewer.
*/
poster: string|null = null;
/**
* An enumerable attribute describing under what conditions the
* <model-viewer> should reveal a model to the viewer.
*
* The default value is "auto". The only supported alternative values are
* "interaction" and "manual".
*/
reveal: RevealAttributeValue = RevealStrategy.AUTO;
/**
* An enumerable attribute describing under what conditions the
* <model-viewer> should preload a model.
*
* The default value is "auto". The only supported alternative values are
* "lazy" and "eager". Auto is equivalent to lazy, which loads the model
* when it is near the viewport for reveal = "auto", and when interacted
* with for reveal = "interaction". Eager loads the model immediately.
*/
loading: LoadingAttributeValue = LoadingStrategy.AUTO;
/**
* Dismisses the poster, causing the model to load and render if
* necessary. This is currently effectively the same as interacting with
* the poster via user input.
*/
dismissPoster() {
if (this[$sceneIsReady]()) {
this[$hidePoster]();
} else {
this[$posterDismissalSource] = PosterDismissalSource.INTERACTION;
this[$updateSource]();
}
}
/**
* Displays the poster, hiding the 3D model. If this is called after the 3D
* model has been revealed, then it will behave as though
* reveal='interaction', being dismissed either by a user click or a call to
* dismissPoster().
*/
showPoster() {
const posterContainerElement = this[$posterContainerElement];
const defaultPosterElement = this[$defaultPosterElement];
defaultPosterElement.removeAttribute('tabindex');
defaultPosterElement.removeAttribute('aria-hidden');
posterContainerElement.classList.add('show');
const oldVisibility = this.modelIsVisible;
this[$modelIsRevealed] = false;
this[$announceModelVisibility](oldVisibility);
this[$transitioned] = false;
}
/**
* Returns the model's bounding box dimensions in meters, independent of
* turntable rotation.
*/
getDimensions(): Vector3D {
return toVector3D(this[$scene].size);
}
protected[$modelIsRevealed] = false;
protected[$transitioned] = false;
protected[$lastReportedProgress]: number = 0;
protected[$posterDismissalSource]: DismissalSource|null = null;
// TODO: Add this to the shadow root as part of this mixin's
// implementation:
protected[$posterContainerElement]: HTMLElement =
this.shadowRoot!.querySelector('.slot.poster') as HTMLElement;
protected[$defaultPosterElement]: HTMLElement =
this.shadowRoot!.querySelector('#default-poster') as HTMLElement;
protected[$defaultProgressBarElement]: HTMLElement =
this.shadowRoot!.querySelector('#default-progress-bar > .bar') as
HTMLElement;
protected[$defaultProgressMaskElement]: HTMLElement =
this.shadowRoot!.querySelector('#default-progress-bar > .mask') as
HTMLElement;
protected[$ariaLabelCallToAction] =
this[$defaultPosterElement].getAttribute('aria-label');
protected[$updateProgressBar] = throttle((progress: number) => {
const parentNode = this[$defaultProgressBarElement].parentNode as Element;
requestAnimationFrame(() => {
this[$defaultProgressMaskElement].style.opacity =
`${(1.0 - progress) * PROGRESS_MASK_BASE_OPACITY}`;
this[$defaultProgressBarElement].style.transform =
`scaleX(${progress})`;
if (progress === 0) {
// NOTE(cdata): We remove and re-append the progress bar in this
// condition so that the progress bar does not appear to
// transition backwards from the right when we reset to 0 (or
// otherwise <1) progress after having already reached 1 progress
// previously.
parentNode.removeChild(this[$defaultProgressBarElement]);
parentNode.appendChild(this[$defaultProgressBarElement]);
}
// NOTE(cdata): IE11 does not properly respect the second parameter
// of classList.toggle, which this implementation originally used.
// @see https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/11865865/
if (progress === 1.0) {
this[$defaultProgressBarElement].classList.add('hide');
} else {
this[$defaultProgressBarElement].classList.remove('hide');
}
});
}, PROGRESS_BAR_UPDATE_THRESHOLD);
constructor(...args: Array<any>) {
super(...args);
const ModelViewerElement: ModelViewerGlobalConfig =
(self as any).ModelViewerElement || {};
const dracoDecoderLocation = ModelViewerElement.dracoDecoderLocation ||
DEFAULT_DRACO_DECODER_LOCATION;
CachingGLTFLoader.setDRACODecoderLocation(dracoDecoderLocation);
const ktx2TranscoderLocation =
ModelViewerElement.ktx2TranscoderLocation ||
DEFAULT_KTX2_TRANSCODER_LOCATION;
CachingGLTFLoader.setKTX2TranscoderLocation(ktx2TranscoderLocation);
}
connectedCallback() {
super.connectedCallback();
// Fired when a user first clicks the model element. Used to
// change the visibility of a poster image, or start loading
// a model.
this[$posterContainerElement].addEventListener('click', this[$onClick]);
this[$posterContainerElement].addEventListener(
'keydown', this[$onKeydown]);
this[$progressTracker].addEventListener('progress', this[$onProgress]);
loadingStatusAnnouncer.registerInstance(this);
}
disconnectedCallback() {
super.disconnectedCallback();
this[$posterContainerElement].removeEventListener(
'click', this[$onClick]);
this[$posterContainerElement].removeEventListener(
'keydown', this[$onKeydown]);
this[$progressTracker].removeEventListener('progress', this[$onProgress]);
loadingStatusAnnouncer.unregisterInstance(this)
}
async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('poster') && this.poster != null) {
this[$defaultPosterElement].style.backgroundImage =
`url(${this.poster})`;
}
if (changedProperties.has('alt')) {
this[$defaultPosterElement].setAttribute(
'aria-label',
`${this[$ariaLabel]}. ${this[$ariaLabelCallToAction]}`);
}
if (changedProperties.has('reveal') || changedProperties.has('loaded')) {
if (!this[$sceneIsReady]()) {
this[$updateSource]();
}
}
}
[$onClick] = () => {
if (this.reveal === RevealStrategy.MANUAL) {
return;
}
this.dismissPoster();
};
[$onKeydown] = (event: KeyboardEvent) => {
if (this.reveal === RevealStrategy.MANUAL) {
return;
}
switch (event.keyCode) {
// NOTE(cdata): Links and buttons can typically be activated with
// both spacebar and enter to produce a synthetic click action
case SPACE_KEY:
case ENTER_KEY:
this.dismissPoster();
break;
default:
break;
}
};
[$onProgress] = (event: Event) => {
const progress = (event as any).detail.totalProgress;
this[$lastReportedProgress] =
Math.max(progress, this[$lastReportedProgress]);
if (progress === 1.0) {
this[$updateProgressBar].flush();
if (this[$sceneIsReady]() &&
(this[$posterDismissalSource] != null ||
this.reveal === RevealStrategy.AUTO)) {
this[$hidePoster]();
}
}
this[$updateProgressBar](progress);
this.dispatchEvent(
new CustomEvent('progress', {detail: {totalProgress: progress}}));
};
[$shouldAttemptPreload](): boolean {
return !!this.src &&
(this[$posterDismissalSource] != null ||
this.loading === LoadingStrategy.EAGER ||
(this.reveal === RevealStrategy.AUTO && this[$isElementInViewport]));
}
[$sceneIsReady](): boolean {
const {src} = this;
return !!src && super[$sceneIsReady]() &&
this[$lastReportedProgress] === 1.0;
}
[$hidePoster]() {
this[$posterDismissalSource] = null;
const posterContainerElement = this[$posterContainerElement];
const defaultPosterElement = this[$defaultPosterElement];
if (posterContainerElement.classList.contains('show')) {
posterContainerElement.classList.remove('show');
const oldVisibility = this.modelIsVisible;
this[$modelIsRevealed] = true;
this[$announceModelVisibility](oldVisibility);
// We might need to forward focus to our internal canvas, but that
// cannot happen until the poster has completely transitioned away
posterContainerElement.addEventListener('transitionend', () => {
requestAnimationFrame(() => {
this[$transitioned] = true;
const root = this.getRootNode();
// If the <model-viewer> is still focused, forward the focus to
// the canvas that has just been revealed
if (root &&
(root as Document | ShadowRoot).activeElement === this) {
this[$userInputElement].focus();
}
// Ensure that the poster is no longer focusable or visible to
// screen readers
defaultPosterElement.setAttribute('aria-hidden', 'true');
defaultPosterElement.tabIndex = -1;
this.dispatchEvent(new CustomEvent('poster-dismissed'));
});
}, {once: true});
}
}
[$getModelIsVisible]() {
return super[$getModelIsVisible]() && this[$modelIsRevealed];
}
[$hasTransitioned](): boolean {
return super[$hasTransitioned]() && this[$transitioned];
}
async[$updateSource]() {
this[$lastReportedProgress] = 0;
if (this[$scene].currentGLTF == null || this.src == null ||
!this[$shouldAttemptPreload]()) {
// Don't show the poster when switching models.
this.showPoster();
}
await super[$updateSource]();
}
}
return LoadingModelViewerElement;
};