@preference-sl/pref-viewer 2.16.0-beta.0 → 2.16.0-beta.2

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.
@@ -165,6 +165,9 @@ 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;
168
171
 
169
172
  // Babylon.js core objects
170
173
  #engine = null;
@@ -174,6 +177,7 @@ export default class BabylonJSController {
174
177
  #dirLight = null;
175
178
  #moonLight = null;
176
179
  #cameraLight = null;
180
+ #debugCameraFrameInfo = null;
177
181
  #shadowGen = [];
178
182
  #XRExperience = null;
179
183
  #canvasResizeObserver = null;
@@ -185,6 +189,11 @@ export default class BabylonJSController {
185
189
 
186
190
  #gltfResolver = null; // GLTFResolver instance
187
191
  #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)
188
197
 
189
198
  #renderPipelines = {
190
199
  default: null,
@@ -748,8 +757,8 @@ export default class BabylonJSController {
748
757
  * @param {number} [options.continuousMs=0] - Milliseconds to keep continuous rendering active.
749
758
  * @returns {boolean} True when the request was accepted, false when scene/engine are unavailable.
750
759
  */
751
- #requestRender({ frames = 1, continuousMs = 0 } = {}) {
752
- if (!this.#scene || !this.#engine) {
760
+ #requestRender({ frames = 1, continuousMs = 0, force = false } = {}) {
761
+ if (!this.#scene || !this.#engine || (this.#loadingInProgress && !force)) {
753
762
  return false;
754
763
  }
755
764
 
@@ -1112,6 +1121,11 @@ export default class BabylonJSController {
1112
1121
 
1113
1122
  const pipelineName = "PrefViewerSSAORenderingPipeline";
1114
1123
 
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
+
1115
1129
  const ssaoRatio = {
1116
1130
  ssaoRatio: 0.5,
1117
1131
  combineRatio: 1.0,
@@ -1213,6 +1227,21 @@ export default class BabylonJSController {
1213
1227
 
1214
1228
  const pipelineName = "PrefViewerDefaultRenderingPipeline";
1215
1229
 
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
+
1216
1245
  let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
1217
1246
 
1218
1247
  if (!defaultPipeline) {
@@ -1390,6 +1419,11 @@ export default class BabylonJSController {
1390
1419
 
1391
1420
  const pipelineName = "PrefViewerIblShadowsRenderPipeline";
1392
1421
 
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
+
1393
1427
  // Use hardware-adaptive quality settings
1394
1428
  const pipelineOptions = {
1395
1429
  resolutionExp: this.#config.quality.iblShadowResolution, // Adaptive: 0=1024, 1=2048, 2=4096
@@ -1641,7 +1675,7 @@ export default class BabylonJSController {
1641
1675
  const showroom = this.#config.look?.showroom || {};
1642
1676
  const imageProcessing = this.#scene.imageProcessingConfiguration;
1643
1677
 
1644
- this.#scene.clearColor = new Color4(0.98, 0.98, 0.98, 1).toLinearSpace();
1678
+ this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
1645
1679
  imageProcessing.exposure = showroom.exposure ?? 0.95;
1646
1680
  imageProcessing.contrast = showroom.contrast ?? 1.08;
1647
1681
  imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
@@ -1681,12 +1715,7 @@ export default class BabylonJSController {
1681
1715
  imageProcessing.colorCurvesEnabled = false;
1682
1716
  }
1683
1717
 
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();
1718
+ this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
1690
1719
 
1691
1720
  if ("environmentIntensity" in this.#scene) {
1692
1721
  this.#scene.environmentIntensity = lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
@@ -2175,6 +2204,10 @@ export default class BabylonJSController {
2175
2204
  }
2176
2205
  this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2177
2206
  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();
2178
2211
  this.#applyShowroomCameraFraming();
2179
2212
  this.#engine.resize();
2180
2213
  const needsEnhancedBurst = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled || this.#settings.shadowsEnabled;
@@ -2217,7 +2250,7 @@ export default class BabylonJSController {
2217
2250
  * @param {MaterialData} optionMaterial - Material option containing value, nodePrefixes, nodeNames, and state.
2218
2251
  * @returns {boolean} True if any mesh material was set, false otherwise.
2219
2252
  */
2220
- #setOptionsMaterial(optionMaterial) {
2253
+ #setOptionsMaterial(optionMaterial, force = false) {
2221
2254
  if (!optionMaterial || !(optionMaterial.value || (optionMaterial.isPending && optionMaterial.update.value)) || !(optionMaterial.nodePrefixes.length || optionMaterial.nodeNames.length)) {
2222
2255
  return false;
2223
2256
  }
@@ -2235,7 +2268,7 @@ export default class BabylonJSController {
2235
2268
  if (container.state.name === "materials") {
2236
2269
  return;
2237
2270
  }
2238
- if (container.assetContainer && (container.state.isPending || materialContainer.state.isPending || optionMaterial.isPending)) {
2271
+ if (container.assetContainer && (force || container.state.isPending || materialContainer.state.isPending || optionMaterial.isPending)) {
2239
2272
  assetContainersToProcess.push(container.assetContainer);
2240
2273
  }
2241
2274
  });
@@ -2268,10 +2301,10 @@ export default class BabylonJSController {
2268
2301
  * @private
2269
2302
  * @returns {boolean} True if any material option was set, false otherwise.
2270
2303
  */
2271
- #setOptions_Materials() {
2304
+ #setOptions_Materials(force = false) {
2272
2305
  let someSetted = false;
2273
2306
  Object.values(this.#options.materials).forEach((material) => {
2274
- let settedMaterial = this.#setOptionsMaterial(material);
2307
+ let settedMaterial = this.#setOptionsMaterial(material, force);
2275
2308
  someSetted = someSetted || settedMaterial;
2276
2309
  });
2277
2310
  return someSetted;
@@ -2380,7 +2413,9 @@ export default class BabylonJSController {
2380
2413
  prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : undefined;
2381
2414
  }
2382
2415
 
2383
- this.#prefViewer3D = prefViewer3D;
2416
+ if (prefViewer3D) {
2417
+ this.#prefViewer3D = prefViewer3D;
2418
+ }
2384
2419
  }
2385
2420
 
2386
2421
  /**
@@ -2395,7 +2430,6 @@ export default class BabylonJSController {
2395
2430
 
2396
2431
  this.#getPrefViewer3DComponent();
2397
2432
  if (this.#prefViewer3D === undefined) {
2398
- this.#prefViewer = undefined;
2399
2433
  return;
2400
2434
  }
2401
2435
 
@@ -2405,7 +2439,9 @@ export default class BabylonJSController {
2405
2439
  prefViewer = host?.nodeName === "PREF-VIEWER" ? host : undefined;
2406
2440
  }
2407
2441
 
2408
- this.#prefViewer = prefViewer;
2442
+ if (prefViewer) {
2443
+ this.#prefViewer = prefViewer;
2444
+ }
2409
2445
  }
2410
2446
 
2411
2447
  /**
@@ -2419,13 +2455,94 @@ export default class BabylonJSController {
2419
2455
  // Cache references to parent custom elements
2420
2456
  this.#getPrefViewer3DComponent();
2421
2457
  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
+ };
2422
2468
  if (name === "model") {
2423
- this.#prefViewer3D?.setAttribute("show-model", isVisible ? "true" : "false");
2424
- this.#prefViewer?.setAttribute("show-model", isVisible ? "true" : "false");
2469
+ syncVisibility(this.#prefViewer3D, "show-model");
2470
+ syncVisibility(this.#prefViewer, "show-model");
2425
2471
  } else if (name === "environment") {
2426
- this.#prefViewer3D?.setAttribute("show-scene", isVisible ? "true" : "false");
2427
- this.#prefViewer?.setAttribute("show-scene", isVisible ? "true" : "false");
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;
2428
2513
  }
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
+ });
2429
2546
  }
2430
2547
 
2431
2548
  /**
@@ -2473,8 +2590,21 @@ export default class BabylonJSController {
2473
2590
  * @param {AssetContainer} newAssetContainer - The new asset container to add to the scene.
2474
2591
  * @returns {boolean} True if the container was replaced and added, false otherwise.
2475
2592
  */
2476
- #replaceContainer(container, newAssetContainer) {
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
+ }
2477
2598
  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
+ }
2478
2608
  this.#removeContainer(container, false);
2479
2609
  container.assetContainer.dispose();
2480
2610
  container.assetContainer = null;
@@ -2485,13 +2615,75 @@ export default class BabylonJSController {
2485
2615
  newAssetContainer?.dispose();
2486
2616
  return false;
2487
2617
  }
2488
- this.#scene.getEngine().releaseEffects();
2489
- this.#scene.getEngine().releaseComputeEffects();
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
+ }
2490
2625
 
2491
2626
  container.assetContainer = newAssetContainer;
2492
2627
  return this.#addContainer(container, false);
2493
2628
  }
2494
2629
 
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
+
2495
2687
  /**
2496
2688
  * Stops every animation group on the provided asset container to guarantee new loads start from a clean state.
2497
2689
  * @private
@@ -2548,12 +2740,209 @@ export default class BabylonJSController {
2548
2740
  if (!this.#containers.model.assetContainer || !this.#containers.model.state.isVisible) {
2549
2741
  return false;
2550
2742
  }
2551
- show = show !== undefined ? show : this.#containers.environment.state.isVisible;
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);
2552
2750
  const nodePrefixes = Object.values(this.#options.materials).flatMap((material) => material.nodePrefixes);
2553
2751
  const nodeNames = Object.values(this.#options.materials).flatMap((material) => material.nodeNames);
2554
2752
  this.#containers.model.assetContainer.meshes.filter((meshToFilter) => nodePrefixes.some((prefix) => meshToFilter.name.startsWith(prefix)) || nodeNames.includes(meshToFilter.name)).forEach((mesh) => mesh.setEnabled(show));
2555
2753
  }
2556
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
+ };
2944
+ }
2945
+
2557
2946
  /**
2558
2947
  * Stops the Babylon.js render loop for the current scene.
2559
2948
  * @private
@@ -2571,12 +2960,25 @@ export default class BabylonJSController {
2571
2960
  * @private
2572
2961
  * @returns {Promise<void>}
2573
2962
  */
2574
- async #startRender() {
2963
+ async #startRender({ skipWhenReady = false, forceRender = false, skipCameraDependentEffects = false } = {}) {
2575
2964
  if (!this.#scene) return;
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 });
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
+ }
2580
2982
  }
2581
2983
 
2582
2984
  /**
@@ -2595,7 +2997,7 @@ export default class BabylonJSController {
2595
2997
  * 3. Builds the Babylon plugin options so extras are surfaced as metadata, then imports the container with
2596
2998
  * `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
2597
2999
  */
2598
- async #loadAssetContainer(container, force = false) {
3000
+ async #loadAssetContainer(container, force = false, { isIndependent = false } = {}) {
2599
3001
  if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
2600
3002
  return [container, false];
2601
3003
  }
@@ -2611,12 +3013,30 @@ export default class BabylonJSController {
2611
3013
  const currentSize = force ? 0 : container.state.size;
2612
3014
  const currentTimeStamp = force ? null : container.state.timeStamp;
2613
3015
 
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();
2614
3019
  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"}`);
2615
3021
 
2616
3022
  if (!sourceData) {
2617
3023
  return [container, false];
2618
3024
  }
2619
3025
 
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
+
2620
3040
  container.state.setPendingCacheData(sourceData.size, sourceData.timeStamp, sourceData.metadata);
2621
3041
 
2622
3042
  // https://doc.babylonjs.com/typedoc/interfaces/BABYLON.LoadAssetContainerOptions
@@ -2635,9 +3055,12 @@ export default class BabylonJSController {
2635
3055
  };
2636
3056
 
2637
3057
  let assetContainer = null;
3058
+ const t0 = performance.now();
2638
3059
 
2639
3060
  try {
2640
3061
  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`);
2641
3064
  return [container, assetContainer];
2642
3065
  } catch (error) {
2643
3066
  return [container, assetContainer];
@@ -2657,7 +3080,29 @@ export default class BabylonJSController {
2657
3080
  */
2658
3081
  async #loadContainers(force = false) {
2659
3082
  this.#detachAnimationChangedListener();
2660
- await this.#stopRender();
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
3106
 
2662
3107
  let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
2663
3108
  let newModelMetadata = {};
@@ -2672,8 +3117,12 @@ export default class BabylonJSController {
2672
3117
  error: null,
2673
3118
  };
2674
3119
 
3120
+ const tSceneStart = performance.now();
3121
+ let tAfterLoad, tAfterMerge;
3122
+
2675
3123
  await Promise.allSettled(promiseArray)
2676
3124
  .then(async (values) => {
3125
+ tAfterLoad = performance.now();
2677
3126
  // Scene may have been disposed (disconnectedCallback) while async loading was in
2678
3127
  // progress. Abort cleanly: #replaceContainer already guards the GPU calls, but
2679
3128
  // we skip the post-load option/visibility calls too to avoid further null-derefs.
@@ -2682,6 +3131,12 @@ export default class BabylonJSController {
2682
3131
  return;
2683
3132
  }
2684
3133
  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
+ });
2685
3140
  values.forEach((result) => {
2686
3141
  const container = result.value ? result.value[0] : null;
2687
3142
  const assetContainer = result.value ? result.value[1] : null;
@@ -2697,17 +3152,29 @@ export default class BabylonJSController {
2697
3152
  this.#replaceContainer(container, assetContainer);
2698
3153
  container.state.setSuccess(true);
2699
3154
  } else {
2700
- if (container.assetContainer && container.state.mustBeShown !== container.state.isVisible && container.state.name === "materials") {
2701
- container.state.mustBeShown ? this.#addContainer(container) : this.#removeContainer(container);
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);
2702
3162
  }
2703
- container.state.setSuccess(false);
2704
3163
  }
2705
3164
  });
2706
3165
 
2707
- this.#setOptions_Materials();
3166
+ this.#setOptions_Materials(true);
2708
3167
  this.#setOptions_Camera();
2709
3168
  await this.#setOptions_IBL();
2710
3169
  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
+ });
2711
3178
  detail.success = true;
2712
3179
  })
2713
3180
  .catch((error) => {
@@ -2720,7 +3187,9 @@ export default class BabylonJSController {
2720
3187
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
2721
3188
  this.#setMaxSimultaneousLights();
2722
3189
  this.#enhanceTextureQuality();
2723
- this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
3190
+ if (this.#containers.model.assetContainer) {
3191
+ this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
3192
+ }
2724
3193
  // Apply stored highlight settings so fresh page loads respect persisted state
2725
3194
  this.#setHighlightConfig({
2726
3195
  enabled: this.#settings.highlightEnabled,
@@ -2729,7 +3198,24 @@ export default class BabylonJSController {
2729
3198
  if (this.#babylonJSAnimationController?.hasAnimations?.()) {
2730
3199
  this.#attachAnimationChangedListener();
2731
3200
  }
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.
2732
3207
  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`);
2733
3219
  });
2734
3220
  return detail;
2735
3221
  }
@@ -3035,6 +3521,7 @@ export default class BabylonJSController {
3035
3521
  this.#disablingPromises.general = (async () => {
3036
3522
  this.#disableInteraction();
3037
3523
  this.#disposeAnimationController();
3524
+ this.#finalizePendingModelReplacement();
3038
3525
  this.#disposeGLTFResolver();
3039
3526
  try {
3040
3527
  await this.#disposeXRExperience();
@@ -3180,6 +3667,297 @@ export default class BabylonJSController {
3180
3667
  return await this.#loadContainers(force);
3181
3668
  }
3182
3669
 
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
+
3183
3961
  /**
3184
3962
  * Applies camera options from the configuration to the active camera.
3185
3963
  * Stops and restarts the render loop to apply changes.
@@ -3384,3 +4162,4 @@ export default class BabylonJSController {
3384
4162
  }
3385
4163
  }
3386
4164
  }
4165
+