@preference-sl/pref-viewer 2.13.0-beta.11 → 2.13.0-beta.13

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.11",
3
+ "version": "2.13.0-beta.13",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -18,14 +18,15 @@ import { translate } from "./localization/i18n.js";
18
18
  * turns it into a deterministic scene lifecycle with exports.
19
19
  *
20
20
  * Overview
21
- * - Creates the Babylon.js engine/scene/camera stack, configures Draco decoders, wires resize + render loops, and
22
- * exposes download/xR helpers.
21
+ * - Creates the Babylon.js engine/scene/camera stack, configures Draco decoders, wires render loops plus a throttled
22
+ * ResizeObserver, and exposes download/XR helpers.
23
23
  * - Resolves GLTF/GLB sources via GLTFResolver, loads them into `AssetContainer`s, and toggles visibility by
24
24
  * mutating container state plus `show-model/show-scene` attributes.
25
25
  * - Applies/persists AA, SSAO, IBL, and shadow flags; when they change it stops the render loop, tears down pipelines,
26
26
  * reloads containers, and reinstalls effects after environment textures finish loading.
27
27
  * - Manages keyboard/pointer/wheel handlers, animation menus, and WebXR so PrefViewer menus and DOM attributes stay
28
- * synchronized with Babylon state, using sampled pointer picking to reduce raycast cost on dense scenes.
28
+ * synchronized with Babylon state, using sampled pointer picking and last-picked tracking to reduce raycast/highlight
29
+ * cost on dense scenes.
29
30
  * - Generates GLB, glTF+ZIP, or USDZ exports with timestamped names and localized dialog copy.
30
31
  * - Translates metadata (inner floor offsets, cast/receive shadows, camera locks) into scene adjustments after reloads.
31
32
  *
@@ -39,7 +40,8 @@ import { translate } from "./localization/i18n.js";
39
40
  * 5. Use `setContainerVisibility`, `setMaterialOptions`, `setCameraOptions`, or `setIBLOptions` for targeted updates; these
40
41
  * helpers stop/restart the render loop while they rebuild camera-dependent resources.
41
42
  * 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.
43
+ * `disable()` is asynchronous; it waits for XR/session shutdown before engine disposal and also disposes the
44
+ * shared GLTF resolver, which closes its internal IndexedDB handle.
43
45
  *
44
46
  * Public API Highlights
45
47
  * - constructor(canvas, containers, options)
@@ -65,6 +67,12 @@ import { translate } from "./localization/i18n.js";
65
67
  * in SSR/Node contexts (though functionality activates only in browsers).
66
68
  * - Pointer-pick lifecycle: hover/highlight raycasts are sampled on POINTERMOVE (time + distance thresholds), wheel
67
69
  * input avoids picks entirely, and right-click POINTERUP performs an on-demand pick for context-menu targeting.
70
+ * - Runtime bookkeeping is split into mutable `#state` and tuning constants under `#config` to keep behavior changes
71
+ * explicit and reduce cross-field drift.
72
+ * - Resize lifecycle: canvas resize notifications are throttled (with trailing execution) before calling `engine.resize()`
73
+ * and any queued resize callback is canceled during teardown.
74
+ * - Teardown lifecycle: concurrent `disable()` calls are coalesced into a single in-flight promise to avoid races
75
+ * during XR exit and engine disposal.
68
76
  */
69
77
  export default class BabylonJSController {
70
78
  #RENDER_SETTINGS_STORAGE_KEY = "pref-viewer/render-settings";
@@ -96,9 +104,8 @@ export default class BabylonJSController {
96
104
  #shadowGen = [];
97
105
  #XRExperience = null;
98
106
  #canvasResizeObserver = null;
99
-
107
+
100
108
  #hdrTexture = null; // reusable in-memory HDR source cloned into scene.environmentTexture across reloads
101
- #lastPickedMeshId = null;
102
109
 
103
110
  #containers = {};
104
111
  #options = {};
@@ -113,39 +120,61 @@ export default class BabylonJSController {
113
120
  };
114
121
 
115
122
  #handlers = {
123
+ onAnimationGroupChanged: null,
116
124
  onKeyUp: null,
117
125
  onPointerObservable: null,
118
- onAnimationGroupChanged: null,
119
126
  onResize: null,
120
127
  renderLoop: null,
121
128
  };
122
129
 
123
130
  #settings = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
124
131
 
125
- // State and config for render loop management to balance performance with responsiveness; uses a dirty frame counter plus continuous render windows triggered by interactions, animations, or XR sessions.
126
- #renderState = {
127
- isLoopRunning: false,
128
- dirtyFrames: 0,
129
- continuousUntil: 0,
130
- lastRenderAt: 0,
131
- };
132
- #renderConfig = {
133
- burstFramesBase: 2,
134
- burstFramesEnhanced: 32, // when AA/SSAO/IBL is enabled, more frames are needed to reach stable output
135
- interactionMs: 250,
136
- animationMs: 200,
137
- idleThrottleMs: 1000 / 15,
132
+ // Runtime mutable state (changes while the app is running).
133
+ #state = {
134
+ // Pointer-picking sampling state avoids expensive scene.pick on every move.
135
+ pointerPicking: {
136
+ lastMovePickAt: 0,
137
+ lastMovePickX: NaN,
138
+ lastMovePickY: NaN,
139
+ lastPickedMeshId: null,
140
+ },
141
+ // Render loop state balances performance with responsiveness.
142
+ render: {
143
+ isLoopRunning: false,
144
+ dirtyFrames: 0,
145
+ continuousUntil: 0,
146
+ lastRenderAt: 0,
147
+ },
148
+ // Resize state batches frequent ResizeObserver notifications.
149
+ resize: {
150
+ isScheduled: false,
151
+ timeoutId: null,
152
+ lastAppliedAt: 0,
153
+ },
138
154
  };
139
155
 
140
- // State and config for pointer-picking sampling to avoid expensive scene.pick calls on every mouse move; uses time + distance heuristics to balance responsiveness with performance.
141
- #pointerPickingState = {
142
- lastMovePickAt: 0,
143
- lastMovePickX: NaN,
144
- lastMovePickY: NaN,
156
+ // Runtime configuration constants (tuning knobs, not per-frame state).
157
+ #config = {
158
+ pointerPicking: {
159
+ movePickIntervalMs: 50, // cap expensive scene.pick calls to ~20 Hz while moving the pointer
160
+ movePickMinDistancePx: 2, // skip picks for sub-pixel jitter
161
+ },
162
+ render: {
163
+ burstFramesBase: 2,
164
+ burstFramesEnhanced: 32, // when AA/SSAO/IBL is enabled, more frames are needed to reach stable output
165
+ interactionMs: 250,
166
+ animationMs: 200,
167
+ idleThrottleMs: 1000 / 15,
168
+ },
169
+ resize: {
170
+ throttleMs: 50, // cap resize work to ~20 Hz while dragging/resizing containers
171
+ },
145
172
  };
146
- #pointerPickingConfig = {
147
- movePickIntervalMs: 50, // cap expensive scene.pick calls to ~20 Hz while moving the pointer
148
- movePickMinDistancePx: 2, // skip picks for sub-pixel jitter
173
+
174
+ // Promises to track async disable() lifecycle when XR and general teardown may run concurrently; ensures idempotent disable calls are safe and callers can await full teardown completion.
175
+ #disablingPromises = {
176
+ xr: null,
177
+ general: null,
149
178
  };
150
179
 
151
180
  /**
@@ -312,11 +341,11 @@ export default class BabylonJSController {
312
341
  * @returns {boolean} True when the loop was started, false when no engine is available or it was already running.
313
342
  */
314
343
  #startEngineRenderLoop() {
315
- if (!this.#engine || this.#renderState.isLoopRunning) {
344
+ if (!this.#engine || this.#state.render.isLoopRunning) {
316
345
  return false;
317
346
  }
318
347
  this.#engine.runRenderLoop(this.#handlers.renderLoop);
319
- this.#renderState.isLoopRunning = true;
348
+ this.#state.render.isLoopRunning = true;
320
349
  return true;
321
350
  }
322
351
 
@@ -326,13 +355,25 @@ export default class BabylonJSController {
326
355
  * @returns {boolean} True when the loop was stopped, false when no engine is available or it was already stopped.
327
356
  */
328
357
  #stopEngineRenderLoop() {
329
- if (!this.#engine || !this.#renderState.isLoopRunning) {
358
+ if (!this.#engine || !this.#state.render.isLoopRunning) {
330
359
  return false;
331
360
  }
332
361
  this.#engine.stopRenderLoop(this.#handlers.renderLoop);
333
- this.#renderState.isLoopRunning = false;
362
+ this.#state.render.isLoopRunning = false;
334
363
  return true;
335
364
  }
365
+
366
+ /**
367
+ * Resets transient render-loop bookkeeping so the next render request starts from a clean baseline.
368
+ * Clears queued burst frames, ends any active continuous-render window, and drops the idle-throttle timestamp.
369
+ * @private
370
+ * @returns {void}
371
+ */
372
+ #resetRenderState() {
373
+ this.#state.render.dirtyFrames = 0;
374
+ this.#state.render.continuousUntil = 0;
375
+ this.#state.render.lastRenderAt = 0;
376
+ }
336
377
 
337
378
  /**
338
379
  * Marks the scene as dirty and optionally extends a short continuous-render window.
@@ -349,9 +390,9 @@ export default class BabylonJSController {
349
390
  }
350
391
 
351
392
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
352
- this.#renderState.dirtyFrames = Math.max(this.#renderState.dirtyFrames, Math.max(1, frames));
393
+ this.#state.render.dirtyFrames = Math.max(this.#state.render.dirtyFrames, Math.max(1, frames));
353
394
  if (continuousMs > 0) {
354
- this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + continuousMs);
395
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + continuousMs);
355
396
  }
356
397
  this.#startEngineRenderLoop();
357
398
  return true;
@@ -436,13 +477,13 @@ export default class BabylonJSController {
436
477
  const cameraInMotion = this.#isCameraInMotion();
437
478
 
438
479
  if (animationRunning) {
439
- this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + this.#renderConfig.animationMs);
480
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.animationMs);
440
481
  }
441
482
  if (cameraInMotion) {
442
- this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + this.#renderConfig.interactionMs);
483
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.interactionMs);
443
484
  }
444
485
 
445
- return animationRunning || cameraInMotion || this.#renderState.continuousUntil > now;
486
+ return animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
446
487
  }
447
488
 
448
489
  /**
@@ -460,22 +501,22 @@ export default class BabylonJSController {
460
501
 
461
502
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
462
503
  const continuous = this.#shouldRenderContinuously(now);
463
- const needsRender = continuous || this.#renderState.dirtyFrames > 0;
504
+ const needsRender = continuous || this.#state.render.dirtyFrames > 0;
464
505
 
465
506
  if (!needsRender) {
466
507
  this.#stopEngineRenderLoop();
467
508
  return;
468
509
  }
469
510
 
470
- if (!continuous && this.#renderState.lastRenderAt > 0 && now - this.#renderState.lastRenderAt < this.#renderConfig.idleThrottleMs) {
511
+ if (!continuous && this.#state.render.lastRenderAt > 0 && now - this.#state.render.lastRenderAt < this.#config.render.idleThrottleMs) {
471
512
  return;
472
513
  }
473
-
514
+
474
515
  this.#scene.render();
475
- this.#renderState.lastRenderAt = now;
516
+ this.#state.render.lastRenderAt = now;
476
517
 
477
- if (this.#renderState.dirtyFrames > 0) {
478
- this.#renderState.dirtyFrames -= 1;
518
+ if (this.#state.render.dirtyFrames > 0) {
519
+ this.#state.render.dirtyFrames -= 1;
479
520
  }
480
521
  }
481
522
 
@@ -1200,10 +1241,10 @@ export default class BabylonJSController {
1200
1241
  * @returns {void}
1201
1242
  */
1202
1243
  #resetPointerPickingState() {
1203
- this.#pointerPickingState.lastMovePickAt = 0;
1204
- this.#pointerPickingState.lastMovePickX = NaN;
1205
- this.#pointerPickingState.lastMovePickY = NaN;
1206
- this.#lastPickedMeshId = null;
1244
+ this.#state.pointerPicking.lastMovePickAt = 0;
1245
+ this.#state.pointerPicking.lastMovePickX = NaN;
1246
+ this.#state.pointerPicking.lastMovePickY = NaN;
1247
+ this.#state.pointerPicking.lastPickedMeshId = null;
1207
1248
  }
1208
1249
 
1209
1250
  /**
@@ -1220,8 +1261,8 @@ export default class BabylonJSController {
1220
1261
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1221
1262
  const x = this.#scene.pointerX;
1222
1263
  const y = this.#scene.pointerY;
1223
- const state = this.#pointerPickingState;
1224
- const config = this.#pointerPickingConfig;
1264
+ const state = this.#state.pointerPicking;
1265
+ const config = this.#config.pointerPicking;
1225
1266
 
1226
1267
  if (!Number.isFinite(state.lastMovePickX) || !Number.isFinite(state.lastMovePickY)) {
1227
1268
  state.lastMovePickX = x;
@@ -1256,10 +1297,7 @@ export default class BabylonJSController {
1256
1297
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
1257
1298
  }
1258
1299
  if (this.#engine) {
1259
- this.#canvasResizeObserver = new ResizeObserver(() => {
1260
- this.#engine.resize();
1261
- this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1262
- });
1300
+ this.#canvasResizeObserver = new ResizeObserver(this.#handlers.onResize);
1263
1301
  this.#canvasResizeObserver.observe(this.#canvas);
1264
1302
  }
1265
1303
  }
@@ -1276,6 +1314,7 @@ export default class BabylonJSController {
1276
1314
  if (this.#scene !== null) {
1277
1315
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
1278
1316
  }
1317
+ this.#cancelScheduledResize();
1279
1318
  this.#canvasResizeObserver?.disconnect();
1280
1319
  this.#canvasResizeObserver = null;
1281
1320
  this.#detachAnimationChangedListener();
@@ -1308,30 +1347,43 @@ export default class BabylonJSController {
1308
1347
 
1309
1348
  /**
1310
1349
  * Disposes the Babylon.js WebXR experience if it exists.
1350
+ * If XR is currently active, waits for `exitXRAsync()` before disposing to avoid
1351
+ * tearing down the engine while the XR session is still shutting down.
1352
+ * Concurrent calls share the same in-flight promise so disposal runs only once.
1311
1353
  * @private
1312
- * @returns {void}
1354
+ * @returns {Promise<void>}
1313
1355
  */
1314
- #disposeXRExperience() {
1315
- if (!this.#XRExperience) {
1356
+ async #disposeXRExperience() {
1357
+ if (this.#disablingPromises.xr) {
1358
+ return await this.#disablingPromises.xr;
1359
+ }
1360
+
1361
+ const xrExperience = this.#XRExperience;
1362
+ if (!xrExperience) {
1316
1363
  return;
1317
1364
  }
1318
1365
 
1319
- if (this.#XRExperience.baseExperience.state === WebXRState.IN_XR) {
1320
- this.#XRExperience.baseExperience
1321
- .exitXRAsync()
1322
- .then(() => {
1323
- this.#XRExperience.dispose();
1324
- this.#XRExperience = null;
1325
- })
1326
- .catch((error) => {
1327
- console.warn("Error exiting XR experience:", error);
1328
- this.#XRExperience.dispose();
1366
+ this.#disablingPromises.xr = (async () => {
1367
+ try {
1368
+ if (xrExperience.baseExperience?.state === WebXRState.IN_XR) {
1369
+ await xrExperience.baseExperience.exitXRAsync();
1370
+ }
1371
+ } catch (error) {
1372
+ console.warn("PrefViewer: Error exiting XR experience:", error);
1373
+ } finally {
1374
+ try {
1375
+ xrExperience.dispose();
1376
+ } catch (error) {
1377
+ console.warn("PrefViewer: Error disposing XR experience:", error);
1378
+ }
1379
+ if (this.#XRExperience === xrExperience) {
1329
1380
  this.#XRExperience = null;
1330
- });
1331
- } else {
1332
- this.#XRExperience.dispose();
1333
- this.#XRExperience = null;
1334
- }
1381
+ }
1382
+ this.#disablingPromises.xr = null;
1383
+ }
1384
+ })();
1385
+
1386
+ await this.#disablingPromises.xr;
1335
1387
  }
1336
1388
 
1337
1389
  /**
@@ -1379,7 +1431,7 @@ export default class BabylonJSController {
1379
1431
  * @returns {void}
1380
1432
  */
1381
1433
  #onAnimationGroupPlay() {
1382
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.animationMs });
1434
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.animationMs });
1383
1435
  }
1384
1436
 
1385
1437
  /**
@@ -1388,7 +1440,12 @@ export default class BabylonJSController {
1388
1440
  * @returns {void}
1389
1441
  */
1390
1442
  #onAnimationGroupStop() {
1391
- const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1443
+ debugger;
1444
+ if (this.#settings.iblEnabled && this.#renderPipelines.iblShadows) {
1445
+ this.#renderPipelines.iblShadows.updateVoxelization();
1446
+ this.#scene?.postProcessRenderPipelineManager?.update();
1447
+ }
1448
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
1392
1449
  this.#requestRender({ frames: frames });
1393
1450
  }
1394
1451
 
@@ -1466,7 +1523,7 @@ export default class BabylonJSController {
1466
1523
  const movementVector = direction.scale(zoomSpeed);
1467
1524
  camera.position = camera.position.add(movementVector);
1468
1525
  }
1469
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1526
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1470
1527
  }
1471
1528
  }
1472
1529
 
@@ -1499,14 +1556,14 @@ export default class BabylonJSController {
1499
1556
  #onPointerMove(event, pickInfo) {
1500
1557
  const camera = this.#scene?.activeCamera;
1501
1558
  if (camera && !camera.metadata?.locked) {
1502
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1559
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1503
1560
  }
1504
1561
  if (this.#babylonJSAnimationController && pickInfo) {
1505
1562
  const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1506
- if (this.#lastPickedMeshId !== pickedMeshId) {
1563
+ if (this.#state.pointerPicking.lastPickedMeshId !== pickedMeshId) {
1507
1564
  const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1508
1565
  if (highlightResult.changed) {
1509
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1566
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1510
1567
  }
1511
1568
  }
1512
1569
  }
@@ -1528,11 +1585,11 @@ export default class BabylonJSController {
1528
1585
  lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
1529
1586
  }
1530
1587
  this.#onPointerMove(info.event, pickInfo);
1531
- this.#lastPickedMeshId = lastPickedMeshId;
1588
+ this.#state.pointerPicking.lastPickedMeshId = lastPickedMeshId;
1532
1589
  } else if (info.type === PointerEventTypes.POINTERUP) {
1533
1590
  const pickInfo = info.event?.button === 2 ? this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY) : null;
1534
1591
  if (pickInfo) {
1535
- this.#lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
1592
+ this.#state.pointerPicking.lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
1536
1593
  }
1537
1594
  this.#onPointerUp(info.event, pickInfo);
1538
1595
  } else if (info.type === PointerEventTypes.POINTERWHEEL) {
@@ -1551,8 +1608,47 @@ export default class BabylonJSController {
1551
1608
  if (!this.#engine) {
1552
1609
  return;
1553
1610
  }
1554
- this.#engine.resize();
1555
- this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1611
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1612
+ const elapsed = now - this.#state.resize.lastAppliedAt;
1613
+ const applyResize = () => {
1614
+ this.#state.resize.timeoutId = null;
1615
+ this.#state.resize.isScheduled = false;
1616
+ if (!this.#engine) {
1617
+ return;
1618
+ }
1619
+ this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1620
+ console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
1621
+ this.#engine.resize();
1622
+ this.#requestRender({ frames: this.#config.render.burstFramesBase, continuousMs: this.#config.render.interactionMs });
1623
+ };
1624
+
1625
+ if (elapsed >= this.#config.resize.throttleMs) {
1626
+ applyResize();
1627
+ return;
1628
+ }
1629
+
1630
+ if (this.#state.resize.isScheduled) {
1631
+ return;
1632
+ }
1633
+
1634
+ this.#state.resize.isScheduled = true;
1635
+ const waitMs = Math.max(0, this.#config.resize.throttleMs - elapsed);
1636
+ this.#state.resize.timeoutId = setTimeout(() => {
1637
+ applyResize();
1638
+ }, waitMs);
1639
+ }
1640
+
1641
+ /**
1642
+ * Clears any queued throttled resize callback.
1643
+ * @private
1644
+ * @returns {void}
1645
+ */
1646
+ #cancelScheduledResize() {
1647
+ if (this.#state.resize.timeoutId !== null) {
1648
+ clearTimeout(this.#state.resize.timeoutId);
1649
+ }
1650
+ this.#state.resize.timeoutId = null;
1651
+ this.#state.resize.isScheduled = false;
1556
1652
  }
1557
1653
 
1558
1654
  /**
@@ -1898,11 +1994,10 @@ export default class BabylonJSController {
1898
1994
  */
1899
1995
  async #stopRender() {
1900
1996
  this.#stopEngineRenderLoop();
1901
- this.#renderState.dirtyFrames = 0;
1902
- this.#renderState.continuousUntil = 0;
1903
- this.#renderState.lastRenderAt = 0;
1997
+ this.#resetRenderState();
1904
1998
  await this.#unloadCameraDependentEffects();
1905
1999
  }
2000
+
1906
2001
  /**
1907
2002
  * Starts the Babylon.js render loop for the current scene.
1908
2003
  * Waits until the scene is ready before beginning continuous rendering.
@@ -1912,8 +2007,8 @@ export default class BabylonJSController {
1912
2007
  async #startRender() {
1913
2008
  await this.#loadCameraDependentEffects();
1914
2009
  await this.#scene.whenReadyAsync();
1915
- const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1916
- this.#requestRender({ frames: frames, continuousMs: this.#renderConfig.interactionMs });
2010
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2011
+ this.#requestRender({ frames: frames, continuousMs: this.#config.render.interactionMs });
1917
2012
  }
1918
2013
 
1919
2014
  /**
@@ -2345,17 +2440,40 @@ export default class BabylonJSController {
2345
2440
  /**
2346
2441
  * Disposes the Babylon.js engine and disconnects the canvas resize observer.
2347
2442
  * Cleans up all scene, camera, light, XR, and GLTF resolver resources.
2443
+ * The teardown is asynchronous: it waits for XR/session-dependent shutdown work
2444
+ * before disposing the engine, and coalesces concurrent calls into one in-flight promise.
2348
2445
  * @public
2349
- * @returns {void}
2446
+ * @returns {Promise<void>}
2350
2447
  */
2351
- disable() {
2352
- this.#disableInteraction();
2353
- this.#disposeAnimationController();
2354
- this.#disposeGLTFResolver();
2355
- this.#disposeXRExperience();
2356
- this.#unloadCameraDependentEffects();
2357
- this.#stopEngineRenderLoop();
2358
- this.#disposeEngine();
2448
+ async disable() {
2449
+ if (this.#disablingPromises.general) {
2450
+ return await this.#disablingPromises.general;
2451
+ }
2452
+
2453
+ this.#disablingPromises.general = (async () => {
2454
+ this.#disableInteraction();
2455
+ this.#disposeAnimationController();
2456
+ this.#disposeGLTFResolver();
2457
+ try {
2458
+ await this.#disposeXRExperience();
2459
+ } catch (error) {
2460
+ console.warn("PrefViewer: Error while disposing XR experience:", error);
2461
+ }
2462
+ try {
2463
+ await this.#unloadCameraDependentEffects();
2464
+ } catch (error) {
2465
+ console.warn("PrefViewer: Error while unloading camera-dependent effects:", error);
2466
+ } finally {
2467
+ this.#stopEngineRenderLoop();
2468
+ this.#disposeEngine();
2469
+ }
2470
+ })();
2471
+
2472
+ try {
2473
+ await this.#disablingPromises.general;
2474
+ } finally {
2475
+ this.#disablingPromises.general = null;
2476
+ }
2359
2477
  }
2360
2478
 
2361
2479
  /**
@@ -40,7 +40,7 @@ export const setLocale = (localeId) => {
40
40
  try {
41
41
  listener(currentLocale);
42
42
  } catch (error) {
43
- console.warn("PrefViewer i18n listener failed", error);
43
+ console.warn("PrefViewer: i18n listener failed", error);
44
44
  }
45
45
  });
46
46
  return currentLocale;
@@ -162,7 +162,7 @@ export default class PrefViewer3D extends HTMLElement {
162
162
  */
163
163
  disconnectedCallback() {
164
164
  if (this.#babylonJSController) {
165
- this.#babylonJSController.disable();
165
+ void this.#babylonJSController.disable();
166
166
  }
167
167
  }
168
168