diff --git a/packages/model-viewer-effects/src/test/utilities-spec.ts b/packages/model-viewer-effects/src/test/utilities-spec.ts index 467c745443..494b94a962 100644 --- a/packages/model-viewer-effects/src/test/utilities-spec.ts +++ b/packages/model-viewer-effects/src/test/utilities-spec.ts @@ -79,6 +79,10 @@ suite('Screenshot Baseline Test', () => { expect(renderer).to.not.be.undefined; element.jumpCameraToGoal(); await rafPasses(); + await new Promise( + resolve => + setTimeout(resolve, 100)); // Allow shader recompilation frames + // to catch up on Chromium composerScreenshot = screenshot(element); await rafPasses(); const screenshot2 = screenshot(element); @@ -89,7 +93,10 @@ suite('Screenshot Baseline Test', () => { } }); - test('Empty EffectComposer and base Renderer are identical', () => { + test('Empty EffectComposer and base Renderer are identical', async () => { + await new Promise( + resolve => setTimeout( + resolve, 100)); // Wait for potential rendering stabilization const similarity = CompareArrays(baseScreenshot, composerScreenshot); if (!Number.isNaN(similarity)) { expect(similarity).to.be.greaterThan(0.999); diff --git a/packages/model-viewer/src/features/animation.ts b/packages/model-viewer/src/features/animation.ts index 0af24666e5..9ff94831f9 100644 --- a/packages/model-viewer/src/features/animation.ts +++ b/packages/model-viewer/src/features/animation.ts @@ -29,6 +29,7 @@ const $paused = Symbol('paused'); interface PlayAnimationOptions { repetitions: number; pingpong: boolean; + modelIndex?: number; } interface AppendAnimationOptions { @@ -40,10 +41,12 @@ interface AppendAnimationOptions { warp?: boolean|number; relativeWarp?: boolean; time?: number|null; + modelIndex?: number; } interface DetachAnimationOptions { fade?: boolean|number; + modelIndex?: number; } const DEFAULT_PLAY_OPTIONS: PlayAnimationOptions = { @@ -71,6 +74,7 @@ export declare interface AnimationInterface { animationName: string|void; animationCrossfadeDuration: number; readonly availableAnimations: Array; + readonly appendedAnimations: Array; readonly paused: boolean; readonly duration: number; currentTime: number; @@ -145,7 +149,7 @@ export const AnimationMixin = >( } get paused(): boolean { - return this[$paused]; + return this[$scene].isAllAnimationsPaused(); } get currentTime(): number { @@ -169,18 +173,21 @@ export const AnimationMixin = >( this[$scene].animationTimeScale = value; } - pause() { - if (this[$paused]) { + pause(options?: {modelIndex?: number}) { + if (options?.modelIndex == null && this.paused) { return; } - this[$paused] = true; + const modelIndex = options?.modelIndex ?? null; + this[$scene].pauseAnimation(modelIndex); + this.dispatchEvent(new CustomEvent('pause')); } play(options?: PlayAnimationOptions) { if (this.availableAnimations.length > 0) { - this[$paused] = false; + const modelIndex = options?.modelIndex ?? null; + this[$scene].unpauseAnimation(modelIndex); this[$changeAnimation](options); @@ -191,6 +198,7 @@ export const AnimationMixin = >( appendAnimation(animationName: string, options?: AppendAnimationOptions) { if (this.availableAnimations.length > 0) { this[$paused] = false; + this[$scene].unpauseAnimation(options?.modelIndex ?? null); this[$appendAnimation](animationName, options); @@ -201,6 +209,7 @@ export const AnimationMixin = >( detachAnimation(animationName: string, options?: DetachAnimationOptions) { if (this.availableAnimations.length > 0) { this[$paused] = false; + this[$scene].unpauseAnimation(options?.modelIndex ?? null); this[$detachAnimation](animationName, options); @@ -211,7 +220,7 @@ export const AnimationMixin = >( [$onModelLoad]() { super[$onModelLoad](); - this[$paused] = true; + this[$scene].pauseAnimation(); if (this.animationName != null) { this[$changeAnimation](); @@ -225,7 +234,7 @@ export const AnimationMixin = >( [$tick](_time: number, delta: number) { super[$tick](_time, delta); - if (this[$paused] || + if (this.paused || (!this[$getModelIsVisible]() && !this[$renderer].isPresenting)) { return; } @@ -256,7 +265,8 @@ export const AnimationMixin = >( this.animationName, this.animationCrossfadeDuration / MILLISECONDS_PER_SECOND, mode, - repetitions); + repetitions, + options.modelIndex); // If we are currently paused, we need to force a render so that // the scene updates to the first frame of the new animation @@ -267,13 +277,11 @@ export const AnimationMixin = >( } [$appendAnimation]( - animationName: string = '', - options: AppendAnimationOptions = {}) { + animationName: string = '', options: AppendAnimationOptions = {}) { const opts = {...DEFAULT_APPEND_OPTIONS, ...options}; const repetitions = opts.repetitions ?? Infinity; - const mode = opts.pingpong ? - LoopPingPong : - (repetitions === 1 ? LoopOnce : LoopRepeat); + const mode = opts.pingpong ? LoopPingPong : + (repetitions === 1 ? LoopOnce : LoopRepeat); const needsToStop = !!options.repetitions || 'pingpong' in options; @@ -287,7 +295,8 @@ export const AnimationMixin = >( opts.warp, opts.relativeWarp, opts.time, - needsToStop); + needsToStop, + opts.modelIndex); // If we are currently paused, we need to force a render so that // the scene updates to the first frame of the new animation @@ -298,11 +307,12 @@ export const AnimationMixin = >( } [$detachAnimation]( - animationName: string = '', - options: DetachAnimationOptions = {}) { + animationName: string = '', options: DetachAnimationOptions = {}) { const opts = {...DEFAULT_DETACH_OPTIONS, ...options}; this[$scene].detachAnimation( - animationName ? animationName : this.animationName, opts.fade); + animationName ? animationName : this.animationName, + opts.fade, + opts.modelIndex); // If we are currently paused, we need to force a render so that // the scene updates to the first frame of the new animation diff --git a/packages/model-viewer/src/features/annotation.ts b/packages/model-viewer/src/features/annotation.ts index a413c6e6e1..f9055f1090 100644 --- a/packages/model-viewer/src/features/annotation.ts +++ b/packages/model-viewer/src/features/annotation.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import {Matrix4, Vector3} from 'three'; +import {Vector3} from 'three'; import ModelViewerElementBase, {$needsRender, $onModelLoad, $scene, $tick, toVector2D, toVector3D, Vector2D, Vector3D} from '../model-viewer-base.js'; import {Hotspot, HotspotConfiguration} from '../three-components/Hotspot.js'; @@ -26,7 +26,7 @@ const $observer = Symbol('observer'); const $addHotspot = Symbol('addHotspot'); const $removeHotspot = Symbol('removeHotspot'); -const worldToModel = new Matrix4(); + export declare type HotspotData = { position: Vector3D, @@ -38,8 +38,12 @@ export declare type HotspotData = { export declare interface AnnotationInterface { updateHotspot(config: HotspotConfiguration): void; queryHotspot(name: string): HotspotData|null; - positionAndNormalFromPoint(pixelX: number, pixelY: number): - {position: Vector3D, normal: Vector3D, uv: Vector2D|null}|null; + positionAndNormalFromPoint(pixelX: number, pixelY: number): { + position: Vector3D, + normal: Vector3D, + uv: Vector2D|null, + modelIndex?: number + }|null; surfaceFromPoint(pixelX: number, pixelY: number): string|null; } @@ -106,6 +110,7 @@ export const AnnotationMixin = >( const scene = this[$scene]; scene.forHotspots((hotspot) => { + scene.updateHotspotAttachment(hotspot); scene.updateSurfaceHotspot(hotspot); }); } @@ -140,6 +145,9 @@ export const AnnotationMixin = >( hotspot.updatePosition(config.position); hotspot.updateNormal(config.normal); hotspot.surface = config.surface; + if (config.modelIndex !== undefined && config.modelIndex !== null) { + hotspot.modelIndex = config.modelIndex; + } this[$scene].updateSurfaceHotspot(hotspot); this[$needsRender](); } @@ -190,8 +198,12 @@ export const AnnotationMixin = >( * hotspot's data-position and data-normal attributes. If the mesh is * not hit, the result is null. */ - positionAndNormalFromPoint(pixelX: number, pixelY: number): - {position: Vector3D, normal: Vector3D, uv: Vector2D|null}|null { + positionAndNormalFromPoint(pixelX: number, pixelY: number): { + position: Vector3D, + normal: Vector3D, + uv: Vector2D|null, + modelIndex?: number + }|null { const scene = this[$scene]; const ndcPosition = scene.getNDC(pixelX, pixelY); @@ -200,7 +212,7 @@ export const AnnotationMixin = >( return null; } - worldToModel.copy(scene.target.matrixWorld).invert(); + const worldToModel = hit.worldToModel; const position = toVector3D(hit.position.applyMatrix4(worldToModel)); const normal = toVector3D(hit.normal.transformDirection(worldToModel)); @@ -209,7 +221,12 @@ export const AnnotationMixin = >( uv = toVector2D(hit.uv); } - return {position: position, normal: normal, uv: uv}; + return { + position: position, + normal: normal, + uv: uv, + modelIndex: hit.modelIndex + }; } /** @@ -242,6 +259,9 @@ export const AnnotationMixin = >( position: node.dataset.position, normal: node.dataset.normal, surface: node.dataset.surface, + modelIndex: node.dataset.modelIndex != null ? + parseInt(node.dataset.modelIndex) : + null, }); this[$hotspotMap].set(node.slot, hotspot); this[$scene].addHotspot(hotspot); diff --git a/packages/model-viewer/src/features/ar.ts b/packages/model-viewer/src/features/ar.ts index 660f1590c0..cbee5ee4e8 100644 --- a/packages/model-viewer/src/features/ar.ts +++ b/packages/model-viewer/src/features/ar.ts @@ -14,6 +14,7 @@ */ import {property} from 'lit/decorators.js'; +import {Object3D} from 'three'; import {USDZExporter} from 'three/examples/jsm/exporters/USDZExporter.js'; import {IS_AR_QUICKLOOK_CANDIDATE, IS_SCENEVIEWER_CANDIDATE, IS_WEBXR_AR_CANDIDATE} from '../constants.js'; @@ -212,7 +213,7 @@ export const ARMixin = >( await this[$enterARWithWebXR](); break; case ARMode.SCENE_VIEWER: - this[$openSceneViewer](); + await this[$openSceneViewer](); break; default: console.warn( @@ -316,10 +317,30 @@ configuration or device capabilities'); * Takes a URL and a title string, and attempts to launch Scene Viewer on * the current device. */ - [$openSceneViewer]() { + async[$openSceneViewer]() { const location = self.location.toString(); const locationUrl = new URL(location); - const modelUrl = new URL(this.src!, location); + const extraModels = Array.from(this.querySelectorAll('extra-model')) as + Array; + const extraUrlsList = + extraModels.map(m => m.src).filter(src => src != null) as + Array; + const firstSrc = this.src || extraUrlsList[0] || null; + if (!firstSrc) { + console.warn( + 'No src or extra-model provided for Scene Viewer fallback.'); + return; + } + let modelUrl = new URL(firstSrc, location); + + // Note: While it would be ideal to export and pass a composited GLB for + // multi-model scenes, Android's Scene Viewer app cannot securely read + // browser-generated `blob:` URIs due to cross-process security + // restrictions. Attempting to pass one will cause Scene Viewer to crash + // or fail silently. To prevent this, we intentionally skip exporting the + // scene and gracefully degrade to serving only the base model's remote + // URI. + if (modelUrl.hash) modelUrl.hash = ''; const params = new URLSearchParams(modelUrl.search); @@ -392,9 +413,17 @@ configuration or device capabilities'); 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; + const extraModels = Array.from(this.querySelectorAll('extra-model')) as + Array; + const extraUrlsList = + extraModels.map(m => m.src).filter(src => src != null) as + Array; + const firstSrc = this.src || extraUrlsList[0] || null; + if (firstSrc) { + const srcUrl = new URL(firstSrc, locationUrl); + if (srcUrl.hash) { + modelUrl.hash = srcUrl.hash; + } } } @@ -435,8 +464,8 @@ configuration or device capabilities'); await this[$triggerLoad](); - const {model, shadow, target} = this[$scene]; - if (model == null) { + const {models, shadow, target} = this[$scene]; + if (models.length === 0 || models[0] == null) { return ''; } @@ -452,18 +481,25 @@ configuration or device capabilities'); const exporter = new USDZExporter(); - target.remove(model); - model.position.copy(target.position); - model.updateWorldMatrix(false, true); + const exportGroup = new Object3D(); + exportGroup.position.copy(target.position); - const arraybuffer = await exporter.parseAsync(model, { + for (const m of models) { + target.remove(m); + exportGroup.add(m); + } + exportGroup.updateWorldMatrix(false, true); + + const arraybuffer = await exporter.parseAsync(exportGroup, { maxTextureSize: isNaN(this.arUsdzMaxTextureSize as any) ? Infinity : Math.max(parseInt(this.arUsdzMaxTextureSize), 16), }); - model.position.set(0, 0, 0); - target.add(model); + for (const m of models) { + exportGroup.remove(m); + target.add(m); + } const blob = new Blob([arraybuffer], { type: 'model/vnd.usdz+zip', diff --git a/packages/model-viewer/src/features/controls.ts b/packages/model-viewer/src/features/controls.ts index 33a43da884..3c5ba2ebf0 100644 --- a/packages/model-viewer/src/features/controls.ts +++ b/packages/model-viewer/src/features/controls.ts @@ -193,6 +193,7 @@ const maxCameraOrbitIntrinsics = (element: ModelViewerElementBase) => { }; export const cameraTargetIntrinsics = (element: ModelViewerElementBase) => { + element[$scene].updateBoundingBoxAndShadowIfDirty(); const center = element[$scene].boundingBox.getCenter(new Vector3()); return { diff --git a/packages/model-viewer/src/features/extra-model.ts b/packages/model-viewer/src/features/extra-model.ts new file mode 100644 index 0000000000..bb5a447286 --- /dev/null +++ b/packages/model-viewer/src/features/extra-model.ts @@ -0,0 +1,83 @@ +/* @license + * Copyright 2026 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 {ReactiveElement} from 'lit'; +import {property} from 'lit/decorators.js'; + +/** + * Declarative child element for adding multiple models to . + */ +export class ExtraModelElement extends ReactiveElement { + static get is() { + return 'extra-model'; + } + + @property({type: String}) src: string|null = null; + + /** + * Reference to the underlying scene graph Model wrapper. + */ + public model?: import('./scene-graph/model.js').Model; + + /** + * Position offset relative to global origin. + * Format: "x y z" in meters (e.g., "1 0 -0.5") + */ + @property({type: String}) offset: string|null = null; + + /** + * Rotation orientation. + * Format: "x y z" in degrees or radians (mirroring orientation format). + */ + @property({type: String}) orientation: string|null = null; + + /** + * Scale multiplier. + * Format: "x y z" or single number multiplier. + */ + @property({type: String}) scale: string|null = null; + + /** + * Transparently excludes model from AR and casting shadows. + */ + @property({type: Boolean}) background: boolean = false; + + updated(changedProperties: Map) { + super.updated(changedProperties); + + // Whenever attributes change, dispatch a customized event up to + // + if (changedProperties.has('src') || changedProperties.has('offset') || + changedProperties.has('orientation') || + changedProperties.has('scale') || changedProperties.has('background')) { + const srcChanged = changedProperties.has('src'); + + this.dispatchEvent(new CustomEvent('extra-model-changed', { + bubbles: true, + composed: true, + detail: { + srcChanged, + src: this.src, + offset: this.offset, + orientation: this.orientation, + scale: this.scale, + background: this.background + } + })); + } + } +} + +customElements.define('extra-model', ExtraModelElement); diff --git a/packages/model-viewer/src/features/loading.ts b/packages/model-viewer/src/features/loading.ts index fdb0f3da8e..5515e574e4 100644 --- a/packages/model-viewer/src/features/loading.ts +++ b/packages/model-viewer/src/features/loading.ts @@ -252,10 +252,12 @@ export const LoadingMixin = >( * turntable rotation. */ getDimensions(): Vector3D { + this[$scene].updateBoundingBoxAndShadowIfDirty(); return toVector3D(this[$scene].size); } getBoundingBoxCenter(): Vector3D { + this[$scene].updateBoundingBoxAndShadowIfDirty(); return toVector3D(this[$scene].boundingBox.getCenter(new Vector3())); } @@ -381,7 +383,8 @@ export const LoadingMixin = >( }; [$shouldAttemptPreload](): boolean { - return !!this.src && + const extraModels = Array.from(this.querySelectorAll('extra-model')); + return !!(this.src || extraModels.length > 0) && (this[$shouldDismissPoster] || this.loading === LoadingStrategy.EAGER || (this.reveal === RevealStrategy.AUTO && this[$isElementInViewport])); diff --git a/packages/model-viewer/src/features/scene-graph.ts b/packages/model-viewer/src/features/scene-graph.ts index f7977f70ad..e1563f2ab7 100644 --- a/packages/model-viewer/src/features/scene-graph.ts +++ b/packages/model-viewer/src/features/scene-graph.ts @@ -14,7 +14,7 @@ */ import {property} from 'lit/decorators.js'; -import {CanvasTexture, RepeatWrapping, SRGBColorSpace, Texture, VideoTexture} from 'three'; +import {CanvasTexture, Object3D, RepeatWrapping, SRGBColorSpace, Texture, VideoTexture} from 'three'; import {GLTFExporter, GLTFExporterOptions} from 'three/examples/jsm/exporters/GLTFExporter.js'; import ModelViewerElementBase, {$needsRender, $onModelLoad, $progressTracker, $renderer, $scene} from '../model-viewer-base.js'; @@ -33,6 +33,7 @@ import {Texture as ModelViewerTexture} from './scene-graph/texture.js'; export const $currentGLTF = Symbol('currentGLTF'); export const $originalGltfJson = Symbol('originalGltfJson'); export const $model = Symbol('model'); +export const $extraModels = Symbol('extraModels'); const $getOnUpdateMethod = Symbol('getOnUpdateMethod'); const $buildTexture = Symbol('buildTexture'); @@ -44,6 +45,7 @@ interface SceneExportOptions { export interface SceneGraphInterface { readonly model?: Model; + readonly extraModels: Model[]; variantName: string|null; readonly availableVariants: string[]; orientation: string; @@ -73,6 +75,7 @@ export const SceneGraphMixin = >( ModelViewerElement: T): Constructor&T => { class SceneGraphModelViewerElement extends ModelViewerElement { protected[$model]: Model|undefined = undefined; + protected[$extraModels]: Model[] = []; protected[$currentGLTF]: ModelViewerGLTFInstance|null = null; private[$originalGltfJson]: GLTF|null = null; @@ -90,6 +93,11 @@ export const SceneGraphMixin = >( return this[$model]; } + /** @export */ + get extraModels() { + return this[$extraModels]; + } + get availableVariants() { return this.model ? this.model[$availableVariants]() : [] as string[]; } @@ -203,7 +211,8 @@ export const SceneGraphMixin = >( [$onModelLoad]() { super[$onModelLoad](); - const {currentGLTF} = this[$scene]; + const {currentGLTFs} = this[$scene]; + const currentGLTF = currentGLTFs.length > 0 ? currentGLTFs[0] : null; if (currentGLTF != null) { const {correlatedSceneGraph} = currentGLTF; @@ -224,6 +233,24 @@ export const SceneGraphMixin = >( } } + this[$extraModels] = []; + const extraNodes = Array.from(this.querySelectorAll('extra-model')) as + Array; + + for (let i = 1; i < currentGLTFs.length; i++) { + const gltf = currentGLTFs[i]; + if (gltf != null && gltf.correlatedSceneGraph != null) { + const modelWrapper = + new Model(gltf.correlatedSceneGraph, this[$getOnUpdateMethod]()); + this[$extraModels].push(modelWrapper); + + // Link back to light-dom DOM node! + if (extraNodes[i - 1]) { + extraNodes[i - 1].model = modelWrapper; + } + } + } + this[$currentGLTF] = currentGLTF; } @@ -260,9 +287,26 @@ export const SceneGraphMixin = >( .register( (writer: any) => new GLTFExporterMaterialsVariantsExtension(writer)); + let exportTarget: Object3D; + if (scene.models.length > 1) { + exportTarget = new Object3D(); + for (const m of scene.models) { + exportTarget.add(m); + } + } else { + exportTarget = scene.models[0]; + } + exporter.parse( - scene.model, + exportTarget, (gltf: object) => { + if (scene.models.length > 1) { + for (const m of scene.models) { + scene.target.add(m); + } + } else { + scene.target.add(scene.models[0]); + } return resolve(new Blob( [opts.binary ? gltf as Blob : JSON.stringify(gltf)], { type: opts.binary ? 'application/octet-stream' : @@ -281,10 +325,6 @@ export const SceneGraphMixin = >( } materialFromPoint(pixelX: number, pixelY: number): Material|null { - const model = this[$model]; - if (model == null) { - return null; - } const scene = this[$scene]; const ndcCoords = scene.getNDC(pixelX, pixelY); const hit = scene.hitFromPoint(ndcCoords); @@ -292,7 +332,20 @@ export const SceneGraphMixin = >( return null; } - return model[$materialFromPoint](hit); + const model = this[$model]; + if (model != null) { + const material = model[$materialFromPoint](hit); + if (material != null) + return material; + } + + for (const extraModel of this[$extraModels]) { + const extraMaterial = extraModel[$materialFromPoint](hit); + if (extraMaterial != null) + return extraMaterial; + } + + return null; } } diff --git a/packages/model-viewer/src/features/scene-graph/model.ts b/packages/model-viewer/src/features/scene-graph/model.ts index 7ab2e400ec..920586a2f0 100644 --- a/packages/model-viewer/src/features/scene-graph/model.ts +++ b/packages/model-viewer/src/features/scene-graph/model.ts @@ -211,8 +211,8 @@ export class Model implements ModelInterface { return found == null ? null : found as PrimitiveNode; } - [$nodeFromPoint](hit: Intersection): PrimitiveNode { - return this[$hierarchy].find((node: Node) => { + [$nodeFromPoint](hit: Intersection): PrimitiveNode|null { + const found = this[$hierarchy].find((node: Node) => { if (node instanceof PrimitiveNode) { const primitive = node as PrimitiveNode; if (primitive.mesh === hit.object) { @@ -220,15 +220,13 @@ export class Model implements ModelInterface { } } return false; - }) as PrimitiveNode; + }); + return found ? found as PrimitiveNode : null; } - /** - * Intersects a ray with the Model and returns the first material whose - * object was intersected. - */ - [$materialFromPoint](hit: Intersection): Material { - return this[$nodeFromPoint](hit).getActiveMaterial(); + [$materialFromPoint](hit: Intersection): Material|null { + const node = this[$nodeFromPoint](hit); + return node ? node.getActiveMaterial() : null; } /** diff --git a/packages/model-viewer/src/model-viewer-base.ts b/packages/model-viewer/src/model-viewer-base.ts index 91718f6d72..db9b23feba 100644 --- a/packages/model-viewer/src/model-viewer-base.ts +++ b/packages/model-viewer/src/model-viewer-base.ts @@ -44,6 +44,8 @@ const $loaded = Symbol('loaded'); const $status = Symbol('status'); const $onFocus = Symbol('onFocus'); const $onBlur = Symbol('onBlur'); +const $onSlotChange = Symbol('onSlotChange'); +const $onExtraModelChanged = Symbol('onExtraModelChanged'); export const $updateSize = Symbol('updateSize'); export const $intersectionObserver = Symbol('intersectionObserver'); @@ -196,6 +198,54 @@ export default class ModelViewerElementBase extends ReactiveElement { protected[$defaultAriaLabel]: string; protected[$clearModelTimeout]: number|null = null; + [$onSlotChange] = () => { + if (!this[$scene]) + return; + + const extraModels = Array.from(this.querySelectorAll('extra-model')) as + Array; + const newExtraUrls = + extraModels.map(m => m.src).filter(src => src != null) as string[]; + const currentExtraUrls = this[$scene].extraUrls || []; + + // Only reload if the declarative list of components has + // modified its source set + if (newExtraUrls.join(',') !== currentExtraUrls.join(',') || + extraModels.length !== currentExtraUrls.length) { + this[$updateSource](); + } + }; + + [$onExtraModelChanged] = (event: Event) => { + const customEv = event as CustomEvent; + const targetNode = customEv.target as HTMLElement; + + const extraModels = Array.from(this.querySelectorAll('extra-model')); + const childIndex = (extraModels as HTMLElement[]).indexOf(targetNode); + + console.log(`[onExtraModelChanged] childIndex: ${childIndex} srcChanged: ${ + customEv.detail.srcChanged} offset: ${customEv.detail.offset}`); + + if (childIndex === -1) + return; + + const modelIndex = this.src ? childIndex + 1 : childIndex; + + if (customEv.detail.srcChanged) { + this[$loaded] = false; + this[$updateSource](); + } else { + // Apply Transforms + if (this[$scene]) { + this[$scene].updateModelTransforms( + modelIndex, + customEv.detail.offset, + customEv.detail.orientation, + customEv.detail.scale); + } + } + }; + protected[$fallbackResizeHandler] = debounce(() => { const boundingRect = this.getBoundingClientRect(); this[$updateSize](boundingRect); @@ -299,6 +349,8 @@ export default class ModelViewerElementBase extends ReactiveElement { const oldVisibility = this.modelIsVisible; this[$isElementInViewport] = entry.isIntersecting; this[$announceModelVisibility](oldVisibility); + console.log(`IntersectionObserver fired! isIntersecting: ${ + entry.isIntersecting}`); if (this[$isElementInViewport] && !this.loaded) { this[$updateSource](); } @@ -338,6 +390,13 @@ export default class ModelViewerElementBase extends ReactiveElement { this.addEventListener('focus', this[$onFocus]); this.addEventListener('blur', this[$onBlur]); + this.addEventListener('extra-model-changed', this[$onExtraModelChanged]); + + const defaultSlot = + this.shadowRoot!.querySelector('.slot.default slot') as HTMLSlotElement; + if (defaultSlot) { + defaultSlot.addEventListener('slotchange', this[$onSlotChange]); + } const renderer = this[$renderer]; renderer.addEventListener( @@ -368,6 +427,13 @@ export default class ModelViewerElementBase extends ReactiveElement { this.removeEventListener('focus', this[$onFocus]); this.removeEventListener('blur', this[$onBlur]); + this.removeEventListener('extra-model-changed', this[$onExtraModelChanged]); + + const defaultSlot = + this.shadowRoot!.querySelector('.slot.default slot') as HTMLSlotElement; + if (defaultSlot) { + defaultSlot.removeEventListener('slotchange', this[$onSlotChange]); + } const renderer = this[$renderer]; renderer.removeEventListener( @@ -389,11 +455,19 @@ export default class ModelViewerElementBase extends ReactiveElement { // though the value has effectively not changed, so we need to check to make // sure that the value has actually changed before changing the loaded flag. if (changedProperties.has('src')) { - if (this.src == null) { + const extraModels = Array.from(this.querySelectorAll('extra-model')) as + Array; + const extraUrlsList = + extraModels.map(m => m.src).filter(src => src != null) as + Array; + const extraUrlsMatch = + extraUrlsList.join(',') === (this[$scene].extraUrls || []).join(','); + + if (this.src == null && extraModels.length === 0) { this[$loaded] = false; this[$loadedTime] = 0; this[$scene].reset(); - } else if (this.src !== this[$scene].url) { + } else if (this.src !== this[$scene].url || !extraUrlsMatch) { this[$loaded] = false; this[$loadedTime] = 0; this[$updateSource](); @@ -406,7 +480,13 @@ export default class ModelViewerElementBase extends ReactiveElement { if (changedProperties.has('generateSchema')) { if (this.generateSchema) { - this[$scene].updateSchema(this.src); + const extraModels = Array.from(this.querySelectorAll('extra-model')) as + Array; + const extraUrlsList = + extraModels.map(m => m.src).filter(src => src != null) as + Array; + const heroSrc = this.src || extraUrlsList[0] || null; + this[$scene].updateSchema(heroSrc); } else { this[$scene].updateSchema(null); } @@ -523,7 +603,8 @@ export default class ModelViewerElementBase extends ReactiveElement { } [$shouldAttemptPreload](): boolean { - return !!this.src && this[$isElementInViewport]; + const extraModels = Array.from(this.querySelectorAll('extra-model')); + return !!(this.src || extraModels.length > 0) && this[$isElementInViewport]; } /** @@ -593,27 +674,37 @@ export default class ModelViewerElementBase extends ReactiveElement { */ async[$updateSource]() { const scene = this[$scene]; + const extraModels = Array.from(this.querySelectorAll('extra-model')) as + Array; + const extraUrlsList = + extraModels.map(m => m.src).filter(src => src != null) as Array; + const extraUrlsMatch = + extraUrlsList.join(',') === (scene.extraUrls || []).join(','); + + console.log(`[$updateSource] called! \nsrc: ${this.src}\nextraUrls: ${ + extraUrlsList.join(',')}\nloaded: ${this.loaded}`); + if (this.loaded || !this[$shouldAttemptPreload]() || - this.src === scene.url) { + (this.src === scene.url && extraUrlsMatch)) { + console.log('[$updateSource] BAILING OUT EARLY!'); return; } + const source = this.src; + const heroSrc = source || extraUrlsList[0] || null; + if (this.generateSchema) { - scene.updateSchema(this.src); + scene.updateSchema(heroSrc); } this[$updateStatus]('Loading'); - // If we are loading a new model, we need to stop the animation of - // the current one (if any is playing). Otherwise, we might lose - // the reference to the scene root and running actions start to - // throw exceptions and/or behave in unexpected ways: scene.stopAnimation(); const updateSourceProgress = this[$progressTracker].beginActivity('model-load'); - const source = this.src; try { const srcUpdated = scene.setSource( source, + extraUrlsList, (progress: number) => updateSourceProgress(clamp(progress, 0, 1) * 0.95)); @@ -621,6 +712,14 @@ export default class ModelViewerElementBase extends ReactiveElement { await Promise.all([srcUpdated, envUpdated]); + const extraModels = Array.from(this.querySelectorAll('extra-model')) as + Array; + extraModels.forEach((m, i) => { + const modelIndex = this.src ? i + 1 : i; + this[$scene].updateModelTransforms( + modelIndex, m.offset, m.orientation, m.scale); + }); + this[$markLoaded](); this[$onModelLoad](); @@ -641,7 +740,7 @@ export default class ModelViewerElementBase extends ReactiveElement { }); }); }); - this.dispatchEvent(new CustomEvent('load', {detail: {url: source}})); + this.dispatchEvent(new CustomEvent('load', {detail: {url: heroSrc}})); } catch (error) { this.dispatchEvent(new CustomEvent( 'error', {detail: {type: 'loadfailure', sourceError: error}})); diff --git a/packages/model-viewer/src/model-viewer.ts b/packages/model-viewer/src/model-viewer.ts index f0fca5a328..140d4ec74b 100644 --- a/packages/model-viewer/src/model-viewer.ts +++ b/packages/model-viewer/src/model-viewer.ts @@ -13,6 +13,8 @@ * limitations under the License. */ +import './features/extra-model.js'; + import {AnimationMixin} from './features/animation.js'; import {AnnotationMixin} from './features/annotation.js'; import {ARMixin} from './features/ar.js'; @@ -40,5 +42,6 @@ customElements.define('model-viewer', ModelViewerElement); declare global { interface HTMLElementTagNameMap { 'model-viewer': ModelViewerElement; + 'extra-model': import('./features/extra-model.js').ExtraModelElement; } } diff --git a/packages/model-viewer/src/test/features/animation-spec.ts b/packages/model-viewer/src/test/features/animation-spec.ts index 3634bcbc81..d4eb6502bf 100644 --- a/packages/model-viewer/src/test/features/animation-spec.ts +++ b/packages/model-viewer/src/test/features/animation-spec.ts @@ -29,7 +29,8 @@ const ANIMATED_GLB_DUPLICATE_ANIMATION_NAMES_PATH = assetPath('models/DuplicateAnimationNames.glb'); const animationIsPlaying = (element: any, animationName?: string): boolean => { - const {currentAnimationAction} = element[$scene]; + const currentAnimationAction = + (element[$scene] as any).currentAnimationActions[0]; if (currentAnimationAction != null && (animationName == null || @@ -44,8 +45,9 @@ const animationIsPlaying = (element: any, animationName?: string): boolean => { const animationWithIndexIsPlaying = (element: any, animationIndex = 0): boolean => { - const {currentAnimationAction} = element[$scene]; - const {_currentGLTF} = element[$scene]; + const currentAnimationAction = + (element[$scene] as any).currentAnimationActions[0]; + const _currentGLTF = (element[$scene] as any)._currentGLTFs[0]; if (currentAnimationAction != null && animationIndex >= 0 && animationIndex < _currentGLTF.animations.length && @@ -168,6 +170,22 @@ suite('Animation', () => { }); }); + suite('when appendAnimation is invoked', () => { + setup(async () => { + const appendEvent = waitForEvent(element, 'append-animation'); + element.appendAnimation('Punch', {weight: 1.0}); + await appendEvent; + }); + + test('unpauses the model', () => { + expect(element.paused).to.be.false; + }); + + test('adds the animation to appendedAnimations', () => { + expect(element.appendedAnimations).to.include('Punch'); + }); + }); + suite('when configured to autoplay', () => { setup(async () => { element.autoplay = true; diff --git a/packages/model-viewer/src/test/features/ar-spec.ts b/packages/model-viewer/src/test/features/ar-spec.ts index b638813454..a044e3b5c8 100644 --- a/packages/model-viewer/src/test/features/ar-spec.ts +++ b/packages/model-viewer/src/test/features/ar-spec.ts @@ -119,6 +119,27 @@ suite('AR', () => { expect(search.get('title')).to.equal('bar'); expect(search.get('link')).to.equal('http://linkme.com/'); }); + + test( + 'gracefully degrades to base model URI for multi-model scenes instead of crashing via blob:', + async () => { + element.src = assetPath('models/cube.gltf'); + const extra = document.createElement('extra-model'); + extra.setAttribute('src', assetPath('models/Horse.glb')); + element.appendChild(extra); + + await waitForEvent(element, 'load'); + (element as any)[$openSceneViewer](); + + expect(intentUrls.length).to.be.equal(1); + + const search = new URLSearchParams(new URL(intentUrls[0]).search); + const file = new URL(search.get('file') as any); + + // It must strictly equal the base model URL and NOT a blob URL + expect(file.protocol).to.not.equal('blob:'); + expect(file.pathname).to.include('cube.gltf'); + }); }); suite('openQuickLook', () => { diff --git a/packages/model-viewer/src/test/features/controls-spec.ts b/packages/model-viewer/src/test/features/controls-spec.ts index 91a3d8af45..a0f329816e 100644 --- a/packages/model-viewer/src/test/features/controls-spec.ts +++ b/packages/model-viewer/src/test/features/controls-spec.ts @@ -216,6 +216,7 @@ suite('Controls', () => { expect(fov).to.be.closeTo(DEFAULT_FOV_DEG, .001); element.setAttribute('style', 'width: 200px; height: 300px'); await rafPasses(); + await timePasses(50); await rafPasses(); expect(element.getFieldOfView()).to.be.greaterThan(fov); diff --git a/packages/model-viewer/src/test/features/extra-model-spec.ts b/packages/model-viewer/src/test/features/extra-model-spec.ts new file mode 100644 index 0000000000..0620eaba8a --- /dev/null +++ b/packages/model-viewer/src/test/features/extra-model-spec.ts @@ -0,0 +1,176 @@ +/* @license + * Copyright 2026 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 '../renderer-gate.js'; + +import {expect} from 'chai'; + +import {$scene} from '../../model-viewer-base.js'; +import {ModelViewerElement} from '../../model-viewer.js'; +import {CachingGLTFLoader} from '../../three-components/CachingGLTFLoader.js'; +import {timePasses, waitForEvent} from '../../utilities.js'; +import {assetPath} from '../helpers.js'; + +const CUBE_GLB_PATH = assetPath('models/cube.gltf'); + +suite('ExtraModel', () => { + let element: ModelViewerElement; + + setup(async () => { + element = new ModelViewerElement(); + document.body.appendChild(element); + await timePasses(); + }); + + teardown(() => { + CachingGLTFLoader.clearCache(); + if (element.parentNode != null) { + element.parentNode.removeChild(element); + } + }); + + suite('declarative transforms', () => { + test('appends extra-model and loads multiple mesh components', async () => { + element.loading = 'eager'; + element.src = CUBE_GLB_PATH; + + const extra = document.createElement('extra-model'); + extra.setAttribute('src', CUBE_GLB_PATH); + element.appendChild(extra); + + await waitForEvent(element, 'load'); + + const scene = (element as any)[$scene]; + expect(scene).to.be.ok; + expect(scene._models.length).to.equal(2); + }); + + test('updates position dynamically when offset changes', async () => { + element.loading = 'eager'; + element.src = CUBE_GLB_PATH; + + const extra = document.createElement('extra-model'); + extra.setAttribute('src', CUBE_GLB_PATH); + extra.setAttribute('offset', '1 0 0'); + element.appendChild(extra); + + await waitForEvent(element, 'load'); + + const scene = (element as any)[$scene]; + expect(scene._models[1].position.x).to.equal(1); + + // Update dynamically + extra.setAttribute('offset', '5 0 0'); + await timePasses(); // coordinates sync internally + + expect(scene._models[1].position.x).to.equal(5); + }); + + test( + 'does not calculate bounding box synchronously when offset changes', + async () => { + element.loading = 'eager'; + element.src = CUBE_GLB_PATH; + + const extra = document.createElement('extra-model'); + extra.setAttribute('src', CUBE_GLB_PATH); + extra.setAttribute('offset', '1 0 0'); + element.appendChild(extra); + + await waitForEvent(element, 'load'); + const scene = (element as any)[$scene]; + + // Ensure bounds are clean initially + scene.updateBoundingBoxAndShadowIfDirty(); + const oldMaxX = scene.boundingBox.max.x; + + // Change offset + extra.setAttribute('offset', '10 0 0'); + await timePasses(); // allow MutationObserver to trigger + // updateModelTransforms + + // The bounds should be dirty, but the actual boundingBox value hasn't + // mathematically updated yet! + expect(scene.boundsAndShadowDirty).to.be.true; + expect(scene.boundingBox.max.x).to.equal(oldMaxX); + + // However, reading via getDimensions flushes it + element.getDimensions(); + expect(scene.boundsAndShadowDirty).to.be.false; + expect(scene.boundingBox.max.x).to.be.greaterThan(oldMaxX); + }); + }); + + suite('animation duration syncing', () => { + test( + 'duration reflects the longest animation across all models', + async () => { + element.loading = 'eager'; + element.src = CUBE_GLB_PATH; // Base model has no animation + + const extra = document.createElement('extra-model'); + extra.setAttribute('src', assetPath('models/Horse.glb')); + extra.setAttribute('animation-name', 'horse_A_'); + element.appendChild(extra); + + await waitForEvent(element, 'load'); + element.play(); + await timePasses(); // Allow play state to initialize + // AnimationActions + + expect(element.duration) + .to.be.greaterThan(0); // Should be ~1 second (Horse animation) + }); + }); + + suite('hotspot attachment', () => { + test( + 'data-model-index forces hotspot attachment to extra model', + async () => { + element.loading = 'eager'; + element.src = CUBE_GLB_PATH; + + const extra = document.createElement('extra-model'); + extra.setAttribute('src', CUBE_GLB_PATH); + extra.setAttribute('offset', '5 0 0'); + element.appendChild(extra); + + const hotspot = document.createElement('button'); + hotspot.slot = 'hotspot-test'; + hotspot.setAttribute('data-model-index', '1'); + // Legacy 8-number string implies index 0 (base model). It should be + // overridden! + hotspot.setAttribute('data-surface', '0 0 10 11 12 0.3 0.3 0.4'); + element.appendChild(hotspot); + + await waitForEvent(element, 'load'); + + const scene = (element as any)[$scene]; + + // Find the internal Hotspot instance by checking children for the + // annotation wrapper class + const hotspotNode = scene._models[1].children.find( + (c: any) => c.element && + c.element.classList.contains('annotation-wrapper')); + + // Assert it is reparented to the extra model, NOT the base model + expect(hotspotNode).to.be.ok; + expect(hotspotNode.parent).to.equal(scene._models[1]); + + // Assert the internal modelIndex was coerced + expect(hotspotNode.modelIndex).to.equal(1); + }); + }); +}); diff --git a/packages/model-viewer/src/test/features/loading-spec.ts b/packages/model-viewer/src/test/features/loading-spec.ts index 5a1569c8f0..c882992b5e 100644 --- a/packages/model-viewer/src/test/features/loading-spec.ts +++ b/packages/model-viewer/src/test/features/loading-spec.ts @@ -18,7 +18,7 @@ import '../renderer-gate.js'; import {expect} from 'chai'; import {$defaultPosterElement, $posterContainerElement} from '../../features/loading.js'; -import {$scene, $userInputElement} from '../../model-viewer-base.js'; +import {$isElementInViewport, $scene, $userInputElement} from '../../model-viewer-base.js'; import {ModelViewerElement} from '../../model-viewer.js'; import {CachingGLTFLoader} from '../../three-components/CachingGLTFLoader.js'; import {timePasses, waitForEvent} from '../../utilities.js'; @@ -116,7 +116,7 @@ suite('Loading', () => { element.style.display = 'none'; // Give IntersectionObserver a chance to notify. - await until(() => element.modelIsVisible === false); + await until(() => (element as any)[$isElementInViewport] === false); element.src = CUBE_GLB_PATH; @@ -228,6 +228,63 @@ suite('Loading', () => { }); + suite('extra-model changes', () => { + test('loads models when only extra-model is provided', async () => { + element.loading = 'eager'; + const extraModel1 = document.createElement('extra-model') as any; + extraModel1.src = CUBE_GLB_PATH; + const extraModel2 = document.createElement('extra-model') as any; + extraModel2.src = HORSE_GLB_PATH; + + element.appendChild(extraModel1); + element.appendChild(extraModel2); + + const loadEvent = await waitForEvent(element, 'load') as CustomEvent; + expect(loadEvent.detail.url).to.be.eq(CUBE_GLB_PATH); + + const models = element[$scene].models; + expect(models.length).to.be.eq(2); + }); + + test( + 'loads models when both src and extra-model are provided', + async () => { + element.loading = 'eager'; + element.src = CUBE_GLB_PATH; + const extraModel = document.createElement('extra-model') as any; + extraModel.src = HORSE_GLB_PATH; + element.appendChild(extraModel); + + const loadEvent = + await waitForEvent(element, 'load') as CustomEvent; + expect(loadEvent.detail.url).to.be.eq(CUBE_GLB_PATH); + + const models = element[$scene].models; + expect(models.length).to.be.eq(2); + }); + + test('generates 3DModel schema with extra-model', async () => { + element.generateSchema = true; + const extraModel1 = document.createElement('extra-model') as any; + extraModel1.src = HORSE_GLB_PATH; + const extraModel2 = document.createElement('extra-model') as any; + extraModel2.src = CUBE_GLB_PATH; + element.appendChild(extraModel1); + element.appendChild(extraModel2); + + await waitForEvent(element, 'load'); + await element.updateComplete; + + const {schemaElement} = element[$scene]; + expect(schemaElement.type).to.be.eq('application/ld+json'); + + const json = JSON.parse(schemaElement.textContent!); + const encoding = json.encoding[0]; + + expect(encoding.contentUrl).to.be.eq(HORSE_GLB_PATH); + }); + }); + suite('reveal', () => { suite('auto', () => { test('hides poster when element loads', async () => { diff --git a/packages/model-viewer/src/test/features/scene-graph-spec.ts b/packages/model-viewer/src/test/features/scene-graph-spec.ts index b2577467f3..a4195b268d 100644 --- a/packages/model-viewer/src/test/features/scene-graph-spec.ts +++ b/packages/model-viewer/src/test/features/scene-graph-spec.ts @@ -41,7 +41,24 @@ function getGLTFRoot(scene: ModelScene, hasBeenExportedOnce = false) { // TODO: export is putting in an extra node layer, because the loader // gives us a Group, but if the exporter doesn't get a Scene, then it // wraps everything in an "AuxScene" node. Feels like a three.js bug. - return hasBeenExportedOnce ? scene.model!.children[0] : scene.model!; + // With multi-model we added an extra grouping level globally. + if (!hasBeenExportedOnce) + return scene.model!; + + // If exported, check for the extra node wrapper (AuxScene or otherwise). + if (scene.model!.children.length === 1 && + scene.model!.children[0].name.includes('AuxScene')) { + return scene.model!.children[0]; + } else if ( + scene.model!.children.length > 0 && !scene.model!.userData.variants) { + // Attempt unroll if it isn't an AuxScene but wraps + if (scene.model!.children[0].children && + scene.model!.children[0].children.length > 0) { + return scene.model!.children[0]; + } + } + + return scene.model!; } suite('SceneGraph', () => { @@ -142,9 +159,10 @@ suite('SceneGraph', () => { }); test('has variants', () => { + const gltfRoot = getGLTFRoot(element[$scene]); + expect(element[$scene].currentGLTF!.userData.variants.length) .to.be.eq(3); - const gltfRoot = getGLTFRoot(element[$scene]); expect(gltfRoot.children[0].userData.variantMaterials.size).to.be.eq(3); expect(gltfRoot.children[1].userData.variantMaterials.size).to.be.eq(3); }); @@ -227,9 +245,10 @@ suite('SceneGraph', () => { }); test('has variants', () => { + const gltfRoot = getGLTFRoot(element[$scene]); + expect(element[$scene].currentGLTF!.userData.variants.length) .to.be.eq(2); - const gltfRoot = getGLTFRoot(element[$scene]); expect( gltfRoot.children[0].children[0].userData.variantMaterials.size) .to.be.eq(2); diff --git a/packages/model-viewer/src/three-components/Hotspot.ts b/packages/model-viewer/src/three-components/Hotspot.ts index b15bc94adb..8d02b61ff7 100644 --- a/packages/model-viewer/src/three-components/Hotspot.ts +++ b/packages/model-viewer/src/three-components/Hotspot.ts @@ -36,6 +36,7 @@ export interface HotspotConfiguration { position?: string; normal?: string; surface?: string; + modelIndex?: number|null; } const a = new Vector3(); @@ -52,6 +53,7 @@ const quat = new Quaternion(); export class Hotspot extends CSS2DObject { public normal: Vector3 = new Vector3(0, 1, 0); public surface?: string; + public modelIndex?: number; public mesh?: Mesh; public tri?: Vector3; public bary?: Vector3; @@ -73,6 +75,10 @@ export class Hotspot extends CSS2DObject { this.updatePosition(config.position); this.updateNormal(config.normal); this.surface = config.surface; + + if (config.modelIndex != null) { + this.modelIndex = config.modelIndex; + } } get facingCamera(): boolean { diff --git a/packages/model-viewer/src/three-components/ModelScene.ts b/packages/model-viewer/src/three-components/ModelScene.ts index 70210ec53e..fc9793d8db 100644 --- a/packages/model-viewer/src/three-components/ModelScene.ts +++ b/packages/model-viewer/src/three-components/ModelScene.ts @@ -13,7 +13,7 @@ * limitations under the License. */ -import {AnimationAction, AnimationActionLoopStyles, AnimationClip, AnimationMixer, AnimationMixerEventMap, Box3, Camera, Euler, Event as ThreeEvent, LoopOnce, LoopPingPong, LoopRepeat, Material, Matrix3, Mesh, NeutralToneMapping, Object3D, PerspectiveCamera, Raycaster, Scene, Sphere, Texture, ToneMapping, Triangle, Vector2, Vector3, WebGLRenderer, XRTargetRaySpace} from 'three'; +import {AnimationAction, AnimationActionLoopStyles, AnimationClip, AnimationMixer, AnimationMixerEventMap, Box3, Camera, Euler, Event as ThreeEvent, Intersection, LoopOnce, LoopPingPong, LoopRepeat, Material, Matrix3, Matrix4, Mesh, NeutralToneMapping, Object3D, PerspectiveCamera, Raycaster, Scene, Sphere, Texture, ToneMapping, Triangle, Vector2, Vector3, WebGLRenderer, XRTargetRaySpace} from 'three'; import {CSS2DRenderer} from 'three/examples/jsm/renderers/CSS2DRenderer.js'; import {reduceVertices} from 'three/examples/jsm/utils/SceneUtils.js'; @@ -89,6 +89,7 @@ export class ModelScene extends Scene { xrCamera: Camera|null = null; url: string|null = null; + extraUrls: string[] = []; pivot = new Object3D(); target = new Object3D(); animationNames: Array = []; @@ -114,12 +115,14 @@ export class ModelScene extends Scene { private targetDamperY = new Damper(); private targetDamperZ = new Damper(); - private _currentGLTF: ModelViewerGLTFInstance|null = null; - private _model: Object3D|null = null; - private mixer: AnimationMixer; + private _currentGLTFs: ModelViewerGLTFInstance[] = []; + private _models: Object3D[] = []; + private boundsAndShadowDirty = false; + private mixers: AnimationMixer[] = []; + private mixerPausedStates: boolean[] = []; private cancelPendingSourceChange: (() => void)|null = null; private animationsByName: Map = new Map(); - private currentAnimationAction: AnimationAction|null = null; + private currentAnimationActions: (AnimationAction|null)[] = []; private groundedSkybox = new GroundedSkybox(); @@ -145,7 +148,8 @@ export class ModelScene extends Scene { this.target.name = 'Target'; - this.mixer = new AnimationMixer(this.target); + // Mixers will be array based + this.mixers = []; const {domElement} = this.annotationRenderer; const {style} = domElement; @@ -195,7 +199,7 @@ export class ModelScene extends Scene { */ async setObject(model: Object3D) { this.reset(); - this._model = model; + this._models = [model]; this.target.add(model); await this.setupScene(); } @@ -205,14 +209,16 @@ export class ModelScene extends Scene { */ async setSource( - url: string|null, + url: string|null, extraUrls: string[] = [], progressCallback: (progress: number) => void = () => {}) { - if (!url || url === this.url) { + 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); @@ -229,22 +235,32 @@ export class ModelScene extends Scene { this.cancelPendingSourceChange = null; } - let gltf: ModelViewerGLTFInstance; + let gltfs: ModelViewerGLTFInstance[] = []; try { - gltf = await new Promise((resolve, reject) => { - this.cancelPendingSourceChange = () => reject(); - - (async () => { - try { - const result = await this.element[$renderer].loader.load( - url, this.element, progressCallback); - resolve(result); - } catch (error) { - reject(error); - } - })(); - }); + const urlsToLoad: string[] = []; + 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 as ModelViewerGLTFInstance[]); + } catch (error) { + reject(error); + } + })(); + }); + } } catch (error) { if (error == null) { // Loading was cancelled, so silently return @@ -258,23 +274,36 @@ export class ModelScene extends Scene { this.cancelPendingSourceChange = null; this.reset(); this.url = url; - this._currentGLTF = gltf; - - if (gltf != null) { - this._model = gltf.scene; - this.target.add(gltf.scene); + 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 {animations} = gltf!; const animationsByName = new Map(); const animationNames = []; + const allAnimations = []; - for (const animation of animations) { - animationsByName.set(animation.name, animation); - animationNames.push(animation.name); + 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 = animations; + this.animations = allAnimations; this.animationsByName = animationsByName; this.animationNames = animationNames; @@ -293,6 +322,60 @@ export class ModelScene extends Scene { this.setGroundedSkybox(); } + updateModelTransforms( + index: number, offset?: string|null, _orientation?: string|null, + scale?: string|null) { + 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 `` 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; @@ -302,25 +385,33 @@ export class ModelScene extends Scene { } this.bakedShadows.clear(); - const {_model} = this; - if (_model != null) { - _model.removeFromParent(); - this._model = null; + const {_models} = this; + for (const mod of _models) { + if (mod != null) + mod.removeFromParent(); } + this._models = []; - const gltf = this._currentGLTF; - if (gltf != null) { - gltf.dispose(); - this._currentGLTF = null; + const gltfs = this._currentGLTFs; + for (const gltf of gltfs) { + if (gltf != null) + gltf.dispose(); } + this._currentGLTFs = []; - if (this.currentAnimationAction != null) { - this.currentAnimationAction.stop(); - this.currentAnimationAction = null; + for (const action of this.currentAnimationActions) { + if (action != null) { + action.stop(); + } } + this.currentAnimationActions = []; - this.mixer.stopAllAction(); - this.mixer.uncacheRoot(this); + for (const mixer of this.mixers) { + mixer.stopAllAction(); + mixer.uncacheRoot(this); + } + this.mixers = []; + this.mixerPausedStates = []; } dispose() { @@ -335,7 +426,11 @@ export class ModelScene extends Scene { } get currentGLTF() { - return this._currentGLTF; + return this._currentGLTFs[0] || null; + } + + get currentGLTFs() { + return this._currentGLTFs; } /** @@ -418,8 +513,8 @@ export class ModelScene extends Scene { } applyTransform() { - const {model} = this; - if (model == null) { + const {models} = this; + if (models.length === 0) { return; } const orientation = parseExpressions(this.element.orientation)[0] @@ -429,40 +524,55 @@ export class ModelScene extends Scene { const pitch = normalizeUnit(orientation[1]).number; const yaw = normalizeUnit(orientation[2]).number; - model.quaternion.setFromEuler(new Euler(pitch, yaw, roll, 'YXZ')); - const scale = parseExpressions(this.element.scale)[0] .terms as [NumberNode, NumberNode, NumberNode]; - model.scale.set(scale[0].number, scale[1].number, scale[2].number); + 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 {model} = this; - if (model == null) { + const {models} = this; + if (models.length === 0) { return; } - this.target.remove(model); - this.findBakedShadows(model); + for (const mod of models) { + this.target.remove(mod); + this.findBakedShadows(mod); + } const bound = (box: Box3, vertex: Vector3): Box3 => { return box.expandByPoint(vertex); }; this.setBakedShadowVisibility(false); - this.boundingBox = reduceVertices(model, bound, new Box3()); + + 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)); - this.boundingBox = reduceVertices(model, bound, new Box3()); + 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); - this.target.add(model); + for (const mod of models) { + this.target.add(mod); + } } /** @@ -474,11 +584,14 @@ export class ModelScene extends Scene { * one side instead of both. Proper choice of center can correct this. */ async updateFraming() { - const {model} = this; - if (model == null) { + const {models} = this; + if (models.length === 0) { return; } - this.target.remove(model); + + for (const mod of models) { + this.target.remove(mod); + } this.setBakedShadowVisibility(false); const {center} = this.boundingSphere; @@ -489,8 +602,13 @@ export class ModelScene extends Scene { const radiusSquared = (value: number, vertex: Vector3): number => { return Math.max(value, center!.distanceToSquared(vertex)); }; - this.boundingSphere.radius = - Math.sqrt(reduceVertices(model, radiusSquared, 0)); + + 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: number, vertex: Vector3): number => { vertex.sub(center!); @@ -498,11 +616,18 @@ export class ModelScene extends Scene { return Math.max( value, radiusXZ / (this.idealCameraDistance() - Math.abs(vertex.y))); }; - this.idealAspect = reduceVertices(model, horizontalTanFov, 0) / - Math.tan((this.framedFoVDeg / 2) * Math.PI / 180); + + 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(); - this.target.add(model); + for (const mod of models) { + this.target.add(mod); + } } setBakedShadowVisibility(visible: boolean = this.shadowIntensity <= 0) { @@ -656,7 +781,11 @@ export class ModelScene extends Scene { } get model() { - return this._model; + return this._models[0] || null; + } + + get models() { + return this._models; } /** @@ -674,44 +803,61 @@ export class ModelScene extends Scene { } set animationTime(value: number) { - this.mixer.setTime(value); + for (const mixer of this.mixers) { + mixer.setTime(value); + } this.queueShadowRender(); } get animationTime(): number { - if (this.currentAnimationAction != null) { - const loopCount = - Math.max((this.currentAnimationAction as any)._loopCount, 0); - if (this.currentAnimationAction.loop === LoopPingPong && - (loopCount & 1) === 1) { - return this.duration - this.currentAnimationAction.time - } else { - return this.currentAnimationAction.time; + let maxTime = 0; + + for (const action of this.currentAnimationActions) { + if (action != null) { + let currentTime = action.time; + const loopCount = Math.max((action as any)._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 0; + return maxTime; } set animationTimeScale(value: number) { - this.mixer.timeScale = value; + for (const mixer of this.mixers) { + mixer.timeScale = value; + } } get animationTimeScale(): number { - return this.mixer.timeScale; + return this.mixers.length > 0 ? this.mixers[0].timeScale : 1; } get duration(): number { - if (this.currentAnimationAction != null && - this.currentAnimationAction.getClip()) { - return this.currentAnimationAction.getClip().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 0; + return maxDuration; } get hasActiveAnimation(): boolean { - return this.currentAnimationAction != null; + return this.currentAnimationActions.some(action => action != null); } /** @@ -719,66 +865,84 @@ export class ModelScene extends Scene { * 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: string|null = null, crossfadeTime: number = 0, loopMode: AnimationActionLoopStyles = LoopRepeat, - repetitionCount: number = Infinity) { - if (this._currentGLTF == null) { - return; - } - const {animations} = this; - if (animations == null || animations.length === 0) { - return; - } + repetitionCount: number = Infinity, modelIndex: number|null = 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; - let animationClip = null; + // Collect animations specific to this model + const animations = gltf.animations || []; + if (animations.length === 0) + continue; - if (name != null) { - animationClip = this.animationsByName.get(name); + let animationClip = null; - if (animationClip == null) { - const parsedAnimationIndex = parseInt(name); + 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 (!isNaN(parsedAnimationIndex) && parsedAnimationIndex >= 0 && - parsedAnimationIndex < animations.length) { - animationClip = animations[parsedAnimationIndex]; + 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 {currentAnimationAction: lastAnimationAction} = this; - - const action = this.mixer.clipAction(animationClip, this); - - this.currentAnimationAction = action; - - if (this.element.paused) { - this.mixer.stopAllAction(); - } else { - action.paused = false; - if (lastAnimationAction != null && action !== lastAnimationAction) { - action.crossFadeFrom(lastAnimationAction, crossfadeTime, false); - } else if ( - this.animationTimeScale > 0 && - this.animationTime == this.duration) { - // This is a workaround for what I believe is a three.js bug. - this.animationTime = 0; - } + if (animationClip == null) { + animationClip = animations[0]; } - action.setLoop(loopMode, repetitionCount); + 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.enabled = true; - action.clampWhenFinished = true; - action.play(); - } catch (error) { - console.error(error); + action.setLoop(loopMode, repetitionCount); + action.enabled = true; + action.clampWhenFinished = true; + action.play(); + } catch (error) { + console.error(error); + } } } @@ -787,8 +951,9 @@ export class ModelScene extends Scene { repetitionCount: number = Infinity, weight: number = 1, timeScale: number = 1, fade: boolean|number|string = false, warp: boolean|number|string = false, relativeWarp: boolean = true, - time: null|number|string = null, needsToStop: boolean = false) { - if (this._currentGLTF == null || name === this.element.animationName) { + time: null|number|string = null, needsToStop: boolean = false, + modelIndex: number|null = null) { + if (this.currentGLTF == null || name === this.element.animationName) { return; } const {animations} = this; @@ -893,43 +1058,49 @@ export class ModelScene extends Scene { } try { - const action = this.mixer.existingAction(animationClip) || - this.mixer.clipAction(animationClip, this); - - const currentTimeScale = action.timeScale; - if (needsToStop && this.appendedAnimations.includes(name)) { if (!this.markedAnimations.map(e => e.name).includes(name)) { this.markedAnimations.push({name, loopMode, repetitionCount}); } } - if (typeof time === 'number') { - action.time = Math.min(Math.max(time, 0), animationClip.duration); - } + const startIndex = modelIndex != null ? modelIndex : 0; + const endIndex = modelIndex != null ? modelIndex + 1 : this.mixers.length; - if (shouldFade) { - action.fadeIn(fadeDuration); - } else if (weight >= 0) { - action.weight = Math.min(Math.max(weight, 0), 1); - } + 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 (shouldWarp) { - action.warp( - relativeWarp ? currentTimeScale : 0, timeScale, warpDuration); - } else { - action.timeScale = timeScale; - } + const currentTimeScale = action.timeScale; - if (!action.isRunning()) { - if (action.time == animationClip.duration) { - action.stop(); + 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(); } - action.setLoop(loopMode, repetitionCount); - action.paused = false; - action.enabled = true; - action.clampWhenFinished = true; - action.play(); } if (!this.appendedAnimations.includes(name)) { @@ -982,8 +1153,10 @@ export class ModelScene extends Scene { }; } - detachAnimation(name: string = '', fade: boolean|number|string = true) { - if (this._currentGLTF == null || name === this.element.animationName) { + detachAnimation( + name: string = '', fade: boolean|number|string = true, + modelIndex: number|null = null) { + if (this.currentGLTF == null || name === this.element.animationName) { return; } const {animations} = this; @@ -999,13 +1172,19 @@ export class ModelScene extends Scene { const {shouldFade, duration} = this.parseFadeValue(fade, true, 1.5); try { - const action = this.mixer.existingAction(animationClip) || - this.mixer.clipAction(animationClip, this); + const startIndex = modelIndex != null ? modelIndex : 0; + const endIndex = modelIndex != null ? modelIndex + 1 : this.mixers.length; - if (shouldFade) { - action.fadeOut(duration); - } else { - action.stop(); + 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 = @@ -1017,8 +1196,8 @@ export class ModelScene extends Scene { updateAnimationLoop( name: string = '', loopMode: AnimationActionLoopStyles = LoopRepeat, - repetitionCount: number = Infinity) { - if (this._currentGLTF == null || name === this.element.animationName) { + repetitionCount: number = Infinity, modelIndex: number|null = null) { + if (this.currentGLTF == null || name === this.element.animationName) { return; } const {animations} = this; @@ -1037,29 +1216,64 @@ export class ModelScene extends Scene { } try { - const action = this.mixer.existingAction(animationClip) || - this.mixer.clipAction(animationClip, this); - action.stop(); - action.setLoop(loopMode, repetitionCount); - action.play(); + 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.currentAnimationAction = null; - this.mixer.stopAllAction(); + this.currentAnimationActions.fill(null); + for (const mixer of this.mixers) { + mixer.stopAllAction(); + } + this.mixerPausedStates.fill(true); + } + + isAllAnimationsPaused(): boolean { + return this.mixerPausedStates.every(paused => paused); + } + + pauseAnimation(modelIndex: number|null = 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: number|null = 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: number) { - this.mixer.update(step); + for (let i = 0; i < this.mixers.length; i++) { + if (!this.mixerPausedStates[i]) { + this.mixers[i].update(step); + } + } this.queueShadowRender(); } subscribeMixerEvent( event: keyof AnimationMixerEventMap, callback: (...args: any[]) => void) { - this.mixer.addEventListener(event, callback); + for (const mixer of this.mixers) { + mixer.addEventListener(event, callback); + } } /** @@ -1076,6 +1290,7 @@ export class ModelScene extends Scene { } renderShadow(renderer: WebGLRenderer) { + this.updateBoundingBoxAndShadowIfDirty(); const shadow = this.shadow; if (shadow != null && shadow.needsUpdate == true) { shadow.render(renderer, this); @@ -1094,7 +1309,7 @@ export class ModelScene extends Scene { */ setShadowIntensity(shadowIntensity: number) { this.shadowIntensity = shadowIntensity; - if (this._currentGLTF == null) { + if (this.currentGLTF == null) { return; } this.setBakedShadowVisibility(); @@ -1148,14 +1363,29 @@ export class ModelScene extends Scene { return this.getHit(object); } + getModelIndexFromHit(hit: Intersection): number { + let current: Object3D|null = 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: Vector2, object: Object3D = this): - {position: Vector3, normal: Vector3, uv: Vector2|null}|null { + positionAndNormalFromPoint(ndcPosition: Vector2, object: Object3D = this): { + position: Vector3, + normal: Vector3, + uv: Vector2|null, + modelIndex?: number, worldToModel: Matrix4 + }|null { const hit = this.hitFromPoint(ndcPosition, object); if (hit == null) { return null; @@ -1167,8 +1397,11 @@ export class ModelScene extends Scene { new Matrix3().getNormalMatrix(hit.object.matrixWorld)) : raycaster.ray.direction.clone().multiplyScalar(-1); const uv = hit.uv ?? 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}; + return {position, normal, uv, modelIndex, worldToModel}; } /** @@ -1179,17 +1412,22 @@ export class ModelScene extends Scene { * even as the model animates. If the mesh is not hit, the result is null. */ surfaceFromPoint(ndcPosition: Vector2, object: Object3D = this): string|null { - const model = this.element.model; - if (model == null) { + const hit = this.hitFromPoint(ndcPosition, object); + if (hit == null || hit.face == null) { return null; } - const hit = this.hitFromPoint(ndcPosition, object); - if (hit == null || hit.face == null) { + const modelIndex = this.getModelIndexFromHit(hit); + const model = modelIndex === 0 ? this.element.model : + this.element.extraModels?.[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(); @@ -1204,17 +1442,42 @@ export class ModelScene extends Scene { const uvw = new Vector3(); tri.getBarycoord(mesh.worldToLocal(hit.point), uvw); - return `${meshes} ${primitives} ${a} ${b} ${c} ${uvw.x.toFixed(3)} ${ - uvw.y.toFixed(3)} ${uvw.z.toFixed(3)}`; + 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: 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: Hotspot) { - this.target.add(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. @@ -1223,36 +1486,77 @@ export class ModelScene extends Scene { } removeHotspot(hotspot: Hotspot) { - this.target.remove(hotspot); + if (hotspot.parent) { + hotspot.parent.remove(hotspot); + } } /** * Helper method to apply a function to all hotspots. */ forHotspots(func: (hotspot: Hotspot) => void) { - const {children} = this.target; + 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: Hotspot) { - if (hotspot.surface == null || this.element.model == null) { + if (hotspot.surface == null) { return; } const nodes = parseExpressions(hotspot.surface)[0].terms as NumberNode[]; - if (nodes.length != 8) { - console.warn(hotspot.surface + ' does not have exactly 8 numbers.'); + 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-model-index`) takes precedence over the parsed + // surface index. + const finalModelIndex = hotspot.modelIndex ?? parsedModelIndex; + + // Assign resolved modelIndex to the hotspot + hotspot.modelIndex = finalModelIndex; + + // Ensure physical attachment matches the logical model index + this.updateHotspotAttachment(hotspot); + + const model = finalModelIndex === 0 ? + this.element.model : + this.element.extraModels?.[finalModelIndex - 1]; + if (model == null) { return; } - const primitiveNode = - this.element.model[$nodeFromIndex](nodes[0].number, nodes[1].number); + + const primitiveNode = model[$nodeFromIndex]( + nodes[0 + offset].number, nodes[1 + offset].number); if (primitiveNode == null) { console.warn( hotspot.surface + @@ -1261,7 +1565,10 @@ export class ModelScene extends Scene { } const numVert = primitiveNode.mesh.geometry.attributes.position.count; - const tri = new Vector3(nodes[2].number, nodes[3].number, nodes[4].number); + const tri = new Vector3( + nodes[2 + offset].number, + nodes[3 + offset].number, + nodes[4 + offset].number); if (tri.x >= numVert || tri.y >= numVert || tri.z >= numVert) { console.warn( hotspot.surface + @@ -1269,7 +1576,10 @@ export class ModelScene extends Scene { return; } - const bary = new Vector3(nodes[5].number, nodes[6].number, nodes[7].number); + const bary = new Vector3( + nodes[5 + offset].number, + nodes[6 + offset].number, + nodes[7 + offset].number); hotspot.mesh = primitiveNode.mesh; hotspot.tri = tri; hotspot.bary = bary; diff --git a/packages/modelviewer.dev/data/docs.json b/packages/modelviewer.dev/data/docs.json index accd066064..51fba54643 100644 --- a/packages/modelviewer.dev/data/docs.json +++ b/packages/modelviewer.dev/data/docs.json @@ -15,6 +15,18 @@ "options": "any legal URL" } }, + { + "name": "<extra-model>", + "htmlName": "extra-model", + "description": "A child element that loads additional models into the same scene alongside the primary `src` model. This allows you to compose scenes dynamically while getting the benefits of automatic framing and lighting from a single `` element. You can add multiple `` elements. Attributes: `src`, `offset`, `scale`, `orientation`, `background`.", + "links": [ + "Related examples" + ], + "default": { + "default": "N/A", + "options": "N/A" + } + }, { "name": "alt", "htmlName": "alt", @@ -98,6 +110,15 @@ "options": "true, false" } }, + { + "name": "extraModels", + "htmlName": "extraModels", + "description": "This property is read-only. It returns an array of Model instances corresponding to each of the additional models loaded via `` elements. The elements are ordered the same as the `` elements in the DOM.", + "default": { + "default": "[]", + "options": "Array" + } + }, { "name": "modelIsVisible", "htmlName": "modelIsVisible", @@ -851,7 +872,7 @@ { "name": "hotspot-*", "htmlName": "hotspot", - "description": "Any child element under <model-viewer> with a slot name starting with \"hotspot\" will be aligned with the 3D model using its data-position and data-normal attributes in model coordinates, in the same format as camera-target. Alternately, the data-surface attribute can be used to instead specify the glTF mesh, primitive, three vertex indices and three barycentric coordinates to specify a surface point that will animate along with the mesh. It is recommended to use the surfaceFromPoint method to generate this string interactively. See the annotations example for details." + "description": "Any child element under <model-viewer> with a slot name starting with \"hotspot\" will be aligned with the 3D model using its data-position and data-normal attributes in model coordinates, in the same format as camera-target. Alternately, the data-surface attribute can be used to specify a surface point that will animate along with the mesh. You can anchor the hotspot to a specific model loaded via some `` using the data-model-index attribute (0 for primary, 1 for the first extra model, etc.). It is recommended to use the surfaceFromPoint method to generate this string interactively. See the multi-model example for details." } ] }, @@ -1029,14 +1050,14 @@ ], "Methods": [ { - "name": "play(options: {repetitions, pingpong})", + "name": "play(options: {repetitions, pingpong, modelIndex})", "htmlName": "play", - "description": "Causes animations to be played. You can specify the number of repetitions of the animation by setting the number of repetitions to any value greater than 0 (defaults to Infinity). Also if you set pingpong to true, alternately playing forward and backward (defaults to false). Use the autoplay attribute if you want animations to be played automatically. If there are no animations, nothing will happen, so make sure that the model is loaded before invoking this method." + "description": "Causes animations to be played. You can specify the number of repetitions of the animation by setting the number of repetitions to any value greater than 0 (defaults to Infinity). Also if you set pingpong to true, alternately playing forward and backward (defaults to false). You can optionally pass `modelIndex: number` to target animation playback for a specific model (0 for the primary model, 1 for the first `` element, etc.). Use the autoplay attribute if you want animations to be played automatically." }, { - "name": "pause()", + "name": "pause(options: {modelIndex})", "htmlName": "pause", - "description": "Causes animations to be paused. If you want to reset the current animation to the beginning, you should also set the currentTime property to 0." + "description": "Causes animations to be paused. If you want to reset the current animation to the beginning, you should also set the currentTime property to 0. You can optionally pass `modelIndex: number` to selectively pause animation playback for a specific model (0 for the primary model, 1 for the first `` element, etc.)." }, { "name": "appendAnimation(animationName, options: {repetitions, pingpong, weight, timeScale, time, fade, warp, relativeWarp})", diff --git a/packages/modelviewer.dev/data/examples.json b/packages/modelviewer.dev/data/examples.json index 476e060bd0..5f7c5070df 100644 --- a/packages/modelviewer.dev/data/examples.json +++ b/packages/modelviewer.dev/data/examples.json @@ -282,6 +282,10 @@ { "htmlId": "exporter", "name": "Exporter" + }, + { + "htmlId": "multiModel", + "name": "Multiple Models" } ] }, diff --git a/packages/modelviewer.dev/examples/scenegraph/index.html b/packages/modelviewer.dev/examples/scenegraph/index.html index b69f436145..4e6c36400d 100644 --- a/packages/modelviewer.dev/examples/scenegraph/index.html +++ b/packages/modelviewer.dev/examples/scenegraph/index.html @@ -1289,6 +1289,81 @@

Exporter

+
+
+
+
+
+

Multiple Models in One Scene

+

You can load additional models into the same scene using the <extra-model> element. + This allows you to compose scenes dynamically while getting the benefits of automatic framing and lighting + from a single <model-viewer> element. The AR modes (WebXR and iOS Quick Look) + will correctly handle and export all models loaded this way (Scene Viewer will gracefully degrade to displaying the base model).

+

You can access the secondary models and manipulate their materials independently via standard internal traversal structures. + Animations can be played or paused per model by passing { modelIndex: number }, and hotspots can be anchored to them using data-model-index.

+
+ + + +
+
+
+