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

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.9",
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
  /**
@@ -38,6 +39,7 @@ import { translate } from "./localization/i18n.js";
38
39
  * 5. Use `setContainerVisibility`, `setMaterialOptions`, `setCameraOptions`, or `setIBLOptions` for targeted updates; these
39
40
  * helpers stop/restart the render loop while they rebuild camera-dependent resources.
40
41
  * 6. Invoke `disable()` when the element disconnects to tear down scenes, XR sessions, observers, and handlers.
42
+ * The controller also disposes the shared GLTF resolver, which closes its internal IndexedDB handle.
41
43
  *
42
44
  * Public API Highlights
43
45
  * - constructor(canvas, containers, options)
@@ -63,7 +65,6 @@ import { translate } from "./localization/i18n.js";
63
65
  * in SSR/Node contexts (though functionality activates only in browsers).
64
66
  */
65
67
  export default class BabylonJSController {
66
-
67
68
  #RENDER_SETTINGS_STORAGE_KEY = "pref-viewer/render-settings";
68
69
 
69
70
  // Default render settings
@@ -75,7 +76,7 @@ export default class BabylonJSController {
75
76
  iblEnabled: true,
76
77
  shadowsEnabled: false,
77
78
  };
78
-
79
+
79
80
  // Canvas HTML element
80
81
  #canvas = null;
81
82
 
@@ -93,11 +94,13 @@ export default class BabylonJSController {
93
94
  #shadowGen = [];
94
95
  #XRExperience = null;
95
96
  #canvasResizeObserver = null;
97
+
96
98
  #hdrTexture = null; // reusable in-memory HDR source cloned into scene.environmentTexture across reloads
97
-
99
+ #lastPickedMeshId = null;
100
+
98
101
  #containers = {};
99
102
  #options = {};
100
-
103
+
101
104
  #gltfResolver = null; // GLTFResolver instance
102
105
  #babylonJSAnimationController = null; // AnimationController instance
103
106
 
@@ -110,11 +113,28 @@ export default class BabylonJSController {
110
113
  #handlers = {
111
114
  onKeyUp: null,
112
115
  onPointerObservable: null,
116
+ onAnimationGroupChanged: null,
117
+ onResize: null,
113
118
  renderLoop: null,
114
119
  };
115
-
120
+
116
121
  #settings = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
117
122
 
123
+ #renderState = {
124
+ isLoopRunning: false,
125
+ dirtyFrames: 0,
126
+ continuousUntil: 0,
127
+ lastRenderAt: 0,
128
+ };
129
+
130
+ #renderConfig = {
131
+ burstFramesBase: 2,
132
+ burstFramesEnhanced: 32, // when AA/SSAO/IBL is enabled, more frames are needed to reach stable output
133
+ interactionMs: 250,
134
+ animationMs: 200,
135
+ idleThrottleMs: 1000 / 15,
136
+ };
137
+
118
138
  /**
119
139
  * Constructs a new BabylonJSController instance.
120
140
  * Initializes the canvas, asset containers, and options for the Babylon.js scene.
@@ -147,8 +167,10 @@ export default class BabylonJSController {
147
167
  * @returns {void}
148
168
  */
149
169
  #bindHandlers() {
170
+ this.#handlers.onAnimationGroupChanged = this.#onAnimationGroupChanged.bind(this);
150
171
  this.#handlers.onKeyUp = this.#onKeyUp.bind(this);
151
172
  this.#handlers.onPointerObservable = this.#onPointerObservable.bind(this);
173
+ this.#handlers.onResize = this.#onResize.bind(this);
152
174
  this.#handlers.renderLoop = this.#renderLoop.bind(this);
153
175
  }
154
176
 
@@ -271,16 +293,177 @@ export default class BabylonJSController {
271
293
  }
272
294
  }
273
295
 
296
+ /**
297
+ * Starts Babylon's engine render loop if it is not already running.
298
+ * @private
299
+ * @returns {boolean} True when the loop was started, false when no engine is available or it was already running.
300
+ */
301
+ #startEngineRenderLoop() {
302
+ if (!this.#engine || this.#renderState.isLoopRunning) {
303
+ return false;
304
+ }
305
+ this.#engine.runRenderLoop(this.#handlers.renderLoop);
306
+ this.#renderState.isLoopRunning = true;
307
+ return true;
308
+ }
309
+
310
+ /**
311
+ * Stops Babylon's engine render loop when it is currently active.
312
+ * @private
313
+ * @returns {boolean} True when the loop was stopped, false when no engine is available or it was already stopped.
314
+ */
315
+ #stopEngineRenderLoop() {
316
+ if (!this.#engine || !this.#renderState.isLoopRunning) {
317
+ return false;
318
+ }
319
+ this.#engine.stopRenderLoop(this.#handlers.renderLoop);
320
+ this.#renderState.isLoopRunning = false;
321
+ return true;
322
+ }
323
+
324
+ /**
325
+ * Marks the scene as dirty and optionally extends a short continuous-render window.
326
+ * Ensures the engine loop is running so the requested frames can be produced.
327
+ * @private
328
+ * @param {{frames?:number, continuousMs?:number}} [options={}] - Render request options.
329
+ * @param {number} [options.frames=1] - Minimum number of frames to render.
330
+ * @param {number} [options.continuousMs=0] - Milliseconds to keep continuous rendering active.
331
+ * @returns {boolean} True when the request was accepted, false when scene/engine are unavailable.
332
+ */
333
+ #requestRender({ frames = 1, continuousMs = 0 } = {}) {
334
+ if (!this.#scene || !this.#engine) {
335
+ return false;
336
+ }
337
+
338
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
339
+ this.#renderState.dirtyFrames = Math.max(this.#renderState.dirtyFrames, Math.max(1, frames));
340
+ if (continuousMs > 0) {
341
+ this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + continuousMs);
342
+ }
343
+ this.#startEngineRenderLoop();
344
+ return true;
345
+ }
346
+
347
+ /**
348
+ * Checks whether an ArcRotateCamera still has non-zero inertial movement.
349
+ * @private
350
+ * @param {ArcRotateCamera} camera - Camera to evaluate.
351
+ * @returns {boolean} True when any inertial offset is still active.
352
+ */
353
+ #isArcRotateCameraInMotion(camera) {
354
+ const EPSILON = 0.00001;
355
+ 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;
356
+ }
357
+
358
+ /**
359
+ * Checks whether a FreeCamera/UniversalCamera is currently moving or rotating.
360
+ * @private
361
+ * @param {FreeCamera|UniversalCamera} camera - Camera to evaluate.
362
+ * @returns {boolean} True when translation or rotation deltas are active.
363
+ */
364
+ #isUniversalOrFreeCameraInMotion(camera) {
365
+ const EPSILON = 0.00001;
366
+ const direction = camera?.cameraDirection;
367
+ const rotation = camera?.cameraRotation;
368
+ const directionMoving = !!direction && (Math.abs(direction.x) > EPSILON || Math.abs(direction.y) > EPSILON || Math.abs(direction.z) > EPSILON);
369
+ const rotationMoving = !!rotation && (Math.abs(rotation.x) > EPSILON || Math.abs(rotation.y) > EPSILON || Math.abs(rotation.z) > EPSILON);
370
+ return directionMoving || rotationMoving;
371
+ }
372
+
373
+ /**
374
+ * Detects motion for the current active camera based on its concrete camera type.
375
+ * @private
376
+ * @returns {boolean} True when the active camera is moving, otherwise false.
377
+ */
378
+ #isCameraInMotion() {
379
+ const camera = this.#scene?.activeCamera;
380
+ if (!camera) {
381
+ return false;
382
+ }
383
+ if (camera instanceof ArcRotateCamera) {
384
+ return this.#isArcRotateCameraInMotion(camera);
385
+ }
386
+ if (camera instanceof UniversalCamera || camera instanceof FreeCamera) {
387
+ return this.#isUniversalOrFreeCameraInMotion(camera);
388
+ }
389
+ return false;
390
+ }
391
+
392
+ /**
393
+ * Determines whether scene animations are currently running.
394
+ * @private
395
+ * @returns {boolean} True when at least one animation group is playing.
396
+ */
397
+ #isAnimationRunning() {
398
+ if (!this.#scene) {
399
+ return false;
400
+ }
401
+ const hasAnimatables = (this.#scene.animatables?.length || 0) > 0;
402
+ if (!hasAnimatables) {
403
+ return false;
404
+ }
405
+ return this.#scene.animationGroups?.some((group) => group?.isPlaying) || false;
406
+ }
407
+
408
+ /**
409
+ * Evaluates whether the renderer should stay in continuous mode.
410
+ * XR always forces continuous rendering; animation/camera motion also extends the
411
+ * continuous deadline window to avoid abrupt stop-start behavior.
412
+ * @private
413
+ * @param {number} now - Current high-resolution timestamp.
414
+ * @returns {boolean} True when continuous rendering should remain active.
415
+ */
416
+ #shouldRenderContinuously(now) {
417
+ const inXR = this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR;
418
+ if (inXR) {
419
+ return true;
420
+ }
421
+
422
+ const animationRunning = this.#isAnimationRunning();
423
+ const cameraInMotion = this.#isCameraInMotion();
424
+
425
+ if (animationRunning) {
426
+ this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + this.#renderConfig.animationMs);
427
+ }
428
+ if (cameraInMotion) {
429
+ this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + this.#renderConfig.interactionMs);
430
+ }
431
+
432
+ return animationRunning || cameraInMotion || this.#renderState.continuousUntil > now;
433
+ }
434
+
274
435
  /**
275
436
  * Render loop callback for Babylon.js.
437
+ * Runs only while scene state is dirty, interactive motion is active, animations are running, or XR is active.
438
+ * It self-stops when the scene becomes idle.
276
439
  * @private
277
440
  * @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
441
  */
282
442
  #renderLoop() {
283
- this.#scene && this.#scene.render();
443
+ if (!this.#scene) {
444
+ this.#stopEngineRenderLoop();
445
+ return;
446
+ }
447
+
448
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
449
+ const continuous = this.#shouldRenderContinuously(now);
450
+ const needsRender = continuous || this.#renderState.dirtyFrames > 0;
451
+
452
+ if (!needsRender) {
453
+ this.#stopEngineRenderLoop();
454
+ return;
455
+ }
456
+
457
+ if (!continuous && this.#renderState.lastRenderAt > 0 && now - this.#renderState.lastRenderAt < this.#renderConfig.idleThrottleMs) {
458
+ return;
459
+ }
460
+
461
+ this.#scene.render();
462
+ this.#renderState.lastRenderAt = now;
463
+
464
+ if (this.#renderState.dirtyFrames > 0) {
465
+ this.#renderState.dirtyFrames -= 1;
466
+ }
284
467
  }
285
468
 
286
469
  /**
@@ -417,24 +600,20 @@ export default class BabylonJSController {
417
600
  this.#scene.environmentTexture = null;
418
601
  lightsChanged = true;
419
602
  }
420
- if (this.#hdrTexture) {
421
- this.#hdrTexture.dispose();
422
- this.#hdrTexture = null;
423
- }
424
603
 
425
604
  // Add a hemispheric light for basic ambient illumination
426
605
  if (!this.#hemiLight) {
427
606
  this.#hemiLight = new HemisphericLight(hemiLightName, new Vector3(-10, 10, -10), this.#scene);
428
607
  this.#hemiLight.intensity = 0.6;
429
608
  }
430
-
609
+
431
610
  // Add a directional light to cast shadows and provide stronger directional illumination
432
611
  if (!this.#dirLight) {
433
612
  this.#dirLight = new DirectionalLight(dirLightName, new Vector3(-10, 10, -10), this.#scene);
434
613
  this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
435
614
  this.#dirLight.intensity = 0.6;
436
615
  }
437
-
616
+
438
617
  // Add a point light that follows the camera to ensure the model is always well-lit from the viewer's perspective
439
618
  if (!this.#cameraLight) {
440
619
  this.#cameraLight = new PointLight(cameraLightName, this.#camera.position, this.#scene);
@@ -459,7 +638,7 @@ export default class BabylonJSController {
459
638
  }
460
639
 
461
640
  const supportedPipelines = pipelineManager.supportedPipelines;
462
-
641
+
463
642
  if (supportedPipelines === undefined) {
464
643
  return false;
465
644
  }
@@ -499,23 +678,23 @@ export default class BabylonJSController {
499
678
  return false;
500
679
  }
501
680
  const supportedPipelines = pipelineManager.supportedPipelines;
502
-
681
+
503
682
  if (supportedPipelines === undefined) {
504
683
  return false;
505
684
  }
506
-
685
+
507
686
  const pipelineName = "PrefViewerSSAORenderingPipeline";
508
687
 
509
688
  const ssaoRatio = {
510
689
  ssaoRatio: 0.5,
511
- combineRatio: 1.0
690
+ combineRatio: 1.0,
512
691
  };
513
692
 
514
693
  let ssaoPipeline = new SSAORenderingPipeline(pipelineName, this.#scene, ssaoRatio, [this.#scene.activeCamera]);
515
694
 
516
- if (!ssaoPipeline){
695
+ if (!ssaoPipeline) {
517
696
  return false;
518
- }
697
+ }
519
698
 
520
699
  if (ssaoPipeline.isSupported) {
521
700
  ssaoPipeline.fallOff = 0.000001;
@@ -523,7 +702,7 @@ export default class BabylonJSController {
523
702
  ssaoPipeline.radius = 0.0001;
524
703
  ssaoPipeline.totalStrength = 1;
525
704
  ssaoPipeline.base = 0.6;
526
-
705
+
527
706
  // Configure SSAO to calculate only once instead of every frame for better performance
528
707
  if (ssaoPipeline._ssaoPostProcess) {
529
708
  ssaoPipeline._ssaoPostProcess.autoClear = false;
@@ -533,7 +712,7 @@ export default class BabylonJSController {
533
712
  ssaoPipeline._combinePostProcess.autoClear = false;
534
713
  ssaoPipeline._combinePostProcess.samples = 1;
535
714
  }
536
-
715
+
537
716
  this.#renderPipelines.ssao = ssaoPipeline;
538
717
  pipelineManager.update();
539
718
  return true;
@@ -559,7 +738,7 @@ export default class BabylonJSController {
559
738
  }
560
739
 
561
740
  const supportedPipelines = pipelineManager.supportedPipelines;
562
-
741
+
563
742
  if (supportedPipelines === undefined) {
564
743
  return false;
565
744
  }
@@ -600,18 +779,18 @@ export default class BabylonJSController {
600
779
  return false;
601
780
  }
602
781
  const supportedPipelines = pipelineManager.supportedPipelines;
603
-
782
+
604
783
  if (supportedPipelines === undefined) {
605
784
  return false;
606
785
  }
607
-
786
+
608
787
  const pipelineName = "PrefViewerDefaultRenderingPipeline";
609
788
 
610
789
  let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
611
790
 
612
- if (!defaultPipeline){
791
+ if (!defaultPipeline) {
613
792
  return false;
614
- }
793
+ }
615
794
 
616
795
  if (defaultPipeline.isSupported) {
617
796
  // MSAA - Multisample Anti-Aliasing
@@ -716,7 +895,7 @@ export default class BabylonJSController {
716
895
  }
717
896
 
718
897
  const supportedPipelines = pipelineManager.supportedPipelines;
719
-
898
+
720
899
  if (supportedPipelines === undefined) {
721
900
  return false;
722
901
  }
@@ -751,13 +930,13 @@ export default class BabylonJSController {
751
930
  * @private
752
931
  * @returns {Promise<void|boolean>} Returns false if no environment texture is set; otherwise void.
753
932
  */
754
- async #initializeIBLShadows() {
933
+ async #initializeIBLShadows() {
755
934
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
756
-
935
+
757
936
  if (!this.#scene || !this.#scene?.activeCamera || !this.#scene?.environmentTexture || !pipelineManager) {
758
937
  return false;
759
938
  }
760
-
939
+
761
940
  if (!this.#scene.environmentTexture.isReady()) {
762
941
  const self = this;
763
942
  this.#scene.environmentTexture.onLoadObservable.addOnce(() => {
@@ -771,11 +950,11 @@ export default class BabylonJSController {
771
950
  if (isRootMesh) {
772
951
  return false;
773
952
  }
774
-
953
+
775
954
  const isHDRIMesh = mesh.name?.toLowerCase() === "hdri";
776
955
  const extrasCastShadows = mesh.metadata?.gltf?.extras?.castShadows;
777
956
  const meshGenerateShadows = typeof extrasCastShadows === "boolean" ? extrasCastShadows : isHDRIMesh ? false : true;
778
-
957
+
779
958
  if (meshGenerateShadows) {
780
959
  return true;
781
960
  }
@@ -793,7 +972,7 @@ export default class BabylonJSController {
793
972
  }
794
973
 
795
974
  const supportedPipelines = pipelineManager.supportedPipelines;
796
-
975
+
797
976
  if (!supportedPipelines) {
798
977
  return false;
799
978
  }
@@ -816,7 +995,7 @@ export default class BabylonJSController {
816
995
  if (!iblShadowsPipeline) {
817
996
  return false;
818
997
  }
819
-
998
+
820
999
  if (iblShadowsPipeline.isSupported) {
821
1000
  // Disable all debug passes for performance
822
1001
  const pipelineProps = {
@@ -835,7 +1014,7 @@ export default class BabylonJSController {
835
1014
 
836
1015
  meshesForCastingShadows.forEach((mesh) => iblShadowsPipeline.addShadowCastingMesh(mesh));
837
1016
  materialsForReceivingShadows.forEach((material) => iblShadowsPipeline.addShadowReceivingMaterial(material));
838
-
1017
+
839
1018
  iblShadowsPipeline.updateSceneBounds();
840
1019
  iblShadowsPipeline.toggleShadow(true);
841
1020
  iblShadowsPipeline.updateVoxelization();
@@ -1002,23 +1181,6 @@ export default class BabylonJSController {
1002
1181
  }
1003
1182
  }
1004
1183
 
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
1184
  /**
1023
1185
  * Sets up interaction handlers for the Babylon.js canvas and scene.
1024
1186
  * @private
@@ -1031,6 +1193,13 @@ export default class BabylonJSController {
1031
1193
  if (this.#scene) {
1032
1194
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
1033
1195
  }
1196
+ if (this.#engine) {
1197
+ this.#canvasResizeObserver = new ResizeObserver(() => {
1198
+ this.#engine.resize();
1199
+ this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1200
+ });
1201
+ this.#canvasResizeObserver.observe(this.#canvas);
1202
+ }
1034
1203
  }
1035
1204
 
1036
1205
  /**
@@ -1045,6 +1214,9 @@ export default class BabylonJSController {
1045
1214
  if (this.#scene !== null) {
1046
1215
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
1047
1216
  }
1217
+ this.#canvasResizeObserver?.disconnect();
1218
+ this.#canvasResizeObserver = null;
1219
+ this.#detachAnimationChangedListener();
1048
1220
  }
1049
1221
 
1050
1222
  /**
@@ -1059,6 +1231,18 @@ export default class BabylonJSController {
1059
1231
  }
1060
1232
  }
1061
1233
 
1234
+ /**
1235
+ * Disposes the shared GLTFResolver instance and closes its underlying storage handle.
1236
+ * @private
1237
+ * @returns {void}
1238
+ */
1239
+ #disposeGLTFResolver() {
1240
+ if (this.#gltfResolver) {
1241
+ this.#gltfResolver.dispose();
1242
+ this.#gltfResolver = null;
1243
+ }
1244
+ }
1245
+
1062
1246
  /**
1063
1247
  * Disposes the Babylon.js WebXR experience if it exists.
1064
1248
  * @private
@@ -1105,6 +1289,75 @@ export default class BabylonJSController {
1105
1289
  this.#hemiLight = this.#dirLight = this.#cameraLight = null;
1106
1290
  }
1107
1291
 
1292
+ /**
1293
+ * Handles animation state events emitted by `OpeningAnimation` instances.
1294
+ * Routes opening/closing states to continuous rendering and all other states
1295
+ * (paused/opened/closed) to a short final render burst.
1296
+ * @private
1297
+ * @param {CustomEvent} event - Event carrying animation state in `event.detail.state`.
1298
+ * @returns {void}
1299
+ */
1300
+ #onAnimationGroupChanged(event) {
1301
+ const state = event?.detail?.state;
1302
+ if (state === undefined) {
1303
+ return;
1304
+ }
1305
+
1306
+ if (state === OpeningAnimation.states.opening || state === OpeningAnimation.states.closing) {
1307
+ this.#onAnimationGroupPlay();
1308
+ } else {
1309
+ this.#onAnimationGroupStop();
1310
+ }
1311
+ }
1312
+
1313
+ /**
1314
+ * Marks animation playback as active and requests short continuous rendering so animated transforms remain smooth while state is changing.
1315
+ * @private
1316
+ * @returns {void}
1317
+ */
1318
+ #onAnimationGroupPlay() {
1319
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.animationMs });
1320
+ }
1321
+
1322
+ /**
1323
+ * Handles animation stop/pause/end transitions by requesting a final render burst.
1324
+ * @private
1325
+ * @returns {void}
1326
+ */
1327
+ #onAnimationGroupStop() {
1328
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1329
+ this.#requestRender({ frames: frames });
1330
+ }
1331
+
1332
+ /**
1333
+ * Attaches the `prefviewer-animation-changed` listener to the nearest `pref-viewer-3d` host.
1334
+ * Removes any previous registration first to avoid duplicate callbacks across reload cycles.
1335
+ * @private
1336
+ * @returns {boolean} True when the listener is attached, false when no host is available.
1337
+ */
1338
+ #attachAnimationChangedListener() {
1339
+ this.#getPrefViewer3DComponent();
1340
+ if (!this.#prefViewer3D) {
1341
+ return false;
1342
+ }
1343
+ this.#detachAnimationChangedListener();
1344
+ this.#prefViewer3D.addEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
1345
+ return true;
1346
+ }
1347
+
1348
+ /**
1349
+ * Detaches the `prefviewer-animation-changed` listener from the cached `pref-viewer-3d` host.
1350
+ * @private
1351
+ * @returns {boolean} True when a host exists and the listener removal was attempted, false otherwise.
1352
+ */
1353
+ #detachAnimationChangedListener() {
1354
+ if (!this.#prefViewer3D) {
1355
+ return false;
1356
+ }
1357
+ this.#prefViewer3D.removeEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
1358
+ return true;
1359
+ }
1360
+
1108
1361
  /**
1109
1362
  * Handles keyup events on the Babylon.js canvas for triggering model and scene downloads.
1110
1363
  * @private
@@ -1132,11 +1385,12 @@ export default class BabylonJSController {
1132
1385
  * @returns {void|false} Returns false if there is no active camera; otherwise, void.
1133
1386
  */
1134
1387
  #onMouseWheel(event, pickInfo) {
1388
+ event.preventDefault();
1135
1389
  const camera = this.#scene?.activeCamera;
1136
1390
  if (!camera) {
1137
1391
  return false;
1138
1392
  }
1139
- if (!camera.metadata?.locked) {
1393
+ if (!camera.metadata?.locked) {
1140
1394
  if (camera instanceof ArcRotateCamera) {
1141
1395
  camera.wheelPrecision = camera.wheelPrecision || 3.0;
1142
1396
  camera.inertialRadiusOffset -= event.deltaY * camera.wheelPrecision * 0.001;
@@ -1149,8 +1403,8 @@ export default class BabylonJSController {
1149
1403
  const movementVector = direction.scale(zoomSpeed);
1150
1404
  camera.position = camera.position.add(movementVector);
1151
1405
  }
1406
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1152
1407
  }
1153
- event.preventDefault();
1154
1408
  }
1155
1409
 
1156
1410
  /**
@@ -1178,9 +1432,54 @@ export default class BabylonJSController {
1178
1432
  * @returns {void}
1179
1433
  */
1180
1434
  #onPointerMove(event, pickInfo) {
1435
+ const camera = this.#scene?.activeCamera;
1436
+ if (camera && !camera.metadata?.locked) {
1437
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1438
+ }
1181
1439
  if (this.#babylonJSAnimationController) {
1182
- this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1440
+ const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1441
+ if (this.#lastPickedMeshId !== pickedMeshId) {
1442
+ const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1443
+ if (highlightResult.changed) {
1444
+ this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1445
+ }
1446
+ }
1447
+ }
1448
+ }
1449
+
1450
+ /**
1451
+ * Handles pointer events observed on the Babylon.js scene.
1452
+ * @private
1453
+ * @param {PointerInfo} info - The pointer event information from Babylon.js.
1454
+ * @returns {void}
1455
+ */
1456
+ #onPointerObservable(info) {
1457
+ const pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
1458
+ const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1459
+
1460
+ if (info.type === PointerEventTypes.POINTERMOVE) {
1461
+ this.#onPointerMove(info.event, pickInfo);
1462
+ } else if (info.type === PointerEventTypes.POINTERUP) {
1463
+ this.#onPointerUp(info.event, pickInfo);
1464
+ } else if (info.type === PointerEventTypes.POINTERWHEEL) {
1465
+ this.#onMouseWheel(info.event, pickInfo);
1183
1466
  }
1467
+ this.#lastPickedMeshId = pickedMeshId;
1468
+ }
1469
+
1470
+ /**
1471
+ * Handles canvas resize notifications.
1472
+ * Resizes the Babylon engine and requests a short on-demand render burst so camera-dependent
1473
+ * buffers and post-process pipelines are redrawn at the new viewport size.
1474
+ * @private
1475
+ * @returns {void}
1476
+ */
1477
+ #onResize() {
1478
+ if (!this.#engine) {
1479
+ return;
1480
+ }
1481
+ this.#engine.resize();
1482
+ this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1184
1483
  }
1185
1484
 
1186
1485
  /**
@@ -1222,7 +1521,7 @@ export default class BabylonJSController {
1222
1521
  .forEach((mesh) => {
1223
1522
  mesh.material = material;
1224
1523
  someSetted = true;
1225
- })
1524
+ }),
1226
1525
  );
1227
1526
 
1228
1527
  if (someSetted) {
@@ -1336,34 +1635,47 @@ export default class BabylonJSController {
1336
1635
  }
1337
1636
 
1338
1637
  /**
1339
- * Caches and retrieves the parent custom element "PREF-VIEWER-3D" for efficient access.
1638
+ * Resolves and caches the closest `pref-viewer-3d` host associated with the rendering canvas.
1340
1639
  * @private
1341
1640
  * @returns {void}
1342
1641
  */
1343
1642
  #getPrefViewer3DComponent() {
1344
- if (this.#prefViewer3D === undefined) {
1345
- const grandParentElement = this.#canvas.parentElement.parentElement;
1346
- this.#prefViewer3D = grandParentElement && grandParentElement.nodeName === "PREF-VIEWER-3D" ? grandParentElement : null;
1643
+ if (this.#prefViewer3D !== undefined) {
1644
+ return;
1645
+ }
1646
+
1647
+ let prefViewer3D = this.#canvas?.closest?.("pref-viewer-3d") || undefined;
1648
+ if (!prefViewer3D) {
1649
+ const host = this.#canvas?.getRootNode?.()?.host;
1650
+ prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : undefined;
1347
1651
  }
1652
+
1653
+ this.#prefViewer3D = prefViewer3D;
1348
1654
  }
1349
1655
 
1350
1656
  /**
1351
- * Caches and retrieves the parent custom element "PREF-VIEWER" for efficient access.
1657
+ * Resolves and caches the closest `pref-viewer` host associated with `#prefViewer3D`.
1352
1658
  * @private
1353
1659
  * @returns {void}
1354
1660
  */
1355
1661
  #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;
1662
+ if (this.#prefViewer !== undefined) {
1663
+ return;
1366
1664
  }
1665
+
1666
+ this.#getPrefViewer3DComponent();
1667
+ if (this.#prefViewer3D === undefined) {
1668
+ this.#prefViewer = undefined;
1669
+ return;
1670
+ }
1671
+
1672
+ let prefViewer = this.#prefViewer3D.closest?.("pref-viewer") || undefined;
1673
+ if (!prefViewer) {
1674
+ const host = this.#prefViewer3D.getRootNode?.()?.host;
1675
+ prefViewer = host?.nodeName === "PREF-VIEWER" ? host : undefined;
1676
+ }
1677
+
1678
+ this.#prefViewer = prefViewer;
1367
1679
  }
1368
1680
 
1369
1681
  /**
@@ -1512,7 +1824,10 @@ export default class BabylonJSController {
1512
1824
  * @returns {void}
1513
1825
  */
1514
1826
  async #stopRender() {
1515
- this.#engine.stopRenderLoop(this.#handlers.renderLoop);
1827
+ this.#stopEngineRenderLoop();
1828
+ this.#renderState.dirtyFrames = 0;
1829
+ this.#renderState.continuousUntil = 0;
1830
+ this.#renderState.lastRenderAt = 0;
1516
1831
  await this.#unloadCameraDependentEffects();
1517
1832
  }
1518
1833
  /**
@@ -1523,9 +1838,9 @@ export default class BabylonJSController {
1523
1838
  */
1524
1839
  async #startRender() {
1525
1840
  await this.#loadCameraDependentEffects();
1526
- this.#scene.executeWhenReady(() => {
1527
- this.#engine.runRenderLoop(this.#handlers.renderLoop);
1528
- });
1841
+ await this.#scene.whenReadyAsync();
1842
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1843
+ this.#requestRender({ frames: frames, continuousMs: this.#renderConfig.interactionMs });
1529
1844
  }
1530
1845
 
1531
1846
  /**
@@ -1545,7 +1860,6 @@ export default class BabylonJSController {
1545
1860
  * `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
1546
1861
  */
1547
1862
  async #loadAssetContainer(container, force = false) {
1548
-
1549
1863
  if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
1550
1864
  return [container, false];
1551
1865
  }
@@ -1606,6 +1920,7 @@ export default class BabylonJSController {
1606
1920
  * Returns an object with success status and error details.
1607
1921
  */
1608
1922
  async #loadContainers() {
1923
+ this.#detachAnimationChangedListener();
1609
1924
  await this.#stopRender();
1610
1925
 
1611
1926
  let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
@@ -1662,6 +1977,9 @@ export default class BabylonJSController {
1662
1977
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
1663
1978
  this.#setMaxSimultaneousLights();
1664
1979
  this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
1980
+ if (this.#babylonJSAnimationController?.hasAnimations?.()) {
1981
+ this.#attachAnimationChangedListener();
1982
+ }
1665
1983
  await this.#startRender();
1666
1984
  });
1667
1985
  return detail;
@@ -1921,10 +2239,10 @@ export default class BabylonJSController {
1921
2239
  this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false });
1922
2240
  this.#engine.disableUniformBuffers = true;
1923
2241
  this.#scene = new Scene(this.#engine);
1924
-
2242
+
1925
2243
  // 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.
2244
+ // SSAO, and Velocity-Texture-Animation (VAT), allowing for complex post-processing by separating rendering into
2245
+ // different buffers (depth, normals, velocity) for later use in shaders.
1928
2246
  const geometryBufferRenderer = this.#scene.enableGeometryBufferRenderer();
1929
2247
  if (geometryBufferRenderer) {
1930
2248
  geometryBufferRenderer.enableScreenspaceDepth = true;
@@ -1941,26 +2259,29 @@ export default class BabylonJSController {
1941
2259
  this.#scene.imageProcessingConfiguration.vignetteEnabled = false;
1942
2260
  this.#scene.imageProcessingConfiguration.colorCurvesEnabled = false;
1943
2261
 
2262
+ // Skip the built-in pointer picking logic since the controller implements its own optimized raycasting for interaction.
2263
+ this.#scene.skipPointerMovePicking = true;
2264
+ this.#scene.skipPointerDownPicking = true;
2265
+ this.#scene.skipPointerUpPicking = true;
2266
+
1944
2267
  this.#createCamera();
1945
2268
  this.#enableInteraction();
1946
2269
  await this.#createXRExperience();
1947
- this.#startRender();
1948
- this.#canvasResizeObserver = new ResizeObserver(() => this.#engine && this.#engine.resize());
1949
- this.#canvasResizeObserver.observe(this.#canvas);
1950
2270
  }
1951
2271
 
1952
2272
  /**
1953
2273
  * Disposes the Babylon.js engine and disconnects the canvas resize observer.
1954
- * Cleans up all scene, camera, light, and XR resources.
2274
+ * Cleans up all scene, camera, light, XR, and GLTF resolver resources.
1955
2275
  * @public
1956
2276
  * @returns {void}
1957
2277
  */
1958
2278
  disable() {
1959
- this.#canvasResizeObserver.disconnect();
1960
2279
  this.#disableInteraction();
1961
2280
  this.#disposeAnimationController();
2281
+ this.#disposeGLTFResolver();
1962
2282
  this.#disposeXRExperience();
1963
2283
  this.#unloadCameraDependentEffects();
2284
+ this.#stopEngineRenderLoop();
1964
2285
  this.#disposeEngine();
1965
2286
  }
1966
2287
 
@@ -36,6 +36,7 @@ import { openDB } from "idb";
36
36
  * - getBlob(uri): Retrieves file blob from cache or server.
37
37
  * - get(uri): Gets file from cache with automatic server sync and cache versioning.
38
38
  * - put(uri): Stores file from server in IndexedDB cache.
39
+ * - dispose(): Closes the active IndexedDB handle held by the instance.
39
40
  *
40
41
  * Features:
41
42
  * - Automatic Cache Versioning: Compares server and cached timestamps to update cache.
@@ -105,6 +106,7 @@ import { openDB } from "idb";
105
106
  * - Supports both HTTPS and HTTP (HTTP not recommended for production)
106
107
  * - Object URLs should be revoked after use to free memory
107
108
  * - IndexedDB cache is auto-maintained (TTL, max entries, quota cleanup)
109
+ * - Call dispose() when the owner is torn down to release the DB connection proactively.
108
110
  *
109
111
  * Error Handling:
110
112
  * - Network failures: Returns undefined (get) or false (other methods)
@@ -782,4 +784,19 @@ export default class FileStorage {
782
784
  const fileToStore = await this.#getServerFile(uri);
783
785
  return fileToStore ? !!(await this.#putFile(fileToStore, uri)) : false;
784
786
  }
787
+
788
+ /**
789
+ * Closes the IndexedDB handle held by this storage instance.
790
+ * Safe to call multiple times; next storage operation will lazily reopen the DB.
791
+ * @public
792
+ * @returns {void}
793
+ */
794
+ dispose() {
795
+ if (this.#db) {
796
+ try {
797
+ this.#db.close();
798
+ } catch {}
799
+ }
800
+ this.#db = undefined;
801
+ }
785
802
  }
@@ -19,6 +19,7 @@ import { initDb, loadModel } from "./gltf-storage.js";
19
19
  * Public Methods:
20
20
  * - getSource(storage, currentSize, currentTimeStamp): Resolves and prepares a glTF/GLB source for loading.
21
21
  * - revokeObjectURLs(objectURLs): Releases temporary blob URLs generated during source resolution.
22
+ * - dispose(): Releases resolver resources and closes the internal FileStorage DB handle.
22
23
  *
23
24
  * Private Methods:
24
25
  * - #initializeStorage(db, table): Ensures IndexedDB store is initialized.
@@ -231,6 +232,16 @@ export default class GLTFResolver {
231
232
  objectURLs.length = 0;
232
233
  }
233
234
 
235
+ /**
236
+ * Disposes resolver-owned resources.
237
+ * @public
238
+ * @returns {void}
239
+ */
240
+ dispose() {
241
+ this.#fileStorage?.dispose?.();
242
+ this.#fileStorage = null;
243
+ }
244
+
234
245
  /**
235
246
  * Resolves and prepares a glTF/GLB source from various storage backends.
236
247
  * Supports IndexedDB, direct URLs, and base64-encoded data.