@preference-sl/pref-viewer 2.16.0-beta.4 → 2.16.0-beta.7

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.
@@ -1,4 +1,4 @@
1
- import { ArcRotateCamera, AssetContainer, Camera, Color3, Color4, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, ImageProcessingConfiguration, LoadAssetContainerAsync, Material, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Texture, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRFeatureName, WebXRSessionManager, WebXRState, WhenTextureReadyAsync } from "@babylonjs/core";
1
+ import { ArcRotateCamera, AssetContainer, Camera, Color3, Color4, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, ImageProcessingConfiguration, LoadAssetContainerAsync, Material, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Texture, 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";
@@ -165,9 +165,6 @@ export default class BabylonJSController {
165
165
  // References to parent custom elements
166
166
  #prefViewer3D = undefined;
167
167
  #prefViewer = undefined;
168
- #modelFirstPaintedEmitted = false;
169
- #modelFirstPaintedLoadToken = 0;
170
- #pendingModelReplacementCleanup = null;
171
168
 
172
169
  // Babylon.js core objects
173
170
  #engine = null;
@@ -177,7 +174,6 @@ export default class BabylonJSController {
177
174
  #dirLight = null;
178
175
  #moonLight = null;
179
176
  #cameraLight = null;
180
- #debugCameraFrameInfo = null;
181
177
  #shadowGen = [];
182
178
  #XRExperience = null;
183
179
  #canvasResizeObserver = null;
@@ -189,11 +185,6 @@ export default class BabylonJSController {
189
185
 
190
186
  #gltfResolver = null; // GLTFResolver instance
191
187
  #babylonJSAnimationController = null; // AnimationController instance
192
- #loadingInProgress = false; // blocks #requestRender while assets are loading to prevent WebGL errors from rendering with disposed shaders
193
- #isLoadingModelIndependent = false; // guards against concurrent independent model loads
194
- #isLoadingEnvironmentIndependent = false; // guards against concurrent independent environment loads
195
- #isLoadingMaterialsIndependent = false; // guards against concurrent independent materials loads
196
- #startRenderInProgress = false; // true while #startRender() is running (after #loadingInProgress clears)
197
188
 
198
189
  #renderPipelines = {
199
190
  default: null,
@@ -757,8 +748,8 @@ export default class BabylonJSController {
757
748
  * @param {number} [options.continuousMs=0] - Milliseconds to keep continuous rendering active.
758
749
  * @returns {boolean} True when the request was accepted, false when scene/engine are unavailable.
759
750
  */
760
- #requestRender({ frames = 1, continuousMs = 0, force = false } = {}) {
761
- if (!this.#scene || !this.#engine || (this.#loadingInProgress && !force)) {
751
+ #requestRender({ frames = 1, continuousMs = 0 } = {}) {
752
+ if (!this.#scene || !this.#engine) {
762
753
  return false;
763
754
  }
764
755
 
@@ -1121,11 +1112,6 @@ export default class BabylonJSController {
1121
1112
 
1122
1113
  const pipelineName = "PrefViewerSSAORenderingPipeline";
1123
1114
 
1124
- // Skip recreation if already alive — same reason as DefaultRenderingPipeline guard
1125
- if (this.#renderPipelines.ssao && supportedPipelines.find((p) => p.name === pipelineName)) {
1126
- return true;
1127
- }
1128
-
1129
1115
  const ssaoRatio = {
1130
1116
  ssaoRatio: 0.5,
1131
1117
  combineRatio: 1.0,
@@ -1227,21 +1213,6 @@ export default class BabylonJSController {
1227
1213
 
1228
1214
  const pipelineName = "PrefViewerDefaultRenderingPipeline";
1229
1215
 
1230
- // Skip recreation if the pipeline is already alive — creating a new DefaultRenderingPipeline
1231
- // while one exists disposes the underlying GeometryBufferRenderer, deleting GL programs that
1232
- // subMeshes still reference, causing useProgram:deleted errors on the next render frame.
1233
- if (this.#renderPipelines.default && supportedPipelines.find((p) => p.name === pipelineName)) {
1234
- return true;
1235
- }
1236
-
1237
- // Purge stale effect cache before creating a new pipeline. pipeline.dispose() calls
1238
- // gl.deleteProgram() per post-process but leaves entries in Engine._compiledEffects.
1239
- // A new DefaultRenderingPipeline with the same shader key gets a cache hit and receives
1240
- // the deleted program → useProgram:deleted on the first render frame.
1241
- // Safe here because #startRender always stops the loop before reaching this point.
1242
- this.#scene.getEngine().releaseEffects?.();
1243
- this.#scene.getEngine().releaseComputeEffects?.();
1244
-
1245
1216
  let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
1246
1217
 
1247
1218
  if (!defaultPipeline) {
@@ -1419,11 +1390,6 @@ export default class BabylonJSController {
1419
1390
 
1420
1391
  const pipelineName = "PrefViewerIblShadowsRenderPipeline";
1421
1392
 
1422
- // Skip recreation if already alive — same reason as DefaultRenderingPipeline guard
1423
- if (this.#renderPipelines.iblShadows && supportedPipelines.find((p) => p.name === pipelineName)) {
1424
- return true;
1425
- }
1426
-
1427
1393
  // Use hardware-adaptive quality settings
1428
1394
  const pipelineOptions = {
1429
1395
  resolutionExp: this.#config.quality.iblShadowResolution, // Adaptive: 0=1024, 1=2048, 2=4096
@@ -1675,7 +1641,7 @@ export default class BabylonJSController {
1675
1641
  const showroom = this.#config.look?.showroom || {};
1676
1642
  const imageProcessing = this.#scene.imageProcessingConfiguration;
1677
1643
 
1678
- this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
1644
+ this.#scene.clearColor = new Color4(0.98, 0.98, 0.98, 1).toLinearSpace();
1679
1645
  imageProcessing.exposure = showroom.exposure ?? 0.95;
1680
1646
  imageProcessing.contrast = showroom.contrast ?? 1.08;
1681
1647
  imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
@@ -1715,7 +1681,12 @@ export default class BabylonJSController {
1715
1681
  imageProcessing.colorCurvesEnabled = false;
1716
1682
  }
1717
1683
 
1718
- this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
1684
+ this.#scene.clearColor = new Color4(
1685
+ lerp(0.035, 0.98, daylight),
1686
+ lerp(0.045, 0.98, daylight),
1687
+ lerp(0.085, 0.98, daylight),
1688
+ 1,
1689
+ ).toLinearSpace();
1719
1690
 
1720
1691
  if ("environmentIntensity" in this.#scene) {
1721
1692
  this.#scene.environmentIntensity = lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
@@ -2204,10 +2175,6 @@ export default class BabylonJSController {
2204
2175
  }
2205
2176
  this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2206
2177
  console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
2207
- // Stop the loop before resize: engine.resize() rebuilds DefaultRenderingPipeline
2208
- // via onResizeObservable, which disposes GL programs. If the loop is running,
2209
- // the next frame would call useProgram on a deleted program → INVALID_OPERATION.
2210
- this.#stopEngineRenderLoop();
2211
2178
  this.#applyShowroomCameraFraming();
2212
2179
  this.#engine.resize();
2213
2180
  const needsEnhancedBurst = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled || this.#settings.shadowsEnabled;
@@ -2250,7 +2217,7 @@ export default class BabylonJSController {
2250
2217
  * @param {MaterialData} optionMaterial - Material option containing value, nodePrefixes, nodeNames, and state.
2251
2218
  * @returns {boolean} True if any mesh material was set, false otherwise.
2252
2219
  */
2253
- #setOptionsMaterial(optionMaterial, force = false) {
2220
+ #setOptionsMaterial(optionMaterial) {
2254
2221
  if (!optionMaterial || !(optionMaterial.value || (optionMaterial.isPending && optionMaterial.update.value)) || !(optionMaterial.nodePrefixes.length || optionMaterial.nodeNames.length)) {
2255
2222
  return false;
2256
2223
  }
@@ -2268,7 +2235,7 @@ export default class BabylonJSController {
2268
2235
  if (container.state.name === "materials") {
2269
2236
  return;
2270
2237
  }
2271
- if (container.assetContainer && (force || container.state.isPending || materialContainer.state.isPending || optionMaterial.isPending)) {
2238
+ if (container.assetContainer && (container.state.isPending || materialContainer.state.isPending || optionMaterial.isPending)) {
2272
2239
  assetContainersToProcess.push(container.assetContainer);
2273
2240
  }
2274
2241
  });
@@ -2279,7 +2246,7 @@ export default class BabylonJSController {
2279
2246
  let someSetted = false;
2280
2247
  assetContainersToProcess.forEach((assetContainer) =>
2281
2248
  assetContainer.meshes
2282
- .filter((meshToFilter) => optionMaterial.nodePrefixes.some((prefix) => meshToFilter.name.startsWith(prefix)) || optionMaterial.nodeNames.includes(meshToFilter.name))
2249
+ .filter((meshToFilter) => optionMaterial.nodePrefixes.some((prefix) => meshToFilter.name.toLowerCase().startsWith(prefix.toLowerCase())) || optionMaterial.nodeNames.some((n) => n.toLowerCase() === meshToFilter.name.toLowerCase()))
2283
2250
  .forEach((mesh) => {
2284
2251
  mesh.material = material;
2285
2252
  someSetted = true;
@@ -2301,10 +2268,10 @@ export default class BabylonJSController {
2301
2268
  * @private
2302
2269
  * @returns {boolean} True if any material option was set, false otherwise.
2303
2270
  */
2304
- #setOptions_Materials(force = false) {
2271
+ #setOptions_Materials() {
2305
2272
  let someSetted = false;
2306
2273
  Object.values(this.#options.materials).forEach((material) => {
2307
- let settedMaterial = this.#setOptionsMaterial(material, force);
2274
+ let settedMaterial = this.#setOptionsMaterial(material);
2308
2275
  someSetted = someSetted || settedMaterial;
2309
2276
  });
2310
2277
  return someSetted;
@@ -2413,9 +2380,7 @@ export default class BabylonJSController {
2413
2380
  prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : undefined;
2414
2381
  }
2415
2382
 
2416
- if (prefViewer3D) {
2417
- this.#prefViewer3D = prefViewer3D;
2418
- }
2383
+ this.#prefViewer3D = prefViewer3D;
2419
2384
  }
2420
2385
 
2421
2386
  /**
@@ -2430,6 +2395,7 @@ export default class BabylonJSController {
2430
2395
 
2431
2396
  this.#getPrefViewer3DComponent();
2432
2397
  if (this.#prefViewer3D === undefined) {
2398
+ this.#prefViewer = undefined;
2433
2399
  return;
2434
2400
  }
2435
2401
 
@@ -2439,9 +2405,7 @@ export default class BabylonJSController {
2439
2405
  prefViewer = host?.nodeName === "PREF-VIEWER" ? host : undefined;
2440
2406
  }
2441
2407
 
2442
- if (prefViewer) {
2443
- this.#prefViewer = prefViewer;
2444
- }
2408
+ this.#prefViewer = prefViewer;
2445
2409
  }
2446
2410
 
2447
2411
  /**
@@ -2455,94 +2419,13 @@ export default class BabylonJSController {
2455
2419
  // Cache references to parent custom elements
2456
2420
  this.#getPrefViewer3DComponent();
2457
2421
  this.#getPrefViewerComponent();
2458
- const syncVisibility = (component, fallbackAttr) => {
2459
- if (!component) {
2460
- return;
2461
- }
2462
- if (typeof component.syncVisibility === "function") {
2463
- component.syncVisibility(name, isVisible);
2464
- return;
2465
- }
2466
- component.setAttribute(fallbackAttr, isVisible ? "true" : "false");
2467
- };
2468
2422
  if (name === "model") {
2469
- syncVisibility(this.#prefViewer3D, "show-model");
2470
- syncVisibility(this.#prefViewer, "show-model");
2423
+ this.#prefViewer3D?.setAttribute("show-model", isVisible ? "true" : "false");
2424
+ this.#prefViewer?.setAttribute("show-model", isVisible ? "true" : "false");
2471
2425
  } else if (name === "environment") {
2472
- syncVisibility(this.#prefViewer3D, "show-scene");
2473
- syncVisibility(this.#prefViewer, "show-scene");
2474
- }
2475
- }
2476
-
2477
- /**
2478
- * Dispatches a "model-first-painted" event once the model-only preview has had a chance to render a frame.
2479
- * @private
2480
- * @param {object} [detail] - Optional metadata describing the paint gate.
2481
- * @returns {void}
2482
- */
2483
- #emitModelFirstPainted(detail = {}) {
2484
- this.#getPrefViewer3DComponent();
2485
- this.#getPrefViewerComponent();
2486
- const target = this.#prefViewer || this.#prefViewer3D || this.#canvas;
2487
- if (!target) {
2488
- return;
2489
- }
2490
-
2491
- const customEventOptions = {
2492
- bubbles: true,
2493
- cancelable: false,
2494
- composed: true,
2495
- detail,
2496
- };
2497
- target.dispatchEvent(new CustomEvent("model-first-painted", customEventOptions));
2498
- }
2499
-
2500
- /**
2501
- * Dispatches a granular 3D load phase event so the ecommerce trace UI can measure tail phases.
2502
- * @private
2503
- * @param {string} phase - Phase event name.
2504
- * @param {object} [detail] - Optional phase metadata.
2505
- * @returns {void}
2506
- */
2507
- #emitSceneLoadPhase(phase, detail = {}) {
2508
- this.#getPrefViewer3DComponent();
2509
- this.#getPrefViewerComponent();
2510
- const target = this.#prefViewer || this.#prefViewer3D || this.#canvas;
2511
- if (!target) {
2512
- return;
2426
+ this.#prefViewer3D?.setAttribute("show-scene", isVisible ? "true" : "false");
2427
+ this.#prefViewer?.setAttribute("show-scene", isVisible ? "true" : "false");
2513
2428
  }
2514
-
2515
- const customEventOptions = {
2516
- bubbles: true,
2517
- cancelable: false,
2518
- composed: true,
2519
- detail,
2520
- };
2521
- target.dispatchEvent(new CustomEvent(phase, customEventOptions));
2522
- }
2523
-
2524
- /**
2525
- * Waits for one or more animation frames so the browser can paint the current model-only state.
2526
- * @private
2527
- * @param {number} [frameCount=2] - Number of animation frames to wait.
2528
- * @returns {Promise<void>}
2529
- */
2530
- async #waitForPaint(frameCount = 2) {
2531
- if (typeof requestAnimationFrame !== "function") {
2532
- return;
2533
- }
2534
-
2535
- const totalFrames = Math.max(1, frameCount);
2536
- await new Promise((resolve) => {
2537
- const step = (remaining) => {
2538
- if (remaining <= 0) {
2539
- resolve();
2540
- return;
2541
- }
2542
- requestAnimationFrame(() => step(remaining - 1));
2543
- };
2544
- requestAnimationFrame(() => step(totalFrames - 1));
2545
- });
2546
2429
  }
2547
2430
 
2548
2431
  /**
@@ -2590,21 +2473,8 @@ export default class BabylonJSController {
2590
2473
  * @param {AssetContainer} newAssetContainer - The new asset container to add to the scene.
2591
2474
  * @returns {boolean} True if the container was replaced and added, false otherwise.
2592
2475
  */
2593
- #replaceContainer(container, newAssetContainer, { releaseEffects = true } = {}) {
2594
- const keepPreviousVisibleUntilPaint = container.state.name === "model" && container.assetContainer && container.state.isVisible;
2595
- if (this.#pendingModelReplacementCleanup && container.state.name === "model") {
2596
- this.#finalizePendingModelReplacement();
2597
- }
2476
+ #replaceContainer(container, newAssetContainer) {
2598
2477
  if (container.assetContainer) {
2599
- if (keepPreviousVisibleUntilPaint) {
2600
- if (this.#pendingModelReplacementCleanup) {
2601
- this.#finalizePendingModelReplacement();
2602
- }
2603
- const oldAssetContainer = container.assetContainer;
2604
- container.assetContainer = newAssetContainer;
2605
- this.#pendingModelReplacementCleanup = { container, oldAssetContainer, releaseEffects };
2606
- return true;
2607
- }
2608
2478
  this.#removeContainer(container, false);
2609
2479
  container.assetContainer.dispose();
2610
2480
  container.assetContainer = null;
@@ -2615,75 +2485,13 @@ export default class BabylonJSController {
2615
2485
  newAssetContainer?.dispose();
2616
2486
  return false;
2617
2487
  }
2618
- // Skip releaseEffects when the render loop is active: deleting GL programs while frames
2619
- // are being produced causes useProgram:deleted errors. Also skipped when the caller
2620
- // explicitly passes releaseEffects:false (e.g. loadContainerIndependent).
2621
- if (releaseEffects && !this.#state.render.isLoopRunning) {
2622
- this.#scene.getEngine().releaseEffects();
2623
- this.#scene.getEngine().releaseComputeEffects();
2624
- }
2488
+ this.#scene.getEngine().releaseEffects();
2489
+ this.#scene.getEngine().releaseComputeEffects();
2625
2490
 
2626
2491
  container.assetContainer = newAssetContainer;
2627
2492
  return this.#addContainer(container, false);
2628
2493
  }
2629
2494
 
2630
- /**
2631
- * Finalizes a deferred model replacement by disposing the previously visible model container.
2632
- * @private
2633
- * @returns {void}
2634
- */
2635
- #finalizePendingModelReplacement() {
2636
- if (!this.#pendingModelReplacementCleanup) {
2637
- return;
2638
- }
2639
- const { container, oldAssetContainer } = this.#pendingModelReplacementCleanup;
2640
- this.#pendingModelReplacementCleanup = null;
2641
- if (!oldAssetContainer) {
2642
- return;
2643
- }
2644
- const stagedAssetContainer = container?.assetContainer ?? null;
2645
- if (stagedAssetContainer) {
2646
- try {
2647
- stagedAssetContainer.meshes?.forEach((mesh) => {
2648
- mesh.visibility = 0;
2649
- });
2650
- } catch {
2651
- // ignore
2652
- }
2653
- try {
2654
- container.state.visible = false;
2655
- this.#addContainer(container, false);
2656
- } catch {
2657
- // ignore
2658
- }
2659
- }
2660
- try {
2661
- oldAssetContainer.removeAllFromScene();
2662
- } catch {
2663
- // ignore
2664
- }
2665
- try {
2666
- oldAssetContainer.dispose();
2667
- } catch {
2668
- // ignore
2669
- }
2670
- if (stagedAssetContainer) {
2671
- try {
2672
- stagedAssetContainer.meshes?.forEach((mesh) => {
2673
- mesh.visibility = 1;
2674
- });
2675
- this.#setVisibilityOfWallAndFloorInModel();
2676
- } catch {
2677
- // ignore
2678
- }
2679
- }
2680
- this.#requestRender({
2681
- frames: 3,
2682
- continuousMs: this.#config.render.interactionMs,
2683
- force: true,
2684
- });
2685
- }
2686
-
2687
2495
  /**
2688
2496
  * Stops every animation group on the provided asset container to guarantee new loads start from a clean state.
2689
2497
  * @private
@@ -2740,207 +2548,10 @@ export default class BabylonJSController {
2740
2548
  if (!this.#containers.model.assetContainer || !this.#containers.model.state.isVisible) {
2741
2549
  return false;
2742
2550
  }
2743
- // In model-only previews, or while the environment is still loading, keep wall/floor meshes visible.
2744
- // Only defer to the environment's visibility after it has fully loaded.
2745
- show = show !== undefined
2746
- ? show
2747
- : (this.#containers.environment.state.isSuccess
2748
- ? this.#containers.environment.state.isVisible
2749
- : true);
2551
+ show = show !== undefined ? show : this.#containers.environment.state.isVisible;
2750
2552
  const nodePrefixes = Object.values(this.#options.materials).flatMap((material) => material.nodePrefixes);
2751
2553
  const nodeNames = Object.values(this.#options.materials).flatMap((material) => material.nodeNames);
2752
- this.#containers.model.assetContainer.meshes.filter((meshToFilter) => nodePrefixes.some((prefix) => meshToFilter.name.startsWith(prefix)) || nodeNames.includes(meshToFilter.name)).forEach((mesh) => mesh.setEnabled(show));
2753
- }
2754
-
2755
- /**
2756
- * Frames the active ArcRotateCamera around the visible model when no environment scene is loaded.
2757
- * @private
2758
- * @returns {boolean} True if the camera was adjusted, otherwise false.
2759
- */
2760
- #frameCameraToModel({ allowWhenEnvironmentVisible = false } = {}) {
2761
- const camera = this.#scene?.activeCamera instanceof ArcRotateCamera
2762
- ? this.#scene.activeCamera
2763
- : this.#camera;
2764
- const modelContainer = this.#containers.model;
2765
- const inspect = {
2766
- cameraType: camera?.getClassName?.() ?? camera?.constructor?.name ?? null,
2767
- hasModelContainer: !!modelContainer.assetContainer,
2768
- modelVisible: modelContainer.state.isVisible,
2769
- envHasContainer: !!this.#containers.environment.assetContainer,
2770
- envVisible: this.#containers.environment.state.isVisible,
2771
- meshCount: modelContainer.assetContainer?.meshes?.length ?? 0,
2772
- };
2773
- this.#debugCameraFrameInfo = { phase: "inspect", ...inspect };
2774
- console.log("[viewer] frameCameraToModel inspect", inspect);
2775
- if (!(camera instanceof ArcRotateCamera) || !modelContainer.assetContainer || !modelContainer.state.isVisible) {
2776
- this.#debugCameraFrameInfo = {
2777
- phase: "skipped",
2778
- reason: !(camera instanceof ArcRotateCamera)
2779
- ? "no-arcrotate-camera"
2780
- : !modelContainer.assetContainer
2781
- ? "no-model"
2782
- : !modelContainer.state.isVisible
2783
- ? "model-hidden"
2784
- : "unknown",
2785
- ...inspect,
2786
- };
2787
- return false;
2788
- }
2789
- if (!allowWhenEnvironmentVisible && this.#containers.environment.assetContainer && this.#containers.environment.state.isVisible) {
2790
- this.#debugCameraFrameInfo = {
2791
- phase: "skipped",
2792
- reason: "environment-visible",
2793
- ...inspect,
2794
- };
2795
- return false;
2796
- }
2797
-
2798
- let min = null;
2799
- let max = null;
2800
- modelContainer.assetContainer.meshes.forEach((mesh) => {
2801
- if (!mesh?.getTotalVertices?.() || mesh.getTotalVertices() <= 0) {
2802
- return;
2803
- }
2804
- mesh.computeWorldMatrix?.(true);
2805
- const boundingBox = mesh.getBoundingInfo?.()?.boundingBox;
2806
- if (!boundingBox) {
2807
- return;
2808
- }
2809
- const minimum = boundingBox.minimumWorld;
2810
- const maximum = boundingBox.maximumWorld;
2811
- if (!minimum || !maximum) {
2812
- return;
2813
- }
2814
- min = min
2815
- ? new Vector3(Math.min(min.x, minimum.x), Math.min(min.y, minimum.y), Math.min(min.z, minimum.z))
2816
- : minimum.clone();
2817
- max = max
2818
- ? new Vector3(Math.max(max.x, maximum.x), Math.max(max.y, maximum.y), Math.max(max.z, maximum.z))
2819
- : maximum.clone();
2820
- });
2821
-
2822
- if (!min || !max) {
2823
- const skipped = {
2824
- hasCamera: camera instanceof ArcRotateCamera,
2825
- hasModel: !!modelContainer.assetContainer,
2826
- modelVisible: modelContainer.state.isVisible,
2827
- environmentVisible: this.#containers.environment.state.isVisible,
2828
- };
2829
- this.#debugCameraFrameInfo = {
2830
- phase: "skipped",
2831
- reason: "no-bounds",
2832
- ...inspect,
2833
- ...skipped,
2834
- };
2835
- console.log("[viewer] frameCameraToModel skipped (no bounds)", skipped);
2836
- return false;
2837
- }
2838
-
2839
- const size = max.subtract(min);
2840
- const largestDimension = Math.max(size.x, size.y, size.z);
2841
- if (!Number.isFinite(largestDimension) || largestDimension <= 0) {
2842
- return false;
2843
- }
2844
-
2845
- const center = min.add(max).scale(0.5);
2846
- camera.setTarget(center);
2847
- const radiusMultiplier = allowWhenEnvironmentVisible ? 1.7 : 1.4;
2848
- camera.radius = Math.min(
2849
- Math.max(largestDimension * radiusMultiplier, camera.lowerRadiusLimit ?? largestDimension * radiusMultiplier),
2850
- camera.upperRadiusLimit ?? largestDimension * radiusMultiplier,
2851
- );
2852
- // Keep the initial model-only view aligned to the showroom's frontal composition.
2853
- // This preserves the user's camera intent once the scene becomes visible, but gives
2854
- // the standalone model a consistent front-facing presentation.
2855
- // Rotate the front-facing composition 180° while keeping the same framing/radius.
2856
- camera.alpha = Math.PI;
2857
- camera.beta = Math.PI * 0.47;
2858
- console.log("[viewer] frameCameraToModel applied", {
2859
- center,
2860
- size,
2861
- radius: camera.radius,
2862
- });
2863
- this.#debugCameraFrameInfo = {
2864
- phase: "applied",
2865
- ...inspect,
2866
- center: { x: center.x, y: center.y, z: center.z },
2867
- size: { x: size.x, y: size.y, z: size.z },
2868
- radius: camera.radius,
2869
- cameraTarget: camera.target ? { x: camera.target.x, y: camera.target.y, z: camera.target.z } : null,
2870
- cameraPosition: camera.position ? { x: camera.position.x, y: camera.position.y, z: camera.position.z } : null,
2871
- cameraLocked: !!camera.metadata?.locked,
2872
- };
2873
- return true;
2874
- }
2875
-
2876
- /**
2877
- * Public wrapper to force the active camera to frame the visible model.
2878
- * @public
2879
- * @returns {boolean}
2880
- */
2881
- focusModel() {
2882
- return this.#frameCameraToModel();
2883
- }
2884
-
2885
- /**
2886
- * Returns a compact snapshot of the current debug-relevant 3D state.
2887
- * @public
2888
- * @returns {object}
2889
- */
2890
- getDebugState() {
2891
- const activeCamera = this.#scene?.activeCamera ?? this.#camera ?? null;
2892
- const modelMeshes = this.#containers.model?.assetContainer?.meshes ?? [];
2893
- let boundsMin = null;
2894
- let boundsMax = null;
2895
- for (const mesh of modelMeshes) {
2896
- if (!mesh?.getTotalVertices?.() || mesh.getTotalVertices() <= 0) continue;
2897
- mesh.computeWorldMatrix?.(true);
2898
- const boundingBox = mesh.getBoundingInfo?.()?.boundingBox;
2899
- if (!boundingBox?.minimumWorld || !boundingBox?.maximumWorld) continue;
2900
- const minimum = boundingBox.minimumWorld;
2901
- const maximum = boundingBox.maximumWorld;
2902
- boundsMin = boundsMin
2903
- ? { x: Math.min(boundsMin.x, minimum.x), y: Math.min(boundsMin.y, minimum.y), z: Math.min(boundsMin.z, minimum.z) }
2904
- : { x: minimum.x, y: minimum.y, z: minimum.z };
2905
- boundsMax = boundsMax
2906
- ? { x: Math.max(boundsMax.x, maximum.x), y: Math.max(boundsMax.y, maximum.y), z: Math.max(boundsMax.z, maximum.z) }
2907
- : { x: maximum.x, y: maximum.y, z: maximum.z };
2908
- }
2909
- const modelBounds = boundsMin && boundsMax ? {
2910
- min: boundsMin,
2911
- max: boundsMax,
2912
- center: {
2913
- x: (boundsMin.x + boundsMax.x) / 2,
2914
- y: (boundsMin.y + boundsMax.y) / 2,
2915
- z: (boundsMin.z + boundsMax.z) / 2,
2916
- },
2917
- size: {
2918
- x: boundsMax.x - boundsMin.x,
2919
- y: boundsMax.y - boundsMin.y,
2920
- z: boundsMax.z - boundsMin.z,
2921
- },
2922
- } : null;
2923
- return {
2924
- camera: activeCamera ? {
2925
- className: activeCamera.getClassName?.() ?? activeCamera.constructor?.name ?? null,
2926
- position: activeCamera.position ? { x: activeCamera.position.x, y: activeCamera.position.y, z: activeCamera.position.z } : null,
2927
- target: activeCamera.target ? { x: activeCamera.target.x, y: activeCamera.target.y, z: activeCamera.target.z } : null,
2928
- radius: typeof activeCamera.radius === "number" ? activeCamera.radius : null,
2929
- locked: !!activeCamera.metadata?.locked,
2930
- } : null,
2931
- model: {
2932
- visible: !!this.#containers.model?.state?.isVisible,
2933
- hasContainer: !!this.#containers.model?.assetContainer,
2934
- meshCount: this.#containers.model?.assetContainer?.meshes?.length ?? 0,
2935
- bounds: modelBounds,
2936
- },
2937
- environment: {
2938
- visible: !!this.#containers.environment?.state?.isVisible,
2939
- hasContainer: !!this.#containers.environment?.assetContainer,
2940
- },
2941
- lastCameraFrame: this.#debugCameraFrameInfo,
2942
- renderLoopRunning: !!this.#state?.render?.isLoopRunning,
2943
- };
2554
+ this.#containers.model.assetContainer.meshes.filter((meshToFilter) => nodePrefixes.some((prefix) => meshToFilter.name.toLowerCase().startsWith(prefix.toLowerCase())) || nodeNames.some((n) => n.toLowerCase() === meshToFilter.name.toLowerCase())).forEach((mesh) => mesh.setEnabled(show));
2944
2555
  }
2945
2556
 
2946
2557
  /**
@@ -2960,25 +2571,12 @@ export default class BabylonJSController {
2960
2571
  * @private
2961
2572
  * @returns {Promise<void>}
2962
2573
  */
2963
- async #startRender({ skipWhenReady = false, forceRender = false, skipCameraDependentEffects = false } = {}) {
2574
+ async #startRender() {
2964
2575
  if (!this.#scene) return;
2965
- this.#startRenderInProgress = true;
2966
- try {
2967
- if (!skipCameraDependentEffects) {
2968
- await this.#loadCameraDependentEffects();
2969
- }
2970
- // skipWhenReady: model materials already compiled via compileMaterials:true.
2971
- // Skips scene-wide whenReadyAsync so the model appears immediately (~1s).
2972
- if (!skipWhenReady) {
2973
- await this.#scene.whenReadyAsync();
2974
- }
2975
- const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2976
- // forceRender: bypasses #loadingInProgress gate so loadContainerIndependent
2977
- // can show the model preview while #loadContainers is still loading scene/materials.
2978
- this.#requestRender({ frames: frames, continuousMs: this.#config.render.interactionMs, force: forceRender });
2979
- } finally {
2980
- this.#startRenderInProgress = false;
2981
- }
2576
+ await this.#loadCameraDependentEffects();
2577
+ await this.#scene.whenReadyAsync();
2578
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2579
+ this.#requestRender({ frames: frames, continuousMs: this.#config.render.interactionMs });
2982
2580
  }
2983
2581
 
2984
2582
  /**
@@ -2997,7 +2595,7 @@ export default class BabylonJSController {
2997
2595
  * 3. Builds the Babylon plugin options so extras are surfaced as metadata, then imports the container with
2998
2596
  * `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
2999
2597
  */
3000
- async #loadAssetContainer(container, force = false, { isIndependent = false } = {}) {
2598
+ async #loadAssetContainer(container, force = false) {
3001
2599
  if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
3002
2600
  return [container, false];
3003
2601
  }
@@ -3013,30 +2611,12 @@ export default class BabylonJSController {
3013
2611
  const currentSize = force ? 0 : container.state.size;
3014
2612
  const currentTimeStamp = force ? null : container.state.timeStamp;
3015
2613
 
3016
- const storageType = container.state.update.storage?.db ? "idb" : container.state.update.storage?.url ? "url" : "?";
3017
- const storageId = container.state.update.storage?.id ?? container.state.update.storage?.url ?? "?";
3018
- const tResolve = performance.now();
3019
2614
  let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, currentSize, currentTimeStamp);
3020
- console.log(`[perf][${container.state.name}] getSource(${storageType}:${storageId}) ${Math.round(performance.now() - tResolve)}ms → ${sourceData ? `${sourceData.extension} ${sourceData.objectURLs?.length ?? 0} assets` : "skipped"}`);
3021
2615
 
3022
2616
  if (!sourceData) {
3023
2617
  return [container, false];
3024
2618
  }
3025
2619
 
3026
- // Abort if loadContainerIndependent claimed this container while getSource was in flight.
3027
- // isIndependent=true means THIS call IS the independent load — don't abort yourself.
3028
- // Returns null (not false) so #loadContainers skips setSuccess(false) for this container.
3029
- const isClaimedIndependently =
3030
- (container.state.name === "model" && this.#isLoadingModelIndependent) ||
3031
- (container.state.name === "environment" && this.#isLoadingEnvironmentIndependent) ||
3032
- (container.state.name === "materials" && this.#isLoadingMaterialsIndependent);
3033
- if (isClaimedIndependently && !isIndependent) {
3034
- if (sourceData.objectURLs?.length) {
3035
- this.#gltfResolver.revokeObjectURLs(sourceData.objectURLs);
3036
- }
3037
- return [container, null];
3038
- }
3039
-
3040
2620
  container.state.setPendingCacheData(sourceData.size, sourceData.timeStamp, sourceData.metadata);
3041
2621
 
3042
2622
  // https://doc.babylonjs.com/typedoc/interfaces/BABYLON.LoadAssetContainerOptions
@@ -3055,14 +2635,12 @@ export default class BabylonJSController {
3055
2635
  };
3056
2636
 
3057
2637
  let assetContainer = null;
3058
- const t0 = performance.now();
3059
2638
 
3060
2639
  try {
3061
2640
  assetContainer = await LoadAssetContainerAsync(sourceData.source, this.#scene, options);
3062
- const elapsed = Math.round(performance.now() - t0);
3063
- console.log(`[perf][${container.state.name}] LoadAssetContainerAsync ${elapsed}ms`);
3064
2641
  return [container, assetContainer];
3065
2642
  } catch (error) {
2643
+ console.error(`[PrefViewer] LoadAssetContainerAsync failed for [${container.state.name}]`, error);
3066
2644
  return [container, assetContainer];
3067
2645
  } finally {
3068
2646
  this.#gltfResolver.revokeObjectURLs(sourceData.objectURLs);
@@ -3080,29 +2658,7 @@ export default class BabylonJSController {
3080
2658
  */
3081
2659
  async #loadContainers(force = false) {
3082
2660
  this.#detachAnimationChangedListener();
3083
- this.#loadingInProgress = true;
3084
-
3085
- // When only the model is pending (scene/environment already rendered), skip
3086
- // unloading camera-dependent effects (DefaultRenderingPipeline, SSAO, shadows).
3087
- // Disposing those pipelines while the render loop was recently active deletes GL
3088
- // programs that are still referenced, causing WebGL INVALID_OPERATION errors.
3089
- const isModelOnlyLoad =
3090
- this.#containers.model?.state?.isPending === true &&
3091
- this.#containers.environment?.state?.isPending !== true &&
3092
- this.#containers.materials?.state?.isPending !== true;
3093
-
3094
- if (isModelOnlyLoad) {
3095
- this.#stopEngineRenderLoop();
3096
- this.#resetRenderState();
3097
- // Replacing an already-visible model can leave Babylon post-process chains attached to
3098
- // a camera that is about to be discarded or re-bound. Tear them down here so the next
3099
- // startRender() rebuilds a clean pipeline instead of tripping over stale null entries.
3100
- if (this.#containers.model?.assetContainer) {
3101
- await this.#unloadCameraDependentEffects();
3102
- }
3103
- } else {
3104
- await this.#stopRender();
3105
- }
2661
+ await this.#stopRender();
3106
2662
 
3107
2663
  let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
3108
2664
  let newModelMetadata = {};
@@ -3117,12 +2673,8 @@ export default class BabylonJSController {
3117
2673
  error: null,
3118
2674
  };
3119
2675
 
3120
- const tSceneStart = performance.now();
3121
- let tAfterLoad, tAfterMerge;
3122
-
3123
2676
  await Promise.allSettled(promiseArray)
3124
2677
  .then(async (values) => {
3125
- tAfterLoad = performance.now();
3126
2678
  // Scene may have been disposed (disconnectedCallback) while async loading was in
3127
2679
  // progress. Abort cleanly: #replaceContainer already guards the GPU calls, but
3128
2680
  // we skip the post-load option/visibility calls too to avoid further null-derefs.
@@ -3131,12 +2683,6 @@ export default class BabylonJSController {
3131
2683
  return;
3132
2684
  }
3133
2685
  this.#disposeAnimationController();
3134
- this.#emitSceneLoadPhase("scene-load-assets-complete", {
3135
- path: "full",
3136
- containerCount: values.filter((result) => result.status === "fulfilled" && result.value?.[1]).length,
3137
- tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
3138
- tSceneStart: Math.round(tSceneStart),
3139
- });
3140
2686
  values.forEach((result) => {
3141
2687
  const container = result.value ? result.value[0] : null;
3142
2688
  const assetContainer = result.value ? result.value[1] : null;
@@ -3152,29 +2698,17 @@ export default class BabylonJSController {
3152
2698
  this.#replaceContainer(container, assetContainer);
3153
2699
  container.state.setSuccess(true);
3154
2700
  } else {
3155
- // null sentinel = claimed by loadContainerIndependent — already merged independently.
3156
- // false = normal skip (no data change) or error. Only mark failure for the latter.
3157
- if (assetContainer !== null) {
3158
- if (container.assetContainer && container.state.mustBeShown !== container.state.isVisible && container.state.name === "materials") {
3159
- container.state.mustBeShown ? this.#addContainer(container) : this.#removeContainer(container);
3160
- }
3161
- container.state.setSuccess(false);
2701
+ if (container.assetContainer && container.state.mustBeShown !== container.state.isVisible && container.state.name === "materials") {
2702
+ container.state.mustBeShown ? this.#addContainer(container) : this.#removeContainer(container);
3162
2703
  }
2704
+ container.state.setSuccess(false);
3163
2705
  }
3164
2706
  });
3165
2707
 
3166
- this.#setOptions_Materials(true);
2708
+ this.#setOptions_Materials();
3167
2709
  this.#setOptions_Camera();
3168
2710
  await this.#setOptions_IBL();
3169
2711
  this.#setVisibilityOfWallAndFloorInModel();
3170
- this.#frameCameraToModel();
3171
- tAfterMerge = performance.now();
3172
- this.#emitSceneLoadPhase("scene-load-merge-complete", {
3173
- path: "full",
3174
- tAfterMerge: tAfterMerge != null ? Math.round(tAfterMerge) : null,
3175
- tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
3176
- tSceneStart: Math.round(tSceneStart),
3177
- });
3178
2712
  detail.success = true;
3179
2713
  })
3180
2714
  .catch((error) => {
@@ -3187,9 +2721,7 @@ export default class BabylonJSController {
3187
2721
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
3188
2722
  this.#setMaxSimultaneousLights();
3189
2723
  this.#enhanceTextureQuality();
3190
- if (this.#containers.model.assetContainer) {
3191
- this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
3192
- }
2724
+ this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
3193
2725
  // Apply stored highlight settings so fresh page loads respect persisted state
3194
2726
  this.#setHighlightConfig({
3195
2727
  enabled: this.#settings.highlightEnabled,
@@ -3198,24 +2730,7 @@ export default class BabylonJSController {
3198
2730
  if (this.#babylonJSAnimationController?.hasAnimations?.()) {
3199
2731
  this.#attachAnimationChangedListener();
3200
2732
  }
3201
- this.#loadingInProgress = false;
3202
- // If loadContainerIndependent already merged a container and started the render
3203
- // loop while this scene load was in flight, #startRender was already called.
3204
- // Calling it again is safe (pipeline guards skip recreation) but redundant only
3205
- // when the loop is already running AND scene/shaders are ready; whenReadyAsync
3206
- // will settle quickly for the newly added containers.
3207
2733
  await this.#startRender();
3208
- this.#frameCameraToModel({ allowWhenEnvironmentVisible: true });
3209
- this.#emitSceneLoadPhase("scene-load-shaders-complete", {
3210
- path: "full",
3211
- tAfterShaders: Math.round(performance.now()),
3212
- tAfterMerge: tAfterMerge != null ? Math.round(tAfterMerge) : null,
3213
- });
3214
- const tLoad = tAfterLoad ? Math.round(tAfterLoad - tSceneStart) : null;
3215
- const tMerge = tAfterLoad && tAfterMerge ? Math.round(tAfterMerge - tAfterLoad) : null;
3216
- const tShaders = tAfterMerge ? Math.round(performance.now() - tAfterMerge) : null;
3217
- const tTotal = Math.round(performance.now() - tSceneStart);
3218
- console.log(`[perf][scene] load=${tLoad}ms merge=${tMerge}ms shaders=${tShaders}ms total=${tTotal}ms`);
3219
2734
  });
3220
2735
  return detail;
3221
2736
  }
@@ -3331,7 +2846,7 @@ export default class BabylonJSController {
3331
2846
  if (!this.#scene) {
3332
2847
  return false;
3333
2848
  }
3334
- const node = this.#scene.getNodeByName(name);
2849
+ const node = this.#scene.getNodeByName(name) ?? this.#scene.getNodes().find((n) => n.name.toLowerCase() === name.toLowerCase());
3335
2850
  if (!node || !node.position) {
3336
2851
  return false;
3337
2852
  }
@@ -3521,7 +3036,6 @@ export default class BabylonJSController {
3521
3036
  this.#disablingPromises.general = (async () => {
3522
3037
  this.#disableInteraction();
3523
3038
  this.#disposeAnimationController();
3524
- this.#finalizePendingModelReplacement();
3525
3039
  this.#disposeGLTFResolver();
3526
3040
  try {
3527
3041
  await this.#disposeXRExperience();
@@ -3667,297 +3181,6 @@ export default class BabylonJSController {
3667
3181
  return await this.#loadContainers(force);
3668
3182
  }
3669
3183
 
3670
- /**
3671
- * Loads a single named container in the background while the render loop keeps running,
3672
- * then merges it into the scene atomically (only the merge momentarily pauses the loop).
3673
- * Camera-dependent effects (post-processing, SSAO, shadows) are preserved across the merge.
3674
- * If a full #loadContainers is in progress, waits for it to finish before merging.
3675
- * @public
3676
- * @param {string} containerName - Name of the container to load ("model", "environment", "materials").
3677
- * @param {Function} [onReady] - Optional callback invoked after any concurrent scene load finishes
3678
- * and before the asset is fetched. Use this to mark the container pending (e.g. via
3679
- * #checkNeedToUpdateContainers) so that the concurrent #loadContainers doesn't consume it first.
3680
- * @returns {Promise<{success: boolean, error: any, loadedContainers: string[]}>}
3681
- */
3682
- async loadContainerIndependent(containerName, onReady, options = {}) {
3683
- const loadSessionId = containerName === "model" ? (options?.loadSessionId ?? null) : null;
3684
- if (containerName === "model" && this.#isLoadingModelIndependent) {
3685
- return { success: false, error: "Already loading model independently", loadedContainers: [], loadSessionId };
3686
- }
3687
- if (containerName === "environment" && this.#isLoadingEnvironmentIndependent) {
3688
- return { success: false, error: "Already loading environment independently", loadedContainers: [], loadSessionId };
3689
- }
3690
- if (containerName === "materials" && this.#isLoadingMaterialsIndependent) {
3691
- return { success: false, error: "Already loading materials independently", loadedContainers: [], loadSessionId };
3692
- }
3693
-
3694
- if (containerName === "model") this.#isLoadingModelIndependent = true;
3695
- else if (containerName === "environment") this.#isLoadingEnvironmentIndependent = true;
3696
- else if (containerName === "materials") this.#isLoadingMaterialsIndependent = true;
3697
-
3698
- // Only reset model-first-painted tracking for model loads — resetting it for
3699
- // environment/materials would stale any in-flight model-first-painted gate.
3700
- if (containerName === "model") {
3701
- this.#modelFirstPaintedEmitted = false;
3702
- }
3703
- const modelFirstPaintedLoadToken = containerName === "model"
3704
- ? ++this.#modelFirstPaintedLoadToken
3705
- : this.#modelFirstPaintedLoadToken;
3706
- let assetContainer = null;
3707
- const tIndepStart = performance.now();
3708
- // Whether the container was already pending inside a running #loadContainers batch.
3709
- // If so, we fall back to the sequential path (wait → onReady → load) to avoid a
3710
- // double-load conflict. Otherwise, load the GLTF in parallel with the scene load
3711
- // and only wait for the atomic merge window.
3712
- const isAlreadyHandled =
3713
- this.#containers[containerName]?.state?.isPending === true && this.#loadingInProgress;
3714
- const path = isAlreadyHandled ? "claimed" : "par";
3715
- let container;
3716
- let tAfterLoad, tAfterWait;
3717
- try {
3718
- if (!isAlreadyHandled) {
3719
- // Optimistic path: prepare state and load GLTF NOW, in parallel with any running
3720
- // #loadContainers. The merge waits for the scene, but the download does not.
3721
- if (!this.#scene) {
3722
- return { success: false, error: "No scene available", loadedContainers: [], loadSessionId };
3723
- }
3724
- onReady?.();
3725
- container = this.#containers[containerName];
3726
- if (!container) {
3727
- return { success: false, error: `Container "${containerName}" not found`, loadedContainers: [], loadSessionId };
3728
- }
3729
- // Phase 1 (parallel): load GLTF while scene may still be loading.
3730
- // No wait — merge happens immediately after load. #replaceContainer skips
3731
- // releaseEffects when the loop is running, so no WebGL programs are deleted.
3732
- const [, loaded] = await this.#loadAssetContainer(container, false, { isIndependent: true });
3733
- assetContainer = loaded || null;
3734
- tAfterLoad = performance.now();
3735
- tAfterWait = tAfterLoad; // no wait phase in optimistic path
3736
- } else {
3737
- // Parallel claim path: container was already submitted to a running #loadContainers
3738
- // batch. #isLoadingModelIndependent (set at the top of this method) signals
3739
- // #loadAssetContainer to abort the in-flight batch load and return a null sentinel,
3740
- // so #loadContainers skips merging this container. We load it here in parallel —
3741
- // no waiting for scene/materials to finish.
3742
- if (!this.#scene) {
3743
- return { success: false, error: "No scene available", loadedContainers: [], loadSessionId };
3744
- }
3745
- // Call onReady() to ensure the container state carries this load's payload,
3746
- // which may differ from what #loadContainers was given if the caller swapped the model.
3747
- onReady?.();
3748
- container = this.#containers[containerName];
3749
- if (!container) {
3750
- return { success: false, error: `Container "${containerName}" not found`, loadedContainers: [], loadSessionId };
3751
- }
3752
- const [, loaded] = await this.#loadAssetContainer(container, false, { isIndependent: true });
3753
- assetContainer = loaded || null;
3754
- tAfterLoad = performance.now();
3755
- tAfterWait = tIndepStart; // no wait phase — measure load from start
3756
- }
3757
-
3758
- if (!assetContainer) {
3759
- return { success: false, error: "Failed to load asset container", loadedContainers: [], loadSessionId };
3760
- }
3761
- if (!this.#scene) {
3762
- assetContainer.dispose();
3763
- return { success: false, error: "Scene was disposed", loadedContainers: [], loadSessionId };
3764
- }
3765
-
3766
- this.#emitSceneLoadPhase("scene-load-assets-complete", {
3767
- containerName,
3768
- path,
3769
- tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
3770
- tIndepStart: Math.round(tIndepStart),
3771
- });
3772
-
3773
- // Phase 3: atomic merge — briefly stop the loop for the merge only
3774
- this.#stopEngineRenderLoop();
3775
-
3776
- const oldModelMetadata = containerName === "model"
3777
- ? { ...(this.#containers.model?.state?.metadata ?? {}) }
3778
- : {};
3779
-
3780
- this.#disposeAnimationController();
3781
-
3782
- if (containerName === "model") {
3783
- this.#assetContainer_deleteLights(assetContainer);
3784
- this.#assetContainer_stopAnimations(assetContainer);
3785
- }
3786
- if (containerName === "model" || containerName === "environment") {
3787
- this.#assetContainer_retagCameras(assetContainer);
3788
- }
3789
-
3790
- this.#replaceContainer(container, assetContainer, { releaseEffects: false });
3791
- container.state.setSuccess(true);
3792
-
3793
- // Progressive independent loads may bring model/materials/environment in any order.
3794
- // Force material rebinding after each merge so already-visible model meshes pick up
3795
- // the latest material definitions instead of waiting for pending flags that may
3796
- // already have been cleared.
3797
- this.#setOptions_Materials(true);
3798
- this.#setOptions_Camera();
3799
- // For model: IBL not yet loaded (scene still in flight) — create only ambient/directional
3800
- // lights. #loadContainers will call #createLights() again with full IBL once scene merges.
3801
- // For environment: IBL state was pre-marked pending by loadSceneIndependent before calling
3802
- // here, so #setOptions_IBL() picks it up and applies it immediately after lights are ready.
3803
- await this.#createLights();
3804
- if (containerName === "environment") {
3805
- await this.#setOptions_IBL();
3806
- }
3807
-
3808
- if (containerName === "model") {
3809
- const newModelMetadata = { ...(container.state.update?.metadata ?? {}) };
3810
- this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
3811
- }
3812
-
3813
- if (this.#containers.model.assetContainer) {
3814
- this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
3815
- }
3816
- const tAfterMerge = performance.now();
3817
- this.#emitSceneLoadPhase("scene-load-merge-complete", {
3818
- containerName,
3819
- path,
3820
- tAfterMerge: Math.round(tAfterMerge),
3821
- tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
3822
- tIndepStart: Math.round(tIndepStart),
3823
- });
3824
-
3825
- // Use #startRender() so the next frame is scheduled after the atomic merge.
3826
- // In progressive loads, rebuilding camera-dependent effects (DefaultRenderingPipeline,
3827
- // SSAO, shadows) while the model is already visible can delete GL programs that are
3828
- // still referenced by the current frame, which leaves the canvas gray until a later
3829
- // full reload. Keep the existing pipeline alive for independent model/environment/
3830
- // materials appends; the full load path still rebuilds everything when needed.
3831
- await this.#startRender({ skipWhenReady: true, forceRender: true, skipCameraDependentEffects: true });
3832
- const tAfterShaders = performance.now();
3833
-
3834
- this.#setHighlightConfig({
3835
- enabled: this.#settings.highlightEnabled,
3836
- color: this.#settings.highlightColor,
3837
- });
3838
- this.#setMaxSimultaneousLights();
3839
- this.#enhanceTextureQuality();
3840
- this.#setVisibilityOfWallAndFloorInModel();
3841
-
3842
- if (this.#babylonJSAnimationController?.hasAnimations?.()) {
3843
- this.#attachAnimationChangedListener();
3844
- }
3845
-
3846
- this.#requestRender({ frames: 3, continuousMs: this.#config.render.interactionMs });
3847
- if (containerName === "model" && !this.#modelFirstPaintedEmitted) {
3848
- try {
3849
- performance.mark(`viewer:model-first-painted:scheduled:${modelFirstPaintedLoadToken}`);
3850
- } catch {
3851
- // ignore
3852
- }
3853
- console.log("[viewer] model-first-painted gate scheduled", {
3854
- containerName,
3855
- path: isAlreadyHandled ? "claimed" : "par",
3856
- loadToken: modelFirstPaintedLoadToken,
3857
- renderLoopRunning: this.#state.render.isLoopRunning,
3858
- loopRequested: true,
3859
- });
3860
- void (async () => {
3861
- await this.#waitForPaint(2);
3862
- try {
3863
- performance.mark(`viewer:model-first-painted:after-wait:${modelFirstPaintedLoadToken}`);
3864
- } catch {
3865
- // ignore
3866
- }
3867
- console.log("[viewer] model-first-painted gate after paint wait", {
3868
- containerName,
3869
- path: isAlreadyHandled ? "claimed" : "par",
3870
- loadToken: modelFirstPaintedLoadToken,
3871
- currentToken: this.#modelFirstPaintedLoadToken,
3872
- alreadyEmitted: this.#modelFirstPaintedEmitted,
3873
- renderLoopRunning: this.#state.render.isLoopRunning,
3874
- });
3875
- if (modelFirstPaintedLoadToken !== this.#modelFirstPaintedLoadToken) {
3876
- try {
3877
- performance.mark(`viewer:model-first-painted:stale:${modelFirstPaintedLoadToken}`);
3878
- } catch {
3879
- // ignore
3880
- }
3881
- console.log("[viewer] model-first-painted gate skipped (stale load token)", {
3882
- containerName,
3883
- path: isAlreadyHandled ? "claimed" : "par",
3884
- loadToken: modelFirstPaintedLoadToken,
3885
- currentToken: this.#modelFirstPaintedLoadToken,
3886
- });
3887
- return;
3888
- }
3889
- const paintTarget = this.#prefViewer || this.#prefViewer3D || this.#canvas;
3890
- if (!this.#modelFirstPaintedEmitted && paintTarget) {
3891
- this.#modelFirstPaintedEmitted = true;
3892
- try {
3893
- performance.mark(`viewer:model-first-painted:emitted:${modelFirstPaintedLoadToken}`);
3894
- } catch {
3895
- // ignore
3896
- }
3897
- console.log("[viewer] model-first-painted ✅ emitted", {
3898
- containerName,
3899
- path: isAlreadyHandled ? "claimed" : "par",
3900
- loadToken: modelFirstPaintedLoadToken,
3901
- tAfterPaint: Math.round(performance.now()),
3902
- });
3903
- this.#emitModelFirstPainted({
3904
- containerName,
3905
- path: isAlreadyHandled ? "claimed" : "par",
3906
- tIndepStart: Math.round(tIndepStart),
3907
- tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
3908
- tAfterWait: tAfterWait != null ? Math.round(tAfterWait) : null,
3909
- tAfterMerge: Math.round(tAfterMerge),
3910
- tAfterPaint: Math.round(performance.now()),
3911
- });
3912
- this.#finalizePendingModelReplacement();
3913
- }
3914
- })();
3915
- }
3916
-
3917
- // In the optimistic path: load runs first, then wait. In the sequential path: wait
3918
- // runs first, then load. Compute each phase relative to its actual start point.
3919
- const tLoad = isAlreadyHandled
3920
- ? Math.round(tAfterLoad - tAfterWait)
3921
- : Math.round(tAfterLoad - tIndepStart);
3922
- const tWait = isAlreadyHandled
3923
- ? Math.round(tAfterWait - tIndepStart)
3924
- : Math.round(tAfterWait - tAfterLoad);
3925
- // Merge starts after the wait in both paths (optimistic: load→wait→merge; sequential: wait→load→merge)
3926
- const tMerge = Math.round(tAfterMerge - (isAlreadyHandled ? tAfterLoad : tAfterWait));
3927
- const tShaders = Math.round(tAfterShaders - tAfterMerge);
3928
- const tTotal = Math.round(tAfterShaders - tIndepStart);
3929
- this.#emitSceneLoadPhase("scene-load-shaders-complete", {
3930
- containerName,
3931
- path,
3932
- tAfterShaders: Math.round(tAfterShaders),
3933
- tAfterMerge: Math.round(tAfterMerge),
3934
- tTotal,
3935
- });
3936
- this.#emitSceneLoadPhase("scene-load-container-complete", {
3937
- containerName,
3938
- path,
3939
- tLoad,
3940
- tWait,
3941
- tMerge,
3942
- tShaders,
3943
- tTotal,
3944
- });
3945
- console.log(`[perf][${containerName}:indep:${path}] load=${tLoad}ms wait=${tWait}ms merge=${tMerge}ms shaders=${tShaders}ms total=${tTotal}ms`);
3946
-
3947
- return { success: true, error: null, loadedContainers: [containerName], loadSessionId };
3948
- } catch (error) {
3949
- assetContainer?.dispose();
3950
- if (!this.#state.render.isLoopRunning && this.#scene && this.#engine) {
3951
- this.#startEngineRenderLoop();
3952
- }
3953
- return { success: false, error, loadedContainers: [], loadSessionId };
3954
- } finally {
3955
- if (containerName === "model") this.#isLoadingModelIndependent = false;
3956
- else if (containerName === "environment") this.#isLoadingEnvironmentIndependent = false;
3957
- else if (containerName === "materials") this.#isLoadingMaterialsIndependent = false;
3958
- }
3959
- }
3960
-
3961
3184
  /**
3962
3185
  * Applies camera options from the configuration to the active camera.
3963
3186
  * Stops and restarts the render loop to apply changes.
@@ -4161,5 +3384,4 @@ export default class BabylonJSController {
4161
3384
  this.#requestRender({ frames: 2 });
4162
3385
  }
4163
3386
  }
4164
- }
4165
-
3387
+ }