@preference-sl/pref-viewer 2.13.0-beta.7 → 2.13.0-beta.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.13.0-beta.7",
3
+ "version": "2.13.0-beta.8",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -6,22 +6,32 @@ import OpeningAnimation from "./babylonjs-animation-opening.js";
6
6
  * BabylonJSAnimationController - Manages animation playback, highlighting, and interactive controls for animated nodes in Babylon.js scenes.
7
7
  *
8
8
  * Summary:
9
- * This class detects, groups, and manages opening/closing animations for scene nodes, provides interactive highlighting of animated nodes and their meshes, and displays a menu for animation control. It is designed for integration with product configurators and interactive 3D applications using Babylon.js.
9
+ * This class detects and groups opening/closing animations, manages hover highlighting for animated nodes,
10
+ * exposes whether interactive animations are available, and coordinates the contextual animation menu.
10
11
  *
11
12
  * Key features:
12
13
  * - Detects and groups opening/closing animations in the scene.
13
14
  * - Tracks animated transformation nodes and their relationships to meshes.
15
+ * - Exposes animation availability through `hasAnimations()`.
14
16
  * - Highlights animated nodes and their child meshes on pointer hover.
17
+ * - Avoids redundant highlight rebuilds using cached mesh/node IDs and order-insensitive node-id comparison.
18
+ * - Reports highlight-state deltas from `highlightMeshes()` so callers can react efficiently.
15
19
  * - Displays and disposes the animation control menu for animated nodes.
16
- * - Provides public API for highlighting, showing the animation menu, and disposing resources.
20
+ * - Provides public API for availability checks, highlighting, showing the animation menu, and disposing resources.
17
21
  * - Cleans up all resources and observers to prevent memory leaks.
18
22
  *
19
23
  * Public Methods:
20
24
  * - dispose(): Disposes all resources managed by the animation controller.
21
- * - highlightMeshes(pickingInfo): Highlights meshes that are children of an animated node when hovered.
25
+ * - hasAnimations(): Returns whether opening/closing animations are available.
26
+ * - highlightMeshes(pickingInfo): Updates hover highlighting and returns `{ changed, highlighted }`.
22
27
  * - hideMenu(): Hides and disposes the animation control menu if it exists.
23
28
  * - showMenu(pickingInfo): Displays the animation control menu for the animated node under the pointer.
24
29
  *
30
+ * Private Methods (high level):
31
+ * - #haveSameNodeIds(arr1, arr2): Compares hovered animated node sets ignoring order.
32
+ * - #addHighlight(nodeIds): Applies highlighting with HighlightLayer or overlay meshes.
33
+ * - #removeHighlight(): Clears current highlights and cached hover state.
34
+ *
25
35
  * @class
26
36
  */
27
37
  export default class BabylonJSAnimationController {
@@ -37,6 +47,7 @@ export default class BabylonJSAnimationController {
37
47
  #overlayLayer = null;
38
48
  #useHighlightLayer = false; // Set to true to use HighlightLayer (better performance) and false to use overlay meshes (UtilityLayerRenderer - always on top)
39
49
  #lastHighlightedMeshId = null; // Cache to avoid redundant highlight updates
50
+ #lastHighlightedNodeIds = []; // Cache to avoid redundant highlight updates
40
51
 
41
52
  /**
42
53
  * Creates a new BabylonJSAnimationController for a Babylon.js scene.
@@ -126,15 +137,18 @@ export default class BabylonJSAnimationController {
126
137
  * @returns {Array<string>} Array of animated node IDs associated with the mesh.
127
138
  */
128
139
  #getNodesAnimatedByMesh(mesh) {
129
- let nodeId = [];
140
+ let nodeIds = [];
141
+ if (!mesh || !mesh.id) {
142
+ return nodeIds;
143
+ }
130
144
  let node = mesh;
131
145
  while (node.parent !== null) {
132
146
  node = node.parent;
133
- if (this.#animatedNodes.includes(node.id) && !nodeId.includes(node.id)) {
134
- nodeId.push(node.id);
147
+ if (this.#animatedNodes.includes(node.id) && !nodeIds.includes(node.id)) {
148
+ nodeIds.push(node.id);
135
149
  }
136
150
  }
137
- return nodeId;
151
+ return nodeIds;
138
152
  }
139
153
 
140
154
  /**
@@ -308,62 +322,60 @@ export default class BabylonJSAnimationController {
308
322
  }
309
323
 
310
324
  /**
311
- * ---------------------------
312
- * Public methods
313
- * ---------------------------
325
+ * Compares two node ID arrays and checks whether they contain the same values,
326
+ * regardless of element order.
327
+ * @private
328
+ * @param {string[]} arr1 - First node ID array.
329
+ * @param {string[]} arr2 - Second node ID array.
330
+ * @returns {boolean} True when both arrays contain the same node IDs; otherwise false.
314
331
  */
332
+ #haveSameNodeIds(arr1, arr2) {
333
+ if (arr1.length !== arr2.length) {
334
+ return false;
335
+ }
336
+ const sortedArr1 = [...arr1].sort();
337
+ const sortedArr2 = [...arr2].sort();
338
+ return sortedArr1.every((value, index) => value === sortedArr2[index]);
339
+ }
315
340
 
316
341
  /**
317
- * Disposes all resources managed by the animation controller.
318
- * Cleans up the highlight layer, animation menu, and internal animation/node lists.
319
- * Should be called when the controller is no longer needed to prevent memory leaks.
320
- * @public
342
+ * Clears the current highlight state from either HighlightLayer or overlay meshes.
343
+ * Also resets cached mesh/node tracking used to avoid redundant highlight updates.
344
+ * @private
345
+ * @returns {void}
321
346
  */
322
- dispose() {
323
- if (this.#highlightLayer) {
324
- this.#highlightLayer.removeAllMeshes();
325
- this.#highlightLayer.dispose();
326
- this.#highlightLayer = null;
327
- }
328
- if (this.#overlayLayer) {
329
- this.#overlayLayer.dispose();
330
- this.#overlayLayer = null;
347
+ #removeHighlight() {
348
+ if (this.#useHighlightLayer) {
349
+ if (this.#highlightLayer) {
350
+ this.#highlightLayer.removeAllMeshes();
351
+ }
352
+ } else {
353
+ if (this.#overlayLayer) {
354
+ this.#overlayLayer.dispose();
355
+ this.#overlayLayer = null;
356
+ }
331
357
  }
332
- this.hideMenu();
333
- this.#animatedNodes = [];
334
- this.#openingAnimations.forEach((openingAnimation) => openingAnimation.dispose());
335
- this.#openingAnimations = [];
358
+ this.#lastHighlightedMeshId = null;
359
+ this.#lastHighlightedNodeIds = [];
336
360
  }
337
361
 
338
362
  /**
339
- * Highlights meshes that are children of an animated node when hovered.
340
- * @public
341
- * @param {PickingInfo} pickingInfo - Raycast info from pointer position.
363
+ * Applies highlighting for all meshes belonging to the provided animated node IDs.
364
+ * Depending on configuration, it uses Babylon's HighlightLayer or overlay meshes rendered
365
+ * in a utility layer for always-on-top outlines/fills.
366
+ * @private
367
+ * @param {string[]} nodeIds - Animated node IDs whose child meshes should be highlighted.
368
+ * @returns {void}
342
369
  */
343
- highlightMeshes(pickingInfo) {
344
- // Check if we're hovering the same mesh to avoid recreating overlays/highlights
345
- const pickedMeshId = pickingInfo?.pickedMesh?.id;
346
- if (this.#lastHighlightedMeshId === pickedMeshId) {
347
- return; // No need to update if hovering the same mesh
370
+ #addHighlight(nodeIds) {
371
+ this.#removeHighlight();
372
+ if (!nodeIds.length) {
373
+ return;
348
374
  }
349
- this.#lastHighlightedMeshId = pickedMeshId;
350
-
351
375
  if (this.#useHighlightLayer) {
352
376
  if (!this.#highlightLayer) {
353
377
  this.#highlightLayer = new HighlightLayer("hl_animations", this.#scene);
354
378
  }
355
-
356
- this.#highlightLayer.removeAllMeshes();
357
-
358
- if (!pickingInfo?.hit || !pickingInfo?.pickedMesh) {
359
- return;
360
- }
361
-
362
- const nodeIds = this.#getNodesAnimatedByMesh(pickingInfo.pickedMesh);
363
- if (!nodeIds.length) {
364
- return;
365
- }
366
-
367
379
  const transformNodes = [];
368
380
  nodeIds.forEach((nodeId) => {
369
381
  const transformNode = this.#scene.getTransformNodeByID(nodeId);
@@ -383,20 +395,6 @@ export default class BabylonJSAnimationController {
383
395
  }
384
396
  });
385
397
  } else {
386
- if (this.#overlayLayer) {
387
- this.#overlayLayer.dispose();
388
- this.#overlayLayer = null;
389
- }
390
-
391
- if (!pickingInfo?.hit || !pickingInfo?.pickedMesh) {
392
- return;
393
- }
394
-
395
- const nodeIds = this.#getNodesAnimatedByMesh(pickingInfo.pickedMesh);
396
- if (!nodeIds.length) {
397
- return;
398
- }
399
-
400
398
  const meshes = [];
401
399
  nodeIds.forEach((nodeId) => {
402
400
  const transformNode = this.#scene.getTransformNodeByID(nodeId);
@@ -408,6 +406,99 @@ export default class BabylonJSAnimationController {
408
406
 
409
407
  this.#overlayLayer = this.#addGroupOutlineOverlayForInstances(meshes);
410
408
  }
409
+ this.#lastHighlightedNodeIds = nodeIds;
410
+ }
411
+
412
+ /**
413
+ * ---------------------------
414
+ * Public methods
415
+ * ---------------------------
416
+ */
417
+
418
+ /**
419
+ * Disposes all resources managed by the animation controller.
420
+ * Cleans up the highlight layer, animation menu, and internal animation/node lists.
421
+ * Should be called when the controller is no longer needed to prevent memory leaks.
422
+ * @public
423
+ */
424
+ dispose() {
425
+ if (this.#highlightLayer) {
426
+ this.#highlightLayer.removeAllMeshes();
427
+ this.#highlightLayer.dispose();
428
+ this.#highlightLayer = null;
429
+ }
430
+ if (this.#overlayLayer) {
431
+ this.#overlayLayer.dispose();
432
+ this.#overlayLayer = null;
433
+ }
434
+ this.hideMenu();
435
+ this.#animatedNodes = [];
436
+ this.#openingAnimations.forEach((openingAnimation) => openingAnimation.dispose());
437
+ this.#openingAnimations = [];
438
+ }
439
+
440
+ /**
441
+ * Indicates whether the current asset container exposes at least one opening/closing animation pair.
442
+ * @public
443
+ * @returns {boolean} True when interactive opening animations are available; otherwise false.
444
+ */
445
+ hasAnimations() {
446
+ return this.#openingAnimations.length > 0;
447
+ }
448
+
449
+ /**
450
+ * Updates hover highlighting for meshes under animated nodes.
451
+ * Uses cached mesh/node state to avoid rebuilding highlight layers when the effective
452
+ * highlighted node set has not changed.
453
+ * @public
454
+ * @param {PickingInfo} pickingInfo - Raycast info from pointer position.
455
+ * @returns {{changed:boolean, highlighted:boolean}}
456
+ * Returns whether highlight visuals changed in this call and whether any mesh remains highlighted.
457
+ */
458
+ highlightMeshes(pickingInfo) {
459
+ let changed = false;
460
+ let highlighted = false;
461
+
462
+ // Check if we're hovering the same mesh to avoid recreating overlays/highlights
463
+ const pickedMeshId = !pickingInfo?.hit || !pickingInfo?.pickedMesh?.id ? null : pickingInfo.pickedMesh.id;
464
+ const pickedMesh = pickingInfo?.pickedMesh ? pickingInfo.pickedMesh : null;
465
+
466
+ if (this.#lastHighlightedMeshId === pickedMeshId) {
467
+ // No need to update if hovering the same mesh
468
+ if (this.#lastHighlightedMeshId !== null) {
469
+ changed = false;
470
+ highlighted = true;
471
+ } else {
472
+ changed = false;
473
+ highlighted = false;
474
+ }
475
+ } else {
476
+ const nodeIds = this.#getNodesAnimatedByMesh(pickedMesh);
477
+ if (!nodeIds.length) {
478
+ if (this.#lastHighlightedMeshId !== null) {
479
+ this.#removeHighlight();
480
+ changed = true;
481
+ highlighted = false;
482
+ } else {
483
+ changed = false;
484
+ highlighted = false;
485
+ }
486
+ } else {
487
+ if (this.#haveSameNodeIds(nodeIds, this.#lastHighlightedNodeIds)) {
488
+ // No need to update if hovering a mesh under the same animated nodes
489
+ changed = false;
490
+ highlighted = true;
491
+ } else {
492
+ // Hovering a different mesh or same mesh under different animated nodes - update highlight
493
+ this.#addHighlight(nodeIds);
494
+ changed = true;
495
+ highlighted = true;
496
+ }
497
+ this.#lastHighlightedMeshId = pickedMeshId;
498
+ }
499
+ }
500
+
501
+ return { changed: changed, highlighted: highlighted };
411
502
  }
412
503
 
413
504
  /**
@@ -8,6 +8,7 @@ import OpeningAnimationMenu from "./babylonjs-animation-opening-menu.js";
8
8
  * - Tracks animation state (paused, closed, opened, opening, closing).
9
9
  * - Synchronizes animation progress and UI controls.
10
10
  * - Handles loop mode and progress threshold logic.
11
+ * - Dispatches `prefviewer-animation-update` events with animation state snapshots.
11
12
  * - Provides methods for play, pause, go to opened/closed, and progress control.
12
13
  * - Manages the animation control menu (OpeningAnimationMenu) and its callbacks.
13
14
  *
@@ -36,9 +37,11 @@ import OpeningAnimationMenu from "./babylonjs-animation-opening-menu.js";
36
37
  * - #getCurrentFrame(): Gets the current frame based on state.
37
38
  * - #getFrameFromProgress(progress): Calculates frame from progress value.
38
39
  * - #getProgress(): Calculates progress (0-1) from current frame.
40
+ * - #getPrefViewer3DComponent(): Resolves and caches the nearest `pref-viewer-3d` host element.
39
41
  * - #checkProgress(progress): Applies threshold logic to progress.
42
+ * - #notifyStateChange(): Emits `prefviewer-animation-update` with state/progress/loop metadata.
40
43
  * - #updateControlsSlider(): Updates the slider in the control menu.
41
- * - #updateControls(): Updates all controls in the menu.
44
+ * - #updateControls(): Emits state updates and syncs controls when the menu is visible.
42
45
  */
43
46
  export default class OpeningAnimation {
44
47
  static states = {
@@ -54,6 +57,7 @@ export default class OpeningAnimation {
54
57
  #closeAnimation = null;
55
58
  #nodes = [];
56
59
  #menu = null;
60
+ #prefViewer3D = undefined; // Reference to parent custom elements for event dispatching
57
61
 
58
62
  #state = OpeningAnimation.states.closed;
59
63
  #lastPausedFrame = 0;
@@ -271,6 +275,29 @@ export default class OpeningAnimation {
271
275
  return progress;
272
276
  }
273
277
 
278
+ /**
279
+ * Resolves and caches the closest `pref-viewer-3d` host associated with the animation canvas.
280
+ * @private
281
+ * @returns {void}
282
+ */
283
+ #getPrefViewer3DComponent() {
284
+ if (this.#prefViewer3D !== undefined) {
285
+ return;
286
+ }
287
+
288
+ const scene = this.#openAnimation?.getScene?.();
289
+ const engine = scene?.getEngine?.();
290
+ const canvas = engine?.getRenderingCanvas?.();
291
+
292
+ let prefViewer3D = canvas?.closest?.("pref-viewer-3d") || null;
293
+ if (!prefViewer3D) {
294
+ const host = canvas?.getRootNode?.()?.host;
295
+ prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : null;
296
+ }
297
+
298
+ this.#prefViewer3D = prefViewer3D;
299
+ }
300
+
274
301
  /**
275
302
  * Applies threshold logic to the progress value to snap to 0 or 1 if near the ends.
276
303
  * Prevents floating point errors from leaving the animation in an in-between state.
@@ -287,6 +314,33 @@ export default class OpeningAnimation {
287
314
  return progress;
288
315
  }
289
316
 
317
+ /**
318
+ * Dispatches a `prefviewer-animation-update` CustomEvent from the nearest `pref-viewer-3d` host.
319
+ * The event bubbles and crosses shadow DOM boundaries, exposing the current animation snapshot
320
+ * (`name`, `state`, `progress`, and `loop`) in `event.detail`.
321
+ * @private
322
+ * @returns {boolean} True when the event was dispatched, false when no `pref-viewer-3d` host is available.
323
+ */
324
+ #notifyStateChange() {
325
+ this.#getPrefViewer3DComponent();
326
+ if (!this.#prefViewer3D) {
327
+ return false;
328
+ }
329
+
330
+ const customEventOptions = {
331
+ bubbles: true,
332
+ composed: true,
333
+ detail: {
334
+ name: this.name,
335
+ state: this.#state,
336
+ progress: this.#getProgress(),
337
+ loop: this.#loop,
338
+ },
339
+ };
340
+ this.#prefViewer3D.dispatchEvent(new CustomEvent("prefviewer-animation-changed", customEventOptions));
341
+ return true;
342
+ }
343
+
290
344
  /**
291
345
  * Updates the slider value in the animation control menu to match the current progress.
292
346
  * @private
@@ -299,10 +353,12 @@ export default class OpeningAnimation {
299
353
  }
300
354
 
301
355
  /**
302
- * Updates all controls in the animation menu (buttons, slider) to reflect the current state and progress.
356
+ * Broadcasts the latest animation state and, when the menu is visible, syncs its controls
357
+ * (buttons and progress slider) with the current state/progress values.
303
358
  * @private
304
359
  */
305
360
  #updateControls() {
361
+ this.#notifyStateChange();
306
362
  if (!this.isControlsVisible()) {
307
363
  return;
308
364
  }
@@ -8,6 +8,7 @@ import JSZip from "jszip";
8
8
  import GLTFResolver from "./gltf-resolver.js";
9
9
  import { MaterialData } from "./pref-viewer-3d-data.js";
10
10
  import BabylonJSAnimationController from "./babylonjs-animation-controller.js";
11
+ import OpeningAnimation from "./babylonjs-animation-opening.js";
11
12
  import { translate } from "./localization/i18n.js";
12
13
 
13
14
  /**
@@ -63,7 +64,6 @@ import { translate } from "./localization/i18n.js";
63
64
  * in SSR/Node contexts (though functionality activates only in browsers).
64
65
  */
65
66
  export default class BabylonJSController {
66
-
67
67
  #RENDER_SETTINGS_STORAGE_KEY = "pref-viewer/render-settings";
68
68
 
69
69
  // Default render settings
@@ -75,7 +75,7 @@ export default class BabylonJSController {
75
75
  iblEnabled: true,
76
76
  shadowsEnabled: false,
77
77
  };
78
-
78
+
79
79
  // Canvas HTML element
80
80
  #canvas = null;
81
81
 
@@ -93,11 +93,13 @@ export default class BabylonJSController {
93
93
  #shadowGen = [];
94
94
  #XRExperience = null;
95
95
  #canvasResizeObserver = null;
96
+
96
97
  #hdrTexture = null; // reusable in-memory HDR source cloned into scene.environmentTexture across reloads
97
-
98
+ #lastPickedMeshId = null;
99
+
98
100
  #containers = {};
99
101
  #options = {};
100
-
102
+
101
103
  #gltfResolver = null; // GLTFResolver instance
102
104
  #babylonJSAnimationController = null; // AnimationController instance
103
105
 
@@ -110,11 +112,28 @@ export default class BabylonJSController {
110
112
  #handlers = {
111
113
  onKeyUp: null,
112
114
  onPointerObservable: null,
115
+ onAnimationGroupChanged: null,
116
+ onResize: null,
113
117
  renderLoop: null,
114
118
  };
115
-
119
+
116
120
  #settings = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
117
121
 
122
+ #renderState = {
123
+ isLoopRunning: false,
124
+ dirtyFrames: 0,
125
+ continuousUntil: 0,
126
+ lastRenderAt: 0,
127
+ };
128
+
129
+ #renderConfig = {
130
+ burstFramesBase: 2,
131
+ burstFramesEnhanced: 32, // when AA/SSAO/IBL is enabled, more frames are needed to reach stable output
132
+ interactionMs: 250,
133
+ animationMs: 200,
134
+ idleThrottleMs: 1000 / 15,
135
+ };
136
+
118
137
  /**
119
138
  * Constructs a new BabylonJSController instance.
120
139
  * Initializes the canvas, asset containers, and options for the Babylon.js scene.
@@ -147,8 +166,10 @@ export default class BabylonJSController {
147
166
  * @returns {void}
148
167
  */
149
168
  #bindHandlers() {
169
+ this.#handlers.onAnimationGroupChanged = this.#onAnimationGroupChanged.bind(this);
150
170
  this.#handlers.onKeyUp = this.#onKeyUp.bind(this);
151
171
  this.#handlers.onPointerObservable = this.#onPointerObservable.bind(this);
172
+ this.#handlers.onResize = this.#onResize.bind(this);
152
173
  this.#handlers.renderLoop = this.#renderLoop.bind(this);
153
174
  }
154
175
 
@@ -271,16 +292,177 @@ export default class BabylonJSController {
271
292
  }
272
293
  }
273
294
 
295
+ /**
296
+ * Starts Babylon's engine render loop if it is not already running.
297
+ * @private
298
+ * @returns {boolean} True when the loop was started, false when no engine is available or it was already running.
299
+ */
300
+ #startEngineRenderLoop() {
301
+ if (!this.#engine || this.#renderState.isLoopRunning) {
302
+ return false;
303
+ }
304
+ this.#engine.runRenderLoop(this.#handlers.renderLoop);
305
+ this.#renderState.isLoopRunning = true;
306
+ return true;
307
+ }
308
+
309
+ /**
310
+ * Stops Babylon's engine render loop when it is currently active.
311
+ * @private
312
+ * @returns {boolean} True when the loop was stopped, false when no engine is available or it was already stopped.
313
+ */
314
+ #stopEngineRenderLoop() {
315
+ if (!this.#engine || !this.#renderState.isLoopRunning) {
316
+ return false;
317
+ }
318
+ this.#engine.stopRenderLoop(this.#handlers.renderLoop);
319
+ this.#renderState.isLoopRunning = false;
320
+ return true;
321
+ }
322
+
323
+ /**
324
+ * Marks the scene as dirty and optionally extends a short continuous-render window.
325
+ * Ensures the engine loop is running so the requested frames can be produced.
326
+ * @private
327
+ * @param {{frames?:number, continuousMs?:number}} [options={}] - Render request options.
328
+ * @param {number} [options.frames=1] - Minimum number of frames to render.
329
+ * @param {number} [options.continuousMs=0] - Milliseconds to keep continuous rendering active.
330
+ * @returns {boolean} True when the request was accepted, false when scene/engine are unavailable.
331
+ */
332
+ #requestRender({ frames = 1, continuousMs = 0 } = {}) {
333
+ if (!this.#scene || !this.#engine) {
334
+ return false;
335
+ }
336
+
337
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
338
+ this.#renderState.dirtyFrames = Math.max(this.#renderState.dirtyFrames, Math.max(1, frames));
339
+ if (continuousMs > 0) {
340
+ this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + continuousMs);
341
+ }
342
+ this.#startEngineRenderLoop();
343
+ return true;
344
+ }
345
+
346
+ /**
347
+ * Checks whether an ArcRotateCamera still has non-zero inertial movement.
348
+ * @private
349
+ * @param {ArcRotateCamera} camera - Camera to evaluate.
350
+ * @returns {boolean} True when any inertial offset is still active.
351
+ */
352
+ #isArcRotateCameraInMotion(camera) {
353
+ const EPSILON = 0.00001;
354
+ return Math.abs(camera?.inertialAlphaOffset || 0) > EPSILON || Math.abs(camera?.inertialBetaOffset || 0) > EPSILON || Math.abs(camera?.inertialRadiusOffset || 0) > EPSILON || Math.abs(camera?.inertialPanningX || 0) > EPSILON || Math.abs(camera?.inertialPanningY || 0) > EPSILON;
355
+ }
356
+
357
+ /**
358
+ * Checks whether a FreeCamera/UniversalCamera is currently moving or rotating.
359
+ * @private
360
+ * @param {FreeCamera|UniversalCamera} camera - Camera to evaluate.
361
+ * @returns {boolean} True when translation or rotation deltas are active.
362
+ */
363
+ #isUniversalOrFreeCameraInMotion(camera) {
364
+ const EPSILON = 0.00001;
365
+ const direction = camera?.cameraDirection;
366
+ const rotation = camera?.cameraRotation;
367
+ const directionMoving = !!direction && (Math.abs(direction.x) > EPSILON || Math.abs(direction.y) > EPSILON || Math.abs(direction.z) > EPSILON);
368
+ const rotationMoving = !!rotation && (Math.abs(rotation.x) > EPSILON || Math.abs(rotation.y) > EPSILON || Math.abs(rotation.z) > EPSILON);
369
+ return directionMoving || rotationMoving;
370
+ }
371
+
372
+ /**
373
+ * Detects motion for the current active camera based on its concrete camera type.
374
+ * @private
375
+ * @returns {boolean} True when the active camera is moving, otherwise false.
376
+ */
377
+ #isCameraInMotion() {
378
+ const camera = this.#scene?.activeCamera;
379
+ if (!camera) {
380
+ return false;
381
+ }
382
+ if (camera instanceof ArcRotateCamera) {
383
+ return this.#isArcRotateCameraInMotion(camera);
384
+ }
385
+ if (camera instanceof UniversalCamera || camera instanceof FreeCamera) {
386
+ return this.#isUniversalOrFreeCameraInMotion(camera);
387
+ }
388
+ return false;
389
+ }
390
+
391
+ /**
392
+ * Determines whether scene animations are currently running.
393
+ * @private
394
+ * @returns {boolean} True when at least one animation group is playing.
395
+ */
396
+ #isAnimationRunning() {
397
+ if (!this.#scene) {
398
+ return false;
399
+ }
400
+ const hasAnimatables = (this.#scene.animatables?.length || 0) > 0;
401
+ if (!hasAnimatables) {
402
+ return false;
403
+ }
404
+ return this.#scene.animationGroups?.some((group) => group?.isPlaying) || false;
405
+ }
406
+
407
+ /**
408
+ * Evaluates whether the renderer should stay in continuous mode.
409
+ * XR always forces continuous rendering; animation/camera motion also extends the
410
+ * continuous deadline window to avoid abrupt stop-start behavior.
411
+ * @private
412
+ * @param {number} now - Current high-resolution timestamp.
413
+ * @returns {boolean} True when continuous rendering should remain active.
414
+ */
415
+ #shouldRenderContinuously(now) {
416
+ const inXR = this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR;
417
+ if (inXR) {
418
+ return true;
419
+ }
420
+
421
+ const animationRunning = this.#isAnimationRunning();
422
+ const cameraInMotion = this.#isCameraInMotion();
423
+
424
+ if (animationRunning) {
425
+ this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + this.#renderConfig.animationMs);
426
+ }
427
+ if (cameraInMotion) {
428
+ this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + this.#renderConfig.interactionMs);
429
+ }
430
+
431
+ return animationRunning || cameraInMotion || this.#renderState.continuousUntil > now;
432
+ }
433
+
274
434
  /**
275
435
  * Render loop callback for Babylon.js.
436
+ * Runs only while scene state is dirty, interactive motion is active, animations are running, or XR is active.
437
+ * It self-stops when the scene becomes idle.
276
438
  * @private
277
439
  * @returns {void}
278
- * @description
279
- * Continuously renders the current scene if it exists.
280
- * Used by the engine's runRenderLoop method to update the view.
281
440
  */
282
441
  #renderLoop() {
283
- this.#scene && this.#scene.render();
442
+ if (!this.#scene) {
443
+ this.#stopEngineRenderLoop();
444
+ return;
445
+ }
446
+
447
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
448
+ const continuous = this.#shouldRenderContinuously(now);
449
+ const needsRender = continuous || this.#renderState.dirtyFrames > 0;
450
+
451
+ if (!needsRender) {
452
+ this.#stopEngineRenderLoop();
453
+ return;
454
+ }
455
+
456
+ if (!continuous && this.#renderState.lastRenderAt > 0 && now - this.#renderState.lastRenderAt < this.#renderConfig.idleThrottleMs) {
457
+ return;
458
+ }
459
+
460
+ this.#scene.render();
461
+ this.#renderState.lastRenderAt = now;
462
+
463
+ if (this.#renderState.dirtyFrames > 0) {
464
+ this.#renderState.dirtyFrames -= 1;
465
+ }
284
466
  }
285
467
 
286
468
  /**
@@ -417,24 +599,20 @@ export default class BabylonJSController {
417
599
  this.#scene.environmentTexture = null;
418
600
  lightsChanged = true;
419
601
  }
420
- if (this.#hdrTexture) {
421
- this.#hdrTexture.dispose();
422
- this.#hdrTexture = null;
423
- }
424
602
 
425
603
  // Add a hemispheric light for basic ambient illumination
426
604
  if (!this.#hemiLight) {
427
605
  this.#hemiLight = new HemisphericLight(hemiLightName, new Vector3(-10, 10, -10), this.#scene);
428
606
  this.#hemiLight.intensity = 0.6;
429
607
  }
430
-
608
+
431
609
  // Add a directional light to cast shadows and provide stronger directional illumination
432
610
  if (!this.#dirLight) {
433
611
  this.#dirLight = new DirectionalLight(dirLightName, new Vector3(-10, 10, -10), this.#scene);
434
612
  this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
435
613
  this.#dirLight.intensity = 0.6;
436
614
  }
437
-
615
+
438
616
  // Add a point light that follows the camera to ensure the model is always well-lit from the viewer's perspective
439
617
  if (!this.#cameraLight) {
440
618
  this.#cameraLight = new PointLight(cameraLightName, this.#camera.position, this.#scene);
@@ -459,7 +637,7 @@ export default class BabylonJSController {
459
637
  }
460
638
 
461
639
  const supportedPipelines = pipelineManager.supportedPipelines;
462
-
640
+
463
641
  if (supportedPipelines === undefined) {
464
642
  return false;
465
643
  }
@@ -499,23 +677,23 @@ export default class BabylonJSController {
499
677
  return false;
500
678
  }
501
679
  const supportedPipelines = pipelineManager.supportedPipelines;
502
-
680
+
503
681
  if (supportedPipelines === undefined) {
504
682
  return false;
505
683
  }
506
-
684
+
507
685
  const pipelineName = "PrefViewerSSAORenderingPipeline";
508
686
 
509
687
  const ssaoRatio = {
510
688
  ssaoRatio: 0.5,
511
- combineRatio: 1.0
689
+ combineRatio: 1.0,
512
690
  };
513
691
 
514
692
  let ssaoPipeline = new SSAORenderingPipeline(pipelineName, this.#scene, ssaoRatio, [this.#scene.activeCamera]);
515
693
 
516
- if (!ssaoPipeline){
694
+ if (!ssaoPipeline) {
517
695
  return false;
518
- }
696
+ }
519
697
 
520
698
  if (ssaoPipeline.isSupported) {
521
699
  ssaoPipeline.fallOff = 0.000001;
@@ -523,7 +701,7 @@ export default class BabylonJSController {
523
701
  ssaoPipeline.radius = 0.0001;
524
702
  ssaoPipeline.totalStrength = 1;
525
703
  ssaoPipeline.base = 0.6;
526
-
704
+
527
705
  // Configure SSAO to calculate only once instead of every frame for better performance
528
706
  if (ssaoPipeline._ssaoPostProcess) {
529
707
  ssaoPipeline._ssaoPostProcess.autoClear = false;
@@ -533,7 +711,7 @@ export default class BabylonJSController {
533
711
  ssaoPipeline._combinePostProcess.autoClear = false;
534
712
  ssaoPipeline._combinePostProcess.samples = 1;
535
713
  }
536
-
714
+
537
715
  this.#renderPipelines.ssao = ssaoPipeline;
538
716
  pipelineManager.update();
539
717
  return true;
@@ -559,7 +737,7 @@ export default class BabylonJSController {
559
737
  }
560
738
 
561
739
  const supportedPipelines = pipelineManager.supportedPipelines;
562
-
740
+
563
741
  if (supportedPipelines === undefined) {
564
742
  return false;
565
743
  }
@@ -600,18 +778,18 @@ export default class BabylonJSController {
600
778
  return false;
601
779
  }
602
780
  const supportedPipelines = pipelineManager.supportedPipelines;
603
-
781
+
604
782
  if (supportedPipelines === undefined) {
605
783
  return false;
606
784
  }
607
-
785
+
608
786
  const pipelineName = "PrefViewerDefaultRenderingPipeline";
609
787
 
610
788
  let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
611
789
 
612
- if (!defaultPipeline){
790
+ if (!defaultPipeline) {
613
791
  return false;
614
- }
792
+ }
615
793
 
616
794
  if (defaultPipeline.isSupported) {
617
795
  // MSAA - Multisample Anti-Aliasing
@@ -716,7 +894,7 @@ export default class BabylonJSController {
716
894
  }
717
895
 
718
896
  const supportedPipelines = pipelineManager.supportedPipelines;
719
-
897
+
720
898
  if (supportedPipelines === undefined) {
721
899
  return false;
722
900
  }
@@ -751,13 +929,13 @@ export default class BabylonJSController {
751
929
  * @private
752
930
  * @returns {Promise<void|boolean>} Returns false if no environment texture is set; otherwise void.
753
931
  */
754
- async #initializeIBLShadows() {
932
+ async #initializeIBLShadows() {
755
933
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
756
-
934
+
757
935
  if (!this.#scene || !this.#scene?.activeCamera || !this.#scene?.environmentTexture || !pipelineManager) {
758
936
  return false;
759
937
  }
760
-
938
+
761
939
  if (!this.#scene.environmentTexture.isReady()) {
762
940
  const self = this;
763
941
  this.#scene.environmentTexture.onLoadObservable.addOnce(() => {
@@ -771,11 +949,11 @@ export default class BabylonJSController {
771
949
  if (isRootMesh) {
772
950
  return false;
773
951
  }
774
-
952
+
775
953
  const isHDRIMesh = mesh.name?.toLowerCase() === "hdri";
776
954
  const extrasCastShadows = mesh.metadata?.gltf?.extras?.castShadows;
777
955
  const meshGenerateShadows = typeof extrasCastShadows === "boolean" ? extrasCastShadows : isHDRIMesh ? false : true;
778
-
956
+
779
957
  if (meshGenerateShadows) {
780
958
  return true;
781
959
  }
@@ -793,7 +971,7 @@ export default class BabylonJSController {
793
971
  }
794
972
 
795
973
  const supportedPipelines = pipelineManager.supportedPipelines;
796
-
974
+
797
975
  if (!supportedPipelines) {
798
976
  return false;
799
977
  }
@@ -816,7 +994,7 @@ export default class BabylonJSController {
816
994
  if (!iblShadowsPipeline) {
817
995
  return false;
818
996
  }
819
-
997
+
820
998
  if (iblShadowsPipeline.isSupported) {
821
999
  // Disable all debug passes for performance
822
1000
  const pipelineProps = {
@@ -835,7 +1013,7 @@ export default class BabylonJSController {
835
1013
 
836
1014
  meshesForCastingShadows.forEach((mesh) => iblShadowsPipeline.addShadowCastingMesh(mesh));
837
1015
  materialsForReceivingShadows.forEach((material) => iblShadowsPipeline.addShadowReceivingMaterial(material));
838
-
1016
+
839
1017
  iblShadowsPipeline.updateSceneBounds();
840
1018
  iblShadowsPipeline.toggleShadow(true);
841
1019
  iblShadowsPipeline.updateVoxelization();
@@ -1002,23 +1180,6 @@ export default class BabylonJSController {
1002
1180
  }
1003
1181
  }
1004
1182
 
1005
- /**
1006
- * Handles pointer events observed on the Babylon.js scene.
1007
- * @private
1008
- * @param {PointerInfo} info - The pointer event information from Babylon.js.
1009
- * @returns {void}
1010
- */
1011
- #onPointerObservable(info) {
1012
- const pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
1013
- if (info.type === PointerEventTypes.POINTERUP) {
1014
- this.#onPointerUp(info.event, pickInfo);
1015
- } else if (info.type === PointerEventTypes.POINTERMOVE) {
1016
- this.#onPointerMove(info.event, pickInfo);
1017
- } else if (info.type === PointerEventTypes.POINTERWHEEL) {
1018
- this.#onMouseWheel(info.event, pickInfo);
1019
- }
1020
- }
1021
-
1022
1183
  /**
1023
1184
  * Sets up interaction handlers for the Babylon.js canvas and scene.
1024
1185
  * @private
@@ -1031,6 +1192,13 @@ export default class BabylonJSController {
1031
1192
  if (this.#scene) {
1032
1193
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
1033
1194
  }
1195
+ if (this.#engine) {
1196
+ this.#canvasResizeObserver = new ResizeObserver(() => {
1197
+ this.#engine.resize();
1198
+ this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1199
+ });
1200
+ this.#canvasResizeObserver.observe(this.#canvas);
1201
+ }
1034
1202
  }
1035
1203
 
1036
1204
  /**
@@ -1045,6 +1213,9 @@ export default class BabylonJSController {
1045
1213
  if (this.#scene !== null) {
1046
1214
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
1047
1215
  }
1216
+ this.#canvasResizeObserver?.disconnect();
1217
+ this.#canvasResizeObserver = null;
1218
+ this.#detachAnimationChangedListener();
1048
1219
  }
1049
1220
 
1050
1221
  /**
@@ -1105,6 +1276,75 @@ export default class BabylonJSController {
1105
1276
  this.#hemiLight = this.#dirLight = this.#cameraLight = null;
1106
1277
  }
1107
1278
 
1279
+ /**
1280
+ * Handles animation state events emitted by `OpeningAnimation` instances.
1281
+ * Routes opening/closing states to continuous rendering and all other states
1282
+ * (paused/opened/closed) to a short final render burst.
1283
+ * @private
1284
+ * @param {CustomEvent} event - Event carrying animation state in `event.detail.state`.
1285
+ * @returns {void}
1286
+ */
1287
+ #onAnimationGroupChanged(event) {
1288
+ const state = event?.detail?.state;
1289
+ if (state === undefined) {
1290
+ return;
1291
+ }
1292
+
1293
+ if (state === OpeningAnimation.states.opening || state === OpeningAnimation.states.closing) {
1294
+ this.#onAnimationGroupPlay();
1295
+ } else {
1296
+ this.#onAnimationGroupStop();
1297
+ }
1298
+ }
1299
+
1300
+ /**
1301
+ * Marks animation playback as active and requests short continuous rendering so animated transforms remain smooth while state is changing.
1302
+ * @private
1303
+ * @returns {void}
1304
+ */
1305
+ #onAnimationGroupPlay() {
1306
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.animationMs });
1307
+ }
1308
+
1309
+ /**
1310
+ * Handles animation stop/pause/end transitions by requesting a final render burst.
1311
+ * @private
1312
+ * @returns {void}
1313
+ */
1314
+ #onAnimationGroupStop() {
1315
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1316
+ this.#requestRender({ frames: frames });
1317
+ }
1318
+
1319
+ /**
1320
+ * Attaches the `prefviewer-animation-changed` listener to the nearest `pref-viewer-3d` host.
1321
+ * Removes any previous registration first to avoid duplicate callbacks across reload cycles.
1322
+ * @private
1323
+ * @returns {boolean} True when the listener is attached, false when no host is available.
1324
+ */
1325
+ #attachAnimationChangedListener() {
1326
+ this.#getPrefViewer3DComponent();
1327
+ if (!this.#prefViewer3D) {
1328
+ return false;
1329
+ }
1330
+ this.#detachAnimationChangedListener();
1331
+ this.#prefViewer3D.addEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
1332
+ return true;
1333
+ }
1334
+
1335
+ /**
1336
+ * Detaches the `prefviewer-animation-changed` listener from the cached `pref-viewer-3d` host.
1337
+ * @private
1338
+ * @returns {boolean} True when a host exists and the listener removal was attempted, false otherwise.
1339
+ */
1340
+ #detachAnimationChangedListener() {
1341
+ if (!this.#prefViewer3D) {
1342
+ return false;
1343
+ }
1344
+ this.#prefViewer3D.removeEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
1345
+ return true;
1346
+ }
1347
+
1108
1348
  /**
1109
1349
  * Handles keyup events on the Babylon.js canvas for triggering model and scene downloads.
1110
1350
  * @private
@@ -1132,11 +1372,12 @@ export default class BabylonJSController {
1132
1372
  * @returns {void|false} Returns false if there is no active camera; otherwise, void.
1133
1373
  */
1134
1374
  #onMouseWheel(event, pickInfo) {
1375
+ event.preventDefault();
1135
1376
  const camera = this.#scene?.activeCamera;
1136
1377
  if (!camera) {
1137
1378
  return false;
1138
1379
  }
1139
- if (!camera.metadata?.locked) {
1380
+ if (!camera.metadata?.locked) {
1140
1381
  if (camera instanceof ArcRotateCamera) {
1141
1382
  camera.wheelPrecision = camera.wheelPrecision || 3.0;
1142
1383
  camera.inertialRadiusOffset -= event.deltaY * camera.wheelPrecision * 0.001;
@@ -1149,8 +1390,8 @@ export default class BabylonJSController {
1149
1390
  const movementVector = direction.scale(zoomSpeed);
1150
1391
  camera.position = camera.position.add(movementVector);
1151
1392
  }
1393
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1152
1394
  }
1153
- event.preventDefault();
1154
1395
  }
1155
1396
 
1156
1397
  /**
@@ -1178,9 +1419,54 @@ export default class BabylonJSController {
1178
1419
  * @returns {void}
1179
1420
  */
1180
1421
  #onPointerMove(event, pickInfo) {
1422
+ const camera = this.#scene?.activeCamera;
1423
+ if (camera && !camera.metadata?.locked) {
1424
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1425
+ }
1181
1426
  if (this.#babylonJSAnimationController) {
1182
- this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1427
+ const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1428
+ if (this.#lastPickedMeshId !== pickedMeshId) {
1429
+ const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1430
+ if (highlightResult.changed) {
1431
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1432
+ }
1433
+ }
1434
+ }
1435
+ }
1436
+
1437
+ /**
1438
+ * Handles pointer events observed on the Babylon.js scene.
1439
+ * @private
1440
+ * @param {PointerInfo} info - The pointer event information from Babylon.js.
1441
+ * @returns {void}
1442
+ */
1443
+ #onPointerObservable(info) {
1444
+ const pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
1445
+ const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1446
+
1447
+ if (info.type === PointerEventTypes.POINTERMOVE) {
1448
+ this.#onPointerMove(info.event, pickInfo);
1449
+ } else if (info.type === PointerEventTypes.POINTERUP) {
1450
+ this.#onPointerUp(info.event, pickInfo);
1451
+ } else if (info.type === PointerEventTypes.POINTERWHEEL) {
1452
+ this.#onMouseWheel(info.event, pickInfo);
1453
+ }
1454
+ this.#lastPickedMeshId = pickedMeshId;
1455
+ }
1456
+
1457
+ /**
1458
+ * Handles canvas resize notifications.
1459
+ * Resizes the Babylon engine and requests a short on-demand render burst so camera-dependent
1460
+ * buffers and post-process pipelines are redrawn at the new viewport size.
1461
+ * @private
1462
+ * @returns {void}
1463
+ */
1464
+ #onResize() {
1465
+ if (!this.#engine) {
1466
+ return;
1183
1467
  }
1468
+ this.#engine.resize();
1469
+ this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1184
1470
  }
1185
1471
 
1186
1472
  /**
@@ -1222,7 +1508,7 @@ export default class BabylonJSController {
1222
1508
  .forEach((mesh) => {
1223
1509
  mesh.material = material;
1224
1510
  someSetted = true;
1225
- })
1511
+ }),
1226
1512
  );
1227
1513
 
1228
1514
  if (someSetted) {
@@ -1336,34 +1622,47 @@ export default class BabylonJSController {
1336
1622
  }
1337
1623
 
1338
1624
  /**
1339
- * Caches and retrieves the parent custom element "PREF-VIEWER-3D" for efficient access.
1625
+ * Resolves and caches the closest `pref-viewer-3d` host associated with the rendering canvas.
1340
1626
  * @private
1341
1627
  * @returns {void}
1342
1628
  */
1343
1629
  #getPrefViewer3DComponent() {
1344
- if (this.#prefViewer3D === undefined) {
1345
- const grandParentElement = this.#canvas.parentElement.parentElement;
1346
- this.#prefViewer3D = grandParentElement && grandParentElement.nodeName === "PREF-VIEWER-3D" ? grandParentElement : null;
1630
+ if (this.#prefViewer3D !== undefined) {
1631
+ return;
1632
+ }
1633
+
1634
+ let prefViewer3D = this.#canvas?.closest?.("pref-viewer-3d") || undefined;
1635
+ if (!prefViewer3D) {
1636
+ const host = this.#canvas?.getRootNode?.()?.host;
1637
+ prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : undefined;
1347
1638
  }
1639
+
1640
+ this.#prefViewer3D = prefViewer3D;
1348
1641
  }
1349
1642
 
1350
1643
  /**
1351
- * Caches and retrieves the parent custom element "PREF-VIEWER" for efficient access.
1644
+ * Resolves and caches the closest `pref-viewer` host associated with `#prefViewer3D`.
1352
1645
  * @private
1353
1646
  * @returns {void}
1354
1647
  */
1355
1648
  #getPrefViewerComponent() {
1356
- if (this.#prefViewer === undefined) {
1357
- if (this.#prefViewer3D === undefined) {
1358
- this.#getPrefViewer3DComponent();
1359
- }
1360
- if (!this.#prefViewer3D) {
1361
- this.#prefViewer = null;
1362
- return;
1363
- }
1364
- const rootNode = this.#prefViewer3D ? this.#prefViewer3D.getRootNode().host : null;
1365
- this.#prefViewer = rootNode && rootNode.nodeName === "PREF-VIEWER" ? rootNode : null;
1649
+ if (this.#prefViewer !== undefined) {
1650
+ return;
1366
1651
  }
1652
+
1653
+ this.#getPrefViewer3DComponent();
1654
+ if (this.#prefViewer3D === undefined) {
1655
+ this.#prefViewer = undefined;
1656
+ return;
1657
+ }
1658
+
1659
+ let prefViewer = this.#prefViewer3D.closest?.("pref-viewer") || undefined;
1660
+ if (!prefViewer) {
1661
+ const host = this.#prefViewer3D.getRootNode?.()?.host;
1662
+ prefViewer = host?.nodeName === "PREF-VIEWER" ? host : undefined;
1663
+ }
1664
+
1665
+ this.#prefViewer = prefViewer;
1367
1666
  }
1368
1667
 
1369
1668
  /**
@@ -1512,7 +1811,10 @@ export default class BabylonJSController {
1512
1811
  * @returns {void}
1513
1812
  */
1514
1813
  async #stopRender() {
1515
- this.#engine.stopRenderLoop(this.#handlers.renderLoop);
1814
+ this.#stopEngineRenderLoop();
1815
+ this.#renderState.dirtyFrames = 0;
1816
+ this.#renderState.continuousUntil = 0;
1817
+ this.#renderState.lastRenderAt = 0;
1516
1818
  await this.#unloadCameraDependentEffects();
1517
1819
  }
1518
1820
  /**
@@ -1523,9 +1825,9 @@ export default class BabylonJSController {
1523
1825
  */
1524
1826
  async #startRender() {
1525
1827
  await this.#loadCameraDependentEffects();
1526
- this.#scene.executeWhenReady(() => {
1527
- this.#engine.runRenderLoop(this.#handlers.renderLoop);
1528
- });
1828
+ await this.#scene.whenReadyAsync();
1829
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1830
+ this.#requestRender({ frames: frames, continuousMs: this.#renderConfig.interactionMs });
1529
1831
  }
1530
1832
 
1531
1833
  /**
@@ -1545,7 +1847,6 @@ export default class BabylonJSController {
1545
1847
  * `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
1546
1848
  */
1547
1849
  async #loadAssetContainer(container, force = false) {
1548
-
1549
1850
  if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
1550
1851
  return [container, false];
1551
1852
  }
@@ -1606,6 +1907,7 @@ export default class BabylonJSController {
1606
1907
  * Returns an object with success status and error details.
1607
1908
  */
1608
1909
  async #loadContainers() {
1910
+ this.#detachAnimationChangedListener();
1609
1911
  await this.#stopRender();
1610
1912
 
1611
1913
  let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
@@ -1662,6 +1964,9 @@ export default class BabylonJSController {
1662
1964
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
1663
1965
  this.#setMaxSimultaneousLights();
1664
1966
  this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
1967
+ if (this.#babylonJSAnimationController?.hasAnimations?.()) {
1968
+ this.#attachAnimationChangedListener();
1969
+ }
1665
1970
  await this.#startRender();
1666
1971
  });
1667
1972
  return detail;
@@ -1921,10 +2226,10 @@ export default class BabylonJSController {
1921
2226
  this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false });
1922
2227
  this.#engine.disableUniformBuffers = true;
1923
2228
  this.#scene = new Scene(this.#engine);
1924
-
2229
+
1925
2230
  // Activate the rendering of geometry data into a G-buffer, essential for advanced effects like deferred shading,
1926
- // SSAO, and Velocity-Texture-Animation (VAT), allowing for complex post-processing by separating rendering into
1927
- // different buffers (depth, normals, velocity) for later use in shaders.
2231
+ // SSAO, and Velocity-Texture-Animation (VAT), allowing for complex post-processing by separating rendering into
2232
+ // different buffers (depth, normals, velocity) for later use in shaders.
1928
2233
  const geometryBufferRenderer = this.#scene.enableGeometryBufferRenderer();
1929
2234
  if (geometryBufferRenderer) {
1930
2235
  geometryBufferRenderer.enableScreenspaceDepth = true;
@@ -1941,12 +2246,14 @@ export default class BabylonJSController {
1941
2246
  this.#scene.imageProcessingConfiguration.vignetteEnabled = false;
1942
2247
  this.#scene.imageProcessingConfiguration.colorCurvesEnabled = false;
1943
2248
 
2249
+ // Skip the built-in pointer picking logic since the controller implements its own optimized raycasting for interaction.
2250
+ this.#scene.skipPointerMovePicking = true;
2251
+ this.#scene.skipPointerDownPicking = true;
2252
+ this.#scene.skipPointerUpPicking = true;
2253
+
1944
2254
  this.#createCamera();
1945
2255
  this.#enableInteraction();
1946
2256
  await this.#createXRExperience();
1947
- this.#startRender();
1948
- this.#canvasResizeObserver = new ResizeObserver(() => this.#engine && this.#engine.resize());
1949
- this.#canvasResizeObserver.observe(this.#canvas);
1950
2257
  }
1951
2258
 
1952
2259
  /**
@@ -1956,11 +2263,11 @@ export default class BabylonJSController {
1956
2263
  * @returns {void}
1957
2264
  */
1958
2265
  disable() {
1959
- this.#canvasResizeObserver.disconnect();
1960
2266
  this.#disableInteraction();
1961
2267
  this.#disposeAnimationController();
1962
2268
  this.#disposeXRExperience();
1963
2269
  this.#unloadCameraDependentEffects();
2270
+ this.#stopEngineRenderLoop();
1964
2271
  this.#disposeEngine();
1965
2272
  }
1966
2273