@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
397 lines • 18.8 kB
JavaScript
/* @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/decorators.js';
import { USDZExporter } from 'three/examples/jsm/exporters/USDZExporter.js';
import { IS_AR_QUICKLOOK_CANDIDATE, IS_SCENEVIEWER_CANDIDATE, IS_WEBXR_AR_CANDIDATE } from '../constants.js';
import { $needsRender, $progressTracker, $renderer, $scene, $shouldAttemptPreload, $updateSource } from '../model-viewer-base.js';
import { enumerationDeserializer } from '../styles/deserializers.js';
import { ARStatus, ARTracking } from '../three-components/ARRenderer.js';
import { waitForEvent } from '../utilities.js';
let isWebXRBlocked = false;
let isSceneViewerBlocked = false;
const noArViewerSigil = '#model-viewer-no-ar-fallback';
const deserializeARModes = enumerationDeserializer(['quick-look', 'scene-viewer', 'webxr', 'none']);
const DEFAULT_AR_MODES = 'webxr scene-viewer quick-look';
const ARMode = {
QUICK_LOOK: 'quick-look',
SCENE_VIEWER: 'scene-viewer',
WEBXR: 'webxr',
NONE: 'none'
};
const $arButtonContainer = Symbol('arButtonContainer');
const $enterARWithWebXR = Symbol('enterARWithWebXR');
export const $openSceneViewer = Symbol('openSceneViewer');
export const $openIOSARQuickLook = Symbol('openIOSARQuickLook');
const $canActivateAR = Symbol('canActivateAR');
const $arMode = Symbol('arMode');
const $arModes = Symbol('arModes');
const $arAnchor = Symbol('arAnchor');
const $preload = Symbol('preload');
const $onARButtonContainerClick = Symbol('onARButtonContainerClick');
const $onARStatus = Symbol('onARStatus');
const $onARTracking = Symbol('onARTracking');
const $onARTap = Symbol('onARTap');
const $selectARMode = Symbol('selectARMode');
const $triggerLoad = Symbol('triggerLoad');
export const ARMixin = (ModelViewerElement) => {
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k;
class ARModelViewerElement extends ModelViewerElement {
constructor() {
super(...arguments);
this.ar = false;
this.arScale = 'auto';
this.arUsdzMaxTextureSize = 'auto';
this.arPlacement = 'floor';
this.arModes = DEFAULT_AR_MODES;
this.iosSrc = null;
this.xrEnvironment = false;
this[_a] = false;
// TODO: Add this to the shadow root as part of this mixin's
// implementation:
this[_b] = this.shadowRoot.querySelector('.ar-button');
this[_c] = document.createElement('a');
this[_d] = new Set();
this[_e] = ARMode.NONE;
this[_f] = false;
this[_g] = (event) => {
event.preventDefault();
this.activateAR();
};
this[_h] = ({ status }) => {
if (status === ARStatus.NOT_PRESENTING ||
this[$renderer].arRenderer.presentedScene === this[$scene]) {
this.setAttribute('ar-status', status);
this.dispatchEvent(new CustomEvent('ar-status', { detail: { status } }));
if (status === ARStatus.NOT_PRESENTING) {
this.removeAttribute('ar-tracking');
}
else if (status === ARStatus.SESSION_STARTED) {
this.setAttribute('ar-tracking', ARTracking.TRACKING);
}
}
};
this[_j] = ({ status }) => {
this.setAttribute('ar-tracking', status);
this.dispatchEvent(new CustomEvent('ar-tracking', { detail: { status } }));
};
this[_k] = (event) => {
if (event.data == '_apple_ar_quicklook_button_tapped') {
this.dispatchEvent(new CustomEvent('quick-look-button-tapped'));
}
};
}
get canActivateAR() {
return this[$arMode] !== ARMode.NONE;
}
connectedCallback() {
super.connectedCallback();
this[$renderer].arRenderer.addEventListener('status', this[$onARStatus]);
this.setAttribute('ar-status', ARStatus.NOT_PRESENTING);
this[$renderer].arRenderer.addEventListener('tracking', this[$onARTracking]);
this[$arAnchor].addEventListener('message', this[$onARTap]);
}
disconnectedCallback() {
super.disconnectedCallback();
this[$renderer].arRenderer.removeEventListener('status', this[$onARStatus]);
this[$renderer].arRenderer.removeEventListener('tracking', this[$onARTracking]);
this[$arAnchor].removeEventListener('message', this[$onARTap]);
}
update(changedProperties) {
super.update(changedProperties);
if (changedProperties.has('arScale')) {
this[$scene].canScale = this.arScale !== 'fixed';
}
if (changedProperties.has('arPlacement')) {
this[$scene].updateShadow();
this[$needsRender]();
}
if (changedProperties.has('arModes')) {
this[$arModes] = deserializeARModes(this.arModes);
}
if (changedProperties.has('ar') || changedProperties.has('arModes') ||
changedProperties.has('src') || changedProperties.has('iosSrc') ||
changedProperties.has('arUsdzMaxTextureSize')) {
this[$selectARMode]();
}
}
/**
* Activates AR. Note that for any mode that is not WebXR-based, this
* method most likely has to be called synchronous from a user
* interaction handler. Otherwise, attempts to activate modes that
* require user interaction will most likely be ignored.
*/
async activateAR() {
switch (this[$arMode]) {
case ARMode.QUICK_LOOK:
await this[$openIOSARQuickLook]();
break;
case ARMode.WEBXR:
await this[$enterARWithWebXR]();
break;
case ARMode.SCENE_VIEWER:
this[$openSceneViewer]();
break;
default:
console.warn('No AR Mode can be activated. This is probably due to missing \
configuration or device capabilities');
break;
}
}
async [(_a = $canActivateAR, _b = $arButtonContainer, _c = $arAnchor, _d = $arModes, _e = $arMode, _f = $preload, _g = $onARButtonContainerClick, _h = $onARStatus, _j = $onARTracking, _k = $onARTap, $selectARMode)]() {
var _l;
let arMode = ARMode.NONE;
if (this.ar) {
if (this.src != null) {
for (const value of this[$arModes]) {
if (value === 'webxr' && IS_WEBXR_AR_CANDIDATE && !isWebXRBlocked &&
await this[$renderer].arRenderer.supportsPresentation()) {
arMode = ARMode.WEBXR;
break;
}
if (value === 'scene-viewer' && !isSceneViewerBlocked &&
(IS_SCENEVIEWER_CANDIDATE ||
(navigator.userAgentData &&
navigator.userAgentData.getHighEntropyValues &&
((_l = (await navigator.userAgentData.getHighEntropyValues([
'formFactor'
])).formFactor) === null || _l === void 0 ? void 0 : _l.includes('XR'))))) {
arMode = ARMode.SCENE_VIEWER;
break;
}
if (value === 'quick-look' && IS_AR_QUICKLOOK_CANDIDATE) {
arMode = ARMode.QUICK_LOOK;
break;
}
}
}
// The presence of ios-src overrides the absence of quick-look
// ar-mode.
if (arMode === ARMode.NONE && this.iosSrc != null &&
IS_AR_QUICKLOOK_CANDIDATE) {
arMode = ARMode.QUICK_LOOK;
}
}
if (arMode !== ARMode.NONE) {
this[$arButtonContainer].classList.add('enabled');
this[$arButtonContainer].addEventListener('click', this[$onARButtonContainerClick]);
}
else if (this[$arButtonContainer].classList.contains('enabled')) {
this[$arButtonContainer].removeEventListener('click', this[$onARButtonContainerClick]);
this[$arButtonContainer].classList.remove('enabled');
// If AR went from working to not, notify the element.
const status = ARStatus.FAILED;
this.setAttribute('ar-status', status);
this.dispatchEvent(new CustomEvent('ar-status', { detail: { status } }));
}
this[$arMode] = arMode;
}
async [$enterARWithWebXR]() {
console.log('Attempting to present in AR with WebXR...');
await this[$triggerLoad]();
try {
this[$arButtonContainer].removeEventListener('click', this[$onARButtonContainerClick]);
const { arRenderer } = this[$renderer];
arRenderer.placeOnWall = this.arPlacement === 'wall';
await arRenderer.present(this[$scene], this.xrEnvironment);
}
catch (error) {
console.warn('Error while trying to present in AR with WebXR');
console.error(error);
await this[$renderer].arRenderer.stopPresenting();
isWebXRBlocked = true;
console.warn('Falling back to next ar-mode');
await this[$selectARMode]();
this.activateAR();
}
finally {
this[$selectARMode]();
}
}
async [$triggerLoad]() {
if (!this.loaded) {
this[$preload] = true;
this[$updateSource]();
await waitForEvent(this, 'load');
this[$preload] = false;
}
}
[$shouldAttemptPreload]() {
return super[$shouldAttemptPreload]() || this[$preload];
}
/**
* Takes a URL and a title string, and attempts to launch Scene Viewer on
* the current device.
*/
[$openSceneViewer]() {
const location = self.location.toString();
const locationUrl = new URL(location);
const modelUrl = new URL(this.src, location);
if (modelUrl.hash)
modelUrl.hash = '';
const params = new URLSearchParams(modelUrl.search);
locationUrl.hash = noArViewerSigil;
// modelUrl can contain title/link/sound etc.
params.set('mode', 'ar_preferred');
if (!params.has('disable_occlusion')) {
params.set('disable_occlusion', 'true');
}
if (this.arScale === 'fixed') {
params.set('resizable', 'false');
}
if (this.arPlacement === 'wall') {
params.set('enable_vertical_placement', 'true');
}
if (params.has('sound')) {
const soundUrl = new URL(params.get('sound'), location);
params.set('sound', soundUrl.toString());
}
if (params.has('link')) {
const linkUrl = new URL(params.get('link'), location);
params.set('link', linkUrl.toString());
}
const intent = `intent://arvr.google.com/scene-viewer/1.2?${params.toString() + '&file=' +
encodeURIComponent(modelUrl
.toString())}#Intent;scheme=https;package=com.google.android.googlequicksearchbox;action=android.intent.action.VIEW;S.browser_fallback_url=${encodeURIComponent(locationUrl.toString())};end;`;
const undoHashChange = () => {
if (self.location.hash === noArViewerSigil) {
isSceneViewerBlocked = true;
// The new history will be the current URL with a new hash.
// Go back one step so that we reset to the expected URL.
// NOTE(cdata): this should not invoke any browser-level navigation
// because hash-only changes modify the URL in-place without
// navigating:
self.history.back();
console.warn('Error while trying to present in AR with Scene Viewer');
console.warn('Falling back to next ar-mode');
this[$selectARMode]();
// Would be nice to activateAR() here, but webXR fails due to not
// seeing a user activation.
}
};
self.addEventListener('hashchange', undoHashChange, { once: true });
this[$arAnchor].setAttribute('href', intent);
console.log('Attempting to present in AR with Scene Viewer...');
this[$arAnchor].click();
}
/**
* Takes a URL to a USDZ file and sets the appropriate fields so that
* Safari iOS can intent to their AR Quick Look.
*/
async [$openIOSARQuickLook]() {
const generateUsdz = !this.iosSrc;
this[$arButtonContainer].classList.remove('enabled');
const objectURL = generateUsdz ? await this.prepareUSDZ() : this.iosSrc;
const modelUrl = new URL(objectURL, self.location.toString());
if (generateUsdz) {
const location = self.location.toString();
const locationUrl = new URL(location);
const srcUrl = new URL(this.src, locationUrl);
if (srcUrl.hash) {
modelUrl.hash = srcUrl.hash;
}
}
if (this.arScale === 'fixed') {
if (modelUrl.hash) {
modelUrl.hash += '&';
}
modelUrl.hash += 'allowsContentScaling=0';
}
const anchor = this[$arAnchor];
anchor.setAttribute('rel', 'ar');
const img = document.createElement('img');
anchor.appendChild(img);
anchor.setAttribute('href', modelUrl.toString());
if (generateUsdz) {
anchor.setAttribute('download', 'model.usdz');
}
// attach anchor to shadow DOM to ensure iOS16 ARQL banner click message
// event propagation
anchor.style.display = 'none';
if (!anchor.isConnected)
this.shadowRoot.appendChild(anchor);
console.log('Attempting to present in AR with Quick Look...');
anchor.click();
anchor.removeChild(img);
if (generateUsdz) {
URL.revokeObjectURL(objectURL);
}
this[$arButtonContainer].classList.add('enabled');
}
async prepareUSDZ() {
const updateSourceProgress = this[$progressTracker].beginActivity('usdz-conversion');
await this[$triggerLoad]();
const { model, shadow, target } = this[$scene];
if (model == null) {
return '';
}
let visible = false;
// Remove shadow from export
if (shadow != null) {
visible = shadow.visible;
shadow.visible = false;
}
updateSourceProgress(0.2);
const exporter = new USDZExporter();
target.remove(model);
model.position.copy(target.position);
model.updateWorldMatrix(false, true);
const arraybuffer = await exporter.parseAsync(model, {
maxTextureSize: isNaN(this.arUsdzMaxTextureSize) ?
Infinity :
Math.max(parseInt(this.arUsdzMaxTextureSize), 16),
});
model.position.set(0, 0, 0);
target.add(model);
const blob = new Blob([arraybuffer], {
type: 'model/vnd.usdz+zip',
});
const url = URL.createObjectURL(blob);
updateSourceProgress(1);
if (shadow != null) {
shadow.visible = visible;
}
return url;
}
}
__decorate([
property({ type: Boolean, attribute: 'ar' })
], ARModelViewerElement.prototype, "ar", void 0);
__decorate([
property({ type: String, attribute: 'ar-scale' })
], ARModelViewerElement.prototype, "arScale", void 0);
__decorate([
property({ type: String, attribute: 'ar-usdz-max-texture-size' })
], ARModelViewerElement.prototype, "arUsdzMaxTextureSize", void 0);
__decorate([
property({ type: String, attribute: 'ar-placement' })
], ARModelViewerElement.prototype, "arPlacement", void 0);
__decorate([
property({ type: String, attribute: 'ar-modes' })
], ARModelViewerElement.prototype, "arModes", void 0);
__decorate([
property({ type: String, attribute: 'ios-src' })
], ARModelViewerElement.prototype, "iosSrc", void 0);
__decorate([
property({ type: Boolean, attribute: 'xr-environment' })
], ARModelViewerElement.prototype, "xrEnvironment", void 0);
return ARModelViewerElement;
};
//# sourceMappingURL=ar.js.map