Skip to content

Commit 292b753

Browse files
committed
fix(multi-model): resolve hotspot anchoring, test stability, and defer bounding box recalcs
1 parent 0d3956f commit 292b753

8 files changed

Lines changed: 138 additions & 15 deletions

File tree

packages/model-viewer-effects/src/test/utilities-spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ suite('Screenshot Baseline Test', () => {
7979
expect(renderer).to.not.be.undefined;
8080
element.jumpCameraToGoal();
8181
await rafPasses();
82+
await new Promise(resolve => setTimeout(resolve, 100)); // Allow shader recompilation frames to catch up on Chromium
8283
composerScreenshot = screenshot(element);
8384
await rafPasses();
8485
const screenshot2 = screenshot(element);
@@ -89,7 +90,8 @@ suite('Screenshot Baseline Test', () => {
8990
}
9091
});
9192

92-
test('Empty EffectComposer and base Renderer are identical', () => {
93+
test('Empty EffectComposer and base Renderer are identical', async () => {
94+
await new Promise(resolve => setTimeout(resolve, 100)); // Wait for potential rendering stabilization
9395
const similarity = CompareArrays(baseScreenshot, composerScreenshot);
9496
if (!Number.isNaN(similarity)) {
9597
expect(similarity).to.be.greaterThan(0.999);

packages/model-viewer/src/features/annotation.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ export const AnnotationMixin = <T extends Constructor<ModelViewerElementBase>>(
106106

107107
const scene = this[$scene];
108108
scene.forHotspots((hotspot) => {
109+
scene.updateHotspotAttachment(hotspot);
109110
scene.updateSurfaceHotspot(hotspot);
110111
});
111112
}

packages/model-viewer/src/features/controls.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ const maxCameraOrbitIntrinsics = (element: ModelViewerElementBase) => {
193193
};
194194

195195
export const cameraTargetIntrinsics = (element: ModelViewerElementBase) => {
196+
element[$scene].updateBoundingBoxAndShadowIfDirty();
196197
const center = element[$scene].boundingBox.getCenter(new Vector3());
197198

198199
return {

packages/model-viewer/src/features/loading.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,10 +252,12 @@ export const LoadingMixin = <T extends Constructor<ModelViewerElementBase>>(
252252
* turntable rotation.
253253
*/
254254
getDimensions(): Vector3D {
255+
this[$scene].updateBoundingBoxAndShadowIfDirty();
255256
return toVector3D(this[$scene].size);
256257
}
257258

258259
getBoundingBoxCenter(): Vector3D {
260+
this[$scene].updateBoundingBoxAndShadowIfDirty();
259261
return toVector3D(this[$scene].boundingBox.getCenter(new Vector3()));
260262
}
261263

packages/model-viewer/src/test/features/controls-spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ suite('Controls', () => {
216216
expect(fov).to.be.closeTo(DEFAULT_FOV_DEG, .001);
217217
element.setAttribute('style', 'width: 200px; height: 300px');
218218
await rafPasses();
219+
await timePasses(50);
219220
await rafPasses();
220221

221222
expect(element.getFieldOfView()).to.be.greaterThan(fov);

packages/model-viewer/src/test/features/extra-model-spec.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,5 +75,68 @@ suite('ExtraModel', () => {
7575

7676
expect(scene._models[1].position.x).to.equal(5);
7777
});
78+
79+
test('does not calculate bounding box synchronously when offset changes', async () => {
80+
element.loading = 'eager';
81+
element.src = CUBE_GLB_PATH;
82+
83+
const extra = document.createElement('extra-model');
84+
extra.setAttribute('src', CUBE_GLB_PATH);
85+
extra.setAttribute('offset', '1 0 0');
86+
element.appendChild(extra);
87+
88+
await waitForEvent(element, 'load');
89+
const scene = (element as any)[$scene];
90+
91+
// Ensure bounds are clean initially
92+
scene.updateBoundingBoxAndShadowIfDirty();
93+
const oldMaxX = scene.boundingBox.max.x;
94+
95+
// Change offset
96+
extra.setAttribute('offset', '10 0 0');
97+
await timePasses(); // allow MutationObserver to trigger updateModelTransforms
98+
99+
// The bounds should be dirty, but the actual boundingBox value hasn't mathematically updated yet!
100+
expect(scene.boundsAndShadowDirty).to.be.true;
101+
expect(scene.boundingBox.max.x).to.equal(oldMaxX);
102+
103+
// However, reading via getDimensions flushes it
104+
element.getDimensions();
105+
expect(scene.boundsAndShadowDirty).to.be.false;
106+
expect(scene.boundingBox.max.x).to.be.greaterThan(oldMaxX);
107+
});
108+
});
109+
110+
suite('hotspot attachment', () => {
111+
test('data-model-index forces hotspot attachment to extra model', async () => {
112+
element.loading = 'eager';
113+
element.src = CUBE_GLB_PATH;
114+
115+
const extra = document.createElement('extra-model');
116+
extra.setAttribute('src', CUBE_GLB_PATH);
117+
extra.setAttribute('offset', '5 0 0');
118+
element.appendChild(extra);
119+
120+
const hotspot = document.createElement('button');
121+
hotspot.slot = 'hotspot-test';
122+
hotspot.setAttribute('data-model-index', '1');
123+
// Legacy 8-number string implies index 0 (base model). It should be overridden!
124+
hotspot.setAttribute('data-surface', '0 0 10 11 12 0.3 0.3 0.4');
125+
element.appendChild(hotspot);
126+
127+
await waitForEvent(element, 'load');
128+
129+
const scene = (element as any)[$scene];
130+
131+
// Find the internal Hotspot instance by checking children for the annotation wrapper class
132+
const hotspotNode = scene._models[1].children.find((c: any) => c.element && c.element.classList.contains('annotation-wrapper'));
133+
134+
// Assert it is reparented to the extra model, NOT the base model
135+
expect(hotspotNode).to.be.ok;
136+
expect(hotspotNode.parent).to.equal(scene._models[1]);
137+
138+
// Assert the internal modelIndex was coerced
139+
expect(hotspotNode.modelIndex).to.equal(1);
140+
});
78141
});
79142
});

packages/model-viewer/src/three-components/ModelScene.ts

Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ export class ModelScene extends Scene {
117117

118118
private _currentGLTFs: ModelViewerGLTFInstance[] = [];
119119
private _models: Object3D[] = [];
120+
private boundsAndShadowDirty = false;
120121
private mixers: AnimationMixer[] = [];
121122
private mixerPausedStates: boolean[] = [];
122123
private cancelPendingSourceChange: (() => void)|null = null;
@@ -320,9 +321,8 @@ export class ModelScene extends Scene {
320321
this.setGroundedSkybox();
321322
}
322323

323-
updateModelTransforms(index: number, offset?: string|null, orientation?: string|null, scale?: string|null) {
324+
updateModelTransforms(index: number, offset?: string|null, _orientation?: string|null, scale?: string|null) {
324325
const model = this._models[index];
325-
console.log(`[ModelScene] updateModelTransforms index: ${index} modelFound: ${!!model} offset: ${offset} orientation: ${orientation}`);
326326
if (!model) return;
327327

328328
if (offset) {
@@ -342,11 +342,27 @@ export class ModelScene extends Scene {
342342
}
343343

344344
model.updateMatrixWorld(true);
345-
this.updateBoundingBox();
346-
this.updateShadow();
345+
// Defer bounding box and shadow recalculations.
346+
// If developers animate `<extra-model>` offset or scale properties via requestAnimationFrame,
347+
// recalculating bounding boxes synchronously every single frame here blocks the main thread and tanks frame rates.
348+
// Instead, we mark the bounds as dirty and wait for the render loop or a public dimensions getter to flush the changes.
349+
this.boundsAndShadowDirty = true;
347350
this.queueRender();
348351
}
349352

353+
/**
354+
* Evaluates bounding box recalculations asynchronously.
355+
* Flushed right before a frame is rendered or when dimension properties are formally queried
356+
* to ensure that high-frequency layout changes don't stall execution natively.
357+
*/
358+
updateBoundingBoxAndShadowIfDirty() {
359+
if (this.boundsAndShadowDirty) {
360+
this.boundsAndShadowDirty = false;
361+
this.updateBoundingBox();
362+
this.updateShadow();
363+
}
364+
}
365+
350366
reset() {
351367
this.url = null;
352368
this.renderCount = 0;
@@ -1241,6 +1257,7 @@ export class ModelScene extends Scene {
12411257
}
12421258

12431259
renderShadow(renderer: WebGLRenderer) {
1260+
this.updateBoundingBoxAndShadowIfDirty();
12441261
const shadow = this.shadow;
12451262
if (shadow != null && shadow.needsUpdate == true) {
12461263
shadow.render(renderer, this);
@@ -1397,9 +1414,25 @@ export class ModelScene extends Scene {
13971414
* The following methods are for operating on the set of Hotspot objects
13981415
* attached to the scene. These come from DOM elements, provided to slots
13991416
* by the Annotation Mixin.
1417+
/**
1418+
* Evaluates the intended `modelIndex` of the hotspot and safely reparents it
1419+
* to the corresponding `Object3D` node mapped inside this scene's `_models` array.
1420+
* This guarantees that declarative offset and layout transforms affect positional anchors.
14001421
*/
1422+
updateHotspotAttachment(hotspot: Hotspot) {
1423+
const targetNode = (hotspot.modelIndex != null && hotspot.modelIndex > 0 && this._models[hotspot.modelIndex])
1424+
? this._models[hotspot.modelIndex]
1425+
: this.target;
1426+
1427+
if (hotspot.parent !== targetNode) {
1428+
targetNode.add(hotspot);
1429+
hotspot.updatePosition(hotspot.position.toArray().join(' ') + 'm'); // Force bounds sync to fresh parent
1430+
hotspot.updateMatrixWorld(true);
1431+
}
1432+
}
1433+
14011434
addHotspot(hotspot: Hotspot) {
1402-
this.target.add(hotspot);
1435+
this.updateHotspotAttachment(hotspot);
14031436
// This happens automatically in render(), but we do it early so that
14041437
// the slots appear in the shadow DOM and the elements get attached,
14051438
// allowing us to dispatch events on them.
@@ -1408,20 +1441,35 @@ export class ModelScene extends Scene {
14081441
}
14091442

14101443
removeHotspot(hotspot: Hotspot) {
1411-
this.target.remove(hotspot);
1444+
if (hotspot.parent) {
1445+
hotspot.parent.remove(hotspot);
1446+
}
14121447
}
14131448

14141449
/**
14151450
* Helper method to apply a function to all hotspots.
14161451
*/
14171452
forHotspots(func: (hotspot: Hotspot) => void) {
1418-
const {children} = this.target;
1453+
const children = [...this.target.children];
14191454
for (let i = 0, l = children.length; i < l; i++) {
14201455
const hotspot = children[i];
14211456
if (hotspot instanceof Hotspot) {
14221457
func(hotspot);
14231458
}
14241459
}
1460+
1461+
// Also traverse extra models to find any hotspots already reparented to them
1462+
for (const model of this._models) {
1463+
if (model && model !== this.target) {
1464+
const extraChildren = [...model.children];
1465+
for (let i = 0, l = extraChildren.length; i < l; i++) {
1466+
const hotspot = extraChildren[i];
1467+
if (hotspot instanceof Hotspot) {
1468+
func(hotspot);
1469+
}
1470+
}
1471+
}
1472+
}
14251473
}
14261474

14271475
/**
@@ -1439,18 +1487,22 @@ export class ModelScene extends Scene {
14391487

14401488
// Determine format: 8 numbers = legacy (index 0), 9 numbers = indexed
14411489
const isLegacy = nodes.length === 8;
1442-
const modelIndex = isLegacy ? 0 : nodes[0].number;
1490+
const parsedModelIndex = isLegacy ? 0 : nodes[0].number;
14431491
const offset = isLegacy ? 0 : 1;
14441492

1445-
const model = modelIndex === 0 ? this.element.model : this.element.extraModels?.[modelIndex - 1];
1493+
// DOM attribute (`data-model-index`) takes precedence over the parsed surface index.
1494+
const finalModelIndex = hotspot.modelIndex ?? parsedModelIndex;
1495+
1496+
// Assign resolved modelIndex to the hotspot
1497+
hotspot.modelIndex = finalModelIndex;
1498+
1499+
// Ensure physical attachment matches the logical model index
1500+
this.updateHotspotAttachment(hotspot);
1501+
1502+
const model = finalModelIndex === 0 ? this.element.model : this.element.extraModels?.[finalModelIndex - 1];
14461503
if (model == null) {
14471504
return;
14481505
}
1449-
1450-
// Assign parsed modelIndex to the hotspot if not already set manually
1451-
if (hotspot.modelIndex == null) {
1452-
hotspot.modelIndex = modelIndex;
1453-
}
14541506

14551507
const primitiveNode =
14561508
model[$nodeFromIndex](nodes[0 + offset].number, nodes[1 + offset].number);

packages/modelviewer.dev/examples/scenegraph/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,6 +1308,7 @@ <h2 class="demo-title">Multiple Models in One Scene</h2>
13081308
shadow-intensity="1" camera-controls touch-action="pan-y" ar alt="A multi-model scene">
13091309
<extra-model id="duck-model" src="../../shared-assets/models/glTF-Sample-Assets/Models/Duck/glTF-Binary/Duck.glb" offset="-2 0 0"></extra-model>
13101310
<extra-model src="../../shared-assets/models/glTF-Sample-Assets/Models/BoxAnimated/glTF-Binary/BoxAnimated.glb" offset="2 0 0"></extra-model>
1311+
<button slot="hotspot-duck" class="hotspot" data-model-index="1" data-surface="0 0 10 11 12 0.5 0.5 0" style="padding: 4px; border-radius: 4px; border: none;">Duck</button>
13111312
<div class="controls glass">
13121313
<button id="toggle-animations">Play/Pause Box Animation</button>
13131314
<button id="color-duck">Randomize Duck Color</button>

0 commit comments

Comments
 (0)