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

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/Readme.md CHANGED
@@ -5,4 +5,4 @@ A Web Component for visualizing GLTF models using Babylon.js.
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install @preference-sl/pref-viewer
8
+ npm install @preference-sl/pref-viewer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.13.0-beta.9",
3
+ "version": "2.13.0",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -45,7 +45,7 @@ export default class BabylonJSAnimationController {
45
45
  #highlightColor = Color3.FromHexString(PrefViewerStyleVariables.colorPrimary);
46
46
  #highlightLayer = null;
47
47
  #overlayLayer = null;
48
- #useHighlightLayer = false; // Set to true to use HighlightLayer (better performance) and false to use overlay meshes (UtilityLayerRenderer - always on top)
48
+ #useHighlightLayer = true; // Set to true to use HighlightLayer (better performance) and false to use overlay meshes (UtilityLayerRenderer - always on top)
49
49
  #lastHighlightedMeshId = null; // Cache to avoid redundant highlight updates
50
50
  #lastHighlightedNodeIds = []; // Cache to avoid redundant highlight updates
51
51
 
@@ -317,6 +317,9 @@ export default class BabylonJSAnimationController {
317
317
  baseToOverlayBase.forEach((overlayBase) => {
318
318
  overlayBase.dispose();
319
319
  });
320
+
321
+ // Dispose utility layer scene/resources; without this each rebuild leaks GPU/JS resources.
322
+ utilityLayer.dispose();
320
323
  },
321
324
  };
322
325
  }
@@ -513,20 +516,26 @@ export default class BabylonJSAnimationController {
513
516
 
514
517
  /**
515
518
  * Displays the animation control menu for the animated node under the pointer.
519
+ * Hides the current menu when picking info is invalid or when no animated node is found.
520
+ * Prefers a currently started animation; otherwise falls back to the first available one.
516
521
  * @public
517
- * @param {PickingInfo} pickingInfo - Raycast info from pointer position.
522
+ * @param {PickingInfo|null|undefined} pickingInfo - Raycast info from pointer position.
523
+ * Can be null/undefined when caller intentionally skips picking.
524
+ * @returns {void}
518
525
  */
519
526
  showMenu(pickingInfo) {
520
- if (!pickingInfo?.hit && !pickingInfo?.pickedMesh) {
527
+ const pickedMesh = pickingInfo?.pickedMesh;
528
+ if (!pickingInfo?.hit || !pickedMesh) {
529
+ this.hideMenu();
521
530
  return;
522
531
  }
523
532
 
524
- this.hideMenu();
525
-
526
- const nodeIds = this.#getNodesAnimatedByMesh(pickingInfo.pickedMesh);
533
+ const nodeIds = this.#getNodesAnimatedByMesh(pickedMesh);
527
534
  if (!nodeIds.length) {
535
+ this.hideMenu();
528
536
  return;
529
537
  }
538
+
530
539
  const openingAnimations = [];
531
540
  nodeIds.forEach((nodeId) => {
532
541
  const openingAnimation = this.#getOpeningAnimationByNode(nodeId);
@@ -536,7 +545,19 @@ export default class BabylonJSAnimationController {
536
545
  openingAnimations.push(openingAnimation);
537
546
  });
538
547
 
548
+ if (!openingAnimations.length) {
549
+ this.hideMenu();
550
+ return;
551
+ }
552
+
539
553
  const startedAnimation = openingAnimations.find((animation) => animation.state !== OpeningAnimation.states.closed);
540
- startedAnimation ? startedAnimation.showControls(this.#canvas, openingAnimations) : openingAnimations[0].showControls(this.#canvas, openingAnimations);
554
+ const animationToShow = startedAnimation ? startedAnimation : openingAnimations[0];
555
+
556
+ if (animationToShow.isControlsVisible()) {
557
+ return;
558
+ }
559
+
560
+ this.hideMenu();
561
+ animationToShow.showControls(this.#canvas, openingAnimations);
541
562
  }
542
563
  }
@@ -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.
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)
@@ -63,6 +65,14 @@ import { translate } from "./localization/i18n.js";
63
65
  * while `options.ibl.valid` remains true.
64
66
  * - Browser-only features guard `window`, localStorage, and XR APIs before use so the controller is safe to construct
65
67
  * in SSR/Node contexts (though functionality activates only in browsers).
68
+ * - Pointer-pick lifecycle: hover/highlight raycasts are sampled on POINTERMOVE (time + distance thresholds), wheel
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.
66
76
  */
67
77
  export default class BabylonJSController {
68
78
  #RENDER_SETTINGS_STORAGE_KEY = "pref-viewer/render-settings";
@@ -94,9 +104,8 @@ export default class BabylonJSController {
94
104
  #shadowGen = [];
95
105
  #XRExperience = null;
96
106
  #canvasResizeObserver = null;
97
-
107
+
98
108
  #hdrTexture = null; // reusable in-memory HDR source cloned into scene.environmentTexture across reloads
99
- #lastPickedMeshId = null;
100
109
 
101
110
  #containers = {};
102
111
  #options = {};
@@ -111,28 +120,61 @@ export default class BabylonJSController {
111
120
  };
112
121
 
113
122
  #handlers = {
123
+ onAnimationGroupChanged: null,
114
124
  onKeyUp: null,
115
125
  onPointerObservable: null,
116
- onAnimationGroupChanged: null,
117
126
  onResize: null,
118
127
  renderLoop: null,
119
128
  };
120
129
 
121
130
  #settings = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
122
131
 
123
- #renderState = {
124
- isLoopRunning: false,
125
- dirtyFrames: 0,
126
- continuousUntil: 0,
127
- lastRenderAt: 0,
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
+ },
128
154
  };
129
155
 
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,
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
+ },
172
+ };
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,
136
178
  };
137
179
 
138
180
  /**
@@ -299,11 +341,11 @@ export default class BabylonJSController {
299
341
  * @returns {boolean} True when the loop was started, false when no engine is available or it was already running.
300
342
  */
301
343
  #startEngineRenderLoop() {
302
- if (!this.#engine || this.#renderState.isLoopRunning) {
344
+ if (!this.#engine || this.#state.render.isLoopRunning) {
303
345
  return false;
304
346
  }
305
347
  this.#engine.runRenderLoop(this.#handlers.renderLoop);
306
- this.#renderState.isLoopRunning = true;
348
+ this.#state.render.isLoopRunning = true;
307
349
  return true;
308
350
  }
309
351
 
@@ -313,13 +355,25 @@ export default class BabylonJSController {
313
355
  * @returns {boolean} True when the loop was stopped, false when no engine is available or it was already stopped.
314
356
  */
315
357
  #stopEngineRenderLoop() {
316
- if (!this.#engine || !this.#renderState.isLoopRunning) {
358
+ if (!this.#engine || !this.#state.render.isLoopRunning) {
317
359
  return false;
318
360
  }
319
361
  this.#engine.stopRenderLoop(this.#handlers.renderLoop);
320
- this.#renderState.isLoopRunning = false;
362
+ this.#state.render.isLoopRunning = false;
321
363
  return true;
322
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
+ }
323
377
 
324
378
  /**
325
379
  * Marks the scene as dirty and optionally extends a short continuous-render window.
@@ -336,9 +390,9 @@ export default class BabylonJSController {
336
390
  }
337
391
 
338
392
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
339
- 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));
340
394
  if (continuousMs > 0) {
341
- this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + continuousMs);
395
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + continuousMs);
342
396
  }
343
397
  this.#startEngineRenderLoop();
344
398
  return true;
@@ -423,13 +477,13 @@ export default class BabylonJSController {
423
477
  const cameraInMotion = this.#isCameraInMotion();
424
478
 
425
479
  if (animationRunning) {
426
- 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);
427
481
  }
428
482
  if (cameraInMotion) {
429
- 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);
430
484
  }
431
485
 
432
- return animationRunning || cameraInMotion || this.#renderState.continuousUntil > now;
486
+ return animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
433
487
  }
434
488
 
435
489
  /**
@@ -447,22 +501,22 @@ export default class BabylonJSController {
447
501
 
448
502
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
449
503
  const continuous = this.#shouldRenderContinuously(now);
450
- const needsRender = continuous || this.#renderState.dirtyFrames > 0;
504
+ const needsRender = continuous || this.#state.render.dirtyFrames > 0;
451
505
 
452
506
  if (!needsRender) {
453
507
  this.#stopEngineRenderLoop();
454
508
  return;
455
509
  }
456
510
 
457
- 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) {
458
512
  return;
459
513
  }
460
-
514
+
461
515
  this.#scene.render();
462
- this.#renderState.lastRenderAt = now;
516
+ this.#state.render.lastRenderAt = now;
463
517
 
464
- if (this.#renderState.dirtyFrames > 0) {
465
- this.#renderState.dirtyFrames -= 1;
518
+ if (this.#state.render.dirtyFrames > 0) {
519
+ this.#state.render.dirtyFrames -= 1;
466
520
  }
467
521
  }
468
522
 
@@ -1181,6 +1235,55 @@ export default class BabylonJSController {
1181
1235
  }
1182
1236
  }
1183
1237
 
1238
+ /**
1239
+ * Resets pointer-picking sampling state.
1240
+ * @private
1241
+ * @returns {void}
1242
+ */
1243
+ #resetPointerPickingState() {
1244
+ this.#state.pointerPicking.lastMovePickAt = 0;
1245
+ this.#state.pointerPicking.lastMovePickX = NaN;
1246
+ this.#state.pointerPicking.lastMovePickY = NaN;
1247
+ this.#state.pointerPicking.lastPickedMeshId = null;
1248
+ }
1249
+
1250
+ /**
1251
+ * Decides whether a POINTERMOVE event should trigger a scene raycast.
1252
+ * Uses time + distance sampling to avoid expensive pick calls on every mouse move.
1253
+ * @private
1254
+ * @returns {boolean}
1255
+ */
1256
+ #shouldPickOnPointerMove() {
1257
+ if (!this.#scene || !this.#babylonJSAnimationController) {
1258
+ return false;
1259
+ }
1260
+
1261
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1262
+ const x = this.#scene.pointerX;
1263
+ const y = this.#scene.pointerY;
1264
+ const state = this.#state.pointerPicking;
1265
+ const config = this.#config.pointerPicking;
1266
+
1267
+ if (!Number.isFinite(state.lastMovePickX) || !Number.isFinite(state.lastMovePickY)) {
1268
+ state.lastMovePickX = x;
1269
+ state.lastMovePickY = y;
1270
+ state.lastMovePickAt = now;
1271
+ return true;
1272
+ }
1273
+
1274
+ const elapsed = now - state.lastMovePickAt >= config.movePickIntervalMs;
1275
+ const moved = Math.abs(x - state.lastMovePickX) >= config.movePickMinDistancePx || Math.abs(y - state.lastMovePickY) >= config.movePickMinDistancePx;
1276
+
1277
+ if (!elapsed || !moved) {
1278
+ return false;
1279
+ }
1280
+
1281
+ state.lastMovePickX = x;
1282
+ state.lastMovePickY = y;
1283
+ state.lastMovePickAt = now;
1284
+ return true;
1285
+ }
1286
+
1184
1287
  /**
1185
1288
  * Sets up interaction handlers for the Babylon.js canvas and scene.
1186
1289
  * @private
@@ -1194,10 +1297,7 @@ export default class BabylonJSController {
1194
1297
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
1195
1298
  }
1196
1299
  if (this.#engine) {
1197
- this.#canvasResizeObserver = new ResizeObserver(() => {
1198
- this.#engine.resize();
1199
- this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1200
- });
1300
+ this.#canvasResizeObserver = new ResizeObserver(this.#handlers.onResize);
1201
1301
  this.#canvasResizeObserver.observe(this.#canvas);
1202
1302
  }
1203
1303
  }
@@ -1214,9 +1314,11 @@ export default class BabylonJSController {
1214
1314
  if (this.#scene !== null) {
1215
1315
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
1216
1316
  }
1317
+ this.#cancelScheduledResize();
1217
1318
  this.#canvasResizeObserver?.disconnect();
1218
1319
  this.#canvasResizeObserver = null;
1219
1320
  this.#detachAnimationChangedListener();
1321
+ this.#resetPointerPickingState();
1220
1322
  }
1221
1323
 
1222
1324
  /**
@@ -1245,30 +1347,43 @@ export default class BabylonJSController {
1245
1347
 
1246
1348
  /**
1247
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.
1248
1353
  * @private
1249
- * @returns {void}
1354
+ * @returns {Promise<void>}
1250
1355
  */
1251
- #disposeXRExperience() {
1252
- 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) {
1253
1363
  return;
1254
1364
  }
1255
1365
 
1256
- if (this.#XRExperience.baseExperience.state === WebXRState.IN_XR) {
1257
- this.#XRExperience.baseExperience
1258
- .exitXRAsync()
1259
- .then(() => {
1260
- this.#XRExperience.dispose();
1261
- this.#XRExperience = null;
1262
- })
1263
- .catch((error) => {
1264
- console.warn("Error exiting XR experience:", error);
1265
- 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) {
1266
1380
  this.#XRExperience = null;
1267
- });
1268
- } else {
1269
- this.#XRExperience.dispose();
1270
- this.#XRExperience = null;
1271
- }
1381
+ }
1382
+ this.#disablingPromises.xr = null;
1383
+ }
1384
+ })();
1385
+
1386
+ await this.#disablingPromises.xr;
1272
1387
  }
1273
1388
 
1274
1389
  /**
@@ -1316,7 +1431,7 @@ export default class BabylonJSController {
1316
1431
  * @returns {void}
1317
1432
  */
1318
1433
  #onAnimationGroupPlay() {
1319
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.animationMs });
1434
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.animationMs });
1320
1435
  }
1321
1436
 
1322
1437
  /**
@@ -1325,7 +1440,11 @@ export default class BabylonJSController {
1325
1440
  * @returns {void}
1326
1441
  */
1327
1442
  #onAnimationGroupStop() {
1328
- const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1443
+ if (this.#settings.iblEnabled && this.#renderPipelines.iblShadows) {
1444
+ this.#renderPipelines.iblShadows.updateVoxelization();
1445
+ this.#scene?.postProcessRenderPipelineManager?.update();
1446
+ }
1447
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
1329
1448
  this.#requestRender({ frames: frames });
1330
1449
  }
1331
1450
 
@@ -1365,10 +1484,10 @@ export default class BabylonJSController {
1365
1484
  * @returns {void}
1366
1485
  */
1367
1486
  #onKeyUp(event) {
1368
- // CTRL + ALT + letter
1369
- if (event.ctrlKey && event.altKey && event.key !== undefined) {
1370
- switch (event.key.toLowerCase()) {
1371
- case "d":
1487
+ // CTRL + ALT + letter (uses event.code for physical key, layout-independent — fixes Mac Option+D producing "∂" instead of "d")
1488
+ if (event.ctrlKey && event.altKey && event.code !== undefined) {
1489
+ switch (event.code) {
1490
+ case "KeyD":
1372
1491
  this.#openDownloadDialog();
1373
1492
  break;
1374
1493
  default:
@@ -1403,7 +1522,7 @@ export default class BabylonJSController {
1403
1522
  const movementVector = direction.scale(zoomSpeed);
1404
1523
  camera.position = camera.position.add(movementVector);
1405
1524
  }
1406
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1525
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1407
1526
  }
1408
1527
  }
1409
1528
 
@@ -1416,7 +1535,9 @@ export default class BabylonJSController {
1416
1535
  */
1417
1536
  #onPointerUp(event, pickInfo) {
1418
1537
  if (this.#babylonJSAnimationController) {
1419
- this.#babylonJSAnimationController.hideMenu();
1538
+ if (event.button !== 2 || !pickInfo || !pickInfo.pickedMesh) {
1539
+ this.#babylonJSAnimationController.hideMenu();
1540
+ }
1420
1541
  // Right click for showing animation menu
1421
1542
  if (event.button === 2) {
1422
1543
  this.#babylonJSAnimationController.showMenu(pickInfo);
@@ -1428,20 +1549,20 @@ export default class BabylonJSController {
1428
1549
  * Handles pointer move events on the Babylon.js scene.
1429
1550
  * @private
1430
1551
  * @param {PointerEvent} event - The pointer move event.
1431
- * @param {PickInfo} pickInfo - The result of the scene pick operation.
1552
+ * @param {PickInfo|null} pickInfo - The sampled result of the scene pick operation (may be null when sampling skips a raycast).
1432
1553
  * @returns {void}
1433
1554
  */
1434
1555
  #onPointerMove(event, pickInfo) {
1435
1556
  const camera = this.#scene?.activeCamera;
1436
1557
  if (camera && !camera.metadata?.locked) {
1437
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1558
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1438
1559
  }
1439
- if (this.#babylonJSAnimationController) {
1560
+ if (this.#babylonJSAnimationController && pickInfo) {
1440
1561
  const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1441
- if (this.#lastPickedMeshId !== pickedMeshId) {
1562
+ if (this.#state.pointerPicking.lastPickedMeshId !== pickedMeshId) {
1442
1563
  const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1443
1564
  if (highlightResult.changed) {
1444
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1565
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1445
1566
  }
1446
1567
  }
1447
1568
  }
@@ -1449,22 +1570,30 @@ export default class BabylonJSController {
1449
1570
 
1450
1571
  /**
1451
1572
  * Handles pointer events observed on the Babylon.js scene.
1573
+ * Uses pointer-move sampling to avoid running expensive `scene.pick()` on every event.
1452
1574
  * @private
1453
1575
  * @param {PointerInfo} info - The pointer event information from Babylon.js.
1454
1576
  * @returns {void}
1455
1577
  */
1456
1578
  #onPointerObservable(info) {
1457
- const pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
1458
- const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1459
-
1460
1579
  if (info.type === PointerEventTypes.POINTERMOVE) {
1580
+ let pickInfo = null;
1581
+ let lastPickedMeshId = null;
1582
+ if (this.#shouldPickOnPointerMove()) {
1583
+ pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
1584
+ lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
1585
+ }
1461
1586
  this.#onPointerMove(info.event, pickInfo);
1587
+ this.#state.pointerPicking.lastPickedMeshId = lastPickedMeshId;
1462
1588
  } else if (info.type === PointerEventTypes.POINTERUP) {
1589
+ const pickInfo = info.event?.button === 2 ? this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY) : null;
1590
+ if (pickInfo) {
1591
+ this.#state.pointerPicking.lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
1592
+ }
1463
1593
  this.#onPointerUp(info.event, pickInfo);
1464
1594
  } else if (info.type === PointerEventTypes.POINTERWHEEL) {
1465
- this.#onMouseWheel(info.event, pickInfo);
1595
+ this.#onMouseWheel(info.event, null);
1466
1596
  }
1467
- this.#lastPickedMeshId = pickedMeshId;
1468
1597
  }
1469
1598
 
1470
1599
  /**
@@ -1478,8 +1607,47 @@ export default class BabylonJSController {
1478
1607
  if (!this.#engine) {
1479
1608
  return;
1480
1609
  }
1481
- this.#engine.resize();
1482
- this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1610
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1611
+ const elapsed = now - this.#state.resize.lastAppliedAt;
1612
+ const applyResize = () => {
1613
+ this.#state.resize.timeoutId = null;
1614
+ this.#state.resize.isScheduled = false;
1615
+ if (!this.#engine) {
1616
+ return;
1617
+ }
1618
+ this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1619
+ console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
1620
+ this.#engine.resize();
1621
+ this.#requestRender({ frames: this.#config.render.burstFramesBase, continuousMs: this.#config.render.interactionMs });
1622
+ };
1623
+
1624
+ if (elapsed >= this.#config.resize.throttleMs) {
1625
+ applyResize();
1626
+ return;
1627
+ }
1628
+
1629
+ if (this.#state.resize.isScheduled) {
1630
+ return;
1631
+ }
1632
+
1633
+ this.#state.resize.isScheduled = true;
1634
+ const waitMs = Math.max(0, this.#config.resize.throttleMs - elapsed);
1635
+ this.#state.resize.timeoutId = setTimeout(() => {
1636
+ applyResize();
1637
+ }, waitMs);
1638
+ }
1639
+
1640
+ /**
1641
+ * Clears any queued throttled resize callback.
1642
+ * @private
1643
+ * @returns {void}
1644
+ */
1645
+ #cancelScheduledResize() {
1646
+ if (this.#state.resize.timeoutId !== null) {
1647
+ clearTimeout(this.#state.resize.timeoutId);
1648
+ }
1649
+ this.#state.resize.timeoutId = null;
1650
+ this.#state.resize.isScheduled = false;
1483
1651
  }
1484
1652
 
1485
1653
  /**
@@ -1825,11 +1993,10 @@ export default class BabylonJSController {
1825
1993
  */
1826
1994
  async #stopRender() {
1827
1995
  this.#stopEngineRenderLoop();
1828
- this.#renderState.dirtyFrames = 0;
1829
- this.#renderState.continuousUntil = 0;
1830
- this.#renderState.lastRenderAt = 0;
1996
+ this.#resetRenderState();
1831
1997
  await this.#unloadCameraDependentEffects();
1832
1998
  }
1999
+
1833
2000
  /**
1834
2001
  * Starts the Babylon.js render loop for the current scene.
1835
2002
  * Waits until the scene is ready before beginning continuous rendering.
@@ -1839,8 +2006,8 @@ export default class BabylonJSController {
1839
2006
  async #startRender() {
1840
2007
  await this.#loadCameraDependentEffects();
1841
2008
  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 });
2009
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2010
+ this.#requestRender({ frames: frames, continuousMs: this.#config.render.interactionMs });
1844
2011
  }
1845
2012
 
1846
2013
  /**
@@ -1919,7 +2086,7 @@ export default class BabylonJSController {
1919
2086
  * Applies material and camera options, sets wall/floor visibility, and initializes lights and shadows.
1920
2087
  * Returns an object with success status and error details.
1921
2088
  */
1922
- async #loadContainers() {
2089
+ async #loadContainers(force = false) {
1923
2090
  this.#detachAnimationChangedListener();
1924
2091
  await this.#stopRender();
1925
2092
 
@@ -1928,7 +2095,7 @@ export default class BabylonJSController {
1928
2095
 
1929
2096
  const promiseArray = [];
1930
2097
  Object.values(this.#containers).forEach((container) => {
1931
- promiseArray.push(this.#loadAssetContainer(container));
2098
+ promiseArray.push(this.#loadAssetContainer(container, force));
1932
2099
  });
1933
2100
 
1934
2101
  let detail = {
@@ -2272,17 +2439,40 @@ export default class BabylonJSController {
2272
2439
  /**
2273
2440
  * Disposes the Babylon.js engine and disconnects the canvas resize observer.
2274
2441
  * Cleans up all scene, camera, light, XR, and GLTF resolver resources.
2442
+ * The teardown is asynchronous: it waits for XR/session-dependent shutdown work
2443
+ * before disposing the engine, and coalesces concurrent calls into one in-flight promise.
2275
2444
  * @public
2276
- * @returns {void}
2445
+ * @returns {Promise<void>}
2277
2446
  */
2278
- disable() {
2279
- this.#disableInteraction();
2280
- this.#disposeAnimationController();
2281
- this.#disposeGLTFResolver();
2282
- this.#disposeXRExperience();
2283
- this.#unloadCameraDependentEffects();
2284
- this.#stopEngineRenderLoop();
2285
- this.#disposeEngine();
2447
+ async disable() {
2448
+ if (this.#disablingPromises.general) {
2449
+ return await this.#disablingPromises.general;
2450
+ }
2451
+
2452
+ this.#disablingPromises.general = (async () => {
2453
+ this.#disableInteraction();
2454
+ this.#disposeAnimationController();
2455
+ this.#disposeGLTFResolver();
2456
+ try {
2457
+ await this.#disposeXRExperience();
2458
+ } catch (error) {
2459
+ console.warn("PrefViewer: Error while disposing XR experience:", error);
2460
+ }
2461
+ try {
2462
+ await this.#unloadCameraDependentEffects();
2463
+ } catch (error) {
2464
+ console.warn("PrefViewer: Error while unloading camera-dependent effects:", error);
2465
+ } finally {
2466
+ this.#stopEngineRenderLoop();
2467
+ this.#disposeEngine();
2468
+ }
2469
+ })();
2470
+
2471
+ try {
2472
+ await this.#disablingPromises.general;
2473
+ } finally {
2474
+ this.#disablingPromises.general = null;
2475
+ }
2286
2476
  }
2287
2477
 
2288
2478
  /**
@@ -2403,8 +2593,8 @@ export default class BabylonJSController {
2403
2593
  * @public
2404
2594
  * @returns {Promise<boolean>} Resolves to true if loading succeeds, false otherwise.
2405
2595
  */
2406
- async load() {
2407
- return await this.#loadContainers();
2596
+ async load(force = false) {
2597
+ return await this.#loadContainers(force);
2408
2598
  }
2409
2599
 
2410
2600
  /**
@@ -2439,11 +2629,13 @@ export default class BabylonJSController {
2439
2629
  * @public
2440
2630
  * @returns {boolean} True if IBL options were set successfully, false otherwise.
2441
2631
  */
2442
- setIBLOptions() {
2443
- this.#stopRender();
2444
- const IBLOptionsSetted = this.#setOptions_IBL();
2445
- this.#startRender();
2446
- return IBLOptionsSetted;
2632
+ async setIBLOptions() {
2633
+ await this.#stopRender();
2634
+ try {
2635
+ return await this.#setOptions_IBL();
2636
+ } finally {
2637
+ await this.#startRender();
2638
+ }
2447
2639
  }
2448
2640
 
2449
2641
  /**
@@ -755,9 +755,12 @@ export default class FileStorage {
755
755
  }
756
756
  await this.#runCleanupIfDue();
757
757
  let storedFile = await this.#getFile(uri);
758
- const serverFileTimeStamp = storedFile ? await this.#getServerFileTimeStamp(uri) : 0;
759
- const storedFileTimeStamp = storedFile ? storedFile.timeStamp : 0;
760
- if (!storedFile || (storedFile && serverFileTimeStamp !== null && serverFileTimeStamp !== storedFileTimeStamp)) {
758
+ // If there is already a cached file, return it without HEAD revalidation.
759
+ // In ecommerce flow, new IDs/URLs from WASM drive real resource updates.
760
+ if (storedFile) {
761
+ return storedFile;
762
+ }
763
+ if (!storedFile) {
761
764
  const fileToStore = await this.#getServerFile(uri);
762
765
  if (fileToStore && !!(await this.#putFile(fileToStore, uri))) {
763
766
  storedFile = await this.#getFile(uri);
@@ -1,5 +1,5 @@
1
1
  import FileStorage from "./file-storage.js";
2
- import { initDb, loadModel } from "./gltf-storage.js";
2
+ import { closeDb, initDb, loadModel } from "./gltf-storage.js";
3
3
 
4
4
  /**
5
5
  * GLTFResolver - Utility class for resolving, decoding, and preparing glTF/GLB assets from various sources.
@@ -240,6 +240,7 @@ export default class GLTFResolver {
240
240
  dispose() {
241
241
  this.#fileStorage?.dispose?.();
242
242
  this.#fileStorage = null;
243
+ closeDb();
243
244
  }
244
245
 
245
246
  /**
@@ -4,6 +4,8 @@
4
4
  const PC = (globalThis.PrefConfigurator ??= {});
5
5
  PC.version = PC.version ?? "1.0.1"; // bump if your schema changes!!
6
6
  PC.db = PC.db ?? null;
7
+ PC.dbName = PC.dbName ?? null;
8
+ PC.storeName = PC.storeName ?? null;
7
9
 
8
10
  function _getDbOrThrow() {
9
11
  const db = PC.db;
@@ -23,22 +25,50 @@ function _openEnsuringStore(dbName, storeName) {
23
25
  }
24
26
  open.onupgradeneeded = (ev) => {
25
27
  const upgradeDb = ev.target.result;
28
+ const tx = ev.target.transaction;
26
29
 
27
- // Eliminar la object store si ya existe
28
- if (upgradeDb.objectStoreNames.contains(storeName)) {
29
- upgradeDb.deleteObjectStore(storeName);
30
+ let store;
31
+ if (!upgradeDb.objectStoreNames.contains(storeName)) {
32
+ store = upgradeDb.createObjectStore(storeName, { keyPath: 'id' });
33
+ } else if (tx) {
34
+ store = tx.objectStore(storeName);
30
35
  }
31
36
 
32
- const store = upgradeDb.createObjectStore(storeName, { keyPath: 'id' });
33
- store.createIndex('expirationTimeStamp', 'expirationTimeStamp', { unique: false });
37
+ if (store && !store.indexNames.contains('expirationTimeStamp')) {
38
+ store.createIndex('expirationTimeStamp', 'expirationTimeStamp', { unique: false });
39
+ }
34
40
  };
35
41
  });
36
42
  }
37
43
 
44
+ function _isRecoverableDbError(err) {
45
+ const msg = String(err?.message ?? err ?? "");
46
+ if (!msg) return false;
47
+ return msg.includes("Database not initialized") ||
48
+ msg.includes("The database connection is closing") ||
49
+ msg.includes("InvalidStateError") ||
50
+ msg.includes("NotFoundError");
51
+ }
52
+
53
+ async function _withDbRetry(work) {
54
+ try {
55
+ return await work(_getDbOrThrow());
56
+ } catch (e) {
57
+ if (!_isRecoverableDbError(e) || !PC.dbName || !PC.storeName) {
58
+ throw e;
59
+ }
60
+ await initDb(PC.dbName, PC.storeName);
61
+ return await work(_getDbOrThrow());
62
+ }
63
+ }
64
+
38
65
  // --- public API -------------------------------------------------------------
39
66
 
40
67
  // Inicializar IndexedDB y dejar el handle en PrefConfigurator.db (público)
41
68
  export async function initDb(dbName, storeName) {
69
+ PC.dbName = dbName;
70
+ PC.storeName = storeName;
71
+
42
72
  const db = await _openEnsuringStore(dbName, storeName);
43
73
  // Close any previous handle to avoid versionchange blocking
44
74
  try { PC.db?.close?.(); } catch { }
@@ -54,10 +84,7 @@ export async function initDb(dbName, storeName) {
54
84
 
55
85
  // Guardar modelo
56
86
  export async function saveModel(modelDataStr, storeName) {
57
- return new Promise((resolve, reject) => {
58
- let db;
59
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
60
-
87
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
61
88
  let modelData;
62
89
  try { modelData = JSON.parse(modelDataStr); }
63
90
  catch (e) { reject(new Error("saveModel: modelDataStr is not valid JSON")); return; }
@@ -74,22 +101,19 @@ export async function saveModel(modelDataStr, storeName) {
74
101
 
75
102
  req.onerror = () => reject(req.error);
76
103
  req.onsuccess = () => resolve();
77
- });
104
+ }));
78
105
  }
79
106
 
80
107
  // Cargar modelo
81
108
  export function loadModel(modelId, storeName) {
82
- return new Promise((resolve, reject) => {
83
- let db;
84
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
85
-
109
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
86
110
  const tx = db.transaction([storeName], "readonly");
87
111
  const store = tx.objectStore(storeName);
88
112
  const req = store.get(modelId);
89
113
 
90
114
  req.onerror = () => reject(req.error);
91
115
  req.onsuccess = () => resolve(req.result ?? null);
92
- });
116
+ }));
93
117
  }
94
118
 
95
119
  // Descargar archivo desde base64
@@ -104,10 +128,7 @@ export function downloadFileFromBytes(fileName, bytesBase64, mimeType) {
104
128
 
105
129
  // Obtener todos los modelos (solo metadata)
106
130
  export async function getAllModels(storeName) {
107
- return new Promise((resolve, reject) => {
108
- let db;
109
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
110
-
131
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
111
132
  const tx = db.transaction([storeName], "readonly");
112
133
  const store = tx.objectStore(storeName);
113
134
  const req = store.getAll();
@@ -124,45 +145,36 @@ export async function getAllModels(storeName) {
124
145
  // keep old behavior: return JSON string
125
146
  resolve(JSON.stringify(results));
126
147
  };
127
- });
148
+ }));
128
149
  }
129
150
 
130
151
  // Eliminar modelo por id
131
152
  export async function deleteModel(modelId, storeName) {
132
- return new Promise((resolve, reject) => {
133
- let db;
134
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
135
-
153
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
136
154
  const tx = db.transaction([storeName], "readwrite");
137
155
  const store = tx.objectStore(storeName);
138
156
  const req = store.delete(modelId);
139
157
 
140
158
  req.onerror = () => reject(req.error);
141
159
  req.onsuccess = () => resolve();
142
- });
160
+ }));
143
161
  }
144
162
 
145
163
  // Limpiar toda la store
146
164
  export async function clearAll(storeName) {
147
- return new Promise((resolve, reject) => {
148
- let db;
149
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
150
-
165
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
151
166
  const tx = db.transaction([storeName], "readwrite");
152
167
  const store = tx.objectStore(storeName);
153
168
  const req = store.clear();
154
169
 
155
170
  req.onerror = () => reject(req.error);
156
171
  req.onsuccess = () => resolve();
157
- });
172
+ }));
158
173
  }
159
174
 
160
175
  // Borrar modelos expirados usando el índice "expirationTimeStamp"
161
176
  export async function cleanExpiredModels(storeName) {
162
- return new Promise((resolve, reject) => {
163
- let db;
164
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
165
-
177
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
166
178
  const tx = db.transaction([storeName], "readwrite");
167
179
  const store = tx.objectStore(storeName);
168
180
 
@@ -182,7 +194,7 @@ export async function cleanExpiredModels(storeName) {
182
194
 
183
195
  tx.oncomplete = () => resolve();
184
196
  tx.onerror = () => reject(tx.error);
185
- });
197
+ }));
186
198
  }
187
199
 
188
200
  // Utilidades opcionales y visibles (por si las quieres usar en consola)
@@ -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;
@@ -75,6 +75,7 @@ export default class PrefViewer3D extends HTMLElement {
75
75
  #isInitialized = false;
76
76
  #isLoaded = false;
77
77
  #isVisible = false;
78
+ #lastLoadTimestamp = undefined;
78
79
 
79
80
  #wrapper = null;
80
81
  #canvas = null;
@@ -162,7 +163,7 @@ export default class PrefViewer3D extends HTMLElement {
162
163
  */
163
164
  disconnectedCallback() {
164
165
  if (this.#babylonJSController) {
165
- this.#babylonJSController.disable();
166
+ void this.#babylonJSController.disable();
166
167
  }
167
168
  }
168
169
 
@@ -230,6 +231,27 @@ export default class PrefViewer3D extends HTMLElement {
230
231
  this.#babylonJSController.enable();
231
232
  }
232
233
 
234
+ /**
235
+ * Determines whether the next 3D load should bypass cache checks.
236
+ * @private
237
+ * @param {object} config - Incoming config payload.
238
+ * @returns {boolean} True when force reload should be enabled.
239
+ */
240
+ #shouldForceReload(config) {
241
+ if (!config || typeof config !== "object") {
242
+ return false;
243
+ }
244
+ if (config.forceReload === true) {
245
+ return true;
246
+ }
247
+ if (config.timestamp === undefined || config.timestamp === null) {
248
+ return false;
249
+ }
250
+ const changed = config.timestamp !== this.#lastLoadTimestamp;
251
+ this.#lastLoadTimestamp = config.timestamp;
252
+ return changed;
253
+ }
254
+
233
255
  /**
234
256
  * Resets update flags for all containers and material/camera options after loading or setting options.
235
257
  * @private
@@ -558,6 +580,7 @@ export default class PrefViewer3D extends HTMLElement {
558
580
  }
559
581
 
560
582
  this.#onLoading();
583
+ const forceReload = this.#shouldForceReload(config);
561
584
 
562
585
  // Containers
563
586
  this.#checkNeedToUpdateContainers(config);
@@ -566,10 +589,10 @@ export default class PrefViewer3D extends HTMLElement {
566
589
  if (config.options) {
567
590
  this.#checkNeedToUpdateCamera(config.options);
568
591
  this.#checkNeedToUpdateMaterials(config.options);
569
- this.#checkNeedToUpdateIBL(config.options);
592
+ await this.#checkNeedToUpdateIBL(config.options);
570
593
  }
571
594
 
572
- const loadDetail = await this.#babylonJSController.load();
595
+ const loadDetail = await this.#babylonJSController.load(forceReload);
573
596
 
574
597
  return { ...loadDetail, load: this.#onLoaded() };
575
598
  }
@@ -579,9 +602,9 @@ export default class PrefViewer3D extends HTMLElement {
579
602
  * Updates internal states, triggers option setting events, and returns the result.
580
603
  * @public
581
604
  * @param {object} options - Options object containing camera and material settings.
582
- * @returns {object} Object containing success status and details of set options.
605
+ * @returns {Promise<object>} Object containing success status and details of set options.
583
606
  */
584
- setOptions(options) {
607
+ async setOptions(options) {
585
608
  if (!this.#babylonJSController) {
586
609
  return;
587
610
  }
@@ -595,8 +618,9 @@ export default class PrefViewer3D extends HTMLElement {
595
618
  if (this.#checkNeedToUpdateMaterials(options)) {
596
619
  someSetted = someSetted || this.#babylonJSController.setMaterialOptions();
597
620
  }
598
- if (this.#checkNeedToUpdateIBL(options)) {
599
- someSetted = someSetted || this.#babylonJSController.setIBLOptions();
621
+ const needUpdateIBL = await this.#checkNeedToUpdateIBL(options);
622
+ if (needUpdateIBL) {
623
+ someSetted = someSetted || (await this.#babylonJSController.setIBLOptions());
600
624
  }
601
625
  const detail = this.#onSetOptions();
602
626
  return { success: someSetted, detail: detail };
@@ -81,6 +81,7 @@ export default class PrefViewer extends HTMLElement {
81
81
  onViewerHoverStart: null,
82
82
  onViewerHoverEnd: null,
83
83
  on3DSceneLoaded: null,
84
+ on3DSceneError: null,
84
85
  };
85
86
 
86
87
  /**
@@ -136,6 +137,9 @@ export default class PrefViewer extends HTMLElement {
136
137
  this.loadMaterials(value);
137
138
  break;
138
139
  case "mode":
140
+ if (typeof value !== "string") {
141
+ return;
142
+ }
139
143
  if (_old === value || value.toLowerCase() === this.#mode) {
140
144
  return;
141
145
  }
@@ -151,6 +155,9 @@ export default class PrefViewer extends HTMLElement {
151
155
  this.setOptions(value);
152
156
  break;
153
157
  case "show-model":
158
+ if (typeof value !== "string") {
159
+ return;
160
+ }
154
161
  if (_old === value) {
155
162
  return;
156
163
  }
@@ -158,6 +165,9 @@ export default class PrefViewer extends HTMLElement {
158
165
  showModel ? this.showModel() : this.hideModel();
159
166
  break;
160
167
  case "show-scene":
168
+ if (typeof value !== "string") {
169
+ return;
170
+ }
161
171
  if (_old === value) {
162
172
  return;
163
173
  }
@@ -213,6 +223,7 @@ export default class PrefViewer extends HTMLElement {
213
223
  }
214
224
  if (this.#component3D) {
215
225
  this.#component3D.removeEventListener("scene-loaded", this.#handlers.on3DSceneLoaded);
226
+ this.#component3D.removeEventListener("scene-error", this.#handlers.on3DSceneError);
216
227
  this.#component3D.remove();
217
228
  }
218
229
  if (this.#menu3D) {
@@ -249,7 +260,11 @@ export default class PrefViewer extends HTMLElement {
249
260
  this.#menu3DSyncSettings();
250
261
  this.#menu3D?.setApplying(false);
251
262
  };
263
+ this.#handlers.on3DSceneError = () => {
264
+ this.#menu3D?.setApplying(false, true);
265
+ };
252
266
  this.#component3D.addEventListener("scene-loaded", this.#handlers.on3DSceneLoaded);
267
+ this.#component3D.addEventListener("scene-error", this.#handlers.on3DSceneError);
253
268
  this.#wrapper.appendChild(this.#component3D);
254
269
  }
255
270
 
@@ -454,7 +469,14 @@ export default class PrefViewer extends HTMLElement {
454
469
  * @returns {void}
455
470
  */
456
471
  #addTaskToQueue(value, type) {
457
- this.#taskQueue.push(new PrefViewerTask(value, type));
472
+ const task = new PrefViewerTask(value, type);
473
+ if (
474
+ task.type === PrefViewerTask.Types.Options ||
475
+ task.type === PrefViewerTask.Types.Drawing
476
+ ) {
477
+ this.#taskQueue = this.#taskQueue.filter((queuedTask) => queuedTask.type !== task.type);
478
+ }
479
+ this.#taskQueue.push(task);
458
480
  if (this.#isInitialized && !this.#isLoading) {
459
481
  this.#processNextTask();
460
482
  }
@@ -552,6 +574,32 @@ export default class PrefViewer extends HTMLElement {
552
574
  this.dispatchEvent(new CustomEvent("scene-loaded", customEventOptions));
553
575
  }
554
576
 
577
+ /**
578
+ * Handles 3D load errors.
579
+ * Clears loading state and dispatches a "scene-error" event.
580
+ * @private
581
+ * @param {object} [detail] - Optional details about the failure.
582
+ * @returns {void}
583
+ */
584
+ #on3DError(detail) {
585
+ this.#isLoaded = false;
586
+ this.#isLoading = false;
587
+ this.#menu3D?.setApplying(false, true);
588
+
589
+ this.removeAttribute("loading-3d");
590
+ this.removeAttribute("loaded-3d");
591
+
592
+ const customEventOptions = {
593
+ bubbles: true,
594
+ cancelable: true,
595
+ composed: true,
596
+ };
597
+ if (detail) {
598
+ customEventOptions.detail = detail;
599
+ }
600
+ this.dispatchEvent(new CustomEvent("scene-error", customEventOptions));
601
+ }
602
+
555
603
  /**
556
604
  * Handles the start of a 2D loading operation.
557
605
  * Updates loading state, sets attributes, and dispatches a "drawing-loading" event.
@@ -598,6 +646,31 @@ export default class PrefViewer extends HTMLElement {
598
646
  this.dispatchEvent(new CustomEvent("drawing-loaded", customEventOptions));
599
647
  }
600
648
 
649
+ /**
650
+ * Handles 2D load errors.
651
+ * Clears loading state and dispatches a "drawing-error" event.
652
+ * @private
653
+ * @param {object} [detail] - Optional details about the failure.
654
+ * @returns {void}
655
+ */
656
+ #on2DError(detail) {
657
+ this.#isLoaded = false;
658
+ this.#isLoading = false;
659
+
660
+ this.removeAttribute("loading-2d");
661
+ this.removeAttribute("loaded-2d");
662
+
663
+ const customEventOptions = {
664
+ bubbles: true,
665
+ cancelable: true,
666
+ composed: true,
667
+ };
668
+ if (detail) {
669
+ customEventOptions.detail = detail;
670
+ }
671
+ this.dispatchEvent(new CustomEvent("drawing-error", customEventOptions));
672
+ }
673
+
601
674
  /**
602
675
  * Handles the "drawing-zoom-changed" event from the 2D viewer component.
603
676
  * Dispatches a custom "drawing-zoom-changed" event from the PrefViewer element, forwarding the event detail to external listeners.
@@ -640,16 +713,27 @@ export default class PrefViewer extends HTMLElement {
640
713
  * @param {object} config - The configuration object to process.
641
714
  * @returns {void}
642
715
  */
643
- #processConfig(config) {
716
+ async #processConfig(config) {
644
717
  if (!this.#component3D) {
718
+ this.#processNextTask();
645
719
  return;
646
720
  }
647
721
 
648
722
  this.#on3DLoading();
649
- this.#component3D.load(config).then((detail) => {
650
- this.#on3DLoaded(detail);
723
+ try {
724
+ const detail = await this.#component3D.load(config);
725
+ if (detail?.success === false) {
726
+ this.#on3DError(detail);
727
+ } else {
728
+ this.#on3DLoaded(detail);
729
+ }
730
+ } catch (error) {
731
+ this.#on3DError({
732
+ error: error instanceof Error ? error : new Error(String(error)),
733
+ });
734
+ } finally {
651
735
  this.#processNextTask();
652
- });
736
+ }
653
737
  }
654
738
 
655
739
  /**
@@ -670,16 +754,23 @@ export default class PrefViewer extends HTMLElement {
670
754
  * @param {object} drawing - The drawing object to process.
671
755
  * @returns {void}
672
756
  */
673
- #processDrawing(drawing) {
757
+ async #processDrawing(drawing) {
674
758
  if (!this.#component2D) {
759
+ this.#processNextTask();
675
760
  return;
676
761
  }
677
762
 
678
763
  this.#on2DLoading();
679
- this.#component2D.load(drawing).then((detail) => {
764
+ try {
765
+ const detail = await this.#component2D.load(drawing);
680
766
  this.#on2DLoaded(detail);
767
+ } catch (error) {
768
+ this.#on2DError({
769
+ error: error instanceof Error ? error : new Error(String(error)),
770
+ });
771
+ } finally {
681
772
  this.#processNextTask();
682
- });
773
+ }
683
774
  }
684
775
 
685
776
  /**
@@ -725,15 +816,23 @@ export default class PrefViewer extends HTMLElement {
725
816
  * @param {object} options - The options object to process.
726
817
  * @returns {void}
727
818
  */
728
- #processOptions(options) {
819
+ async #processOptions(options) {
729
820
  if (!this.#component3D) {
821
+ this.#processNextTask();
730
822
  return;
731
823
  }
732
824
 
733
825
  this.#on3DLoading();
734
- const detail = this.#component3D.setOptions(options);
735
- this.#on3DLoaded(detail);
736
- this.#processNextTask();
826
+ try {
827
+ const detail = await this.#component3D.setOptions(options);
828
+ this.#on3DLoaded(detail);
829
+ } catch (error) {
830
+ this.#on3DError({
831
+ error: error instanceof Error ? error : new Error(String(error)),
832
+ });
833
+ } finally {
834
+ this.#processNextTask();
835
+ }
737
836
  }
738
837
 
739
838
  /**
@@ -745,6 +844,7 @@ export default class PrefViewer extends HTMLElement {
745
844
  */
746
845
  #processVisibility(config) {
747
846
  if (!this.#component3D) {
847
+ this.#processNextTask();
748
848
  return;
749
849
  }
750
850
  const showModel = config.model?.visible;
@@ -974,7 +1074,7 @@ export default class PrefViewer extends HTMLElement {
974
1074
  * @returns {void}
975
1075
  */
976
1076
  setMode(mode = this.#mode) {
977
- mode = mode.toLowerCase();
1077
+ mode = typeof mode === "string" ? mode.toLowerCase() : this.#mode;
978
1078
  if (mode !== "2d" && mode !== "3d") {
979
1079
  console.warn(`PrefViewer: invalid mode "${mode}". Allowed modes are "2d" and "3d".`);
980
1080
  mode = this.#mode;