@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
1,288 lines • 54.4 kB
JavaScript
/* @license
* Copyright 2025 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 { AnimationMixer, Box3, Euler, LoopOnce, LoopPingPong, LoopRepeat, Matrix3, Matrix4, NeutralToneMapping, Object3D, PerspectiveCamera, Raycaster, Scene, Sphere, Triangle, Vector2, Vector3 } from 'three';
import { CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
import { reduceVertices } from 'three/examples/jsm/utils/SceneUtils.js';
import { $currentGLTF, $model, $originalGltfJson } from '../features/scene-graph.js';
import { $nodeFromIndex, $nodeFromPoint } from '../features/scene-graph/model.js';
import { $renderer, $scene } from '../model-viewer-base.js';
import { normalizeUnit } from '../styles/conversions.js';
import { parseExpressions } from '../styles/parsers.js';
import { Damper, SETTLING_TIME } from './Damper.js';
import { GroundedSkybox } from './GroundedSkybox.js';
import { Hotspot } from './Hotspot.js';
import { Shadow } from './Shadow.js';
export const GROUNDED_SKYBOX_SIZE = 10;
const MIN_SHADOW_RATIO = 100;
export const IlluminationRole = {
Primary: 'primary',
Secondary: 'secondary'
};
const view = new Vector3();
const target = new Vector3();
const normalWorld = new Vector3();
const raycaster = new Raycaster();
const vector3 = new Vector3();
const ndc = new Vector2();
/**
* A THREE.Scene object that takes a Model and CanvasHTMLElement and
* constructs a framed scene based off of the canvas dimensions.
* Provides lights and cameras to be used in a renderer.
*/
export class ModelScene extends Scene {
constructor({ canvas, element, width, height }) {
super();
this.annotationRenderer = new CSS2DRenderer();
this.effectRenderer = null;
this.schemaElement = document.createElement('script');
this.width = 1;
this.height = 1;
this.aspect = 1;
this.scaleStep = 0;
this.renderCount = 0;
this.externalRenderer = null;
this.appendedAnimations = [];
this.markedAnimations = [];
// These default camera values are never used, as they are reset once the
// model is loaded and framing is computed.
this.camera = new PerspectiveCamera(45, 1, 0.1, 100);
this.xrCamera = null;
this.url = null;
this.extraUrls = [];
this.scenePivot = new Object3D();
this.target = new Object3D();
this.animationNames = [];
this.boundingBox = new Box3();
this.boundingSphere = new Sphere();
this.size = new Vector3();
this.idealAspect = 0;
this.framedFoVDeg = 0;
this.shadow = null;
this.shadowIntensity = 0;
this.shadowSoftness = 1;
this.bakedShadows = new Set();
this.exposure = 1;
this.toneMapping = NeutralToneMapping;
this.canScale = true;
this.isDirty = false;
this.goalTarget = new Vector3();
this.targetDamperX = new Damper();
this.targetDamperY = new Damper();
this.targetDamperZ = new Damper();
this._currentGLTFs = [];
this._models = [];
this.boundsAndShadowDirty = false;
this.mixers = [];
this.mixerPausedStates = [];
this.cancelPendingSourceChange = null;
this.animationsByName = new Map();
this.currentAnimationActions = [];
this.groundedSkybox = new GroundedSkybox();
this.name = 'ModelScene';
this.element = element;
this.canvas = canvas;
// These default camera values are never used, as they are reset once the
// model is loaded and framing is computed.
this.camera = new PerspectiveCamera(45, 1, 0.1, 100);
this.camera.name = 'MainCamera';
this.add(this.scenePivot);
this.scenePivot.name = 'Pivot';
this.scenePivot.add(this.target);
this.setSize(width, height);
this.target.name = 'Target';
// Mixers will be array based
this.mixers = [];
const { domElement } = this.annotationRenderer;
const { style } = domElement;
style.display = 'none';
style.pointerEvents = 'none';
style.position = 'absolute';
style.top = '0';
this.element.shadowRoot.querySelector('.default').appendChild(domElement);
this.schemaElement.setAttribute('type', 'application/ld+json');
}
/**
* Function to create the context lazily, as when there is only one
* <model-viewer> element, the renderer's 3D context can be displayed
* directly. This extra context is necessary to copy the renderings into when
* there are more than one.
*/
get context() {
return this.canvas.getContext('2d');
}
getCamera() {
return this.xrCamera != null ? this.xrCamera : this.camera;
}
queueRender() {
this.isDirty = true;
}
shouldRender() {
return this.isDirty;
}
hasRendered() {
this.isDirty = false;
}
forceRescale() {
this.scaleStep = -1;
this.queueRender();
}
/**
* Pass in a THREE.Object3D to be controlled
* by this model.
*/
async setObject(model) {
this.reset();
this._models = [model];
this.target.add(model);
await this.setupScene();
}
/**
* Sets the model via URL.
*/
async setSource(url, extraUrls = [], progressCallback = () => { }) {
if ((!url || url === this.url) &&
extraUrls.join(',') === this.extraUrls.join(',')) {
progressCallback(1);
return;
}
this.reset();
this.url = url;
this.extraUrls = extraUrls;
if (this.externalRenderer != null) {
const framingInfo = await this.externalRenderer.load(progressCallback);
this.boundingSphere.radius = framingInfo.framedRadius;
this.idealAspect = framingInfo.fieldOfViewAspect;
return;
}
// If we have pending work due to a previous source change in progress,
// cancel it so that we do not incur a race condition:
if (this.cancelPendingSourceChange != null) {
this.cancelPendingSourceChange();
this.cancelPendingSourceChange = null;
}
let gltfs = [];
try {
const urlsToLoad = [];
if (url)
urlsToLoad.push(url);
if (extraUrls)
urlsToLoad.push(...extraUrls);
if (urlsToLoad.length > 0) {
gltfs =
await new Promise((resolve, reject) => {
this.cancelPendingSourceChange = () => reject();
(async () => {
try {
const results = await Promise.all(urlsToLoad.map(curUrl => this.element[$renderer].loader.load(curUrl, this.element, progressCallback)));
resolve(results);
}
catch (error) {
reject(error);
}
})();
});
}
}
catch (error) {
if (error == null) {
// Loading was cancelled, so silently return
return;
}
throw error;
}
this.cancelPendingSourceChange = null;
this.reset();
this.url = url;
this.extraUrls = extraUrls;
this._currentGLTFs = gltfs;
for (const gltf of gltfs) {
if (gltf != null) {
this._models.push(gltf.scene);
this.target.add(gltf.scene);
this.mixers.push(new AnimationMixer(gltf.scene));
this.mixerPausedStates.push(false);
this.currentAnimationActions.push(null);
}
else {
this.mixers.push(new AnimationMixer(this.target));
this.mixerPausedStates.push(false);
this.currentAnimationActions.push(null);
}
}
const animationsByName = new Map();
const animationNames = [];
const allAnimations = [];
for (const gltf of gltfs) {
for (const animation of gltf.animations || []) {
animationsByName.set(animation.name, animation);
animationNames.push(animation.name);
allAnimations.push(animation);
}
}
this.animations = allAnimations;
this.animationsByName = animationsByName;
this.animationNames = animationNames;
await this.setupScene();
}
async setupScene() {
this.applyTransform();
this.updateBoundingBox();
await this.updateFraming();
this.updateShadow();
this.setShadowIntensity(this.shadowIntensity);
this.setGroundedSkybox();
}
updateModelTransforms(index, offset, _orientation, scale) {
const model = this._models[index];
if (!model)
return;
if (offset) {
const parts = offset.split(' ')
.map(s => s.trim())
.filter(s => s.length > 0)
.map(Number);
if (parts.length === 3 && !parts.some(isNaN)) {
model.position.set(parts[0], parts[1], parts[2]);
}
}
if (scale) {
const parts = scale.split(' ')
.map(s => s.trim())
.filter(s => s.length > 0)
.map(Number);
if (parts.length === 1 && !isNaN(parts[0])) {
model.scale.setScalar(parts[0]);
}
else if (parts.length === 3 && !parts.some(isNaN)) {
model.scale.set(parts[0], parts[1], parts[2]);
}
}
model.updateMatrixWorld(true);
// Defer bounding box and shadow recalculations.
// If developers animate `<extra-model>` offset or scale properties via
// requestAnimationFrame, recalculating bounding boxes synchronously every
// single frame here blocks the main thread and tanks frame rates. Instead,
// we mark the bounds as dirty and wait for the render loop or a public
// dimensions getter to flush the changes.
this.boundsAndShadowDirty = true;
this.queueRender();
}
/**
* Evaluates bounding box recalculations asynchronously.
* Flushed right before a frame is rendered or when dimension properties are
* formally queried to ensure that high-frequency layout changes don't stall
* execution natively.
*/
updateBoundingBoxAndShadowIfDirty() {
if (this.boundsAndShadowDirty) {
this.boundsAndShadowDirty = false;
this.updateBoundingBox();
this.updateShadow();
}
}
reset() {
this.url = null;
this.renderCount = 0;
this.queueRender();
if (this.shadow != null) {
this.shadow.setIntensity(0);
}
this.bakedShadows.clear();
const { _models } = this;
for (const mod of _models) {
if (mod != null)
mod.removeFromParent();
}
this._models = [];
const gltfs = this._currentGLTFs;
for (const gltf of gltfs) {
if (gltf != null)
gltf.dispose();
}
this._currentGLTFs = [];
for (const action of this.currentAnimationActions) {
if (action != null) {
action.stop();
}
}
this.currentAnimationActions = [];
for (const mixer of this.mixers) {
mixer.stopAllAction();
mixer.uncacheRoot(this);
}
this.mixers = [];
this.mixerPausedStates = [];
}
dispose() {
this.reset();
if (this.shadow != null) {
this.shadow.dispose();
this.shadow = null;
}
this.element[$currentGLTF] = null;
this.element[$originalGltfJson] = null;
this.element[$model] = null;
}
get currentGLTF() {
return this._currentGLTFs[0] || null;
}
get currentGLTFs() {
return this._currentGLTFs;
}
/**
* Updates the ModelScene for a new container size in CSS pixels.
*/
setSize(width, height) {
if (this.width === width && this.height === height) {
return;
}
this.width = Math.max(width, 1);
this.height = Math.max(height, 1);
this.annotationRenderer.setSize(width, height);
this.aspect = this.width / this.height;
if (this.externalRenderer != null) {
const dpr = window.devicePixelRatio;
this.externalRenderer.resize(width * dpr, height * dpr);
}
this.queueRender();
}
markBakedShadow(mesh) {
mesh.userData.noHit = true;
this.bakedShadows.add(mesh);
}
unmarkBakedShadow(mesh) {
mesh.userData.noHit = false;
mesh.visible = true;
this.bakedShadows.delete(mesh);
this.boundingBox.expandByObject(mesh);
}
findBakedShadows(group) {
const boundingBox = new Box3();
group.traverse((object) => {
const mesh = object;
if (!mesh.material) {
return;
}
const material = mesh.material;
if (!material.transparent) {
return;
}
boundingBox.setFromObject(mesh);
const size = boundingBox.getSize(vector3);
const minDim = Math.min(size.x, size.y, size.z);
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim < MIN_SHADOW_RATIO * minDim) {
return;
}
this.markBakedShadow(mesh);
});
}
checkBakedShadows() {
const { min, max } = this.boundingBox;
const shadowBox = new Box3();
this.boundingBox.getSize(this.size);
for (const mesh of this.bakedShadows) {
shadowBox.setFromObject(mesh);
if (shadowBox.min.y < min.y + this.size.y / MIN_SHADOW_RATIO &&
shadowBox.min.x <= min.x && shadowBox.max.x >= max.x &&
shadowBox.min.z <= min.z && shadowBox.max.z >= max.z) {
// floor shadow
continue;
}
if (shadowBox.min.z < min.z + this.size.z / MIN_SHADOW_RATIO &&
shadowBox.min.x <= min.x && shadowBox.max.x >= max.x &&
shadowBox.min.y <= min.y && shadowBox.max.y >= max.y) {
// wall shadow
continue;
}
this.unmarkBakedShadow(mesh);
}
}
applyTransform() {
const { models } = this;
if (models.length === 0) {
return;
}
const orientation = parseExpressions(this.element.orientation)[0]
.terms;
const roll = normalizeUnit(orientation[0]).number;
const pitch = normalizeUnit(orientation[1]).number;
const yaw = normalizeUnit(orientation[2]).number;
const scale = parseExpressions(this.element.scale)[0]
.terms;
for (const mod of models) {
mod.quaternion.setFromEuler(new Euler(pitch, yaw, roll, 'YXZ'));
mod.scale.set(scale[0].number, scale[1].number, scale[2].number);
}
}
updateBoundingBox() {
const { models } = this;
if (models.length === 0) {
return;
}
for (const mod of models) {
this.target.remove(mod);
this.findBakedShadows(mod);
}
const bound = (box, vertex) => {
return box.expandByPoint(vertex);
};
this.setBakedShadowVisibility(false);
let combinedBox = new Box3();
for (const mod of models) {
combinedBox = reduceVertices(mod, bound, combinedBox);
}
this.boundingBox = combinedBox;
// If there's nothing but the baked shadow, then it's not a baked shadow.
if (this.boundingBox.isEmpty()) {
this.setBakedShadowVisibility(true);
this.bakedShadows.forEach((mesh) => this.unmarkBakedShadow(mesh));
combinedBox = new Box3();
for (const mod of models) {
combinedBox = reduceVertices(mod, bound, combinedBox);
}
this.boundingBox = combinedBox;
}
this.checkBakedShadows();
this.setBakedShadowVisibility();
this.boundingBox.getSize(this.size);
for (const mod of models) {
this.target.add(mod);
}
}
/**
* Calculates the boundingSphere and idealAspect that allows the 3D
* object to be framed tightly in a 2D window of any aspect ratio without
* clipping at any camera orbit. The camera's center target point can be
* optionally specified. If no center is specified, it defaults to the center
* of the bounding box, which means asymmetric models will tend to be tight on
* one side instead of both. Proper choice of center can correct this.
*/
async updateFraming() {
const { models } = this;
if (models.length === 0) {
return;
}
for (const mod of models) {
this.target.remove(mod);
}
this.setBakedShadowVisibility(false);
const { center } = this.boundingSphere;
this.element.requestUpdate('cameraTarget');
await this.element.updateComplete;
center.copy(this.getTarget());
const radiusSquared = (value, vertex) => {
return Math.max(value, center.distanceToSquared(vertex));
};
let maxRadiusSq = 0;
for (const mod of models) {
maxRadiusSq =
Math.max(maxRadiusSq, reduceVertices(mod, radiusSquared, 0));
}
this.boundingSphere.radius = Math.sqrt(maxRadiusSq);
const horizontalTanFov = (value, vertex) => {
vertex.sub(center);
const radiusXZ = Math.sqrt(vertex.x * vertex.x + vertex.z * vertex.z);
return Math.max(value, radiusXZ / (this.idealCameraDistance() - Math.abs(vertex.y)));
};
let maxAspect = 0;
for (const mod of models) {
maxAspect = Math.max(maxAspect, reduceVertices(mod, horizontalTanFov, 0));
}
this.idealAspect =
maxAspect / Math.tan((this.framedFoVDeg / 2) * Math.PI / 180);
this.setBakedShadowVisibility();
for (const mod of models) {
this.target.add(mod);
}
}
setBakedShadowVisibility(visible = this.shadowIntensity <= 0) {
for (const shadow of this.bakedShadows) {
shadow.visible = visible;
}
}
idealCameraDistance() {
const halfFovRad = (this.framedFoVDeg / 2) * Math.PI / 180;
return this.boundingSphere.radius / Math.sin(halfFovRad);
}
/**
* Set's the framedFieldOfView based on the aspect ratio of the window in
* order to keep the model fully visible at any camera orientation.
*/
adjustedFoV(fovDeg) {
const vertical = Math.tan((fovDeg / 2) * Math.PI / 180) *
Math.max(1, this.idealAspect / this.aspect);
return 2 * Math.atan(vertical) * 180 / Math.PI;
}
getNDC(clientX, clientY) {
if (this.xrCamera != null) {
ndc.set(clientX / window.screen.width, clientY / window.screen.height);
}
else {
const rect = this.element.getBoundingClientRect();
ndc.set((clientX - rect.x) / this.width, (clientY - rect.y) / this.height);
}
ndc.multiplyScalar(2).subScalar(1);
ndc.y *= -1;
return ndc;
}
/**
* Returns the size of the corresponding canvas element.
*/
getSize() {
return { width: this.width, height: this.height };
}
setEnvironmentAndSkybox(environment, skybox) {
if (this.element[$renderer].arRenderer.presentedScene === this) {
return;
}
this.environment = environment;
this.setBackground(skybox);
this.queueRender();
}
setBackground(skybox) {
this.groundedSkybox.map = skybox;
if (this.groundedSkybox.isUsable()) {
this.target.add(this.groundedSkybox);
this.background = null;
}
else {
this.target.remove(this.groundedSkybox);
this.background = skybox;
}
}
farRadius() {
return this.boundingSphere.radius *
(this.groundedSkybox.parent != null ? GROUNDED_SKYBOX_SIZE : 1);
}
setGroundedSkybox() {
const heightNode = parseExpressions(this.element.skyboxHeight)[0].terms[0];
const height = normalizeUnit(heightNode).number;
const radius = GROUNDED_SKYBOX_SIZE * this.boundingSphere.radius;
this.groundedSkybox.updateGeometry(height, radius);
this.groundedSkybox.position.y =
height - (this.shadow ? 2 * this.shadow.gap() : 0);
this.setBackground(this.groundedSkybox.map);
}
/**
* Sets the point in model coordinates the model should orbit/pivot around.
*/
setTarget(modelX, modelY, modelZ) {
this.goalTarget.set(-modelX, -modelY, -modelZ);
}
/**
* Set the decay time of, affects the speed of target transitions.
*/
setTargetDamperDecayTime(decayMilliseconds) {
this.targetDamperX.setDecayTime(decayMilliseconds);
this.targetDamperY.setDecayTime(decayMilliseconds);
this.targetDamperZ.setDecayTime(decayMilliseconds);
}
/**
* Gets the point in model coordinates the model should orbit/pivot around.
*/
getTarget() {
return this.goalTarget.clone().multiplyScalar(-1);
}
/**
* Gets the current target point, which may not equal the goal returned by
* getTarget() due to finite input decay smoothing.
*/
getDynamicTarget() {
return this.target.position.clone().multiplyScalar(-1);
}
/**
* Shifts the model to the target point immediately instead of easing in.
*/
jumpToGoal() {
this.updateTarget(SETTLING_TIME);
}
/**
* This should be called every frame with the frame delta to cause the target
* to transition to its set point.
*/
updateTarget(delta) {
const goal = this.goalTarget;
const target = this.target.position;
if (!goal.equals(target)) {
const normalization = this.boundingSphere.radius / 10;
let { x, y, z } = target;
x = this.targetDamperX.update(x, goal.x, delta, normalization);
y = this.targetDamperY.update(y, goal.y, delta, normalization);
z = this.targetDamperZ.update(z, goal.z, delta, normalization);
this.groundedSkybox.position.x = -x;
this.groundedSkybox.position.z = -z;
this.target.position.set(x, y, z);
this.target.updateMatrixWorld();
this.queueRender();
return true;
}
else {
return false;
}
}
/**
* Yaw the +z (front) of the model toward the indicated world coordinates.
*/
pointTowards(worldX, worldZ) {
const { x, z } = this.position;
this.yaw = Math.atan2(worldX - x, worldZ - z);
}
get model() {
return this._models[0] || null;
}
get models() {
return this._models;
}
/**
* Yaw is the scene's orientation about the y-axis, around the rotation
* center.
*/
set yaw(radiansY) {
this.scenePivot.rotation.y = radiansY;
this.groundedSkybox.rotation.y = -radiansY;
this.queueRender();
}
get yaw() {
return this.scenePivot.rotation.y;
}
set animationTime(value) {
for (const mixer of this.mixers) {
mixer.setTime(value);
}
this.queueShadowRender();
}
get animationTime() {
let maxTime = 0;
for (const action of this.currentAnimationActions) {
if (action != null) {
let currentTime = action.time;
const loopCount = Math.max(action._loopCount, 0);
if (action.loop === LoopPingPong && (loopCount & 1) === 1) {
const clipDuration = action.getClip() ? action.getClip().duration : 0;
currentTime = clipDuration - action.time;
}
if (currentTime > maxTime) {
maxTime = currentTime;
}
}
}
return maxTime;
}
set animationTimeScale(value) {
for (const mixer of this.mixers) {
mixer.timeScale = value;
}
}
get animationTimeScale() {
return this.mixers.length > 0 ? this.mixers[0].timeScale : 1;
}
get duration() {
let maxDuration = 0;
for (const action of this.currentAnimationActions) {
if (action != null && action.getClip()) {
const clipDuration = action.getClip().duration;
if (clipDuration > maxDuration) {
maxDuration = clipDuration;
}
}
}
return maxDuration;
}
get hasActiveAnimation() {
return this.currentAnimationActions.some(action => action != null);
}
/**
* Plays an animation if there are any associated with the current model.
* Accepts an optional string name of an animation to play. If no name is
* provided, or if no animation is found by the given name, always falls back
* to playing the first animation.
* If a modelIndex is provided, plays the animation only on that model.
*/
playAnimation(name = null, crossfadeTime = 0, loopMode = LoopRepeat, repetitionCount = Infinity, modelIndex = null) {
// Determine which models we're animating
const startIndex = modelIndex != null ? modelIndex : 0;
const endIndex = modelIndex != null ? modelIndex + 1 : this._models.length;
for (let i = startIndex; i < endIndex; i++) {
const gltf = this._currentGLTFs[i];
if (gltf == null)
continue;
// Collect animations specific to this model
const animations = gltf.animations || [];
if (animations.length === 0)
continue;
let animationClip = null;
if (name != null) {
// Look for an animation with this precise name inside this model
// We search backwards to mimic previous Map.set overriding behavior
// so the last animation with the same name takes precedence.
for (let k = animations.length - 1; k >= 0; k--) {
if (animations[k].name === name) {
animationClip = animations[k];
break;
}
}
if (animationClip == null) {
const parsedAnimationIndex = parseInt(name);
if (!isNaN(parsedAnimationIndex) && parsedAnimationIndex >= 0 &&
parsedAnimationIndex < animations.length) {
animationClip = animations[parsedAnimationIndex];
}
}
}
if (animationClip == null) {
animationClip = animations[0];
}
try {
const lastAnimationAction = this.currentAnimationActions[i];
const mixer = this.mixers[i];
const action = mixer.clipAction(animationClip, this._models[i]);
this.currentAnimationActions[i] = action;
if (this.element.paused) {
mixer.stopAllAction();
this.mixerPausedStates[i] = true;
}
else {
action.paused = false;
this.mixerPausedStates[i] = false;
// Crossfade behavior doesn't work perfectly when the actions don't
// map to the same skeleton. Since we're making a new mixer/action for
// each model, if we didn't have one before it's fine.
if (lastAnimationAction != null && action !== lastAnimationAction) {
action.crossFadeFrom(lastAnimationAction, crossfadeTime, false);
}
else if (this.animationTimeScale > 0 &&
this.animationTime == this.duration) {
this.animationTime = 0;
}
}
action.setLoop(loopMode, repetitionCount);
action.enabled = true;
action.clampWhenFinished = true;
action.play();
}
catch (error) {
console.error(error);
}
}
}
appendAnimation(name = '', loopMode = LoopRepeat, repetitionCount = Infinity, weight = 1, timeScale = 1, fade = false, warp = false, relativeWarp = true, time = null, needsToStop = false, modelIndex = null) {
if (this.currentGLTF == null || name === this.element.animationName) {
return;
}
const { animations } = this;
if (animations == null || animations.length === 0) {
return;
}
const animationClip = name ? this.animationsByName.get(name) : null;
if (animationClip == null) {
return;
}
// validate and normalize parameters
if (typeof repetitionCount === 'string') {
if (isNaN(parseFloat(repetitionCount))) {
repetitionCount = Infinity;
console.warn(`Invalid repetitionCount value: ${repetitionCount}. Using default: Infinity`);
}
else {
if (parseInt(repetitionCount) < 1) {
console.warn(`Invalid repetitionCount value: ${repetitionCount}. Using 1 as minimum.`);
}
repetitionCount = Math.max(parseInt(repetitionCount), 1);
}
}
else if (typeof repetitionCount === 'number' && repetitionCount < 1) {
repetitionCount = 1;
console.warn(`Invalid repetitionCount value: ${repetitionCount}. Using 1 value as minimum.`);
}
else {
console.warn(`Invalid repetitionCount value: ${repetitionCount}. Using default: Infinity`);
}
if (repetitionCount === 1 && loopMode !== LoopOnce) {
loopMode = LoopOnce;
}
if (typeof weight === 'string') {
const parsedWeight = parseFloat(weight);
if (isNaN(parsedWeight) || parsedWeight < 0 || parsedWeight > 1) {
weight = 1;
console.warn(`Invalid weight value: ${weight}. Using default: 1`);
}
else {
weight = parsedWeight;
}
}
if (typeof timeScale === 'string') {
const parsedTimeScale = parseFloat(timeScale);
if (isNaN(parsedTimeScale) || parsedTimeScale < 0) {
timeScale = 1;
console.warn(`Invalid timeScale value: ${timeScale}. Using default: 1`);
}
else {
timeScale = parsedTimeScale;
}
}
if (typeof time === 'string') {
// time = !isNaN(parseFloat(time)) ? parseFloat(time) : null;
const parsedTime = parseFloat(time);
if (isNaN(parsedTime)) {
time = null;
console.warn(`Invalid time value: ${time}. Using default: 0 or previous time`);
}
else {
time = parsedTime;
}
}
const { shouldFade, duration: fadeDuration } = this.parseFadeValue(fade, false, 1.25);
const defaultWarpDuration = 1.25;
let shouldWarp = false;
let warpDuration = 0;
if (typeof warp === 'boolean') {
shouldWarp = warp;
warpDuration = warp ? defaultWarpDuration : 0;
}
else if (typeof warp === 'number') {
shouldWarp = warp > 0;
warpDuration = Math.max(warp, 0);
if (warp < 0) {
console.warn(`Invalid warp value: ${warp}. Using default: false`);
}
}
else if (typeof warp === 'string') {
if (warp.toLowerCase().trim() === 'true') {
shouldWarp = true;
warpDuration = defaultWarpDuration;
}
else if (warp.toLowerCase().trim() === 'false') {
shouldWarp = false;
}
else if (!isNaN(parseFloat(warp))) {
warpDuration = Math.max(parseFloat(warp), 0);
shouldWarp = warpDuration > 0;
if (warpDuration <= 0) {
console.warn(`Invalid warp value: ${warp}. Using default: false`);
}
}
else {
console.warn(`Invalid warp value: ${warp}. Using default: false`);
}
}
try {
if (needsToStop && this.appendedAnimations.includes(name)) {
if (!this.markedAnimations.map(e => e.name).includes(name)) {
this.markedAnimations.push({ name, loopMode, repetitionCount });
}
}
const startIndex = modelIndex != null ? modelIndex : 0;
const endIndex = modelIndex != null ? modelIndex + 1 : this.mixers.length;
for (let i = startIndex; i < endIndex; i++) {
const mixer = this.mixers[i];
const action = mixer.existingAction(animationClip) ||
mixer.clipAction(animationClip, this._models[i] || this);
const currentTimeScale = action.timeScale;
if (typeof time === 'number') {
action.time = Math.min(Math.max(time, 0), animationClip.duration);
}
if (shouldFade) {
action.fadeIn(fadeDuration);
}
else if (weight >= 0) {
action.weight = Math.min(Math.max(weight, 0), 1);
}
if (shouldWarp) {
action.warp(relativeWarp ? currentTimeScale : 0, timeScale, warpDuration);
}
else {
action.timeScale = timeScale;
}
if (!action.isRunning()) {
if (action.time == animationClip.duration) {
action.stop();
}
action.setLoop(loopMode, repetitionCount);
action.paused = false;
action.enabled = true;
action.clampWhenFinished = true;
action.play();
}
}
if (!this.appendedAnimations.includes(name)) {
this.element[$scene].appendedAnimations.push(name);
}
}
catch (error) {
console.error(error);
}
}
/**
* Helper function to parse fade parameter values
*/
parseFadeValue(fade, defaultValue = true, defaultDuration = 1.5) {
const normalizeString = (str) => str.toLowerCase().trim();
if (typeof fade === 'boolean') {
return { shouldFade: fade, duration: fade ? defaultDuration : 0 };
}
if (typeof fade === 'number') {
const duration = Math.max(fade, 0);
return { shouldFade: duration > 0, duration };
}
if (typeof fade === 'string') {
const normalized = normalizeString(fade);
if (normalized === 'true') {
return { shouldFade: true, duration: defaultDuration };
}
if (normalized === 'false') {
return { shouldFade: false, duration: 0 };
}
const parsed = parseFloat(normalized);
if (!isNaN(parsed)) {
const duration = Math.max(parsed, 0);
return { shouldFade: duration > 0, duration };
}
}
console.warn(`Invalid fade value: ${fade}. Using default: ${defaultValue}`);
return {
shouldFade: defaultValue,
duration: defaultValue ? defaultDuration : 0
};
}
detachAnimation(name = '', fade = true, modelIndex = null) {
if (this.currentGLTF == null || name === this.element.animationName) {
return;
}
const { animations } = this;
if (animations == null || animations.length === 0) {
return;
}
const animationClip = name ? this.animationsByName.get(name) : null;
if (animationClip == null) {
return;
}
const { shouldFade, duration } = this.parseFadeValue(fade, true, 1.5);
try {
const startIndex = modelIndex != null ? modelIndex : 0;
const endIndex = modelIndex != null ? modelIndex + 1 : this.mixers.length;
for (let i = startIndex; i < endIndex; i++) {
const mixer = this.mixers[i];
const action = mixer.existingAction(animationClip) ||
mixer.clipAction(animationClip, this._models[i] || this);
if (shouldFade) {
action.fadeOut(duration);
}
else {
action.stop();
}
}
this.element[$scene].appendedAnimations =
this.element[$scene].appendedAnimations.filter(i => i !== name);
}
catch (error) {
console.error(error);
}
}
updateAnimationLoop(name = '', loopMode = LoopRepeat, repetitionCount = Infinity, modelIndex = null) {
if (this.currentGLTF == null || name === this.element.animationName) {
return;
}
const { animations } = this;
if (animations == null || animations.length === 0) {
return;
}
let animationClip = null;
if (name) {
animationClip = this.animationsByName.get(name);
}
if (animationClip == null) {
return;
}
try {
const startIndex = modelIndex != null ? modelIndex : 0;
const endIndex = modelIndex != null ? modelIndex + 1 : this.mixers.length;
for (let i = startIndex; i < endIndex; i++) {
const mixer = this.mixers[i];
const action = mixer.existingAction(animationClip) ||
mixer.clipAction(animationClip, this._models[i] || this);
action.stop();
action.setLoop(loopMode, repetitionCount);
action.play();
}
}
catch (error) {
console.error(error);
}
}
stopAnimation() {
this.currentAnimationActions.fill(null);
for (const mixer of this.mixers) {
mixer.stopAllAction();
}
this.mixerPausedStates.fill(true);
}
isAllAnimationsPaused() {
return this.mixerPausedStates.every(paused => paused);
}
pauseAnimation(modelIndex = null) {
const startIndex = modelIndex != null ? modelIndex : 0;
const endIndex = modelIndex != null ? modelIndex + 1 : this.mixers.length;
for (let i = startIndex; i < endIndex; i++) {
this.mixerPausedStates[i] = true;
}
}
unpauseAnimation(modelIndex = null) {
const startIndex = modelIndex != null ? modelIndex : 0;
const endIndex = modelIndex != null ? modelIndex + 1 : this.mixers.length;
for (let i = startIndex; i < endIndex; i++) {
this.mixerPausedStates[i] = false;
}
}
updateAnimation(step) {
for (let i = 0; i < this.mixers.length; i++) {
if (!this.mixerPausedStates[i]) {
this.mixers[i].update(step);
}
}
this.queueShadowRender();
}
subscribeMixerEvent(event, callback) {
for (const mixer of this.mixers) {
mixer.addEventListener(event, callback);
}
}
/**
* Call if the object has been changed in such a way that the shadow's
* shape has changed (not a rotation about the Y axis).
*/
updateShadow() {
const shadow = this.shadow;
if (shadow != null) {
const side = this.element.arPlacement === 'wall' ? 'back' : 'bottom';
shadow.setScene(this, this.shadowSoftness, side);
shadow.needsUpdate = true;
}
}
renderShadow(renderer) {
this.updateBoundingBoxAndShadowIfDirty();
const shadow = this.shadow;
if (shadow != null && shadow.needsUpdate == true) {
shadow.render(renderer, this);
shadow.needsUpdate = false;
}
}
queueShadowRender() {
if (this.shadow != null) {
this.shadow.needsUpdate = true;
}
}
/**
* Sets the shadow's intensity, lazily creating the shadow as necessary.
*/
setShadowIntensity(shadowIntensity) {
this.shadowIntensity = shadowIntensity;
if (this.currentGLTF == null) {
return;
}
this.setBakedShadowVisibility();
if (shadowIntensity <= 0 && this.shadow == null) {
return;
}
if (this.shadow == null) {
const side = this.element.arPlacement === 'wall' ? 'back' : 'bottom';
this.shadow = new Shadow(this, this.shadowSoftness, side);
}
this.shadow.setIntensity(shadowIntensity);
}
/**
* Sets the shadow's softness by mapping a [0, 1] softness parameter to
* the shadow's resolution. This involves reallocation, so it should not
* be changed frequently. Softer shadows are cheaper to render.
*/
setShadowSoftness(softness) {
this.shadowSoftness = softness;
const shadow = this.shadow;
if (shadow != null) {
shadow.setSoftness(softness);
}
}
/**
* Shift the floor vertically from the bottom of the model's bounding box
* by offset (should generally be negative).
*/
setShadowOffset(offset) {
const shadow = this.shadow;
if (shadow != null) {
shadow.setOffset(offset);
}
}
getHit(object = this) {
const hits = raycaster.intersectObject(object, true);
return hits.find((hit) => hit.object.visible && !hit.object.userData.noHit);
}
hitFromController(controller, object = this) {
raycaster.setFromXRController(controller);
return this.getHit(object);
}
hitFromPoint(ndcPosition, object = this) {
raycaster.setFromCamera(ndcPosition, this.getCamera());
return this.getHit(object);
}
getModelIndexFromHit(hit) {
let current = hit.object;
while (current != null) {
const idx = this.models.indexOf(current);
if (idx !== -1)
return idx;
current = current.parent;
}
return 0; // Default to primary model if not found
}
/**
* This method returns the world position, model-space normal and texture
* coordinate of the point on the mesh corresponding to the input pixel
* coordinates given relative to the model-viewer element. If the mesh
* is not hit, the result is null.
*/
positionAndNormalFromPoint(ndcPosition, object = this) {
var _a;
const hit = this.hitFromPoint(ndcPosition, object);
if (hit == null) {
return null;
}
const position = hit.point;
const normal = hit.face != null ?
hit.face.normal.clone().applyNormalMatrix(new Matrix3().getNormalMatrix(hit.object.matrixWorld)) :
raycaster.ray.direction.clone().multiplyScalar(-1);
const uv = (_a = hit.uv) !== null && _a !== void 0 ? _a : null;
const modelIndex = this.getModelIndexFromHit(hit);
const targetModel = this.models[modelIndex] || this.target;
const worldToModel = new Matrix4().copy(targetModel.matrixWorld).invert();
return { position, normal, uv, modelIndex, worldToModel };
}
/**
* This method returns a dynamic hotspot ID string of the point on the
* mesh corresponding to the input pixel coordinates given relative to the
* model-viewer element. The ID string can be used in the data-surface
* attribute of the hotspot to make it follow this point on the surface
* even as the model animates. If the mesh is not hit, the result is null.
*/
surfaceFromPoint(ndcPosition, object = this) {
var _a;
const hit = this.hitFromPoint(ndcPosition, object);
if (hit == null || hit.face == null) {
return null;
}
const modelIndex = this.getModelIndexFromHit(hit);
const model = modelIndex === 0 ? this.element.model :
(_a = this.element.extraModels) === null || _a === void 0 ? void 0 : _a[modelIndex - 1];
if (model == null) {
return null;
}
const node = model[$nodeFromPoint](hit);
if (node == null)
return null;
const { meshes, primitives } = node.mesh.userData.associations;
const va = new Vector3();
const vb = new Vector3();
const vc = new Vector3();
const { a, b, c } = hit.face;
const mesh = hit.object;
mesh.getVertexPosition(a, va);
mesh.getVertexPosition(b, vb);
mesh.getVertexPosition(c, vc);
const tri = new Triangle(va, vb, vc);
const uvw = new Vector3();
tri.getBarycoord(mesh.worldToLocal(hit.point), uvw);
tri.getBarycoord(mesh.worldToLocal(hit.point), uvw);
const baseSurface = `${meshes} ${primitives} ${a} ${b} ${c} ${uvw.x.toFixed(3)} ${uvw.y.toFixed(3)} ${uvw.z.toFixed(3)}`;
return modelIndex === 0 ? baseSurface : `${modelIndex} ${baseSurface}`;
}
/**
* The following methods are for operating on the set of Hotspot objects
* attached to the scene. These come from DOM elements, provided to slots
* by the Annotation Mixin.
/**
* Evaluates the intended `modelIndex` of the hotspot and safely reparents it
* to the corresponding `Object3D` node mapped inside this scene's `_models`
array.
* This guarantees that declarative offset and layout transforms affect
positional anchors.
*/
updateHotspotAttachment(hotspot) {
const targetNode = (hotspot.modelIndex != null && hotspot.modelIndex > 0 &&
this._models[hotspot.modelIndex]) ?
this._models[hotspot.modelIndex] :
this.target;
if (hotspot.parent !== targetNode) {
targetNode.add(hotspot);
hotspot.updatePosition(hotspot.position.toArray().join(' ') +
'm'); // Force bounds sync to fresh parent
hotspot.updateMatrixWorld(true);
}
}
addHotspot(hotspot) {
this.updateHotspotAttachment(hotspot);
// This happens automatically in render(), but we do it early so that
// the slots appear in the shadow DOM and the elements get attached,
// allowing us to dispatch events on them.
this.annotationRenderer.domElement.appendChild(hotspot.element);
this.updateSurfaceHotspot(hotspot);
}
removeHotspot(hotspot) {
if (hotspot.parent) {
hotspot.parent.remove(hotspot);
}
}
/**
* Helper method to apply a function to all hotspots.
*/
forHotspots(func) {
const children = [...this.target.children];
for (let i = 0, l = children.length; i < l; i++) {
const hotspot = children[i];
if (hotspot instanceof Hotspot) {
func(hotspot);
}
}
// Also traverse extra models to find any hotspots already reparented to
// them
for (const model of this._models) {
if (model && model !== this.target) {
const extraChildren = [...model.children];
for (let i = 0, l = extraChildren.length; i < l; i++) {
const hotspot = extraChildren[i];
if (hotspot instanceof Hotspot) {
func(hotspot);
}
}
}
}
}
/**
* Lazy initializer for surface hotspots - will only run once.
*/
updateSurfaceHotspot(hotspot) {
var _a, _b;
if (hotspot.surface == null) {
return;
}
const nodes = parseExpressions(hotspot.surface)[0].terms;
if (nodes.length !== 8 && nodes.length !== 9) {
console.warn(hotspot.surface +
' does not have exactly 8 or 9 numbers. Did you use an outdated string?');
return;
}
// Determine format: 8 numbers = legacy (index 0), 9 numbers = indexed
const isLegacy = nodes.length === 8;
const parsedModelIndex = isLegacy ? 0 : nodes[0].number;
const offset = isLegacy ? 0 : 1;
// DOM attribute (`data-m