@preference-sl/pref-viewer 2.13.0-beta.8 → 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.8",
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,6 +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.
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.
42
45
  *
43
46
  * Public API Highlights
44
47
  * - constructor(canvas, containers, options)
@@ -62,6 +65,14 @@ import { translate } from "./localization/i18n.js";
62
65
  * while `options.ibl.valid` remains true.
63
66
  * - Browser-only features guard `window`, localStorage, and XR APIs before use so the controller is safe to construct
64
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.
65
76
  */
66
77
  export default class BabylonJSController {
67
78
  #RENDER_SETTINGS_STORAGE_KEY = "pref-viewer/render-settings";
@@ -93,9 +104,8 @@ export default class BabylonJSController {
93
104
  #shadowGen = [];
94
105
  #XRExperience = null;
95
106
  #canvasResizeObserver = null;
96
-
107
+
97
108
  #hdrTexture = null; // reusable in-memory HDR source cloned into scene.environmentTexture across reloads
98
- #lastPickedMeshId = null;
99
109
 
100
110
  #containers = {};
101
111
  #options = {};
@@ -110,28 +120,61 @@ export default class BabylonJSController {
110
120
  };
111
121
 
112
122
  #handlers = {
123
+ onAnimationGroupChanged: null,
113
124
  onKeyUp: null,
114
125
  onPointerObservable: null,
115
- onAnimationGroupChanged: null,
116
126
  onResize: null,
117
127
  renderLoop: null,
118
128
  };
119
129
 
120
130
  #settings = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
121
131
 
122
- #renderState = {
123
- isLoopRunning: false,
124
- dirtyFrames: 0,
125
- continuousUntil: 0,
126
- 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
+ },
127
154
  };
128
155
 
129
- #renderConfig = {
130
- burstFramesBase: 2,
131
- burstFramesEnhanced: 32, // when AA/SSAO/IBL is enabled, more frames are needed to reach stable output
132
- interactionMs: 250,
133
- animationMs: 200,
134
- idleThrottleMs: 1000 / 15,
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,
135
178
  };
136
179
 
137
180
  /**
@@ -298,11 +341,11 @@ export default class BabylonJSController {
298
341
  * @returns {boolean} True when the loop was started, false when no engine is available or it was already running.
299
342
  */
300
343
  #startEngineRenderLoop() {
301
- if (!this.#engine || this.#renderState.isLoopRunning) {
344
+ if (!this.#engine || this.#state.render.isLoopRunning) {
302
345
  return false;
303
346
  }
304
347
  this.#engine.runRenderLoop(this.#handlers.renderLoop);
305
- this.#renderState.isLoopRunning = true;
348
+ this.#state.render.isLoopRunning = true;
306
349
  return true;
307
350
  }
308
351
 
@@ -312,13 +355,25 @@ export default class BabylonJSController {
312
355
  * @returns {boolean} True when the loop was stopped, false when no engine is available or it was already stopped.
313
356
  */
314
357
  #stopEngineRenderLoop() {
315
- if (!this.#engine || !this.#renderState.isLoopRunning) {
358
+ if (!this.#engine || !this.#state.render.isLoopRunning) {
316
359
  return false;
317
360
  }
318
361
  this.#engine.stopRenderLoop(this.#handlers.renderLoop);
319
- this.#renderState.isLoopRunning = false;
362
+ this.#state.render.isLoopRunning = false;
320
363
  return true;
321
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
+ }
322
377
 
323
378
  /**
324
379
  * Marks the scene as dirty and optionally extends a short continuous-render window.
@@ -335,9 +390,9 @@ export default class BabylonJSController {
335
390
  }
336
391
 
337
392
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
338
- 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));
339
394
  if (continuousMs > 0) {
340
- this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + continuousMs);
395
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + continuousMs);
341
396
  }
342
397
  this.#startEngineRenderLoop();
343
398
  return true;
@@ -422,13 +477,13 @@ export default class BabylonJSController {
422
477
  const cameraInMotion = this.#isCameraInMotion();
423
478
 
424
479
  if (animationRunning) {
425
- 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);
426
481
  }
427
482
  if (cameraInMotion) {
428
- 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);
429
484
  }
430
485
 
431
- return animationRunning || cameraInMotion || this.#renderState.continuousUntil > now;
486
+ return animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
432
487
  }
433
488
 
434
489
  /**
@@ -446,22 +501,22 @@ export default class BabylonJSController {
446
501
 
447
502
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
448
503
  const continuous = this.#shouldRenderContinuously(now);
449
- const needsRender = continuous || this.#renderState.dirtyFrames > 0;
504
+ const needsRender = continuous || this.#state.render.dirtyFrames > 0;
450
505
 
451
506
  if (!needsRender) {
452
507
  this.#stopEngineRenderLoop();
453
508
  return;
454
509
  }
455
510
 
456
- 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) {
457
512
  return;
458
513
  }
459
-
514
+
460
515
  this.#scene.render();
461
- this.#renderState.lastRenderAt = now;
516
+ this.#state.render.lastRenderAt = now;
462
517
 
463
- if (this.#renderState.dirtyFrames > 0) {
464
- this.#renderState.dirtyFrames -= 1;
518
+ if (this.#state.render.dirtyFrames > 0) {
519
+ this.#state.render.dirtyFrames -= 1;
465
520
  }
466
521
  }
467
522
 
@@ -1180,6 +1235,55 @@ export default class BabylonJSController {
1180
1235
  }
1181
1236
  }
1182
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
+
1183
1287
  /**
1184
1288
  * Sets up interaction handlers for the Babylon.js canvas and scene.
1185
1289
  * @private
@@ -1193,10 +1297,7 @@ export default class BabylonJSController {
1193
1297
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
1194
1298
  }
1195
1299
  if (this.#engine) {
1196
- this.#canvasResizeObserver = new ResizeObserver(() => {
1197
- this.#engine.resize();
1198
- this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1199
- });
1300
+ this.#canvasResizeObserver = new ResizeObserver(this.#handlers.onResize);
1200
1301
  this.#canvasResizeObserver.observe(this.#canvas);
1201
1302
  }
1202
1303
  }
@@ -1213,9 +1314,11 @@ export default class BabylonJSController {
1213
1314
  if (this.#scene !== null) {
1214
1315
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
1215
1316
  }
1317
+ this.#cancelScheduledResize();
1216
1318
  this.#canvasResizeObserver?.disconnect();
1217
1319
  this.#canvasResizeObserver = null;
1218
1320
  this.#detachAnimationChangedListener();
1321
+ this.#resetPointerPickingState();
1219
1322
  }
1220
1323
 
1221
1324
  /**
@@ -1231,31 +1334,56 @@ export default class BabylonJSController {
1231
1334
  }
1232
1335
 
1233
1336
  /**
1234
- * Disposes the Babylon.js WebXR experience if it exists.
1337
+ * Disposes the shared GLTFResolver instance and closes its underlying storage handle.
1235
1338
  * @private
1236
1339
  * @returns {void}
1237
1340
  */
1238
- #disposeXRExperience() {
1239
- if (!this.#XRExperience) {
1341
+ #disposeGLTFResolver() {
1342
+ if (this.#gltfResolver) {
1343
+ this.#gltfResolver.dispose();
1344
+ this.#gltfResolver = null;
1345
+ }
1346
+ }
1347
+
1348
+ /**
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.
1353
+ * @private
1354
+ * @returns {Promise<void>}
1355
+ */
1356
+ async #disposeXRExperience() {
1357
+ if (this.#disablingPromises.xr) {
1358
+ return await this.#disablingPromises.xr;
1359
+ }
1360
+
1361
+ const xrExperience = this.#XRExperience;
1362
+ if (!xrExperience) {
1240
1363
  return;
1241
1364
  }
1242
1365
 
1243
- if (this.#XRExperience.baseExperience.state === WebXRState.IN_XR) {
1244
- this.#XRExperience.baseExperience
1245
- .exitXRAsync()
1246
- .then(() => {
1247
- this.#XRExperience.dispose();
1248
- this.#XRExperience = null;
1249
- })
1250
- .catch((error) => {
1251
- console.warn("Error exiting XR experience:", error);
1252
- 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) {
1253
1380
  this.#XRExperience = null;
1254
- });
1255
- } else {
1256
- this.#XRExperience.dispose();
1257
- this.#XRExperience = null;
1258
- }
1381
+ }
1382
+ this.#disablingPromises.xr = null;
1383
+ }
1384
+ })();
1385
+
1386
+ await this.#disablingPromises.xr;
1259
1387
  }
1260
1388
 
1261
1389
  /**
@@ -1303,7 +1431,7 @@ export default class BabylonJSController {
1303
1431
  * @returns {void}
1304
1432
  */
1305
1433
  #onAnimationGroupPlay() {
1306
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.animationMs });
1434
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.animationMs });
1307
1435
  }
1308
1436
 
1309
1437
  /**
@@ -1312,7 +1440,11 @@ export default class BabylonJSController {
1312
1440
  * @returns {void}
1313
1441
  */
1314
1442
  #onAnimationGroupStop() {
1315
- 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;
1316
1448
  this.#requestRender({ frames: frames });
1317
1449
  }
1318
1450
 
@@ -1352,10 +1484,10 @@ export default class BabylonJSController {
1352
1484
  * @returns {void}
1353
1485
  */
1354
1486
  #onKeyUp(event) {
1355
- // CTRL + ALT + letter
1356
- if (event.ctrlKey && event.altKey && event.key !== undefined) {
1357
- switch (event.key.toLowerCase()) {
1358
- 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":
1359
1491
  this.#openDownloadDialog();
1360
1492
  break;
1361
1493
  default:
@@ -1390,7 +1522,7 @@ export default class BabylonJSController {
1390
1522
  const movementVector = direction.scale(zoomSpeed);
1391
1523
  camera.position = camera.position.add(movementVector);
1392
1524
  }
1393
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1525
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1394
1526
  }
1395
1527
  }
1396
1528
 
@@ -1403,7 +1535,9 @@ export default class BabylonJSController {
1403
1535
  */
1404
1536
  #onPointerUp(event, pickInfo) {
1405
1537
  if (this.#babylonJSAnimationController) {
1406
- this.#babylonJSAnimationController.hideMenu();
1538
+ if (event.button !== 2 || !pickInfo || !pickInfo.pickedMesh) {
1539
+ this.#babylonJSAnimationController.hideMenu();
1540
+ }
1407
1541
  // Right click for showing animation menu
1408
1542
  if (event.button === 2) {
1409
1543
  this.#babylonJSAnimationController.showMenu(pickInfo);
@@ -1415,20 +1549,20 @@ export default class BabylonJSController {
1415
1549
  * Handles pointer move events on the Babylon.js scene.
1416
1550
  * @private
1417
1551
  * @param {PointerEvent} event - The pointer move event.
1418
- * @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).
1419
1553
  * @returns {void}
1420
1554
  */
1421
1555
  #onPointerMove(event, pickInfo) {
1422
1556
  const camera = this.#scene?.activeCamera;
1423
1557
  if (camera && !camera.metadata?.locked) {
1424
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1558
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1425
1559
  }
1426
- if (this.#babylonJSAnimationController) {
1560
+ if (this.#babylonJSAnimationController && pickInfo) {
1427
1561
  const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1428
- if (this.#lastPickedMeshId !== pickedMeshId) {
1562
+ if (this.#state.pointerPicking.lastPickedMeshId !== pickedMeshId) {
1429
1563
  const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1430
1564
  if (highlightResult.changed) {
1431
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1565
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1432
1566
  }
1433
1567
  }
1434
1568
  }
@@ -1436,22 +1570,30 @@ export default class BabylonJSController {
1436
1570
 
1437
1571
  /**
1438
1572
  * Handles pointer events observed on the Babylon.js scene.
1573
+ * Uses pointer-move sampling to avoid running expensive `scene.pick()` on every event.
1439
1574
  * @private
1440
1575
  * @param {PointerInfo} info - The pointer event information from Babylon.js.
1441
1576
  * @returns {void}
1442
1577
  */
1443
1578
  #onPointerObservable(info) {
1444
- const pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
1445
- const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1446
-
1447
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
+ }
1448
1586
  this.#onPointerMove(info.event, pickInfo);
1587
+ this.#state.pointerPicking.lastPickedMeshId = lastPickedMeshId;
1449
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
+ }
1450
1593
  this.#onPointerUp(info.event, pickInfo);
1451
1594
  } else if (info.type === PointerEventTypes.POINTERWHEEL) {
1452
- this.#onMouseWheel(info.event, pickInfo);
1595
+ this.#onMouseWheel(info.event, null);
1453
1596
  }
1454
- this.#lastPickedMeshId = pickedMeshId;
1455
1597
  }
1456
1598
 
1457
1599
  /**
@@ -1465,8 +1607,47 @@ export default class BabylonJSController {
1465
1607
  if (!this.#engine) {
1466
1608
  return;
1467
1609
  }
1468
- this.#engine.resize();
1469
- 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;
1470
1651
  }
1471
1652
 
1472
1653
  /**
@@ -1812,11 +1993,10 @@ export default class BabylonJSController {
1812
1993
  */
1813
1994
  async #stopRender() {
1814
1995
  this.#stopEngineRenderLoop();
1815
- this.#renderState.dirtyFrames = 0;
1816
- this.#renderState.continuousUntil = 0;
1817
- this.#renderState.lastRenderAt = 0;
1996
+ this.#resetRenderState();
1818
1997
  await this.#unloadCameraDependentEffects();
1819
1998
  }
1999
+
1820
2000
  /**
1821
2001
  * Starts the Babylon.js render loop for the current scene.
1822
2002
  * Waits until the scene is ready before beginning continuous rendering.
@@ -1826,8 +2006,8 @@ export default class BabylonJSController {
1826
2006
  async #startRender() {
1827
2007
  await this.#loadCameraDependentEffects();
1828
2008
  await this.#scene.whenReadyAsync();
1829
- const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1830
- this.#requestRender({ frames: frames, continuousMs: this.#renderConfig.interactionMs });
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 });
1831
2011
  }
1832
2012
 
1833
2013
  /**
@@ -1906,7 +2086,7 @@ export default class BabylonJSController {
1906
2086
  * Applies material and camera options, sets wall/floor visibility, and initializes lights and shadows.
1907
2087
  * Returns an object with success status and error details.
1908
2088
  */
1909
- async #loadContainers() {
2089
+ async #loadContainers(force = false) {
1910
2090
  this.#detachAnimationChangedListener();
1911
2091
  await this.#stopRender();
1912
2092
 
@@ -1915,7 +2095,7 @@ export default class BabylonJSController {
1915
2095
 
1916
2096
  const promiseArray = [];
1917
2097
  Object.values(this.#containers).forEach((container) => {
1918
- promiseArray.push(this.#loadAssetContainer(container));
2098
+ promiseArray.push(this.#loadAssetContainer(container, force));
1919
2099
  });
1920
2100
 
1921
2101
  let detail = {
@@ -2258,17 +2438,41 @@ export default class BabylonJSController {
2258
2438
 
2259
2439
  /**
2260
2440
  * Disposes the Babylon.js engine and disconnects the canvas resize observer.
2261
- * Cleans up all scene, camera, light, and XR resources.
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.
2262
2444
  * @public
2263
- * @returns {void}
2445
+ * @returns {Promise<void>}
2264
2446
  */
2265
- disable() {
2266
- this.#disableInteraction();
2267
- this.#disposeAnimationController();
2268
- this.#disposeXRExperience();
2269
- this.#unloadCameraDependentEffects();
2270
- this.#stopEngineRenderLoop();
2271
- 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
+ }
2272
2476
  }
2273
2477
 
2274
2478
  /**
@@ -2389,8 +2593,8 @@ export default class BabylonJSController {
2389
2593
  * @public
2390
2594
  * @returns {Promise<boolean>} Resolves to true if loading succeeds, false otherwise.
2391
2595
  */
2392
- async load() {
2393
- return await this.#loadContainers();
2596
+ async load(force = false) {
2597
+ return await this.#loadContainers(force);
2394
2598
  }
2395
2599
 
2396
2600
  /**
@@ -2425,11 +2629,13 @@ export default class BabylonJSController {
2425
2629
  * @public
2426
2630
  * @returns {boolean} True if IBL options were set successfully, false otherwise.
2427
2631
  */
2428
- setIBLOptions() {
2429
- this.#stopRender();
2430
- const IBLOptionsSetted = this.#setOptions_IBL();
2431
- this.#startRender();
2432
- return IBLOptionsSetted;
2632
+ async setIBLOptions() {
2633
+ await this.#stopRender();
2634
+ try {
2635
+ return await this.#setOptions_IBL();
2636
+ } finally {
2637
+ await this.#startRender();
2638
+ }
2433
2639
  }
2434
2640
 
2435
2641
  /**
@@ -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)
@@ -753,9 +755,12 @@ export default class FileStorage {
753
755
  }
754
756
  await this.#runCleanupIfDue();
755
757
  let storedFile = await this.#getFile(uri);
756
- const serverFileTimeStamp = storedFile ? await this.#getServerFileTimeStamp(uri) : 0;
757
- const storedFileTimeStamp = storedFile ? storedFile.timeStamp : 0;
758
- 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) {
759
764
  const fileToStore = await this.#getServerFile(uri);
760
765
  if (fileToStore && !!(await this.#putFile(fileToStore, uri))) {
761
766
  storedFile = await this.#getFile(uri);
@@ -782,4 +787,19 @@ export default class FileStorage {
782
787
  const fileToStore = await this.#getServerFile(uri);
783
788
  return fileToStore ? !!(await this.#putFile(fileToStore, uri)) : false;
784
789
  }
790
+
791
+ /**
792
+ * Closes the IndexedDB handle held by this storage instance.
793
+ * Safe to call multiple times; next storage operation will lazily reopen the DB.
794
+ * @public
795
+ * @returns {void}
796
+ */
797
+ dispose() {
798
+ if (this.#db) {
799
+ try {
800
+ this.#db.close();
801
+ } catch {}
802
+ }
803
+ this.#db = undefined;
804
+ }
785
805
  }
@@ -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.
@@ -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,17 @@ 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
+ closeDb();
244
+ }
245
+
234
246
  /**
235
247
  * Resolves and prepares a glTF/GLB source from various storage backends.
236
248
  * Supports IndexedDB, direct URLs, and base64-encoded data.
@@ -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;