UNPKG

@google/model-viewer

Version:

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

1,288 lines 54.4 kB
/* @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