@@ -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 ) ;
0 commit comments