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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.13.0-beta.12",
3
+ "version": "2.13.0-beta.13",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -18,14 +18,15 @@ import { translate } from "./localization/i18n.js";
18
18
  * turns it into a deterministic scene lifecycle with exports.
19
19
  *
20
20
  * Overview
21
- * - Creates the Babylon.js engine/scene/camera stack, configures Draco decoders, wires resize + render loops, and
22
- * exposes download/xR helpers.
21
+ * - Creates the Babylon.js engine/scene/camera stack, configures Draco decoders, wires render loops plus a throttled
22
+ * ResizeObserver, and exposes download/XR helpers.
23
23
  * - Resolves GLTF/GLB sources via GLTFResolver, loads them into `AssetContainer`s, and toggles visibility by
24
24
  * mutating container state plus `show-model/show-scene` attributes.
25
25
  * - Applies/persists AA, SSAO, IBL, and shadow flags; when they change it stops the render loop, tears down pipelines,
26
26
  * reloads containers, and reinstalls effects after environment textures finish loading.
27
27
  * - Manages keyboard/pointer/wheel handlers, animation menus, and WebXR so PrefViewer menus and DOM attributes stay
28
- * synchronized with Babylon state, using sampled pointer picking to reduce raycast cost on dense scenes.
28
+ * synchronized with Babylon state, using sampled pointer picking and last-picked tracking to reduce raycast/highlight
29
+ * cost on dense scenes.
29
30
  * - Generates GLB, glTF+ZIP, or USDZ exports with timestamped names and localized dialog copy.
30
31
  * - Translates metadata (inner floor offsets, cast/receive shadows, camera locks) into scene adjustments after reloads.
31
32
  *
@@ -66,6 +67,10 @@ import { translate } from "./localization/i18n.js";
66
67
  * in SSR/Node contexts (though functionality activates only in browsers).
67
68
  * - Pointer-pick lifecycle: hover/highlight raycasts are sampled on POINTERMOVE (time + distance thresholds), wheel
68
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.
69
74
  * - Teardown lifecycle: concurrent `disable()` calls are coalesced into a single in-flight promise to avoid races
70
75
  * during XR exit and engine disposal.
71
76
  */
@@ -101,7 +106,6 @@ export default class BabylonJSController {
101
106
  #canvasResizeObserver = null;
102
107
 
103
108
  #hdrTexture = null; // reusable in-memory HDR source cloned into scene.environmentTexture across reloads
104
- #lastPickedMeshId = null;
105
109
 
106
110
  #containers = {};
107
111
  #options = {};
@@ -116,39 +120,55 @@ export default class BabylonJSController {
116
120
  };
117
121
 
118
122
  #handlers = {
123
+ onAnimationGroupChanged: null,
119
124
  onKeyUp: null,
120
125
  onPointerObservable: null,
121
- onAnimationGroupChanged: null,
122
126
  onResize: null,
123
127
  renderLoop: null,
124
128
  };
125
129
 
126
130
  #settings = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
127
131
 
128
- // State and config for render loop management to balance performance with responsiveness; uses a dirty frame counter plus continuous render windows triggered by interactions, animations, or XR sessions.
129
- #renderState = {
130
- isLoopRunning: false,
131
- dirtyFrames: 0,
132
- continuousUntil: 0,
133
- lastRenderAt: 0,
134
- };
135
- #renderConfig = {
136
- burstFramesBase: 2,
137
- burstFramesEnhanced: 32, // when AA/SSAO/IBL is enabled, more frames are needed to reach stable output
138
- interactionMs: 250,
139
- animationMs: 200,
140
- idleThrottleMs: 1000 / 15,
132
+ // Runtime mutable state (changes while the app is running).
133
+ #state = {
134
+ // Pointer-picking sampling state avoids expensive scene.pick on every move.
135
+ pointerPicking: {
136
+ lastMovePickAt: 0,
137
+ lastMovePickX: NaN,
138
+ lastMovePickY: NaN,
139
+ lastPickedMeshId: null,
140
+ },
141
+ // Render loop state balances performance with responsiveness.
142
+ render: {
143
+ isLoopRunning: false,
144
+ dirtyFrames: 0,
145
+ continuousUntil: 0,
146
+ lastRenderAt: 0,
147
+ },
148
+ // Resize state batches frequent ResizeObserver notifications.
149
+ resize: {
150
+ isScheduled: false,
151
+ timeoutId: null,
152
+ lastAppliedAt: 0,
153
+ },
141
154
  };
142
155
 
143
- // State and config for pointer-picking sampling to avoid expensive scene.pick calls on every mouse move; uses time + distance heuristics to balance responsiveness with performance.
144
- #pointerPickingState = {
145
- lastMovePickAt: 0,
146
- lastMovePickX: NaN,
147
- lastMovePickY: NaN,
148
- };
149
- #pointerPickingConfig = {
150
- movePickIntervalMs: 50, // cap expensive scene.pick calls to ~20 Hz while moving the pointer
151
- movePickMinDistancePx: 2, // skip picks for sub-pixel jitter
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
+ },
152
172
  };
153
173
 
154
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.
@@ -321,11 +341,11 @@ export default class BabylonJSController {
321
341
  * @returns {boolean} True when the loop was started, false when no engine is available or it was already running.
322
342
  */
323
343
  #startEngineRenderLoop() {
324
- if (!this.#engine || this.#renderState.isLoopRunning) {
344
+ if (!this.#engine || this.#state.render.isLoopRunning) {
325
345
  return false;
326
346
  }
327
347
  this.#engine.runRenderLoop(this.#handlers.renderLoop);
328
- this.#renderState.isLoopRunning = true;
348
+ this.#state.render.isLoopRunning = true;
329
349
  return true;
330
350
  }
331
351
 
@@ -335,13 +355,25 @@ export default class BabylonJSController {
335
355
  * @returns {boolean} True when the loop was stopped, false when no engine is available or it was already stopped.
336
356
  */
337
357
  #stopEngineRenderLoop() {
338
- if (!this.#engine || !this.#renderState.isLoopRunning) {
358
+ if (!this.#engine || !this.#state.render.isLoopRunning) {
339
359
  return false;
340
360
  }
341
361
  this.#engine.stopRenderLoop(this.#handlers.renderLoop);
342
- this.#renderState.isLoopRunning = false;
362
+ this.#state.render.isLoopRunning = false;
343
363
  return true;
344
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
+ }
345
377
 
346
378
  /**
347
379
  * Marks the scene as dirty and optionally extends a short continuous-render window.
@@ -358,9 +390,9 @@ export default class BabylonJSController {
358
390
  }
359
391
 
360
392
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
361
- 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));
362
394
  if (continuousMs > 0) {
363
- this.#renderState.continuousUntil = Math.max(this.#renderState.continuousUntil, now + continuousMs);
395
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + continuousMs);
364
396
  }
365
397
  this.#startEngineRenderLoop();
366
398
  return true;
@@ -445,13 +477,13 @@ export default class BabylonJSController {
445
477
  const cameraInMotion = this.#isCameraInMotion();
446
478
 
447
479
  if (animationRunning) {
448
- 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);
449
481
  }
450
482
  if (cameraInMotion) {
451
- 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);
452
484
  }
453
485
 
454
- return animationRunning || cameraInMotion || this.#renderState.continuousUntil > now;
486
+ return animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
455
487
  }
456
488
 
457
489
  /**
@@ -469,22 +501,22 @@ export default class BabylonJSController {
469
501
 
470
502
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
471
503
  const continuous = this.#shouldRenderContinuously(now);
472
- const needsRender = continuous || this.#renderState.dirtyFrames > 0;
504
+ const needsRender = continuous || this.#state.render.dirtyFrames > 0;
473
505
 
474
506
  if (!needsRender) {
475
507
  this.#stopEngineRenderLoop();
476
508
  return;
477
509
  }
478
510
 
479
- 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) {
480
512
  return;
481
513
  }
482
-
514
+
483
515
  this.#scene.render();
484
- this.#renderState.lastRenderAt = now;
516
+ this.#state.render.lastRenderAt = now;
485
517
 
486
- if (this.#renderState.dirtyFrames > 0) {
487
- this.#renderState.dirtyFrames -= 1;
518
+ if (this.#state.render.dirtyFrames > 0) {
519
+ this.#state.render.dirtyFrames -= 1;
488
520
  }
489
521
  }
490
522
 
@@ -1209,10 +1241,10 @@ export default class BabylonJSController {
1209
1241
  * @returns {void}
1210
1242
  */
1211
1243
  #resetPointerPickingState() {
1212
- this.#pointerPickingState.lastMovePickAt = 0;
1213
- this.#pointerPickingState.lastMovePickX = NaN;
1214
- this.#pointerPickingState.lastMovePickY = NaN;
1215
- this.#lastPickedMeshId = null;
1244
+ this.#state.pointerPicking.lastMovePickAt = 0;
1245
+ this.#state.pointerPicking.lastMovePickX = NaN;
1246
+ this.#state.pointerPicking.lastMovePickY = NaN;
1247
+ this.#state.pointerPicking.lastPickedMeshId = null;
1216
1248
  }
1217
1249
 
1218
1250
  /**
@@ -1229,8 +1261,8 @@ export default class BabylonJSController {
1229
1261
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1230
1262
  const x = this.#scene.pointerX;
1231
1263
  const y = this.#scene.pointerY;
1232
- const state = this.#pointerPickingState;
1233
- const config = this.#pointerPickingConfig;
1264
+ const state = this.#state.pointerPicking;
1265
+ const config = this.#config.pointerPicking;
1234
1266
 
1235
1267
  if (!Number.isFinite(state.lastMovePickX) || !Number.isFinite(state.lastMovePickY)) {
1236
1268
  state.lastMovePickX = x;
@@ -1265,10 +1297,7 @@ export default class BabylonJSController {
1265
1297
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
1266
1298
  }
1267
1299
  if (this.#engine) {
1268
- this.#canvasResizeObserver = new ResizeObserver(() => {
1269
- this.#engine.resize();
1270
- this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1271
- });
1300
+ this.#canvasResizeObserver = new ResizeObserver(this.#handlers.onResize);
1272
1301
  this.#canvasResizeObserver.observe(this.#canvas);
1273
1302
  }
1274
1303
  }
@@ -1285,6 +1314,7 @@ export default class BabylonJSController {
1285
1314
  if (this.#scene !== null) {
1286
1315
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
1287
1316
  }
1317
+ this.#cancelScheduledResize();
1288
1318
  this.#canvasResizeObserver?.disconnect();
1289
1319
  this.#canvasResizeObserver = null;
1290
1320
  this.#detachAnimationChangedListener();
@@ -1401,7 +1431,7 @@ export default class BabylonJSController {
1401
1431
  * @returns {void}
1402
1432
  */
1403
1433
  #onAnimationGroupPlay() {
1404
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.animationMs });
1434
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.animationMs });
1405
1435
  }
1406
1436
 
1407
1437
  /**
@@ -1410,7 +1440,12 @@ export default class BabylonJSController {
1410
1440
  * @returns {void}
1411
1441
  */
1412
1442
  #onAnimationGroupStop() {
1413
- const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1443
+ debugger;
1444
+ if (this.#settings.iblEnabled && this.#renderPipelines.iblShadows) {
1445
+ this.#renderPipelines.iblShadows.updateVoxelization();
1446
+ this.#scene?.postProcessRenderPipelineManager?.update();
1447
+ }
1448
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
1414
1449
  this.#requestRender({ frames: frames });
1415
1450
  }
1416
1451
 
@@ -1488,7 +1523,7 @@ export default class BabylonJSController {
1488
1523
  const movementVector = direction.scale(zoomSpeed);
1489
1524
  camera.position = camera.position.add(movementVector);
1490
1525
  }
1491
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1526
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1492
1527
  }
1493
1528
  }
1494
1529
 
@@ -1521,14 +1556,14 @@ export default class BabylonJSController {
1521
1556
  #onPointerMove(event, pickInfo) {
1522
1557
  const camera = this.#scene?.activeCamera;
1523
1558
  if (camera && !camera.metadata?.locked) {
1524
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1559
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1525
1560
  }
1526
1561
  if (this.#babylonJSAnimationController && pickInfo) {
1527
1562
  const pickedMeshId = pickInfo?.pickedMesh?.id || null;
1528
- if (this.#lastPickedMeshId !== pickedMeshId) {
1563
+ if (this.#state.pointerPicking.lastPickedMeshId !== pickedMeshId) {
1529
1564
  const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
1530
1565
  if (highlightResult.changed) {
1531
- this.#requestRender({ frames: 1, continuousMs: this.#renderConfig.interactionMs });
1566
+ this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
1532
1567
  }
1533
1568
  }
1534
1569
  }
@@ -1550,11 +1585,11 @@ export default class BabylonJSController {
1550
1585
  lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
1551
1586
  }
1552
1587
  this.#onPointerMove(info.event, pickInfo);
1553
- this.#lastPickedMeshId = lastPickedMeshId;
1588
+ this.#state.pointerPicking.lastPickedMeshId = lastPickedMeshId;
1554
1589
  } else if (info.type === PointerEventTypes.POINTERUP) {
1555
1590
  const pickInfo = info.event?.button === 2 ? this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY) : null;
1556
1591
  if (pickInfo) {
1557
- this.#lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
1592
+ this.#state.pointerPicking.lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
1558
1593
  }
1559
1594
  this.#onPointerUp(info.event, pickInfo);
1560
1595
  } else if (info.type === PointerEventTypes.POINTERWHEEL) {
@@ -1573,8 +1608,47 @@ export default class BabylonJSController {
1573
1608
  if (!this.#engine) {
1574
1609
  return;
1575
1610
  }
1576
- this.#engine.resize();
1577
- this.#requestRender({ frames: this.#renderConfig.burstFramesBase, continuousMs: this.#renderConfig.interactionMs });
1611
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1612
+ const elapsed = now - this.#state.resize.lastAppliedAt;
1613
+ const applyResize = () => {
1614
+ this.#state.resize.timeoutId = null;
1615
+ this.#state.resize.isScheduled = false;
1616
+ if (!this.#engine) {
1617
+ return;
1618
+ }
1619
+ this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1620
+ console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
1621
+ this.#engine.resize();
1622
+ this.#requestRender({ frames: this.#config.render.burstFramesBase, continuousMs: this.#config.render.interactionMs });
1623
+ };
1624
+
1625
+ if (elapsed >= this.#config.resize.throttleMs) {
1626
+ applyResize();
1627
+ return;
1628
+ }
1629
+
1630
+ if (this.#state.resize.isScheduled) {
1631
+ return;
1632
+ }
1633
+
1634
+ this.#state.resize.isScheduled = true;
1635
+ const waitMs = Math.max(0, this.#config.resize.throttleMs - elapsed);
1636
+ this.#state.resize.timeoutId = setTimeout(() => {
1637
+ applyResize();
1638
+ }, waitMs);
1639
+ }
1640
+
1641
+ /**
1642
+ * Clears any queued throttled resize callback.
1643
+ * @private
1644
+ * @returns {void}
1645
+ */
1646
+ #cancelScheduledResize() {
1647
+ if (this.#state.resize.timeoutId !== null) {
1648
+ clearTimeout(this.#state.resize.timeoutId);
1649
+ }
1650
+ this.#state.resize.timeoutId = null;
1651
+ this.#state.resize.isScheduled = false;
1578
1652
  }
1579
1653
 
1580
1654
  /**
@@ -1920,11 +1994,10 @@ export default class BabylonJSController {
1920
1994
  */
1921
1995
  async #stopRender() {
1922
1996
  this.#stopEngineRenderLoop();
1923
- this.#renderState.dirtyFrames = 0;
1924
- this.#renderState.continuousUntil = 0;
1925
- this.#renderState.lastRenderAt = 0;
1997
+ this.#resetRenderState();
1926
1998
  await this.#unloadCameraDependentEffects();
1927
1999
  }
2000
+
1928
2001
  /**
1929
2002
  * Starts the Babylon.js render loop for the current scene.
1930
2003
  * Waits until the scene is ready before beginning continuous rendering.
@@ -1934,8 +2007,8 @@ export default class BabylonJSController {
1934
2007
  async #startRender() {
1935
2008
  await this.#loadCameraDependentEffects();
1936
2009
  await this.#scene.whenReadyAsync();
1937
- const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#renderConfig.burstFramesEnhanced : this.#renderConfig.burstFramesBase;
1938
- this.#requestRender({ frames: frames, continuousMs: this.#renderConfig.interactionMs });
2010
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2011
+ this.#requestRender({ frames: frames, continuousMs: this.#config.render.interactionMs });
1939
2012
  }
1940
2013
 
1941
2014
  /**