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

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,8 @@ 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;
168
170
 
169
171
  // Babylon.js core objects
170
172
  #engine = null;
@@ -174,6 +176,7 @@ export default class BabylonJSController {
174
176
  #dirLight = null;
175
177
  #moonLight = null;
176
178
  #cameraLight = null;
179
+ #debugCameraFrameInfo = null;
177
180
  #shadowGen = [];
178
181
  #XRExperience = null;
179
182
  #canvasResizeObserver = null;
@@ -185,6 +188,11 @@ export default class BabylonJSController {
185
188
 
186
189
  #gltfResolver = null; // GLTFResolver instance
187
190
  #babylonJSAnimationController = null; // AnimationController instance
191
+ #loadingInProgress = false; // blocks #requestRender while assets are loading to prevent WebGL errors from rendering with disposed shaders
192
+ #isLoadingModelIndependent = false; // guards against concurrent independent model loads
193
+ #isLoadingEnvironmentIndependent = false; // guards against concurrent independent environment loads
194
+ #isLoadingMaterialsIndependent = false; // guards against concurrent independent materials loads
195
+ #startRenderInProgress = false; // true while #startRender() is running (after #loadingInProgress clears)
188
196
 
189
197
  #renderPipelines = {
190
198
  default: null,
@@ -748,8 +756,8 @@ export default class BabylonJSController {
748
756
  * @param {number} [options.continuousMs=0] - Milliseconds to keep continuous rendering active.
749
757
  * @returns {boolean} True when the request was accepted, false when scene/engine are unavailable.
750
758
  */
751
- #requestRender({ frames = 1, continuousMs = 0 } = {}) {
752
- if (!this.#scene || !this.#engine) {
759
+ #requestRender({ frames = 1, continuousMs = 0, force = false } = {}) {
760
+ if (!this.#scene || !this.#engine || (this.#loadingInProgress && !force)) {
753
761
  return false;
754
762
  }
755
763
 
@@ -1112,6 +1120,11 @@ export default class BabylonJSController {
1112
1120
 
1113
1121
  const pipelineName = "PrefViewerSSAORenderingPipeline";
1114
1122
 
1123
+ // Skip recreation if already alive — same reason as DefaultRenderingPipeline guard
1124
+ if (this.#renderPipelines.ssao && supportedPipelines.find((p) => p.name === pipelineName)) {
1125
+ return true;
1126
+ }
1127
+
1115
1128
  const ssaoRatio = {
1116
1129
  ssaoRatio: 0.5,
1117
1130
  combineRatio: 1.0,
@@ -1213,6 +1226,21 @@ export default class BabylonJSController {
1213
1226
 
1214
1227
  const pipelineName = "PrefViewerDefaultRenderingPipeline";
1215
1228
 
1229
+ // Skip recreation if the pipeline is already alive — creating a new DefaultRenderingPipeline
1230
+ // while one exists disposes the underlying GeometryBufferRenderer, deleting GL programs that
1231
+ // subMeshes still reference, causing useProgram:deleted errors on the next render frame.
1232
+ if (this.#renderPipelines.default && supportedPipelines.find((p) => p.name === pipelineName)) {
1233
+ return true;
1234
+ }
1235
+
1236
+ // Purge stale effect cache before creating a new pipeline. pipeline.dispose() calls
1237
+ // gl.deleteProgram() per post-process but leaves entries in Engine._compiledEffects.
1238
+ // A new DefaultRenderingPipeline with the same shader key gets a cache hit and receives
1239
+ // the deleted program → useProgram:deleted on the first render frame.
1240
+ // Safe here because #startRender always stops the loop before reaching this point.
1241
+ this.#scene.getEngine().releaseEffects?.();
1242
+ this.#scene.getEngine().releaseComputeEffects?.();
1243
+
1216
1244
  let defaultPipeline = new DefaultRenderingPipeline(pipelineName, true, this.#scene, [this.#scene.activeCamera], true);
1217
1245
 
1218
1246
  if (!defaultPipeline) {
@@ -1390,6 +1418,11 @@ export default class BabylonJSController {
1390
1418
 
1391
1419
  const pipelineName = "PrefViewerIblShadowsRenderPipeline";
1392
1420
 
1421
+ // Skip recreation if already alive — same reason as DefaultRenderingPipeline guard
1422
+ if (this.#renderPipelines.iblShadows && supportedPipelines.find((p) => p.name === pipelineName)) {
1423
+ return true;
1424
+ }
1425
+
1393
1426
  // Use hardware-adaptive quality settings
1394
1427
  const pipelineOptions = {
1395
1428
  resolutionExp: this.#config.quality.iblShadowResolution, // Adaptive: 0=1024, 1=2048, 2=4096
@@ -1641,7 +1674,7 @@ export default class BabylonJSController {
1641
1674
  const showroom = this.#config.look?.showroom || {};
1642
1675
  const imageProcessing = this.#scene.imageProcessingConfiguration;
1643
1676
 
1644
- this.#scene.clearColor = new Color4(0.98, 0.98, 0.98, 1).toLinearSpace();
1677
+ this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
1645
1678
  imageProcessing.exposure = showroom.exposure ?? 0.95;
1646
1679
  imageProcessing.contrast = showroom.contrast ?? 1.08;
1647
1680
  imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
@@ -1681,12 +1714,7 @@ export default class BabylonJSController {
1681
1714
  imageProcessing.colorCurvesEnabled = false;
1682
1715
  }
1683
1716
 
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();
1717
+ this.#scene.clearColor = new Color4(1, 1, 1, 1).toLinearSpace();
1690
1718
 
1691
1719
  if ("environmentIntensity" in this.#scene) {
1692
1720
  this.#scene.environmentIntensity = lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
@@ -2175,6 +2203,10 @@ export default class BabylonJSController {
2175
2203
  }
2176
2204
  this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2177
2205
  console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
2206
+ // Stop the loop before resize: engine.resize() rebuilds DefaultRenderingPipeline
2207
+ // via onResizeObservable, which disposes GL programs. If the loop is running,
2208
+ // the next frame would call useProgram on a deleted program → INVALID_OPERATION.
2209
+ this.#stopEngineRenderLoop();
2178
2210
  this.#applyShowroomCameraFraming();
2179
2211
  this.#engine.resize();
2180
2212
  const needsEnhancedBurst = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled || this.#settings.shadowsEnabled;
@@ -2217,7 +2249,7 @@ export default class BabylonJSController {
2217
2249
  * @param {MaterialData} optionMaterial - Material option containing value, nodePrefixes, nodeNames, and state.
2218
2250
  * @returns {boolean} True if any mesh material was set, false otherwise.
2219
2251
  */
2220
- #setOptionsMaterial(optionMaterial) {
2252
+ #setOptionsMaterial(optionMaterial, force = false) {
2221
2253
  if (!optionMaterial || !(optionMaterial.value || (optionMaterial.isPending && optionMaterial.update.value)) || !(optionMaterial.nodePrefixes.length || optionMaterial.nodeNames.length)) {
2222
2254
  return false;
2223
2255
  }
@@ -2235,7 +2267,7 @@ export default class BabylonJSController {
2235
2267
  if (container.state.name === "materials") {
2236
2268
  return;
2237
2269
  }
2238
- if (container.assetContainer && (container.state.isPending || materialContainer.state.isPending || optionMaterial.isPending)) {
2270
+ if (container.assetContainer && (force || container.state.isPending || materialContainer.state.isPending || optionMaterial.isPending)) {
2239
2271
  assetContainersToProcess.push(container.assetContainer);
2240
2272
  }
2241
2273
  });
@@ -2268,10 +2300,10 @@ export default class BabylonJSController {
2268
2300
  * @private
2269
2301
  * @returns {boolean} True if any material option was set, false otherwise.
2270
2302
  */
2271
- #setOptions_Materials() {
2303
+ #setOptions_Materials(force = false) {
2272
2304
  let someSetted = false;
2273
2305
  Object.values(this.#options.materials).forEach((material) => {
2274
- let settedMaterial = this.#setOptionsMaterial(material);
2306
+ let settedMaterial = this.#setOptionsMaterial(material, force);
2275
2307
  someSetted = someSetted || settedMaterial;
2276
2308
  });
2277
2309
  return someSetted;
@@ -2380,7 +2412,9 @@ export default class BabylonJSController {
2380
2412
  prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : undefined;
2381
2413
  }
2382
2414
 
2383
- this.#prefViewer3D = prefViewer3D;
2415
+ if (prefViewer3D) {
2416
+ this.#prefViewer3D = prefViewer3D;
2417
+ }
2384
2418
  }
2385
2419
 
2386
2420
  /**
@@ -2395,7 +2429,6 @@ export default class BabylonJSController {
2395
2429
 
2396
2430
  this.#getPrefViewer3DComponent();
2397
2431
  if (this.#prefViewer3D === undefined) {
2398
- this.#prefViewer = undefined;
2399
2432
  return;
2400
2433
  }
2401
2434
 
@@ -2405,7 +2438,9 @@ export default class BabylonJSController {
2405
2438
  prefViewer = host?.nodeName === "PREF-VIEWER" ? host : undefined;
2406
2439
  }
2407
2440
 
2408
- this.#prefViewer = prefViewer;
2441
+ if (prefViewer) {
2442
+ this.#prefViewer = prefViewer;
2443
+ }
2409
2444
  }
2410
2445
 
2411
2446
  /**
@@ -2419,13 +2454,94 @@ export default class BabylonJSController {
2419
2454
  // Cache references to parent custom elements
2420
2455
  this.#getPrefViewer3DComponent();
2421
2456
  this.#getPrefViewerComponent();
2457
+ const syncVisibility = (component, fallbackAttr) => {
2458
+ if (!component) {
2459
+ return;
2460
+ }
2461
+ if (typeof component.syncVisibility === "function") {
2462
+ component.syncVisibility(name, isVisible);
2463
+ return;
2464
+ }
2465
+ component.setAttribute(fallbackAttr, isVisible ? "true" : "false");
2466
+ };
2422
2467
  if (name === "model") {
2423
- this.#prefViewer3D?.setAttribute("show-model", isVisible ? "true" : "false");
2424
- this.#prefViewer?.setAttribute("show-model", isVisible ? "true" : "false");
2468
+ syncVisibility(this.#prefViewer3D, "show-model");
2469
+ syncVisibility(this.#prefViewer, "show-model");
2425
2470
  } else if (name === "environment") {
2426
- this.#prefViewer3D?.setAttribute("show-scene", isVisible ? "true" : "false");
2427
- this.#prefViewer?.setAttribute("show-scene", isVisible ? "true" : "false");
2471
+ syncVisibility(this.#prefViewer3D, "show-scene");
2472
+ syncVisibility(this.#prefViewer, "show-scene");
2473
+ }
2474
+ }
2475
+
2476
+ /**
2477
+ * Dispatches a "model-first-painted" event once the model-only preview has had a chance to render a frame.
2478
+ * @private
2479
+ * @param {object} [detail] - Optional metadata describing the paint gate.
2480
+ * @returns {void}
2481
+ */
2482
+ #emitModelFirstPainted(detail = {}) {
2483
+ this.#getPrefViewer3DComponent();
2484
+ this.#getPrefViewerComponent();
2485
+ const target = this.#prefViewer || this.#prefViewer3D || this.#canvas;
2486
+ if (!target) {
2487
+ return;
2428
2488
  }
2489
+
2490
+ const customEventOptions = {
2491
+ bubbles: true,
2492
+ cancelable: false,
2493
+ composed: true,
2494
+ detail,
2495
+ };
2496
+ target.dispatchEvent(new CustomEvent("model-first-painted", customEventOptions));
2497
+ }
2498
+
2499
+ /**
2500
+ * Dispatches a granular 3D load phase event so the ecommerce trace UI can measure tail phases.
2501
+ * @private
2502
+ * @param {string} phase - Phase event name.
2503
+ * @param {object} [detail] - Optional phase metadata.
2504
+ * @returns {void}
2505
+ */
2506
+ #emitSceneLoadPhase(phase, detail = {}) {
2507
+ this.#getPrefViewer3DComponent();
2508
+ this.#getPrefViewerComponent();
2509
+ const target = this.#prefViewer || this.#prefViewer3D || this.#canvas;
2510
+ if (!target) {
2511
+ return;
2512
+ }
2513
+
2514
+ const customEventOptions = {
2515
+ bubbles: true,
2516
+ cancelable: false,
2517
+ composed: true,
2518
+ detail,
2519
+ };
2520
+ target.dispatchEvent(new CustomEvent(phase, customEventOptions));
2521
+ }
2522
+
2523
+ /**
2524
+ * Waits for one or more animation frames so the browser can paint the current model-only state.
2525
+ * @private
2526
+ * @param {number} [frameCount=2] - Number of animation frames to wait.
2527
+ * @returns {Promise<void>}
2528
+ */
2529
+ async #waitForPaint(frameCount = 2) {
2530
+ if (typeof requestAnimationFrame !== "function") {
2531
+ return;
2532
+ }
2533
+
2534
+ const totalFrames = Math.max(1, frameCount);
2535
+ await new Promise((resolve) => {
2536
+ const step = (remaining) => {
2537
+ if (remaining <= 0) {
2538
+ resolve();
2539
+ return;
2540
+ }
2541
+ requestAnimationFrame(() => step(remaining - 1));
2542
+ };
2543
+ requestAnimationFrame(() => step(totalFrames - 1));
2544
+ });
2429
2545
  }
2430
2546
 
2431
2547
  /**
@@ -2473,7 +2589,7 @@ export default class BabylonJSController {
2473
2589
  * @param {AssetContainer} newAssetContainer - The new asset container to add to the scene.
2474
2590
  * @returns {boolean} True if the container was replaced and added, false otherwise.
2475
2591
  */
2476
- #replaceContainer(container, newAssetContainer) {
2592
+ #replaceContainer(container, newAssetContainer, { releaseEffects = true } = {}) {
2477
2593
  if (container.assetContainer) {
2478
2594
  this.#removeContainer(container, false);
2479
2595
  container.assetContainer.dispose();
@@ -2485,8 +2601,13 @@ export default class BabylonJSController {
2485
2601
  newAssetContainer?.dispose();
2486
2602
  return false;
2487
2603
  }
2488
- this.#scene.getEngine().releaseEffects();
2489
- this.#scene.getEngine().releaseComputeEffects();
2604
+ // Skip releaseEffects when the render loop is active: deleting GL programs while frames
2605
+ // are being produced causes useProgram:deleted errors. Also skipped when the caller
2606
+ // explicitly passes releaseEffects:false (e.g. loadContainerIndependent).
2607
+ if (releaseEffects && !this.#state.render.isLoopRunning) {
2608
+ this.#scene.getEngine().releaseEffects();
2609
+ this.#scene.getEngine().releaseComputeEffects();
2610
+ }
2490
2611
 
2491
2612
  container.assetContainer = newAssetContainer;
2492
2613
  return this.#addContainer(container, false);
@@ -2548,12 +2669,209 @@ export default class BabylonJSController {
2548
2669
  if (!this.#containers.model.assetContainer || !this.#containers.model.state.isVisible) {
2549
2670
  return false;
2550
2671
  }
2551
- show = show !== undefined ? show : this.#containers.environment.state.isVisible;
2672
+ // In model-only previews, or while the environment is still loading, keep wall/floor meshes visible.
2673
+ // Only defer to the environment's visibility after it has fully loaded.
2674
+ show = show !== undefined
2675
+ ? show
2676
+ : (this.#containers.environment.state.isSuccess
2677
+ ? this.#containers.environment.state.isVisible
2678
+ : true);
2552
2679
  const nodePrefixes = Object.values(this.#options.materials).flatMap((material) => material.nodePrefixes);
2553
2680
  const nodeNames = Object.values(this.#options.materials).flatMap((material) => material.nodeNames);
2554
2681
  this.#containers.model.assetContainer.meshes.filter((meshToFilter) => nodePrefixes.some((prefix) => meshToFilter.name.startsWith(prefix)) || nodeNames.includes(meshToFilter.name)).forEach((mesh) => mesh.setEnabled(show));
2555
2682
  }
2556
2683
 
2684
+ /**
2685
+ * Frames the active ArcRotateCamera around the visible model when no environment scene is loaded.
2686
+ * @private
2687
+ * @returns {boolean} True if the camera was adjusted, otherwise false.
2688
+ */
2689
+ #frameCameraToModel({ allowWhenEnvironmentVisible = false } = {}) {
2690
+ const camera = this.#scene?.activeCamera instanceof ArcRotateCamera
2691
+ ? this.#scene.activeCamera
2692
+ : this.#camera;
2693
+ const modelContainer = this.#containers.model;
2694
+ const inspect = {
2695
+ cameraType: camera?.getClassName?.() ?? camera?.constructor?.name ?? null,
2696
+ hasModelContainer: !!modelContainer.assetContainer,
2697
+ modelVisible: modelContainer.state.isVisible,
2698
+ envHasContainer: !!this.#containers.environment.assetContainer,
2699
+ envVisible: this.#containers.environment.state.isVisible,
2700
+ meshCount: modelContainer.assetContainer?.meshes?.length ?? 0,
2701
+ };
2702
+ this.#debugCameraFrameInfo = { phase: "inspect", ...inspect };
2703
+ console.log("[viewer] frameCameraToModel inspect", inspect);
2704
+ if (!(camera instanceof ArcRotateCamera) || !modelContainer.assetContainer || !modelContainer.state.isVisible) {
2705
+ this.#debugCameraFrameInfo = {
2706
+ phase: "skipped",
2707
+ reason: !(camera instanceof ArcRotateCamera)
2708
+ ? "no-arcrotate-camera"
2709
+ : !modelContainer.assetContainer
2710
+ ? "no-model"
2711
+ : !modelContainer.state.isVisible
2712
+ ? "model-hidden"
2713
+ : "unknown",
2714
+ ...inspect,
2715
+ };
2716
+ return false;
2717
+ }
2718
+ if (!allowWhenEnvironmentVisible && this.#containers.environment.assetContainer && this.#containers.environment.state.isVisible) {
2719
+ this.#debugCameraFrameInfo = {
2720
+ phase: "skipped",
2721
+ reason: "environment-visible",
2722
+ ...inspect,
2723
+ };
2724
+ return false;
2725
+ }
2726
+
2727
+ let min = null;
2728
+ let max = null;
2729
+ modelContainer.assetContainer.meshes.forEach((mesh) => {
2730
+ if (!mesh?.getTotalVertices?.() || mesh.getTotalVertices() <= 0) {
2731
+ return;
2732
+ }
2733
+ mesh.computeWorldMatrix?.(true);
2734
+ const boundingBox = mesh.getBoundingInfo?.()?.boundingBox;
2735
+ if (!boundingBox) {
2736
+ return;
2737
+ }
2738
+ const minimum = boundingBox.minimumWorld;
2739
+ const maximum = boundingBox.maximumWorld;
2740
+ if (!minimum || !maximum) {
2741
+ return;
2742
+ }
2743
+ min = min
2744
+ ? new Vector3(Math.min(min.x, minimum.x), Math.min(min.y, minimum.y), Math.min(min.z, minimum.z))
2745
+ : minimum.clone();
2746
+ max = max
2747
+ ? new Vector3(Math.max(max.x, maximum.x), Math.max(max.y, maximum.y), Math.max(max.z, maximum.z))
2748
+ : maximum.clone();
2749
+ });
2750
+
2751
+ if (!min || !max) {
2752
+ const skipped = {
2753
+ hasCamera: camera instanceof ArcRotateCamera,
2754
+ hasModel: !!modelContainer.assetContainer,
2755
+ modelVisible: modelContainer.state.isVisible,
2756
+ environmentVisible: this.#containers.environment.state.isVisible,
2757
+ };
2758
+ this.#debugCameraFrameInfo = {
2759
+ phase: "skipped",
2760
+ reason: "no-bounds",
2761
+ ...inspect,
2762
+ ...skipped,
2763
+ };
2764
+ console.log("[viewer] frameCameraToModel skipped (no bounds)", skipped);
2765
+ return false;
2766
+ }
2767
+
2768
+ const size = max.subtract(min);
2769
+ const largestDimension = Math.max(size.x, size.y, size.z);
2770
+ if (!Number.isFinite(largestDimension) || largestDimension <= 0) {
2771
+ return false;
2772
+ }
2773
+
2774
+ const center = min.add(max).scale(0.5);
2775
+ camera.setTarget(center);
2776
+ const radiusMultiplier = allowWhenEnvironmentVisible ? 1.7 : 1.4;
2777
+ camera.radius = Math.min(
2778
+ Math.max(largestDimension * radiusMultiplier, camera.lowerRadiusLimit ?? largestDimension * radiusMultiplier),
2779
+ camera.upperRadiusLimit ?? largestDimension * radiusMultiplier,
2780
+ );
2781
+ // Keep the initial model-only view aligned to the showroom's frontal composition.
2782
+ // This preserves the user's camera intent once the scene becomes visible, but gives
2783
+ // the standalone model a consistent front-facing presentation.
2784
+ // Rotate the front-facing composition 180° while keeping the same framing/radius.
2785
+ camera.alpha = Math.PI;
2786
+ camera.beta = Math.PI * 0.47;
2787
+ console.log("[viewer] frameCameraToModel applied", {
2788
+ center,
2789
+ size,
2790
+ radius: camera.radius,
2791
+ });
2792
+ this.#debugCameraFrameInfo = {
2793
+ phase: "applied",
2794
+ ...inspect,
2795
+ center: { x: center.x, y: center.y, z: center.z },
2796
+ size: { x: size.x, y: size.y, z: size.z },
2797
+ radius: camera.radius,
2798
+ cameraTarget: camera.target ? { x: camera.target.x, y: camera.target.y, z: camera.target.z } : null,
2799
+ cameraPosition: camera.position ? { x: camera.position.x, y: camera.position.y, z: camera.position.z } : null,
2800
+ cameraLocked: !!camera.metadata?.locked,
2801
+ };
2802
+ return true;
2803
+ }
2804
+
2805
+ /**
2806
+ * Public wrapper to force the active camera to frame the visible model.
2807
+ * @public
2808
+ * @returns {boolean}
2809
+ */
2810
+ focusModel() {
2811
+ return this.#frameCameraToModel();
2812
+ }
2813
+
2814
+ /**
2815
+ * Returns a compact snapshot of the current debug-relevant 3D state.
2816
+ * @public
2817
+ * @returns {object}
2818
+ */
2819
+ getDebugState() {
2820
+ const activeCamera = this.#scene?.activeCamera ?? this.#camera ?? null;
2821
+ const modelMeshes = this.#containers.model?.assetContainer?.meshes ?? [];
2822
+ let boundsMin = null;
2823
+ let boundsMax = null;
2824
+ for (const mesh of modelMeshes) {
2825
+ if (!mesh?.getTotalVertices?.() || mesh.getTotalVertices() <= 0) continue;
2826
+ mesh.computeWorldMatrix?.(true);
2827
+ const boundingBox = mesh.getBoundingInfo?.()?.boundingBox;
2828
+ if (!boundingBox?.minimumWorld || !boundingBox?.maximumWorld) continue;
2829
+ const minimum = boundingBox.minimumWorld;
2830
+ const maximum = boundingBox.maximumWorld;
2831
+ boundsMin = boundsMin
2832
+ ? { x: Math.min(boundsMin.x, minimum.x), y: Math.min(boundsMin.y, minimum.y), z: Math.min(boundsMin.z, minimum.z) }
2833
+ : { x: minimum.x, y: minimum.y, z: minimum.z };
2834
+ boundsMax = boundsMax
2835
+ ? { x: Math.max(boundsMax.x, maximum.x), y: Math.max(boundsMax.y, maximum.y), z: Math.max(boundsMax.z, maximum.z) }
2836
+ : { x: maximum.x, y: maximum.y, z: maximum.z };
2837
+ }
2838
+ const modelBounds = boundsMin && boundsMax ? {
2839
+ min: boundsMin,
2840
+ max: boundsMax,
2841
+ center: {
2842
+ x: (boundsMin.x + boundsMax.x) / 2,
2843
+ y: (boundsMin.y + boundsMax.y) / 2,
2844
+ z: (boundsMin.z + boundsMax.z) / 2,
2845
+ },
2846
+ size: {
2847
+ x: boundsMax.x - boundsMin.x,
2848
+ y: boundsMax.y - boundsMin.y,
2849
+ z: boundsMax.z - boundsMin.z,
2850
+ },
2851
+ } : null;
2852
+ return {
2853
+ camera: activeCamera ? {
2854
+ className: activeCamera.getClassName?.() ?? activeCamera.constructor?.name ?? null,
2855
+ position: activeCamera.position ? { x: activeCamera.position.x, y: activeCamera.position.y, z: activeCamera.position.z } : null,
2856
+ target: activeCamera.target ? { x: activeCamera.target.x, y: activeCamera.target.y, z: activeCamera.target.z } : null,
2857
+ radius: typeof activeCamera.radius === "number" ? activeCamera.radius : null,
2858
+ locked: !!activeCamera.metadata?.locked,
2859
+ } : null,
2860
+ model: {
2861
+ visible: !!this.#containers.model?.state?.isVisible,
2862
+ hasContainer: !!this.#containers.model?.assetContainer,
2863
+ meshCount: this.#containers.model?.assetContainer?.meshes?.length ?? 0,
2864
+ bounds: modelBounds,
2865
+ },
2866
+ environment: {
2867
+ visible: !!this.#containers.environment?.state?.isVisible,
2868
+ hasContainer: !!this.#containers.environment?.assetContainer,
2869
+ },
2870
+ lastCameraFrame: this.#debugCameraFrameInfo,
2871
+ renderLoopRunning: !!this.#state?.render?.isLoopRunning,
2872
+ };
2873
+ }
2874
+
2557
2875
  /**
2558
2876
  * Stops the Babylon.js render loop for the current scene.
2559
2877
  * @private
@@ -2571,12 +2889,25 @@ export default class BabylonJSController {
2571
2889
  * @private
2572
2890
  * @returns {Promise<void>}
2573
2891
  */
2574
- async #startRender() {
2892
+ async #startRender({ skipWhenReady = false, forceRender = false, skipCameraDependentEffects = false } = {}) {
2575
2893
  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 });
2894
+ this.#startRenderInProgress = true;
2895
+ try {
2896
+ if (!skipCameraDependentEffects) {
2897
+ await this.#loadCameraDependentEffects();
2898
+ }
2899
+ // skipWhenReady: model materials already compiled via compileMaterials:true.
2900
+ // Skips scene-wide whenReadyAsync so the model appears immediately (~1s).
2901
+ if (!skipWhenReady) {
2902
+ await this.#scene.whenReadyAsync();
2903
+ }
2904
+ const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2905
+ // forceRender: bypasses #loadingInProgress gate so loadContainerIndependent
2906
+ // can show the model preview while #loadContainers is still loading scene/materials.
2907
+ this.#requestRender({ frames: frames, continuousMs: this.#config.render.interactionMs, force: forceRender });
2908
+ } finally {
2909
+ this.#startRenderInProgress = false;
2910
+ }
2580
2911
  }
2581
2912
 
2582
2913
  /**
@@ -2595,7 +2926,7 @@ export default class BabylonJSController {
2595
2926
  * 3. Builds the Babylon plugin options so extras are surfaced as metadata, then imports the container with
2596
2927
  * `LoadAssetContainerAsync`, returning the tuple so the caller can decide how to attach it to the scene.
2597
2928
  */
2598
- async #loadAssetContainer(container, force = false) {
2929
+ async #loadAssetContainer(container, force = false, { isIndependent = false } = {}) {
2599
2930
  if (container?.state?.update?.storage === undefined || container?.state?.size === undefined || container?.state?.timeStamp === undefined) {
2600
2931
  return [container, false];
2601
2932
  }
@@ -2611,12 +2942,30 @@ export default class BabylonJSController {
2611
2942
  const currentSize = force ? 0 : container.state.size;
2612
2943
  const currentTimeStamp = force ? null : container.state.timeStamp;
2613
2944
 
2945
+ const storageType = container.state.update.storage?.db ? "idb" : container.state.update.storage?.url ? "url" : "?";
2946
+ const storageId = container.state.update.storage?.id ?? container.state.update.storage?.url ?? "?";
2947
+ const tResolve = performance.now();
2614
2948
  let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, currentSize, currentTimeStamp);
2949
+ 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
2950
 
2616
2951
  if (!sourceData) {
2617
2952
  return [container, false];
2618
2953
  }
2619
2954
 
2955
+ // Abort if loadContainerIndependent claimed this container while getSource was in flight.
2956
+ // isIndependent=true means THIS call IS the independent load — don't abort yourself.
2957
+ // Returns null (not false) so #loadContainers skips setSuccess(false) for this container.
2958
+ const isClaimedIndependently =
2959
+ (container.state.name === "model" && this.#isLoadingModelIndependent) ||
2960
+ (container.state.name === "environment" && this.#isLoadingEnvironmentIndependent) ||
2961
+ (container.state.name === "materials" && this.#isLoadingMaterialsIndependent);
2962
+ if (isClaimedIndependently && !isIndependent) {
2963
+ if (sourceData.objectURLs?.length) {
2964
+ this.#gltfResolver.revokeObjectURLs(sourceData.objectURLs);
2965
+ }
2966
+ return [container, null];
2967
+ }
2968
+
2620
2969
  container.state.setPendingCacheData(sourceData.size, sourceData.timeStamp, sourceData.metadata);
2621
2970
 
2622
2971
  // https://doc.babylonjs.com/typedoc/interfaces/BABYLON.LoadAssetContainerOptions
@@ -2635,9 +2984,12 @@ export default class BabylonJSController {
2635
2984
  };
2636
2985
 
2637
2986
  let assetContainer = null;
2987
+ const t0 = performance.now();
2638
2988
 
2639
2989
  try {
2640
2990
  assetContainer = await LoadAssetContainerAsync(sourceData.source, this.#scene, options);
2991
+ const elapsed = Math.round(performance.now() - t0);
2992
+ console.log(`[perf][${container.state.name}] LoadAssetContainerAsync ${elapsed}ms`);
2641
2993
  return [container, assetContainer];
2642
2994
  } catch (error) {
2643
2995
  return [container, assetContainer];
@@ -2657,7 +3009,29 @@ export default class BabylonJSController {
2657
3009
  */
2658
3010
  async #loadContainers(force = false) {
2659
3011
  this.#detachAnimationChangedListener();
2660
- await this.#stopRender();
3012
+ this.#loadingInProgress = true;
3013
+
3014
+ // When only the model is pending (scene/environment already rendered), skip
3015
+ // unloading camera-dependent effects (DefaultRenderingPipeline, SSAO, shadows).
3016
+ // Disposing those pipelines while the render loop was recently active deletes GL
3017
+ // programs that are still referenced, causing WebGL INVALID_OPERATION errors.
3018
+ const isModelOnlyLoad =
3019
+ this.#containers.model?.state?.isPending === true &&
3020
+ this.#containers.environment?.state?.isPending !== true &&
3021
+ this.#containers.materials?.state?.isPending !== true;
3022
+
3023
+ if (isModelOnlyLoad) {
3024
+ this.#stopEngineRenderLoop();
3025
+ this.#resetRenderState();
3026
+ // Replacing an already-visible model can leave Babylon post-process chains attached to
3027
+ // a camera that is about to be discarded or re-bound. Tear them down here so the next
3028
+ // startRender() rebuilds a clean pipeline instead of tripping over stale null entries.
3029
+ if (this.#containers.model?.assetContainer) {
3030
+ await this.#unloadCameraDependentEffects();
3031
+ }
3032
+ } else {
3033
+ await this.#stopRender();
3034
+ }
2661
3035
 
2662
3036
  let oldModelMetadata = { ...(this.#containers.model?.state?.metadata ?? {}) };
2663
3037
  let newModelMetadata = {};
@@ -2672,8 +3046,12 @@ export default class BabylonJSController {
2672
3046
  error: null,
2673
3047
  };
2674
3048
 
3049
+ const tSceneStart = performance.now();
3050
+ let tAfterLoad, tAfterMerge;
3051
+
2675
3052
  await Promise.allSettled(promiseArray)
2676
3053
  .then(async (values) => {
3054
+ tAfterLoad = performance.now();
2677
3055
  // Scene may have been disposed (disconnectedCallback) while async loading was in
2678
3056
  // progress. Abort cleanly: #replaceContainer already guards the GPU calls, but
2679
3057
  // we skip the post-load option/visibility calls too to avoid further null-derefs.
@@ -2682,6 +3060,12 @@ export default class BabylonJSController {
2682
3060
  return;
2683
3061
  }
2684
3062
  this.#disposeAnimationController();
3063
+ this.#emitSceneLoadPhase("scene-load-assets-complete", {
3064
+ path: "full",
3065
+ containerCount: values.filter((result) => result.status === "fulfilled" && result.value?.[1]).length,
3066
+ tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
3067
+ tSceneStart: Math.round(tSceneStart),
3068
+ });
2685
3069
  values.forEach((result) => {
2686
3070
  const container = result.value ? result.value[0] : null;
2687
3071
  const assetContainer = result.value ? result.value[1] : null;
@@ -2697,17 +3081,29 @@ export default class BabylonJSController {
2697
3081
  this.#replaceContainer(container, assetContainer);
2698
3082
  container.state.setSuccess(true);
2699
3083
  } 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);
3084
+ // null sentinel = claimed by loadContainerIndependent already merged independently.
3085
+ // false = normal skip (no data change) or error. Only mark failure for the latter.
3086
+ if (assetContainer !== null) {
3087
+ if (container.assetContainer && container.state.mustBeShown !== container.state.isVisible && container.state.name === "materials") {
3088
+ container.state.mustBeShown ? this.#addContainer(container) : this.#removeContainer(container);
3089
+ }
3090
+ container.state.setSuccess(false);
2702
3091
  }
2703
- container.state.setSuccess(false);
2704
3092
  }
2705
3093
  });
2706
3094
 
2707
- this.#setOptions_Materials();
3095
+ this.#setOptions_Materials(true);
2708
3096
  this.#setOptions_Camera();
2709
3097
  await this.#setOptions_IBL();
2710
3098
  this.#setVisibilityOfWallAndFloorInModel();
3099
+ this.#frameCameraToModel();
3100
+ tAfterMerge = performance.now();
3101
+ this.#emitSceneLoadPhase("scene-load-merge-complete", {
3102
+ path: "full",
3103
+ tAfterMerge: tAfterMerge != null ? Math.round(tAfterMerge) : null,
3104
+ tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
3105
+ tSceneStart: Math.round(tSceneStart),
3106
+ });
2711
3107
  detail.success = true;
2712
3108
  })
2713
3109
  .catch((error) => {
@@ -2720,7 +3116,9 @@ export default class BabylonJSController {
2720
3116
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
2721
3117
  this.#setMaxSimultaneousLights();
2722
3118
  this.#enhanceTextureQuality();
2723
- this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
3119
+ if (this.#containers.model.assetContainer) {
3120
+ this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
3121
+ }
2724
3122
  // Apply stored highlight settings so fresh page loads respect persisted state
2725
3123
  this.#setHighlightConfig({
2726
3124
  enabled: this.#settings.highlightEnabled,
@@ -2729,7 +3127,24 @@ export default class BabylonJSController {
2729
3127
  if (this.#babylonJSAnimationController?.hasAnimations?.()) {
2730
3128
  this.#attachAnimationChangedListener();
2731
3129
  }
3130
+ this.#loadingInProgress = false;
3131
+ // If loadContainerIndependent already merged a container and started the render
3132
+ // loop while this scene load was in flight, #startRender was already called.
3133
+ // Calling it again is safe (pipeline guards skip recreation) but redundant only
3134
+ // when the loop is already running AND scene/shaders are ready; whenReadyAsync
3135
+ // will settle quickly for the newly added containers.
2732
3136
  await this.#startRender();
3137
+ this.#frameCameraToModel({ allowWhenEnvironmentVisible: true });
3138
+ this.#emitSceneLoadPhase("scene-load-shaders-complete", {
3139
+ path: "full",
3140
+ tAfterShaders: Math.round(performance.now()),
3141
+ tAfterMerge: tAfterMerge != null ? Math.round(tAfterMerge) : null,
3142
+ });
3143
+ const tLoad = tAfterLoad ? Math.round(tAfterLoad - tSceneStart) : null;
3144
+ const tMerge = tAfterLoad && tAfterMerge ? Math.round(tAfterMerge - tAfterLoad) : null;
3145
+ const tShaders = tAfterMerge ? Math.round(performance.now() - tAfterMerge) : null;
3146
+ const tTotal = Math.round(performance.now() - tSceneStart);
3147
+ console.log(`[perf][scene] load=${tLoad}ms merge=${tMerge}ms shaders=${tShaders}ms total=${tTotal}ms`);
2733
3148
  });
2734
3149
  return detail;
2735
3150
  }
@@ -3180,6 +3595,296 @@ export default class BabylonJSController {
3180
3595
  return await this.#loadContainers(force);
3181
3596
  }
3182
3597
 
3598
+ /**
3599
+ * Loads a single named container in the background while the render loop keeps running,
3600
+ * then merges it into the scene atomically (only the merge momentarily pauses the loop).
3601
+ * Camera-dependent effects (post-processing, SSAO, shadows) are preserved across the merge.
3602
+ * If a full #loadContainers is in progress, waits for it to finish before merging.
3603
+ * @public
3604
+ * @param {string} containerName - Name of the container to load ("model", "environment", "materials").
3605
+ * @param {Function} [onReady] - Optional callback invoked after any concurrent scene load finishes
3606
+ * and before the asset is fetched. Use this to mark the container pending (e.g. via
3607
+ * #checkNeedToUpdateContainers) so that the concurrent #loadContainers doesn't consume it first.
3608
+ * @returns {Promise<{success: boolean, error: any, loadedContainers: string[]}>}
3609
+ */
3610
+ async loadContainerIndependent(containerName, onReady, options = {}) {
3611
+ const loadSessionId = containerName === "model" ? (options?.loadSessionId ?? null) : null;
3612
+ if (containerName === "model" && this.#isLoadingModelIndependent) {
3613
+ return { success: false, error: "Already loading model independently", loadedContainers: [], loadSessionId };
3614
+ }
3615
+ if (containerName === "environment" && this.#isLoadingEnvironmentIndependent) {
3616
+ return { success: false, error: "Already loading environment independently", loadedContainers: [], loadSessionId };
3617
+ }
3618
+ if (containerName === "materials" && this.#isLoadingMaterialsIndependent) {
3619
+ return { success: false, error: "Already loading materials independently", loadedContainers: [], loadSessionId };
3620
+ }
3621
+
3622
+ if (containerName === "model") this.#isLoadingModelIndependent = true;
3623
+ else if (containerName === "environment") this.#isLoadingEnvironmentIndependent = true;
3624
+ else if (containerName === "materials") this.#isLoadingMaterialsIndependent = true;
3625
+
3626
+ // Only reset model-first-painted tracking for model loads — resetting it for
3627
+ // environment/materials would stale any in-flight model-first-painted gate.
3628
+ if (containerName === "model") {
3629
+ this.#modelFirstPaintedEmitted = false;
3630
+ }
3631
+ const modelFirstPaintedLoadToken = containerName === "model"
3632
+ ? ++this.#modelFirstPaintedLoadToken
3633
+ : this.#modelFirstPaintedLoadToken;
3634
+ let assetContainer = null;
3635
+ const tIndepStart = performance.now();
3636
+ // Whether the container was already pending inside a running #loadContainers batch.
3637
+ // If so, we fall back to the sequential path (wait → onReady → load) to avoid a
3638
+ // double-load conflict. Otherwise, load the GLTF in parallel with the scene load
3639
+ // and only wait for the atomic merge window.
3640
+ const isAlreadyHandled =
3641
+ this.#containers[containerName]?.state?.isPending === true && this.#loadingInProgress;
3642
+ const path = isAlreadyHandled ? "claimed" : "par";
3643
+ let container;
3644
+ let tAfterLoad, tAfterWait;
3645
+ try {
3646
+ if (!isAlreadyHandled) {
3647
+ // Optimistic path: prepare state and load GLTF NOW, in parallel with any running
3648
+ // #loadContainers. The merge waits for the scene, but the download does not.
3649
+ if (!this.#scene) {
3650
+ return { success: false, error: "No scene available", loadedContainers: [], loadSessionId };
3651
+ }
3652
+ onReady?.();
3653
+ container = this.#containers[containerName];
3654
+ if (!container) {
3655
+ return { success: false, error: `Container "${containerName}" not found`, loadedContainers: [], loadSessionId };
3656
+ }
3657
+ // Phase 1 (parallel): load GLTF while scene may still be loading.
3658
+ // No wait — merge happens immediately after load. #replaceContainer skips
3659
+ // releaseEffects when the loop is running, so no WebGL programs are deleted.
3660
+ const [, loaded] = await this.#loadAssetContainer(container, false, { isIndependent: true });
3661
+ assetContainer = loaded || null;
3662
+ tAfterLoad = performance.now();
3663
+ tAfterWait = tAfterLoad; // no wait phase in optimistic path
3664
+ } else {
3665
+ // Parallel claim path: container was already submitted to a running #loadContainers
3666
+ // batch. #isLoadingModelIndependent (set at the top of this method) signals
3667
+ // #loadAssetContainer to abort the in-flight batch load and return a null sentinel,
3668
+ // so #loadContainers skips merging this container. We load it here in parallel —
3669
+ // no waiting for scene/materials to finish.
3670
+ if (!this.#scene) {
3671
+ return { success: false, error: "No scene available", loadedContainers: [], loadSessionId };
3672
+ }
3673
+ // Call onReady() to ensure the container state carries this load's payload,
3674
+ // which may differ from what #loadContainers was given if the caller swapped the model.
3675
+ onReady?.();
3676
+ container = this.#containers[containerName];
3677
+ if (!container) {
3678
+ return { success: false, error: `Container "${containerName}" not found`, loadedContainers: [], loadSessionId };
3679
+ }
3680
+ const [, loaded] = await this.#loadAssetContainer(container, false, { isIndependent: true });
3681
+ assetContainer = loaded || null;
3682
+ tAfterLoad = performance.now();
3683
+ tAfterWait = tIndepStart; // no wait phase — measure load from start
3684
+ }
3685
+
3686
+ if (!assetContainer) {
3687
+ return { success: false, error: "Failed to load asset container", loadedContainers: [], loadSessionId };
3688
+ }
3689
+ if (!this.#scene) {
3690
+ assetContainer.dispose();
3691
+ return { success: false, error: "Scene was disposed", loadedContainers: [], loadSessionId };
3692
+ }
3693
+
3694
+ this.#emitSceneLoadPhase("scene-load-assets-complete", {
3695
+ containerName,
3696
+ path,
3697
+ tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
3698
+ tIndepStart: Math.round(tIndepStart),
3699
+ });
3700
+
3701
+ // Phase 3: atomic merge — briefly stop the loop for the merge only
3702
+ this.#stopEngineRenderLoop();
3703
+
3704
+ const oldModelMetadata = containerName === "model"
3705
+ ? { ...(this.#containers.model?.state?.metadata ?? {}) }
3706
+ : {};
3707
+
3708
+ this.#disposeAnimationController();
3709
+
3710
+ if (containerName === "model") {
3711
+ this.#assetContainer_deleteLights(assetContainer);
3712
+ this.#assetContainer_stopAnimations(assetContainer);
3713
+ }
3714
+ if (containerName === "model" || containerName === "environment") {
3715
+ this.#assetContainer_retagCameras(assetContainer);
3716
+ }
3717
+
3718
+ this.#replaceContainer(container, assetContainer, { releaseEffects: false });
3719
+ container.state.setSuccess(true);
3720
+
3721
+ // Progressive independent loads may bring model/materials/environment in any order.
3722
+ // Force material rebinding after each merge so already-visible model meshes pick up
3723
+ // the latest material definitions instead of waiting for pending flags that may
3724
+ // already have been cleared.
3725
+ this.#setOptions_Materials(true);
3726
+ this.#setOptions_Camera();
3727
+ // For model: IBL not yet loaded (scene still in flight) — create only ambient/directional
3728
+ // lights. #loadContainers will call #createLights() again with full IBL once scene merges.
3729
+ // For environment: IBL state was pre-marked pending by loadSceneIndependent before calling
3730
+ // here, so #setOptions_IBL() picks it up and applies it immediately after lights are ready.
3731
+ await this.#createLights();
3732
+ if (containerName === "environment") {
3733
+ await this.#setOptions_IBL();
3734
+ }
3735
+
3736
+ if (containerName === "model") {
3737
+ const newModelMetadata = { ...(container.state.update?.metadata ?? {}) };
3738
+ this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
3739
+ }
3740
+
3741
+ if (this.#containers.model.assetContainer) {
3742
+ this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
3743
+ }
3744
+ const tAfterMerge = performance.now();
3745
+ this.#emitSceneLoadPhase("scene-load-merge-complete", {
3746
+ containerName,
3747
+ path,
3748
+ tAfterMerge: Math.round(tAfterMerge),
3749
+ tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
3750
+ tIndepStart: Math.round(tIndepStart),
3751
+ });
3752
+
3753
+ // Use #startRender() so the next frame is scheduled after the atomic merge.
3754
+ // In progressive loads, rebuilding camera-dependent effects (DefaultRenderingPipeline,
3755
+ // SSAO, shadows) while the model is already visible can delete GL programs that are
3756
+ // still referenced by the current frame, which leaves the canvas gray until a later
3757
+ // full reload. Keep the existing pipeline alive for independent model/environment/
3758
+ // materials appends; the full load path still rebuilds everything when needed.
3759
+ await this.#startRender({ skipWhenReady: true, forceRender: true, skipCameraDependentEffects: true });
3760
+ const tAfterShaders = performance.now();
3761
+
3762
+ this.#setHighlightConfig({
3763
+ enabled: this.#settings.highlightEnabled,
3764
+ color: this.#settings.highlightColor,
3765
+ });
3766
+ this.#setMaxSimultaneousLights();
3767
+ this.#enhanceTextureQuality();
3768
+ this.#setVisibilityOfWallAndFloorInModel();
3769
+
3770
+ if (this.#babylonJSAnimationController?.hasAnimations?.()) {
3771
+ this.#attachAnimationChangedListener();
3772
+ }
3773
+
3774
+ this.#requestRender({ frames: 3, continuousMs: this.#config.render.interactionMs });
3775
+ if (containerName === "model" && !this.#modelFirstPaintedEmitted) {
3776
+ try {
3777
+ performance.mark(`viewer:model-first-painted:scheduled:${modelFirstPaintedLoadToken}`);
3778
+ } catch {
3779
+ // ignore
3780
+ }
3781
+ console.log("[viewer] model-first-painted gate scheduled", {
3782
+ containerName,
3783
+ path: isAlreadyHandled ? "claimed" : "par",
3784
+ loadToken: modelFirstPaintedLoadToken,
3785
+ renderLoopRunning: this.#state.render.isLoopRunning,
3786
+ loopRequested: true,
3787
+ });
3788
+ void (async () => {
3789
+ await this.#waitForPaint(2);
3790
+ try {
3791
+ performance.mark(`viewer:model-first-painted:after-wait:${modelFirstPaintedLoadToken}`);
3792
+ } catch {
3793
+ // ignore
3794
+ }
3795
+ console.log("[viewer] model-first-painted gate after paint wait", {
3796
+ containerName,
3797
+ path: isAlreadyHandled ? "claimed" : "par",
3798
+ loadToken: modelFirstPaintedLoadToken,
3799
+ currentToken: this.#modelFirstPaintedLoadToken,
3800
+ alreadyEmitted: this.#modelFirstPaintedEmitted,
3801
+ renderLoopRunning: this.#state.render.isLoopRunning,
3802
+ });
3803
+ if (modelFirstPaintedLoadToken !== this.#modelFirstPaintedLoadToken) {
3804
+ try {
3805
+ performance.mark(`viewer:model-first-painted:stale:${modelFirstPaintedLoadToken}`);
3806
+ } catch {
3807
+ // ignore
3808
+ }
3809
+ console.log("[viewer] model-first-painted gate skipped (stale load token)", {
3810
+ containerName,
3811
+ path: isAlreadyHandled ? "claimed" : "par",
3812
+ loadToken: modelFirstPaintedLoadToken,
3813
+ currentToken: this.#modelFirstPaintedLoadToken,
3814
+ });
3815
+ return;
3816
+ }
3817
+ const paintTarget = this.#prefViewer || this.#prefViewer3D || this.#canvas;
3818
+ if (!this.#modelFirstPaintedEmitted && paintTarget) {
3819
+ this.#modelFirstPaintedEmitted = true;
3820
+ try {
3821
+ performance.mark(`viewer:model-first-painted:emitted:${modelFirstPaintedLoadToken}`);
3822
+ } catch {
3823
+ // ignore
3824
+ }
3825
+ console.log("[viewer] model-first-painted ✅ emitted", {
3826
+ containerName,
3827
+ path: isAlreadyHandled ? "claimed" : "par",
3828
+ loadToken: modelFirstPaintedLoadToken,
3829
+ tAfterPaint: Math.round(performance.now()),
3830
+ });
3831
+ this.#emitModelFirstPainted({
3832
+ containerName,
3833
+ path: isAlreadyHandled ? "claimed" : "par",
3834
+ tIndepStart: Math.round(tIndepStart),
3835
+ tAfterLoad: tAfterLoad != null ? Math.round(tAfterLoad) : null,
3836
+ tAfterWait: tAfterWait != null ? Math.round(tAfterWait) : null,
3837
+ tAfterMerge: Math.round(tAfterMerge),
3838
+ tAfterPaint: Math.round(performance.now()),
3839
+ });
3840
+ }
3841
+ })();
3842
+ }
3843
+
3844
+ // In the optimistic path: load runs first, then wait. In the sequential path: wait
3845
+ // runs first, then load. Compute each phase relative to its actual start point.
3846
+ const tLoad = isAlreadyHandled
3847
+ ? Math.round(tAfterLoad - tAfterWait)
3848
+ : Math.round(tAfterLoad - tIndepStart);
3849
+ const tWait = isAlreadyHandled
3850
+ ? Math.round(tAfterWait - tIndepStart)
3851
+ : Math.round(tAfterWait - tAfterLoad);
3852
+ // Merge starts after the wait in both paths (optimistic: load→wait→merge; sequential: wait→load→merge)
3853
+ const tMerge = Math.round(tAfterMerge - (isAlreadyHandled ? tAfterLoad : tAfterWait));
3854
+ const tShaders = Math.round(tAfterShaders - tAfterMerge);
3855
+ const tTotal = Math.round(tAfterShaders - tIndepStart);
3856
+ this.#emitSceneLoadPhase("scene-load-shaders-complete", {
3857
+ containerName,
3858
+ path,
3859
+ tAfterShaders: Math.round(tAfterShaders),
3860
+ tAfterMerge: Math.round(tAfterMerge),
3861
+ tTotal,
3862
+ });
3863
+ this.#emitSceneLoadPhase("scene-load-container-complete", {
3864
+ containerName,
3865
+ path,
3866
+ tLoad,
3867
+ tWait,
3868
+ tMerge,
3869
+ tShaders,
3870
+ tTotal,
3871
+ });
3872
+ console.log(`[perf][${containerName}:indep:${path}] load=${tLoad}ms wait=${tWait}ms merge=${tMerge}ms shaders=${tShaders}ms total=${tTotal}ms`);
3873
+
3874
+ return { success: true, error: null, loadedContainers: [containerName], loadSessionId };
3875
+ } catch (error) {
3876
+ assetContainer?.dispose();
3877
+ if (!this.#state.render.isLoopRunning && this.#scene && this.#engine) {
3878
+ this.#startEngineRenderLoop();
3879
+ }
3880
+ return { success: false, error, loadedContainers: [], loadSessionId };
3881
+ } finally {
3882
+ if (containerName === "model") this.#isLoadingModelIndependent = false;
3883
+ else if (containerName === "environment") this.#isLoadingEnvironmentIndependent = false;
3884
+ else if (containerName === "materials") this.#isLoadingMaterialsIndependent = false;
3885
+ }
3886
+ }
3887
+
3183
3888
  /**
3184
3889
  * Applies camera options from the configuration to the active camera.
3185
3890
  * Stops and restarts the render loop to apply changes.
@@ -3384,3 +4089,4 @@ export default class BabylonJSController {
3384
4089
  }
3385
4090
  }
3386
4091
  }
4092
+