@preference-sl/pref-viewer 2.13.0-beta.2 → 2.13.0-beta.3

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.2",
3
+ "version": "2.13.0-beta.3",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -1,4 +1,4 @@
1
- import { ArcRotateCamera, AssetContainer, Camera, Color4, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, LoadAssetContainerAsync, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRFeatureName, WebXRSessionManager, WebXRState } from "@babylonjs/core";
1
+ import { ArcRotateCamera, AssetContainer, Camera, Color4, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, LoadAssetContainerAsync, Material, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRFeatureName, WebXRSessionManager, WebXRState, WhenTextureReadyAsync } from "@babylonjs/core";
2
2
  import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression.js";
3
3
  import "@babylonjs/loaders";
4
4
  import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_draco_mesh_compression.js";
@@ -11,53 +11,53 @@ import BabylonJSAnimationController from "./babylonjs-animation-controller.js";
11
11
  import { translate } from "./localization/i18n.js";
12
12
 
13
13
  /**
14
- * BabylonJSController is the PrefViewer 3D engine coordinator: it bootstraps Babylon.js, manages asset containers,
15
- * rebuilds post-process pipelines on demand, brokers XR/download interactions, and persists user render toggles so UI
16
- * components can stay declarative. Higher layers hand over container + option state, while this class turns it into a
17
- * fully interactive scene with deterministic reloads and exports.
14
+ * BabylonJSController coordinates the PrefViewer 3D runtime: it bootstraps Babylon.js, manages asset containers,
15
+ * rebuilds camera-dependent pipelines once textures are ready, brokers XR/download interactions, and persists
16
+ * render toggles so the UI can stay declarative. PrefViewer hands over container + option state, while this class
17
+ * turns it into a deterministic scene lifecycle with exports.
18
18
  *
19
19
  * Overview
20
- * - Spins up the Babylon.js engine/scene/camera stack, configures Draco loaders, and wires resize + render loops.
21
- * - Resolves GLTF/GLB sources through GLTFResolver, loads them into `AssetContainer`s, and toggles their visibility.
22
- * - Applies render-setting switches (AA, SSAO, IBL, shadows), persists them in localStorage, and rehydrates on startup.
23
- * - Rebuilds DefaultRenderingPipeline, SSAO, IBL, and shadow pipelines every time the active camera or assets change.
24
- * - Connects keyboard, pointer, wheel, hover, and XR events so menus, animation controllers, and PrefViewer attributes stay in sync.
20
+ * - Creates the Babylon.js engine/scene/camera stack, configures Draco decoders, wires resize + render loops, and
21
+ * exposes download/xR helpers.
22
+ * - Resolves GLTF/GLB sources via GLTFResolver, loads them into `AssetContainer`s, and toggles visibility by
23
+ * mutating container state plus `show-model/show-scene` attributes.
24
+ * - Applies/persists AA, SSAO, IBL, and shadow flags; when they change it stops the render loop, tears down pipelines,
25
+ * reloads containers, and reinstalls effects after environment textures finish loading.
26
+ * - Manages keyboard/pointer/wheel handlers, animation menus, and WebXR so PrefViewer menus and DOM attributes stay
27
+ * synchronized with Babylon state.
25
28
  * - Generates GLB, glTF+ZIP, or USDZ exports with timestamped names and localized dialog copy.
26
29
  * - Translates metadata (inner floor offsets, cast/receive shadows, camera locks) into scene adjustments after reloads.
27
30
  *
28
31
  * Runtime Flow
29
32
  * 1. Instantiate with `new BabylonJSController(canvas, containers, options)`.
30
- * 2. Call `enable()` to configure Draco, create the engine/scene, observers, and XR hooks.
31
- * 3. Whenever PrefViewer marks containers/options pending, invoke `load()` to fetch sources and rebuild pipelines.
32
- * 4. Use `applyRenderSettings` helpers (via menu events) to merge toggles, persist them, and trigger reloads when needed.
33
- * 5. Respond to PrefViewer tasks by calling `showModel()/hideModel()` equivalents through container state changes.
34
- * 6. Surface downloads through `downloadGLB/GLTF/USDZ` or the private `#openDownloadDialog()` triggered by keyboard shortcuts.
35
- * 7. Invoke `disable()` when the element disconnects to teardown scenes, XR sessions, and listeners.
33
+ * 2. Call `enable()` to configure Draco, spin up the engine/scene, attach interaction + XR hooks, and start the render loop.
34
+ * 3. When PrefViewer marks containers/options pending, invoke `load()` (or `reloadWithCurrentSettings()`) so the controller
35
+ * fetches sources, rebuilds containers, and reattaches pipelines.
36
+ * 4. Use `scheduleRenderSettingsReload()` to merge/persist toggles; when it reports `changed: true`, call
37
+ * `reloadWithCurrentSettings()` to apply the staged settings.
38
+ * 5. Use `setContainerVisibility`, `setMaterialOptions`, `setCameraOptions`, or `setIBLOptions` for targeted updates; these
39
+ * helpers stop/restart the render loop while they rebuild camera-dependent resources.
40
+ * 6. Invoke `disable()` when the element disconnects to tear down scenes, XR sessions, observers, and handlers.
36
41
  *
37
42
  * Public API Highlights
38
43
  * - constructor(canvas, containers, options)
39
44
  * - enable() / disable()
40
- * - load()
45
+ * - load() / reloadWithCurrentSettings()
41
46
  * - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
42
- * - getRenderSettings() / applyRenderSettings(partial)
43
- * - setRenderSettings(settings) [via PrefViewer menu integration]
47
+ * - getRenderSettings() / scheduleRenderSettingsReload(settings)
48
+ * - setContainerVisibility(name, show)
49
+ * - setMaterialOptions() / setCameraOptions() / setIBLOptions()
44
50
  *
45
- * Key Subsystems
46
- * - Persistence: #applyRenderSettings, #loadStoredRenderSettings, #saveRenderSettings keep AA/SSAO/IBL/shadow flags synced.
47
- * - Loading pipeline: #markContainersForReload, #markOptionsForReload, #loadAssetContainer, #loadContainers orchestrate deterministic reloads.
48
- * - Visual setup: #configureDracoCompression, #createCamera, #createLights, #initializeVisualImprovements, #initializeAmbientOcclussion,
49
- * #initializeIBLShadows, #initializeDefaultLightShadows, #initializeEnvironmentShadows, #setMaxSimultaneousLights.
50
- * - Interaction + XR: #bindHandlers, #enableInteraction, #onPointerObservable, #onMouseWheel, #onKeyUp, #createXRExperience,
51
- * #addStylesToARButton, #disposeXRExperience.
52
- * - Container helpers: #addContainer, #removeContainer, #replaceContainer, #setOptions_Materials, #setOptions_Camera,
53
- * #setOptions_IBL, #setVisibilityOfWallAndFloorInModel, #getPrefViewerComponent.
54
- * - Metadata + download utilities: #checkModelMetadata, #checkInnerFloorTranslation, #translateNodeY, #addDateToName,
55
- * #downloadZip, #openDownloadDialog.
56
- *
57
- * Notes
58
- * - Designed to be long-lived per PrefViewer instance; it caches parent components to reflect `show-model/show-scene` attributes.
59
- * - All browser-only features guard against SSR/Node usage by checking `window` before touching localStorage or XR APIs.
60
- * - Relies on PrefViewerMenu3D events to trigger render-setting updates, ensuring UI and persisted state never drift apart.
51
+ * Key Invariants
52
+ * - Asset containers must expose `setPendingWithCurrentStorage`/`setPending` before calling load/reload; the controller
53
+ * reads those flags to resolve fresh sources and avoids touching the DOM until data is ready.
54
+ * - Camera-dependent pipelines (DefaultRenderingPipeline, SSAO, IBL shadows, directional shadow generators) are rebuilt
55
+ * only after the active camera and environment textures are ready; render-loop restarts gate those transitions.
56
+ * - `show-model`/`show-scene` DOM attributes reflect container visibility; there are no direct `showModel()/hideModel()` APIs.
57
+ * - IBL shadows require `iblEnabled` plus `options.ibl.shadows` and a loaded HDR texture; otherwise fallback directional
58
+ * lights and environment-contributed lights supply classic shadow generators.
59
+ * - Browser-only features guard `window`, localStorage, and XR APIs before use so the controller is safe to construct
60
+ * in SSR/Node contexts (though functionality activates only in browsers).
61
61
  */
62
62
  export default class BabylonJSController {
63
63
 
@@ -173,7 +173,7 @@ export default class BabylonJSController {
173
173
  * @param {object} [settings={}] - Partial map of render settings (AA, SSAO, IBL, shadows, etc.).
174
174
  * @returns {boolean} True when any setting changed and was saved.
175
175
  */
176
- #applyRenderSettings(settings = {}) {
176
+ #saveRenderSettings(settings = {}) {
177
177
  if (!settings) {
178
178
  return false;
179
179
  }
@@ -186,12 +186,8 @@ export default class BabylonJSController {
186
186
  }
187
187
  });
188
188
 
189
- if (changed && settings.iblEnabled === false && this.#scene) {
190
- this.#scene.environmentTexture = null;
191
- }
192
-
193
189
  if (changed) {
194
- this.#saveRenderSettings();
190
+ this.#storeRenderSettings();
195
191
  }
196
192
 
197
193
  return changed;
@@ -229,14 +225,14 @@ export default class BabylonJSController {
229
225
  * @private
230
226
  * @returns {void}
231
227
  */
232
- #saveRenderSettings() {
228
+ #storeRenderSettings() {
233
229
  if (typeof window === "undefined" || !window?.localStorage) {
234
230
  return;
235
231
  }
236
232
  try {
237
233
  window.localStorage.setItem(this.#RENDER_SETTINGS_STORAGE_KEY, JSON.stringify(this.#settings));
238
234
  } catch (error) {
239
- console.warn("PrefViewer: unable to save render settings", error);
235
+ console.warn("PrefViewer: unable to store render settings", error);
240
236
  }
241
237
  }
242
238
 
@@ -269,9 +265,6 @@ export default class BabylonJSController {
269
265
  if (this.#options?.materials) {
270
266
  Object.values(this.#options.materials).forEach((material) => material?.setPendingWithCurrent?.());
271
267
  }
272
- if (this.#options?.ibl?.setPending) {
273
- this.#options.ibl.setPending();
274
- }
275
268
  }
276
269
 
277
270
  /**
@@ -382,49 +375,71 @@ export default class BabylonJSController {
382
375
  * Adds a hemispheric ambient light, a directional light for shadows, a shadow generator, and a camera-attached point light.
383
376
  * Sets light intensities and shadow properties for realistic rendering.
384
377
  * @private
385
- * @returns {void}
378
+ * @returns {Promise<boolean>} Returns true if lights were changed, false otherwise.
386
379
  */
387
- #createLights() {
388
- if (this.#settings.iblEnabled && this.#options.ibl && this.#options.ibl.url) {
389
- this.#hemiLight = this.#dirLight = this.#cameraLight = null;
390
- this.#initializeEnvironmentTexture();
391
- }
380
+ async #createLights() {
381
+ const hemiLightName = "PrefViewerHemiLight";
382
+ const cameraLightName = "PrefViewerCameraLight";
383
+ const dirLightName = "PrefViewerDirLight";
392
384
 
393
- if (this.#scene.environmentTexture) {
394
- return true;
395
- }
385
+ const hemiLight = this.#scene.getLightByName(hemiLightName);
386
+ const cameraLight = this.#scene.getLightByName(cameraLightName);
387
+ const dirLight = this.#scene.getLightByName(dirLightName);
396
388
 
397
- // 1) Stronger ambient fill
398
- this.#hemiLight = this.#scene.getLightByName("PrefViewerHemiLight");
399
- if (!this.#hemiLight) {
400
- this.#hemiLight = new HemisphericLight("PrefViewerHemiLight", new Vector3(-10, 10, -10), this.#scene);
401
- this.#hemiLight.intensity = 0.6;
402
- }
389
+ let lightsChanged = false;
403
390
 
404
- // 2) Directional light from the front-right, angled slightly down
405
- this.#dirLight = this.#scene.getLightByName("PrefViewerDirLight");
406
- if (!this.#dirLight) {
407
- this.#dirLight = new DirectionalLight("PrefViewerDirLight", new Vector3(-10, 10, -10), this.#scene);
408
- this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
409
- this.#dirLight.intensity = 0.6;
410
- }
391
+ const iblEnabled = this.#settings.iblEnabled && this.#options.ibl && this.#options.ibl.cachedUrl !== null;
392
+
393
+ if (iblEnabled) {
394
+ if (hemiLight) {
395
+ hemiLight.dispose();
396
+ }
397
+ if (cameraLight) {
398
+ cameraLight.dispose();
399
+ }
400
+ if (dirLight) {
401
+ dirLight.dispose();
402
+ }
403
+ this.#hemiLight = this.#dirLight = this.#cameraLight = null;
404
+ lightsChanged = await this.#initializeEnvironmentTexture();
405
+ } else {
406
+ // If IBL is disabled but an environment texture exists, dispose it to save resources and ensure it doesn't affect the lighting
407
+ if (this.#scene.environmentTexture) {
408
+ this.#scene.environmentTexture.dispose();
409
+ this.#scene.environmentTexture = null;
410
+ lightsChanged = true;
411
+ }
411
412
 
412
- // 3) Camera‐attached headlight
413
- this.#cameraLight = this.#scene.getLightByName("PrefViewerCameraLight");
414
- if (!this.#cameraLight) {
415
- this.#cameraLight = new PointLight("PrefViewerCameraLight", this.#camera.position, this.#scene);
416
- this.#cameraLight.parent = this.#camera;
417
- this.#cameraLight.intensity = 0.3;
413
+ // Add a hemispheric light for basic ambient illumination
414
+ if (!this.#hemiLight) {
415
+ this.#hemiLight = new HemisphericLight(hemiLightName, new Vector3(-10, 10, -10), this.#scene);
416
+ this.#hemiLight.intensity = 0.6;
417
+ }
418
+
419
+ // Add a directional light to cast shadows and provide stronger directional illumination
420
+ if (!this.#dirLight) {
421
+ this.#dirLight = new DirectionalLight(dirLightName, new Vector3(-10, 10, -10), this.#scene);
422
+ this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
423
+ this.#dirLight.intensity = 0.6;
424
+ }
425
+
426
+ // Add a point light that follows the camera to ensure the model is always well-lit from the viewer's perspective
427
+ if (!this.#cameraLight) {
428
+ this.#cameraLight = new PointLight(cameraLightName, this.#camera.position, this.#scene);
429
+ this.#cameraLight.parent = this.#camera;
430
+ this.#cameraLight.intensity = 0.3;
431
+ }
418
432
  }
433
+ return lightsChanged;
419
434
  }
420
435
 
421
436
  /**
422
437
  * Detaches and disposes the SSAO render pipeline from the active camera when it exists.
423
438
  * Guards against missing scene resources or absent pipelines, returning false when no cleanup is needed.
424
439
  * @private
425
- * @returns {boolean} Returns true when the SSAO pipeline was disabled, false otherwise.
440
+ * @returns {Promise<boolean>} Returns true when the SSAO pipeline was disabled, false otherwise.
426
441
  */
427
- #disableAmbientOcclusion() {
442
+ async #disableAmbientOcclusion() {
428
443
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
429
444
 
430
445
  if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
@@ -437,19 +452,19 @@ export default class BabylonJSController {
437
452
  return false;
438
453
  }
439
454
 
440
- if (!this.#renderPipelines.ssao) {
455
+ if (!this.#renderPipelines.ssao || !this.#renderPipelines.ssao?.name) {
441
456
  return false;
442
457
  }
443
458
 
444
459
  const pipelineName = this.#renderPipelines.ssao.name;
445
- const ssaoPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
460
+ let ssaoPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
446
461
 
447
462
  if (ssaoPipeline) {
448
463
  pipelineManager.detachCamerasFromRenderPipeline(pipelineName, [this.#scene.activeCamera]);
449
- ssaoPipeline.dispose();
450
464
  pipelineManager.removePipeline(pipelineName);
451
465
  pipelineManager.update();
452
- this.#renderPipelines.ssao = null;
466
+ ssaoPipeline.dispose();
467
+ this.#renderPipelines.ssao = ssaoPipeline = null;
453
468
  }
454
469
 
455
470
  return true;
@@ -460,9 +475,9 @@ export default class BabylonJSController {
460
475
  * Disposes previous SSAO pipelines, instantiates a tuned `SSAORenderingPipeline`, and attaches it to the
461
476
  * current camera so contact shadows enhance depth perception once assets reload or the camera changes.
462
477
  * @private
463
- * @returns {boolean} True if the SSAO pipeline is supported and enabled, otherwise false.
478
+ * @returns {Promise<boolean>} True if the SSAO pipeline is supported and enabled, otherwise false.
464
479
  */
465
- #initializeAmbientOcclussion() {
480
+ async #initializeAmbientOcclussion() {
466
481
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
467
482
  if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
468
483
  return false;
@@ -484,34 +499,35 @@ export default class BabylonJSController {
484
499
  combineRatio: 1.0
485
500
  };
486
501
 
487
- this.#renderPipelines.ssao = new SSAORenderingPipeline(pipelineName, this.#scene, ssaoRatio, [this.#scene.activeCamera]);
502
+ let ssaoPipeline = new SSAORenderingPipeline(pipelineName, this.#scene, ssaoRatio, [this.#scene.activeCamera]);
488
503
 
489
- if (!this.#renderPipelines.ssao){
504
+ if (!ssaoPipeline){
490
505
  return false;
491
506
  }
492
507
 
493
- if (this.#renderPipelines.ssao.isSupported) {
494
- this.#renderPipelines.ssao.fallOff = 0.000001;
495
- this.#renderPipelines.ssao.area = 1;
496
- this.#renderPipelines.ssao.radius = 0.0001;
497
- this.#renderPipelines.ssao.totalStrength = 1;
498
- this.#renderPipelines.ssao.base = 0.6;
508
+ if (ssaoPipeline.isSupported) {
509
+ ssaoPipeline.fallOff = 0.000001;
510
+ ssaoPipeline.area = 1;
511
+ ssaoPipeline.radius = 0.0001;
512
+ ssaoPipeline.totalStrength = 1;
513
+ ssaoPipeline.base = 0.6;
499
514
 
500
515
  // Configure SSAO to calculate only once instead of every frame for better performance
501
- if (this.#renderPipelines.ssao._ssaoPostProcess) {
502
- this.#renderPipelines.ssao._ssaoPostProcess.autoClear = false;
503
- this.#renderPipelines.ssao._ssaoPostProcess.samples = 1;
516
+ if (ssaoPipeline._ssaoPostProcess) {
517
+ ssaoPipeline._ssaoPostProcess.autoClear = false;
518
+ ssaoPipeline._ssaoPostProcess.samples = 1;
504
519
  }
505
- if (this.#renderPipelines.ssao._combinePostProcess) {
506
- this.#renderPipelines.ssao._combinePostProcess.autoClear = false;
507
- this.#renderPipelines.ssao._combinePostProcess.samples = 1;
520
+ if (ssaoPipeline._combinePostProcess) {
521
+ ssaoPipeline._combinePostProcess.autoClear = false;
522
+ ssaoPipeline._combinePostProcess.samples = 1;
508
523
  }
509
524
 
525
+ this.#renderPipelines.ssao = ssaoPipeline;
510
526
  pipelineManager.update();
511
527
  return true;
512
528
  } else {
513
- this.#renderPipelines.ssao.dispose();
514
- this.#renderPipelines.ssao = null;
529
+ ssaoPipeline.dispose();
530
+ this.#renderPipelines.ssao = ssaoPipeline = null;
515
531
  pipelineManager.update();
516
532
  return false;
517
533
  }
@@ -521,9 +537,9 @@ export default class BabylonJSController {
521
537
  * Tears down the default rendering pipeline (MSAA/FXAA/grain) for the active camera when present.
522
538
  * Ensures stale pipelines detach cleanly so a fresh one can be installed on the next load.
523
539
  * @private
524
- * @returns {boolean} Returns true when the pipeline was removed, false otherwise.
540
+ * @returns {Promise<boolean>} Returns true when the pipeline was removed, false otherwise.
525
541
  */
526
- #disableVisualImprovements() {
542
+ async #disableVisualImprovements() {
527
543
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
528
544
 
529
545
  if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
@@ -536,19 +552,19 @@ export default class BabylonJSController {
536
552
  return false;
537
553
  }
538
554
 
539
- if (!this.#renderPipelines.default) {
555
+ if (!this.#renderPipelines.default || !this.#renderPipelines.default?.name) {
540
556
  return false;
541
557
  }
542
558
 
543
559
  const pipelineName = this.#renderPipelines.default.name;
544
- const defaultPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
560
+ let defaultPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
545
561
 
546
562
  if (defaultPipeline) {
547
563
  pipelineManager.detachCamerasFromRenderPipeline(pipelineName, [this.#scene.activeCamera]);
548
- defaultPipeline.dispose();
549
564
  pipelineManager.removePipeline(pipelineName);
550
565
  pipelineManager.update();
551
- this.#renderPipelines.default = null;
566
+ defaultPipeline.dispose();
567
+ this.#renderPipelines.default = defaultPipeline = null;
552
568
  }
553
569
 
554
570
  return true;
@@ -559,10 +575,10 @@ export default class BabylonJSController {
559
575
  * Disposes any previous pipeline instance to avoid duplicates, then attaches a fresh
560
576
  * `DefaultRenderingPipeline` with tuned settings for sharper anti-aliasing and subtle grain.
561
577
  * @private
562
- * @returns {boolean} True when the pipeline is supported and active, otherwise false.
578
+ * @returns {Promise<boolean>} True when the pipeline is supported and active, otherwise false.
563
579
  * @see {@link https://doc.babylonjs.com/features/featuresDeepDive/postProcesses/defaultRenderingPipeline|Using the Default Rendering Pipeline | Babylon.js Documentation}
564
580
  */
565
- #initializeVisualImprovements() {
581
+ async #initializeVisualImprovements() {
566
582
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
567
583
  if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
568
584
  return false;
@@ -579,49 +595,51 @@ export default class BabylonJSController {
579
595
 
580
596
  const pipelineName = "PrefViewerDefaultRenderingPipeline";
581
597
 
582
- this.#renderPipelines.default = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
598
+ let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
583
599
 
584
- if (!this.#renderPipelines.default){
600
+ if (!defaultPipeline){
585
601
  return false;
586
602
  }
587
603
 
588
- if (this.#renderPipelines.default.isSupported) {
604
+ if (defaultPipeline.isSupported) {
589
605
  // MSAA - Multisample Anti-Aliasing
590
606
  const caps = this.#scene.getEngine()?.getCaps?.() || {};
591
607
  const maxSamples = typeof caps.maxMSAASamples === "number" ? caps.maxMSAASamples : 4;
592
- this.#renderPipelines.default.samples = Math.max(1, Math.min(8, maxSamples));
608
+ defaultPipeline.samples = Math.max(1, Math.min(8, maxSamples));
593
609
  // FXAA - Fast Approximate Anti-Aliasing
594
- this.#renderPipelines.default.fxaaEnabled = true;
595
- this.#renderPipelines.default.fxaa.samples = 8;
596
- this.#renderPipelines.default.fxaa.adaptScaleToCurrentViewport = true;
597
- if (this.#renderPipelines.default.fxaa.edgeThreshold !== undefined) {
598
- this.#renderPipelines.default.fxaa.edgeThreshold = 0.125;
610
+ defaultPipeline.fxaaEnabled = true;
611
+ defaultPipeline.fxaa.samples = 8;
612
+ defaultPipeline.fxaa.adaptScaleToCurrentViewport = true;
613
+ if (defaultPipeline.fxaa.edgeThreshold !== undefined) {
614
+ defaultPipeline.fxaa.edgeThreshold = 0.125;
599
615
  }
600
- if (this.#renderPipelines.default.fxaa.edgeThresholdMin !== undefined) {
601
- this.#renderPipelines.default.fxaa.edgeThresholdMin = 0.0625;
616
+ if (defaultPipeline.fxaa.edgeThresholdMin !== undefined) {
617
+ defaultPipeline.fxaa.edgeThresholdMin = 0.0625;
602
618
  }
603
- if (this.#renderPipelines.default.fxaa.subPixelQuality !== undefined) {
604
- this.#renderPipelines.default.fxaa.subPixelQuality = 0.75;
619
+ if (defaultPipeline.fxaa.subPixelQuality !== undefined) {
620
+ defaultPipeline.fxaa.subPixelQuality = 0.75;
605
621
  }
606
622
 
607
623
  // Grain
608
- this.#renderPipelines.default.grainEnabled = true;
609
- this.#renderPipelines.default.grain.adaptScaleToCurrentViewport = true;
610
- this.#renderPipelines.default.grain.animated = false;
611
- this.#renderPipelines.default.grain.intensity = 3;
624
+ defaultPipeline.grainEnabled = true;
625
+ defaultPipeline.grain.adaptScaleToCurrentViewport = true;
626
+ defaultPipeline.grain.animated = false;
627
+ defaultPipeline.grain.intensity = 3;
612
628
 
613
629
  // Configure post-processes to calculate only once instead of every frame for better performance
614
- if (this.#renderPipelines.default.fxaa?._postProcess) {
615
- this.#renderPipelines.default.fxaa._postProcess.autoClear = false;
630
+ if (defaultPipeline.fxaa?._postProcess) {
631
+ defaultPipeline.fxaa._postProcess.autoClear = false;
616
632
  }
617
- if (this.#renderPipelines.default.grain?._postProcess) {
618
- this.#renderPipelines.default.grain._postProcess.autoClear = false;
633
+ if (defaultPipeline.grain?._postProcess) {
634
+ defaultPipeline.grain._postProcess.autoClear = false;
619
635
  }
620
636
 
637
+ this.#renderPipelines.default = defaultPipeline;
621
638
  pipelineManager.update();
622
639
  return true;
623
640
  } else {
624
- this.#renderPipelines.default.dispose();
641
+ defaultPipeline.dispose();
642
+ this.#renderPipelines.default = defaultPipeline = null;
625
643
  pipelineManager.update();
626
644
  return false;
627
645
  }
@@ -632,13 +650,19 @@ export default class BabylonJSController {
632
650
  * Loads an HDR texture from a predefined URI and assigns it to the scene's environmentTexture property.
633
651
  * Configures gamma space, mipmaps, and intensity level for realistic lighting.
634
652
  * @private
635
- * @returns {boolean}
653
+ * @returns {Promise<boolean>} Returns true if the environment texture was changed, false if it was already up to date or failed to load.
636
654
  */
637
- #initializeEnvironmentTexture() {
638
- const hdrTextureURI = this.#options.ibl.url;
639
- const hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, 128, false, false, false, true);
655
+ async #initializeEnvironmentTexture() {
656
+ if (this.#scene.environmentTexture) {
657
+ this.#scene.environmentTexture.dispose();
658
+ this.#scene.environmentTexture = null;
659
+ }
660
+ const hdrTextureURI = this.#options.ibl.cachedUrl;
661
+ const hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, 1024, false, false, false, true, undefined, undefined, false, true, true);
662
+ await WhenTextureReadyAsync(hdrTexture);
640
663
  hdrTexture.level = this.#options.ibl.intensity;
641
664
  this.#scene.environmentTexture = hdrTexture;
665
+ this.#scene.markAllMaterialsAsDirty(Material.TextureDirtyFlag);
642
666
  return true;
643
667
  }
644
668
 
@@ -646,9 +670,9 @@ export default class BabylonJSController {
646
670
  * Removes the IBL shadow render pipeline from the active camera when present.
647
671
  * Ensures voxelized shadow data is disposed so reloading environments installs a clean pipeline.
648
672
  * @private
649
- * @returns {boolean} Returns true when the pipeline was removed, false otherwise.
673
+ * @returns {Promise<boolean>} Returns true when the pipeline was removed, false otherwise.
650
674
  */
651
- #disableIBLShadows() {
675
+ async #disableIBLShadows() {
652
676
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
653
677
 
654
678
  if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
@@ -661,19 +685,23 @@ export default class BabylonJSController {
661
685
  return false;
662
686
  }
663
687
 
664
- if (!this.#renderPipelines.iblShadows) {
688
+ if (!this.#renderPipelines.iblShadows || !this.#renderPipelines.iblShadows?.name) {
665
689
  return false;
666
690
  }
667
691
 
668
692
  const pipelineName = this.#renderPipelines.iblShadows.name;
669
- const defaultPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
693
+ let iblShadowsPipeline = supportedPipelines ? supportedPipelines.find((pipeline) => pipeline.name === pipelineName) : false;
670
694
 
671
- if (defaultPipeline) {
695
+ if (iblShadowsPipeline) {
696
+ iblShadowsPipeline.toggleShadow(false);
697
+ iblShadowsPipeline.clearShadowCastingMeshes();
698
+ iblShadowsPipeline.clearShadowReceivingMaterials();
699
+ iblShadowsPipeline.resetAccumulation();
672
700
  pipelineManager.detachCamerasFromRenderPipeline(pipelineName, [this.#scene.activeCamera]);
673
- defaultPipeline.dispose();
674
701
  pipelineManager.removePipeline(pipelineName);
675
702
  pipelineManager.update();
676
- this.#renderPipelines.iblShadows = null;
703
+ iblShadowsPipeline.dispose();
704
+ this.#renderPipelines.iblShadows = iblShadowsPipeline = null;
677
705
  }
678
706
 
679
707
  return true;
@@ -685,24 +713,36 @@ export default class BabylonJSController {
685
713
  * Configures pipeline options for resolution, sampling, opacity, and debugging.
686
714
  * Only applies if the scene has an environment texture set.
687
715
  * @private
688
- * @returns {void|false} Returns false if no environment texture is set; otherwise void.
716
+ * @returns {Promise<void|boolean>} Returns false if no environment texture is set; otherwise void.
689
717
  */
690
- #initializeIBLShadows() {
718
+ async #initializeIBLShadows() {
719
+
720
+ await this.#scene.whenReadyAsync();
721
+
691
722
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
692
723
 
693
724
  if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
694
725
  return false;
695
726
  }
696
-
727
+
697
728
  if (!this.#scene.environmentTexture) {
698
729
  return false;
699
730
  }
731
+
732
+ if (!this.#scene.environmentTexture.isReady()) {
733
+ const self = this;
734
+ this.#scene.environmentTexture.onLoadObservable.addOnce(() => {
735
+ self.#initializeIBLShadows();
736
+ });
737
+ return false;
738
+ }
739
+
700
740
  const supportedPipelines = pipelineManager.supportedPipelines;
701
741
 
702
742
  if (!supportedPipelines) {
703
743
  return false;
704
744
  }
705
-
745
+
706
746
  const pipelineName = "PrefViewerIblShadowsRenderPipeline";
707
747
 
708
748
  const pipelineOptions = {
@@ -714,13 +754,13 @@ export default class BabylonJSController {
714
754
  shadowOpacity: 0.85,
715
755
  };
716
756
 
717
- this.#renderPipelines.iblShadows = new IblShadowsRenderPipeline(pipelineName, this.#scene, pipelineOptions, [this.#scene.activeCamera]);
757
+ let iblShadowsPipeline = new IblShadowsRenderPipeline(pipelineName, this.#scene, pipelineOptions, [this.#scene.activeCamera]);
718
758
 
719
- if (!this.#renderPipelines.iblShadows) {
759
+ if (!iblShadowsPipeline) {
720
760
  return false;
721
761
  }
722
762
 
723
- if (this.#renderPipelines.iblShadows.isSupported) {
763
+ if (iblShadowsPipeline.isSupported) {
724
764
  // Disable all debug passes for performance
725
765
  const pipelineProps = {
726
766
  allowDebugPasses: false,
@@ -734,13 +774,8 @@ export default class BabylonJSController {
734
774
  accumulationPassDebugEnabled: false,
735
775
  };
736
776
 
737
- Object.assign(this.#renderPipelines.iblShadows, pipelineProps);
738
-
739
- if (this.#renderPipelines.iblShadows._ssaoPostProcess) {
740
- this.#renderPipelines.iblShadows._ssaoPostProcess.autoClear = false;
741
- this.#renderPipelines.iblShadows._ssaoPostProcess.samples = 1;
742
- }
743
-
777
+ Object.assign(iblShadowsPipeline, pipelineProps);
778
+
744
779
  this.#scene.meshes.forEach((mesh) => {
745
780
  const isRootMesh = mesh.id.startsWith("__root__");
746
781
  if (isRootMesh) {
@@ -750,25 +785,28 @@ export default class BabylonJSController {
750
785
  const isHDRIMesh = mesh.name?.toLowerCase() === "hdri";
751
786
  const extrasCastShadows = mesh.metadata?.gltf?.extras?.castShadows;
752
787
  const meshGenerateShadows = typeof extrasCastShadows === "boolean" ? extrasCastShadows : isHDRIMesh ? false : true;
753
-
788
+
754
789
  if (meshGenerateShadows) {
755
- this.#renderPipelines.iblShadows.addShadowCastingMesh(mesh);
756
- this.#renderPipelines.iblShadows.updateSceneBounds();
790
+ iblShadowsPipeline.addShadowCastingMesh(mesh);
791
+ iblShadowsPipeline.updateSceneBounds();
757
792
  }
758
793
  });
759
-
794
+
760
795
  this.#scene.materials.forEach((material) => {
761
796
  if (material instanceof PBRMaterial) {
762
797
  material.enableSpecularAntiAliasing = false;
763
798
  }
764
- this.#renderPipelines.iblShadows.addShadowReceivingMaterial(material);
799
+ iblShadowsPipeline.addShadowReceivingMaterial(material);
765
800
  });
766
-
767
- this.#renderPipelines.iblShadows.updateVoxelization();
801
+
802
+ iblShadowsPipeline.toggleShadow(true);
803
+ iblShadowsPipeline.updateVoxelization();
804
+ this.#renderPipelines.iblShadows = iblShadowsPipeline;
768
805
  pipelineManager.update();
769
806
  return true;
770
807
  } else {
771
- this.#renderPipelines.iblShadows.dispose();
808
+ iblShadowsPipeline.dispose();
809
+ this.#renderPipelines.iblShadows = iblShadowsPipeline = null;
772
810
  pipelineManager.update();
773
811
  return false;
774
812
  }
@@ -802,7 +840,7 @@ export default class BabylonJSController {
802
840
  * @private
803
841
  * @returns {void}
804
842
  */
805
- #initializeDefaultLightShadows() {
843
+ async #initializeDefaultLightShadows() {
806
844
  if (!this.#dirLight) {
807
845
  return;
808
846
  }
@@ -825,7 +863,7 @@ export default class BabylonJSController {
825
863
  * @private
826
864
  * @returns {void}
827
865
  */
828
- #initializeEnvironmentShadows() {
866
+ async #initializeEnvironmentShadows() {
829
867
  this.#shadowGen = this.#shadowGen.filter((generator) => {
830
868
  if (!generator || typeof generator.getLight !== "function") {
831
869
  return false;
@@ -874,12 +912,12 @@ export default class BabylonJSController {
874
912
  * @private
875
913
  * @returns {void}
876
914
  */
877
- #disableShadows() {
915
+ async #disableShadows() {
878
916
  this.#shadowGen.forEach((shadowGenerator) => {
879
917
  shadowGenerator.dispose();
880
918
  });
881
919
  this.#shadowGen = [];
882
- this.#disableIBLShadows();
920
+ await this.#disableIBLShadows();
883
921
  }
884
922
 
885
923
  /**
@@ -890,28 +928,22 @@ export default class BabylonJSController {
890
928
  * If no environment texture is set, initializes IBL shadows.
891
929
  * Otherwise, sets up shadow casting and receiving for all relevant meshes using the shadow generator.
892
930
  */
893
- #initializeShadows() {
931
+ async #initializeShadows() {
894
932
  if (!this.#settings.shadowsEnabled) {
895
933
  return false;
896
934
  }
897
935
 
898
936
  this.#ensureMeshesReceiveShadows();
899
937
 
900
- if (this.#scene.environmentTexture) {
901
- if (this.#options.ibl.shadows) {
902
- if (this.#scene.environmentTexture.isReady()) {
903
- this.#initializeIBLShadows();
904
- } else {
905
- const self = this;
906
- this.#scene.environmentTexture.onLoadObservable.addOnce(() => {
907
- self.#initializeIBLShadows();
908
- });
909
- }
910
- }
938
+ const iblEnabled = this.#settings.iblEnabled && this.#options.ibl && this.#options.ibl.cachedUrl !== null;
939
+ const iblShadowsEnabled = iblEnabled && this.#options.ibl.shadows;
940
+
941
+ if (iblShadowsEnabled) {
942
+ await this.#initializeIBLShadows();
911
943
  } else {
912
- this.#initializeDefaultLightShadows();
944
+ await this.#initializeDefaultLightShadows();
913
945
  }
914
- this.#initializeEnvironmentShadows();
946
+ await this.#initializeEnvironmentShadows();
915
947
  }
916
948
 
917
949
  /**
@@ -1244,14 +1276,8 @@ export default class BabylonJSController {
1244
1276
  * @private
1245
1277
  * @returns {boolean} True when lights were refreshed due to pending IBL changes, otherwise false.
1246
1278
  */
1247
- #setOptions_IBL() {
1248
- if (this.#options.ibl.isPending && this.#settings.iblEnabled) {
1249
- this.#options.ibl.setSuccess(true);
1250
- this.#createLights();
1251
- return true;
1252
- }
1253
- this.#createLights();
1254
- return false;
1279
+ async #setOptions_IBL() {
1280
+ return await this.#createLights();
1255
1281
  }
1256
1282
 
1257
1283
  /**
@@ -1373,6 +1399,52 @@ export default class BabylonJSController {
1373
1399
  return this.#addContainer(container, false);
1374
1400
  }
1375
1401
 
1402
+ /**
1403
+ * Stops every animation group on the provided asset container to guarantee new loads start from a clean state.
1404
+ * @private
1405
+ * @param {AssetContainer} assetContainer - Container whose animation groups should be halted.
1406
+ */
1407
+ #assetContainer_stopAnimations(assetContainer) {
1408
+ if (!assetContainer.animationGroups || assetContainer.animationGroups.length === 0) {
1409
+ return;
1410
+ }
1411
+ assetContainer.animationGroups.forEach((animationGroup) => {
1412
+ animationGroup.stop();
1413
+ });
1414
+ }
1415
+
1416
+ /**
1417
+ * Disposes every imported light so subsequent reloads avoid duplicating scene illumination.
1418
+ * @private
1419
+ * @param {AssetContainer} assetContainer - Container whose lights should be cleaned up.
1420
+ * @returns {void}
1421
+ */
1422
+ #assetContainer_deleteLights(assetContainer) {
1423
+ if (!assetContainer.lights || assetContainer.lights.length === 0) {
1424
+ return;
1425
+ }
1426
+ assetContainer.lights.forEach((light) => {
1427
+ light.dispose();
1428
+ });
1429
+ assetContainer.lights = [];
1430
+ }
1431
+
1432
+ /**
1433
+ * Assigns unique ids to every imported camera so Babylon.js does not reuse stale SSAO effects between reloads.
1434
+ * @private
1435
+ * @param {AssetContainer} assetContainer - Container whose cameras need deterministic id regeneration.
1436
+ * @returns {void}
1437
+ */
1438
+ #assetContainer_retagCameras(assetContainer) {
1439
+ if (!assetContainer.cameras || assetContainer.cameras.length === 0) {
1440
+ return;
1441
+ }
1442
+ assetContainer.cameras.forEach((camera) => {
1443
+ const sufix = "_" + Date.now();
1444
+ camera.id = `${camera.id || camera.name || "camera"}${sufix}`;
1445
+ });
1446
+ }
1447
+
1376
1448
  /**
1377
1449
  * Sets the visibility of wall and floor meshes in the model container based on the provided value or environment visibility.
1378
1450
  * @private
@@ -1394,9 +1466,9 @@ export default class BabylonJSController {
1394
1466
  * @private
1395
1467
  * @returns {void}
1396
1468
  */
1397
- #stopRender() {
1469
+ async #stopRender() {
1398
1470
  this.#engine.stopRenderLoop(this.#handlers.renderLoop);
1399
- this.#unloadCameraDependentEffects();
1471
+ await this.#unloadCameraDependentEffects();
1400
1472
  }
1401
1473
  /**
1402
1474
  * Starts the Babylon.js render loop for the current scene.
@@ -1405,21 +1477,30 @@ export default class BabylonJSController {
1405
1477
  * @returns {Promise<void>}
1406
1478
  */
1407
1479
  async #startRender() {
1408
- this.#loadCameraDependentEffects();
1409
- await this.#scene.whenReadyAsync();
1410
- this.#engine.runRenderLoop(this.#handlers.renderLoop);
1480
+ await this.#loadCameraDependentEffects();
1481
+ this.#scene.executeWhenReady(() => {
1482
+ this.#engine.runRenderLoop(this.#handlers.renderLoop);
1483
+ });
1411
1484
  }
1412
1485
 
1413
1486
  /**
1414
- * Loads an asset container (model, environment, materials, etc.) using the provided container state.
1487
+ * Loads a single asset container (model, environment, materials, etc.) based on the container state flags.
1488
+ * Skips work when nothing is pending, otherwise resolves the GLTF source, refreshes cache metadata and streams it
1489
+ * into the Babylon.js scene via `LoadAssetContainerAsync`.
1415
1490
  * @private
1416
- * @param {object} container - The container object containing asset state and metadata.
1417
- * @returns {Promise<[object, AssetContainer|boolean]>} Resolves to an array with the container and the loaded asset container, or false if loading fails.
1491
+ * @param {object} container - Container descriptor that carries the GLTF storage pointer and current cache info.
1492
+ * @param {boolean} [force=false] - When true, bypasses cached size/timestamp so the resolver re-downloads the asset.
1493
+ * @returns {Promise<[object, AssetContainer|boolean]>} Resolves to `[container, assetContainer]` on success, or
1494
+ * `[container, false]` when loading was skipped or failed.
1418
1495
  * @description
1419
- * Resolves the asset source using GLTFResolver, prepares plugin options, and loads the asset into the Babylon.js scene.
1420
- * Updates the container's cache data and returns the container along with the loaded asset container or false if loading fails.
1496
+ * 1. Validates that the container has pending data and initializes the shared `GLTFResolver` if needed.
1497
+ * 2. Requests the source blob (respecting the cached size/timestamp unless `force` is set) and stores the new cache
1498
+ * metadata via `setPendingCacheData`.
1499
+ * 3. Builds the Babylon plugin options so extras are surfaced as metadata, then imports the container with
1500
+ * `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
1421
1501
  */
1422
- async #loadAssetContainer(container) {
1502
+ async #loadAssetContainer(container, force = false) {
1503
+
1423
1504
  if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
1424
1505
  return [container, false];
1425
1506
  }
@@ -1431,9 +1512,12 @@ export default class BabylonJSController {
1431
1512
  if (!this.#gltfResolver) {
1432
1513
  this.#gltfResolver = new GLTFResolver();
1433
1514
  }
1434
- //let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, container.state.size, container.state.timeStamp);
1435
- // TEMPORARY: We never pass 'size' or 'timeStamp' to always force a reload and avoid issues with the active camera in reloading assetContainers
1436
- let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, 0, null);
1515
+
1516
+ const currentSize = force ? 0 : container.state.size;
1517
+ const currentTimeStamp = force ? null : container.state.timeStamp;
1518
+
1519
+ let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, currentSize, currentTimeStamp);
1520
+
1437
1521
  if (!sourceData) {
1438
1522
  return [container, false];
1439
1523
  }
@@ -1475,7 +1559,7 @@ export default class BabylonJSController {
1475
1559
  * Returns an object with success status and error details.
1476
1560
  */
1477
1561
  async #loadContainers() {
1478
- this.#stopRender();
1562
+ await this.#stopRender();
1479
1563
 
1480
1564
  let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
1481
1565
  let newModelMetadata = {};
@@ -1491,22 +1575,19 @@ export default class BabylonJSController {
1491
1575
  };
1492
1576
 
1493
1577
  await Promise.allSettled(promiseArray)
1494
- .then((values) => {
1578
+ .then(async (values) => {
1495
1579
  this.#disposeAnimationController();
1496
1580
  values.forEach((result) => {
1497
1581
  const container = result.value ? result.value[0] : null;
1498
1582
  const assetContainer = result.value ? result.value[1] : null;
1499
1583
  if (result.status === "fulfilled" && assetContainer) {
1500
1584
  if (container.state.name === "model") {
1501
- assetContainer.lights = [];
1585
+ this.#assetContainer_deleteLights(assetContainer);
1586
+ this.#assetContainer_stopAnimations(assetContainer);
1502
1587
  newModelMetadata = { ...(container.state.update.metadata ?? {}) };
1503
1588
  }
1504
1589
  if (container.state.name === "model" || container.state.name === "environment") {
1505
- assetContainer.cameras.forEach((camera) => {
1506
- // To avoid conflicts when reloading the model we rename the id because Babylon.js caches the camera's SSAO effect by id.
1507
- const sufix = "_" + Date.now();
1508
- camera.id = `${camera.id || camera.name || "camera"}${sufix}`;
1509
- });
1590
+ this.#assetContainer_retagCameras(assetContainer);
1510
1591
  }
1511
1592
  this.#replaceContainer(container, assetContainer);
1512
1593
  container.state.setSuccess(true);
@@ -1520,7 +1601,7 @@ export default class BabylonJSController {
1520
1601
 
1521
1602
  this.#setOptions_Materials();
1522
1603
  this.#setOptions_Camera();
1523
- this.#setOptions_IBL();
1604
+ await this.#setOptions_IBL();
1524
1605
  this.#setVisibilityOfWallAndFloorInModel();
1525
1606
  detail.success = true;
1526
1607
  })
@@ -1534,7 +1615,7 @@ export default class BabylonJSController {
1534
1615
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
1535
1616
  this.#setMaxSimultaneousLights();
1536
1617
  this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
1537
- this.#startRender();
1618
+ await this.#startRender();
1538
1619
  });
1539
1620
  return detail;
1540
1621
  }
@@ -1545,10 +1626,10 @@ export default class BabylonJSController {
1545
1626
  * @private
1546
1627
  * @returns {void}
1547
1628
  */
1548
- #loadCameraDependentEffects() {
1549
- this.#initializeVisualImprovements();
1550
- this.#initializeAmbientOcclussion();
1551
- this.#initializeShadows();
1629
+ async #loadCameraDependentEffects() {
1630
+ await this.#initializeVisualImprovements();
1631
+ await this.#initializeAmbientOcclussion();
1632
+ await this.#initializeShadows();
1552
1633
  }
1553
1634
 
1554
1635
  /**
@@ -1557,10 +1638,10 @@ export default class BabylonJSController {
1557
1638
  * @private
1558
1639
  * @returns {void}
1559
1640
  */
1560
- #unloadCameraDependentEffects() {
1561
- this.#disableVisualImprovements();
1562
- this.#disableAmbientOcclusion();
1563
- this.#disableShadows();
1641
+ async #unloadCameraDependentEffects() {
1642
+ await this.#disableVisualImprovements();
1643
+ await this.#disableAmbientOcclusion();
1644
+ await this.#disableShadows();
1564
1645
  }
1565
1646
 
1566
1647
  /**
@@ -1804,7 +1885,15 @@ export default class BabylonJSController {
1804
1885
  geometryBufferRenderer.generateNormalsInWorldSpace = true;
1805
1886
  }
1806
1887
 
1807
- this.#scene.clearColor = new Color4(1, 1, 1, 1);
1888
+ this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
1889
+
1890
+ // Lowered exposure to prevent scenes from looking blown out when the DefaultRenderingPipeline (Antialiasing) is enabled.
1891
+ this.#scene.imageProcessingConfiguration.exposure = 0.75;
1892
+ this.#scene.imageProcessingConfiguration.contrast = 1.0;
1893
+ this.#scene.imageProcessingConfiguration.toneMappingEnabled = false;
1894
+ this.#scene.imageProcessingConfiguration.vignetteEnabled = false;
1895
+ this.#scene.imageProcessingConfiguration.colorCurvesEnabled = false;
1896
+
1808
1897
  this.#createCamera();
1809
1898
  this.#enableInteraction();
1810
1899
  await this.#createXRExperience();
@@ -1950,26 +2039,6 @@ export default class BabylonJSController {
1950
2039
  return await this.#loadContainers();
1951
2040
  }
1952
2041
 
1953
- /**
1954
- * Merges incoming render flags with the current configuration, persists them, and marks
1955
- * all dependent loaders/options as pending when something actually changed.
1956
- * @public
1957
- * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of render toggles to apply.
1958
- * @returns {{changed:boolean, settings:{antiAliasingEnabled:boolean, ambientOcclusionEnabled:boolean, iblEnabled:boolean, shadowsEnabled:boolean}}}
1959
- * @description
1960
- * Callers can inspect the `changed` flag to decide whether to trigger a reload with
1961
- * `reloadWithCurrentSettings()` or simply reuse the returned snapshot.
1962
- */
1963
- scheduleRenderSettingsReload(settings = {}) {
1964
- const changed = this.#applyRenderSettings(settings);
1965
- if (!changed) {
1966
- return { changed: false, settings: this.getRenderSettings() };
1967
- }
1968
- this.#markContainersForReload();
1969
- this.#markOptionsForReload();
1970
- return { changed: true, settings: this.getRenderSettings() };
1971
- }
1972
-
1973
2042
  /**
1974
2043
  * Applies camera options from the configuration to the active camera.
1975
2044
  * Stops and restarts the render loop to apply changes.
@@ -2000,7 +2069,7 @@ export default class BabylonJSController {
2000
2069
  * Reapplies image-based lighting configuration (HDR URL, intensity, shadow mode).
2001
2070
  * Stops rendering, pushes pending IBL state into the scene, rebuilds camera-dependent effects, then resumes rendering.
2002
2071
  * @public
2003
- * @returns {void}
2072
+ * @returns {boolean} True if IBL options were set successfully, false otherwise.
2004
2073
  */
2005
2074
  setIBLOptions() {
2006
2075
  this.#stopRender();
@@ -2033,6 +2102,26 @@ export default class BabylonJSController {
2033
2102
  this.#startRender();
2034
2103
  }
2035
2104
 
2105
+ /**
2106
+ * Merges incoming render flags with the current configuration, persists them, and marks
2107
+ * all dependent loaders/options as pending when something actually changed.
2108
+ * @public
2109
+ * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of render toggles to apply.
2110
+ * @returns {{changed:boolean, settings:{antiAliasingEnabled:boolean, ambientOcclusionEnabled:boolean, iblEnabled:boolean, shadowsEnabled:boolean}}}
2111
+ * @description
2112
+ * Callers can inspect the `changed` flag to decide whether to trigger a reload with
2113
+ * `reloadWithCurrentSettings()` or simply reuse the returned snapshot.
2114
+ */
2115
+ scheduleRenderSettingsReload(settings = {}) {
2116
+ const changed = this.#saveRenderSettings(settings);
2117
+ if (!changed) {
2118
+ return { changed: false, settings: this.getRenderSettings() };
2119
+ }
2120
+ this.#markContainersForReload();
2121
+ this.#markOptionsForReload();
2122
+ return { changed: true, settings: this.getRenderSettings() };
2123
+ }
2124
+
2036
2125
  /**
2037
2126
  * Reloads every asset container using the latest staged render settings.
2038
2127
  * Intended to be called after `scheduleRenderSettingsReload()` marks data as pending.
@@ -70,9 +70,7 @@ export class ContainerData {
70
70
  }
71
71
  const targetShow = this.update.show !== null ? this.update.show : this.show;
72
72
  this.setPending(storedSource, targetShow);
73
- this.update.size = this.size;
74
- this.update.timeStamp = this.timeStamp;
75
- this.update.metadata = { ...(this.metadata ?? {}) };
73
+ this.setPendingCacheData(this.size, this.timeStamp, this.metadata);
76
74
  return true;
77
75
  }
78
76
  get isPending() {
@@ -225,38 +223,20 @@ export class CameraData {
225
223
  * - Inspect `isPending`/`isSuccess` to drive UI or re-render logic.
226
224
  */
227
225
  export class IBLData {
228
- constructor(url = null, intensity = 1.0, shadows = false, timeStamp = null) {
226
+ defaultIntensity = 1.0;
227
+ defaultShadows = false;
228
+ constructor(url = null, intensity = this.defaultIntensity, shadows = this.defaultShadows, timeStamp = null) {
229
229
  this.url = url;
230
+ this.cachedUrl = null;
230
231
  this.intensity = intensity;
231
232
  this.shadows = shadows;
232
233
  this.timeStamp = timeStamp;
233
- this.reset();
234
- }
235
- reset() {
236
- this.pending = false;
237
- this.success = false;
238
- }
239
- setValues(url, intensity, shadows, timeStamp) {
240
- this.url = url !== undefined ? url : this.url;
241
- this.intensity = intensity !== undefined ? intensity : this.intensity;
242
- this.shadows = shadows !== undefined ? shadows : this.shadows;
243
- this.timeStamp = timeStamp !== undefined ? timeStamp : this.timeStamp;
244
- }
245
- setSuccess(success = false) {
246
- if (success) {
247
- this.success = true;
248
- } else {
249
- this.success = false;
250
- }
251
234
  }
252
- setPending() {
253
- this.pending = true;
254
- this.success = false;
255
- }
256
- get isPending() {
257
- return this.pending === true;
258
- }
259
- get isSuccess() {
260
- return this.success === true;
235
+ setValues(url, cachedUrl = null, intensity = this.defaultIntensity, shadows = this.defaultShadows, timeStamp = null) {
236
+ this.url = url;
237
+ this.cachedUrl = cachedUrl;
238
+ this.intensity = intensity;
239
+ this.shadows = shadows;
240
+ this.timeStamp = timeStamp;
261
241
  }
262
242
  }
@@ -239,7 +239,6 @@ export default class PrefViewer3D extends HTMLElement {
239
239
  Object.values(this.#data.containers).forEach((container) => container.reset());
240
240
  Object.values(this.#data.options.materials).forEach((material) => material.reset());
241
241
  this.#data.options.camera.reset();
242
- this.#data.options.ibl.reset();
243
242
  }
244
243
 
245
244
  /**
@@ -335,6 +334,7 @@ export default class PrefViewer3D extends HTMLElement {
335
334
  const iblState = this.#data.options.ibl;
336
335
 
337
336
  let url = undefined;
337
+ let cachedUrl = undefined;
338
338
  let timeStamp = undefined;
339
339
  let shadows = undefined;
340
340
  let intensity = undefined;
@@ -344,7 +344,7 @@ export default class PrefViewer3D extends HTMLElement {
344
344
  const fileStorage = new FileStorage("PrefViewer", "Files");
345
345
  const newURL = await fileStorage.getURL(options.ibl.url);
346
346
  if (newURL) {
347
- url = newURL;
347
+ cachedUrl = newURL;
348
348
  timeStamp = await fileStorage.getTimeStamp(options.ibl.url);
349
349
  }
350
350
  }
@@ -358,10 +358,11 @@ export default class PrefViewer3D extends HTMLElement {
358
358
  const needUpdate = url !== undefined && url !== iblState.url ||
359
359
  timeStamp !== undefined && timeStamp !== iblState.timeStamp ||
360
360
  shadows !== undefined && shadows !== iblState.shadows ||
361
- intensity !== undefined && intensity !== iblState.intensity;
361
+ shadows === undefined && iblState.shadows !== iblState.defaultShadows ||
362
+ intensity !== undefined && intensity !== iblState.intensity ||
363
+ intensity === undefined && iblState.intensity !== iblState.defaultIntensity;
362
364
  if (needUpdate) {
363
- iblState.setValues(url, intensity, shadows, timeStamp);
364
- iblState.setPending(true);
365
+ iblState.setValues(url, cachedUrl, intensity, shadows, timeStamp);
365
366
  }
366
367
 
367
368
  return needUpdate;
@@ -9,7 +9,7 @@ import { DEFAULT_LOCALE, resolveLocale, translate } from "./localization/i18n.js
9
9
  * - Builds an accessible hover/focus-activated panel with switches for AA, SSAO, IBL, and dynamic shadows.
10
10
  * - Caches translated copy in `#texts` and listens for culture changes via the `culture` attribute or `setCulture()`.
11
11
  * - Tracks applied vs. draft render settings so pending diffs, button enablement, and status text stay in sync.
12
- * - Emits `pref-viewer-menu-apply` whenever the user confirms toggles, allowing PrefViewer/BabylonJSController to persist.
12
+ * - Emits `pref-viewer-menu-3d-apply` whenever the user confirms toggles, allowing PrefViewer/BabylonJSController to persist.
13
13
  * - Shows transient status/error messages and per-switch pending states while operations complete.
14
14
  *
15
15
  * Public API:
@@ -369,8 +369,15 @@ export default class PrefViewerMenu3D extends HTMLElement {
369
369
  if (this.#isApplying || !this.#hasPendingChanges()) {
370
370
  return;
371
371
  }
372
+
372
373
  const detail = { settings: { ...this.#draftSettings } };
373
- this.dispatchEvent(new CustomEvent("pref-viewer-menu-apply", { detail, bubbles: true, composed: true }));
374
+ const customEventOptions = {
375
+ bubbles: true,
376
+ cancelable: true,
377
+ composed: true,
378
+ detail: detail,
379
+ };
380
+ this.dispatchEvent(new CustomEvent("pref-viewer-menu-3d-apply", customEventOptions));
374
381
  }
375
382
 
376
383
  /**
@@ -216,7 +216,7 @@ export default class PrefViewer extends HTMLElement {
216
216
  this.#component3D.remove();
217
217
  }
218
218
  if (this.#menu3D) {
219
- this.#menu3D.removeEventListener("pref-viewer-menu-apply", this.#handlers.onMenuApply);
219
+ this.#menu3D.removeEventListener("pref-viewer-menu-3d-apply", this.#handlers.onMenuApply);
220
220
  this.#menu3D.remove();
221
221
  this.#menu3D = null;
222
222
  }
@@ -264,7 +264,7 @@ export default class PrefViewer extends HTMLElement {
264
264
  return;
265
265
  }
266
266
  if (this.#menu3D) {
267
- this.#menu3D.removeEventListener("pref-viewer-menu-apply", this.#handlers.onMenuApply);
267
+ this.#menu3D.removeEventListener("pref-viewer-menu-3d-apply", this.#handlers.onMenuApply);
268
268
  this.#menu3D.remove();
269
269
  }
270
270
  this.#menu3D = document.createElement("pref-viewer-menu-3d");
@@ -280,7 +280,7 @@ export default class PrefViewer extends HTMLElement {
280
280
  this.#handlers.onViewerHoverStart = () => this.#menu3D.setViewerHover(true);
281
281
  this.#handlers.onViewerHoverEnd = () => this.#menu3D.setViewerHover(false);
282
282
 
283
- this.#menu3D.addEventListener("pref-viewer-menu-apply", this.#handlers.onMenuApply);
283
+ this.#menu3D.addEventListener("pref-viewer-menu-3d-apply", this.#handlers.onMenuApply);
284
284
  this.#wrapper.addEventListener("mouseenter", this.#handlers.onViewerHoverStart);
285
285
  this.#wrapper.addEventListener("mouseleave", this.#handlers.onViewerHoverEnd);
286
286
 
@@ -601,7 +601,7 @@ export default class PrefViewer extends HTMLElement {
601
601
  }
602
602
 
603
603
  /**
604
- * Handles the custom "pref-viewer-menu-apply" event emitted by the menu component.
604
+ * Handles the custom "pref-viewer-menu-3d-apply" event emitted by the menu component.
605
605
  * Forwards the requested settings to the render-application pipeline.
606
606
  * @private
607
607
  * @param {CustomEvent} event - Menu event containing the `settings` payload.