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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { ArcRotateCamera, AssetContainer, Camera, Color3, Color4, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, ImageProcessingConfiguration, LoadAssetContainerAsync, Material, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Texture, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRFeatureName, WebXRSessionManager, WebXRState, WhenTextureReadyAsync } from "@babylonjs/core";
1
+ import { ArcRotateCamera, AssetContainer, BRDFTextureTools, Camera, Color3, Color4, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, ImageProcessingConfiguration, LoadAssetContainerAsync, Material, MeshBuilder, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Texture, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRFeatureName, WebXRSessionManager, WebXRState, WhenTextureReadyAsync } from "@babylonjs/core";
2
2
  import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression.js";
3
3
  import "@babylonjs/loaders";
4
4
  import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_draco_mesh_compression.js";
@@ -54,9 +54,10 @@ export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlass
54
54
  * Public API Highlights
55
55
  * - constructor(canvas, containers, options)
56
56
  * - enable() / disable()
57
- * - load() / reloadWithCurrentSettings()
57
+ * - load(force?) / reloadWithCurrentSettings()
58
58
  * - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
59
59
  * - getRenderSettings() / scheduleRenderSettingsReload(settings)
60
+ * - getDebugInfo()
60
61
  * - setContainerVisibility(name, show)
61
62
  * - setMaterialOptions() / setCameraOptions() / setIBLOptions()
62
63
  * - setIlluminationConfig(config)
@@ -96,10 +97,13 @@ export default class BabylonJSController {
96
97
  ambientOcclusionEnabled: true,
97
98
  iblEnabled: true,
98
99
  shadowsEnabled: false,
99
- lightingTimeOfDay: 0.5,
100
- // Highlight settings
100
+ // Animation menu and highlight settings
101
+ showAnimationMenu: true,
101
102
  highlightEnabled: true,
102
103
  highlightColor: PrefViewerStyleVariables.colorPrimary,
104
+ // Lighting time of day (0=midnight, 0.5=noon, 1=midnight)
105
+ showLightingTimeOfDay: true,
106
+ lightingTimeOfDay: 0.5,
103
107
  };
104
108
 
105
109
  // Canvas HTML element
@@ -120,7 +124,7 @@ export default class BabylonJSController {
120
124
  #shadowGen = [];
121
125
  #XRExperience = null;
122
126
  #canvasResizeObserver = null;
123
-
127
+
124
128
  #hdrTexture = null; // Reusable in-memory HDR source cloned into scene.environmentTexture across reloads
125
129
 
126
130
  #containers = {};
@@ -188,6 +192,11 @@ export default class BabylonJSController {
188
192
  resize: {
189
193
  throttleMs: 50, // Cap resize work to ~20 Hz while dragging/resizing containers
190
194
  },
195
+ defaultCameraFraming: {
196
+ margin: 1.25, // Breathing room so the model doesn't touch the viewport edges
197
+ minRadiusFactor: 0.25, // Zoom in on detail
198
+ maxRadiusFactor: 4, // Pull back past the whole model
199
+ },
191
200
  // Hardware-adaptive quality settings (applied based on GPU capabilities)
192
201
  quality: {
193
202
  // Settings for different hardware tiers: 'low', 'medium', 'high'
@@ -209,6 +218,24 @@ export default class BabylonJSController {
209
218
  hemiLightIntensity: 0.52,
210
219
  dirLightIntensity: 0.56,
211
220
  cameraLightIntensity: 0.16,
221
+ // Daylight background, in the LINEAR domain the tonemapper consumes — NOT sRGB.
222
+ //
223
+ // The clear colour goes through the same exposure + ACES + gamma chain as the
224
+ // render, so the value that reads correctly here is not the value that reaches the
225
+ // screen. The previous literal was an sRGB 0.98 ("almost white") and it arrived as
226
+ // #C0C0C0, a mid grey.
227
+ //
228
+ // This one is solved against the ACES fit at exposure 0.92 and verified on screen:
229
+ // a light studio grey, #D9D9D9. Other targets, if this needs revisiting —
230
+ // #F4F3F0 warm off-white (matches the host app's 2D/PR surface) -> 4.289 3.991 3.304
231
+ // #D9D9D9 light studio (current) -> 1.424
232
+ // #CCCCCC neutral studio -> 1.112
233
+ // #B3B3B3 darker -> 0.688
234
+ // Note how steeply the required input climbs near white: that is the ACES shoulder,
235
+ // and it is why a near-white background needs a value well above 1.
236
+ //
237
+ // Re-solve these if exposure, contrast or the tonemapping curve change.
238
+ backgroundDay: [1.424, 1.424, 1.424],
212
239
  },
213
240
  },
214
241
  };
@@ -222,18 +249,18 @@ export default class BabylonJSController {
222
249
  #detectHardwareTier() {
223
250
  // First, try to get WebGL info to help identify the device
224
251
  const gl = this.#engine?.getRenderingCanvas()?.getContext?.('webgl2') || this.#engine?.getRenderingCanvas()?.getContext?.('webgl');
225
-
252
+
226
253
  const debugInfo = gl?.getExtension('WEBGL_debug_renderer_info');
227
254
  const rendererName = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : '';
228
255
  const isAngleRenderer = /^ANGLE\s*\(/i.test(rendererName);
229
-
256
+
230
257
  // If we have WebGL renderer info and it looks like a desktop GPU, skip mobile detection
231
258
  // Desktop GPUs typically have "NVIDIA", "AMD", "Intel" in the name
232
259
  const isDesktopGPU = /NVIDIA|AMD|Intel|Radeon|GeForce|Quadro/i.test(rendererName);
233
-
260
+
234
261
  // Also check if it's explicitly a mobile GPU
235
262
  const isMobileGPU = /Adreno|Mali|Apple GPU/i.test(rendererName);
236
-
263
+
237
264
  // If we detect a desktop GPU, go directly to desktop detection
238
265
  if (isDesktopGPU && !isMobileGPU) {
239
266
  if (isAngleRenderer) {
@@ -252,7 +279,7 @@ export default class BabylonJSController {
252
279
 
253
280
  // If no WebGL info or unclear, fall back to user agent detection
254
281
  const mobileInfo = this.#detectMobileInfo();
255
-
282
+
256
283
  // Only treat as mobile if we have strong mobile indicators
257
284
  if (mobileInfo.isMobile && !mobileInfo.isDesktopLike) {
258
285
  if (mobileInfo.isHighEnd) {
@@ -279,15 +306,15 @@ export default class BabylonJSController {
279
306
  #detectDesktopTier(gl, rendererName, conservative = false) {
280
307
  // Check WebGL capabilities
281
308
  const maxTextureSize = gl?.getParameter(gl.MAX_TEXTURE_SIZE) || 8192;
282
-
309
+
283
310
  // Detect high-end GPUs
284
311
  const isAppleSilicon = /Apple|M1|M2|M3|M4/i.test(rendererName);
285
312
  const isNvidiaHighEnd = /RTX|GeForce (3|4|5|6|7|8|9|40|50|60|70|80|90)|RTX A[5-9]|RTX Pro|Quadro/i.test(rendererName);
286
313
  const isAmdHighEnd = /RX (6|7|8|9)[0-9]{2,}|RX Vega|Radeon Pro|WX [0-9]/i.test(rendererName);
287
314
  const isIntelIntegrated = /Intel.*UHD|Intel.*Iris|Mesa|llvmpipe/i.test(rendererName);
288
-
315
+
289
316
  let tier = conservative ? 'medium' : 'high'; // Default to conservative desktop when ANGLE is in the path
290
-
317
+
291
318
  if (isIntelIntegrated || maxTextureSize < 8192) {
292
319
  tier = 'medium';
293
320
  } else if (!conservative && (isAppleSilicon || isNvidiaHighEnd || isAmdHighEnd || maxTextureSize >= 16384)) {
@@ -295,7 +322,7 @@ export default class BabylonJSController {
295
322
  } else if (conservative && maxTextureSize >= 16384) {
296
323
  tier = 'high';
297
324
  }
298
-
325
+
299
326
  console.log(`[PrefViewer] Desktop tier: ${tier} (GPU: ${rendererName || 'unknown'}, MaxTexture: ${maxTextureSize})`);
300
327
  return tier;
301
328
  }
@@ -311,13 +338,13 @@ export default class BabylonJSController {
311
338
  const screenWidth = window.screen?.width || 0;
312
339
  const screenHeight = window.screen?.height || 0;
313
340
  const pixelRatio = window.devicePixelRatio || 1;
314
-
341
+
315
342
  // Detect device type
316
343
  const isIPhone = /iPhone/i.test(userAgent);
317
344
  const isIPad = /iPad/i.test(userAgent);
318
345
  const isAndroid = /Android/i.test(userAgent);
319
346
  const isMobile = /Mobile/i.test(userAgent);
320
-
347
+
321
348
  // High-end mobile patterns (recent flagship devices)
322
349
  const highEndPatterns = [
323
350
  /iPhone (1[4-9]|SE)/i, // iPhone 14+
@@ -329,7 +356,7 @@ export default class BabylonJSController {
329
356
  /Xiaomi (1[2-4]|Mi 1[1-4])/i, // Xiaomi 12+
330
357
  /Huawei (P50|Mate 4|)/i, // Huawei P50+
331
358
  ];
332
-
359
+
333
360
  // Mid-range patterns
334
361
  const midRangePatterns = [
335
362
  /iPhone (1[0-3]|SE)/i, // iPhone 10-13
@@ -339,20 +366,20 @@ export default class BabylonJSController {
339
366
  /Pixel [4-6]/i, // Pixel 4-6
340
367
  /Moto G/i, // Moto G
341
368
  ];
342
-
369
+
343
370
  const isHighEnd = highEndPatterns.some(p => p.test(userAgent));
344
371
  const isMidRange = midRangePatterns.some(p => p.test(userAgent));
345
-
372
+
346
373
  // Detect tablet ONLY if explicitly in user agent (not just screen size)
347
374
  // Many desktops have large screens and would incorrectly be detected as tablets
348
375
  const isTablet = /iPad|Android.*Tablet|Nexus 9|Nexus 10/i.test(userAgent);
349
-
376
+
350
377
  // Detect if mobile (phone form factor) - must have explicit mobile indicators
351
378
  const isMobilePhone = /Android.*Mobile|iPhone|iPod/i.test(userAgent);
352
-
379
+
353
380
  // Check if it looks like a desktop/laptop (has "Windows", "Mac", "Linux" but no mobile indicators)
354
381
  const isDesktopLike = /Windows NT|Macintosh|Linux|X11/i.test(userAgent) && !isMobilePhone && !isTablet;
355
-
382
+
356
383
  // Device name for logging
357
384
  let deviceName = 'Unknown';
358
385
  if (isIPhone) {
@@ -364,7 +391,7 @@ export default class BabylonJSController {
364
391
  const match = userAgent.match(/Android.*;.*(.+?)\)/);
365
392
  deviceName = match ? match[1].trim() : 'Android';
366
393
  }
367
-
394
+
368
395
  return {
369
396
  isMobile: isMobilePhone && !isDesktopLike,
370
397
  isTablet: isTablet && !isDesktopLike,
@@ -445,10 +472,10 @@ export default class BabylonJSController {
445
472
  };
446
473
 
447
474
  const preset = presetMap[tier] || qualityPresets.medium;
448
-
475
+
449
476
  // Apply preset to config
450
- this.#config.quality = {
451
- tier,
477
+ this.#config.quality = {
478
+ tier,
452
479
  ...preset,
453
480
  // Legacy compatibility
454
481
  shadowMapSize: preset.shadowMapSize,
@@ -642,6 +669,55 @@ export default class BabylonJSController {
642
669
  }
643
670
  }
644
671
 
672
+ /**
673
+ * Preloads Babylon's internal BRDF lookup texture required by PBR materials.
674
+ * In render-on-demand hosts the RGBD expansion can stall unless a few renders are kept alive.
675
+ * @private
676
+ * @returns {Promise<boolean>} True when the texture is ready, false when it timed out or is unavailable.
677
+ */
678
+ async #ensureEnvironmentBRDFTextureReady() {
679
+ if (!this.#scene || !this.#engine) {
680
+ return false;
681
+ }
682
+
683
+ const brdfTexture = BRDFTextureTools.GetEnvironmentBRDFTexture(this.#scene);
684
+ if (!brdfTexture || brdfTexture.isReady?.()) {
685
+ return true;
686
+ }
687
+
688
+ const timeoutMs = 2000;
689
+ const startedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
690
+ this.#requestRender({ frames: this.#config.render.burstFramesEnhanced, continuousMs: timeoutMs });
691
+
692
+ try {
693
+ await new Promise((resolve) => {
694
+ const tick = () => {
695
+ if (!this.#scene || !this.#engine || brdfTexture.isDisposed?.()) {
696
+ resolve(false);
697
+ return;
698
+ }
699
+ if (brdfTexture.isReady?.()) {
700
+ resolve(true);
701
+ return;
702
+ }
703
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
704
+ if (now - startedAt >= timeoutMs) {
705
+ resolve(false);
706
+ return;
707
+ }
708
+ this.#requestRender({ frames: 1, continuousMs: 100 });
709
+ setTimeout(tick, 16);
710
+ };
711
+ tick();
712
+ });
713
+ } catch (error) {
714
+ console.warn("PrefViewer: Error preparing Babylon BRDF texture:", error);
715
+ }
716
+
717
+ this.#requestRender({ frames: 2 });
718
+ return brdfTexture.isReady?.() ?? false;
719
+ }
720
+
645
721
  /**
646
722
  * Starts Babylon's engine render loop if it is not already running.
647
723
  * @private
@@ -669,7 +745,7 @@ export default class BabylonJSController {
669
745
  this.#state.render.isLoopRunning = false;
670
746
  return true;
671
747
  }
672
-
748
+
673
749
  /**
674
750
  * Resets transient render-loop bookkeeping so the next render request starts from a clean baseline.
675
751
  * Clears queued burst frames, ends any active continuous-render window, and drops the idle-throttle timestamp.
@@ -804,12 +880,12 @@ export default class BabylonJSController {
804
880
  }
805
881
 
806
882
  const shouldRender = animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
807
-
883
+
808
884
  // Reset idle flag if we need to render
809
885
  if (shouldRender) {
810
886
  this.#state.render.isIdle = false;
811
887
  }
812
-
888
+
813
889
  return shouldRender;
814
890
  }
815
891
 
@@ -845,7 +921,7 @@ export default class BabylonJSController {
845
921
  if (!continuous && this.#state.render.lastRenderAt > 0 && now - this.#state.render.lastRenderAt < this.#config.render.idleThrottleMs) {
846
922
  return;
847
923
  }
848
-
924
+
849
925
  this.#scene.render();
850
926
  this.#state.render.lastRenderAt = now;
851
927
 
@@ -1570,7 +1646,7 @@ export default class BabylonJSController {
1570
1646
  }
1571
1647
  });
1572
1648
  }
1573
-
1649
+
1574
1650
  /**
1575
1651
  * Applies the showroom tonemapping curve and lighting bias used by the 3D viewer.
1576
1652
  * @private
@@ -1584,7 +1660,8 @@ export default class BabylonJSController {
1584
1660
  const showroom = this.#config.look?.showroom || {};
1585
1661
  const imageProcessing = this.#scene.imageProcessingConfiguration;
1586
1662
 
1587
- this.#scene.clearColor = new Color4(0.98, 0.98, 0.98, 1).toLinearSpace();
1663
+ const bg = showroom.backgroundDay ?? this.#config.look.showroom.backgroundDay;
1664
+ this.#scene.clearColor = new Color4(bg[0], bg[1], bg[2], 1);
1588
1665
  imageProcessing.exposure = showroom.exposure ?? 0.95;
1589
1666
  imageProcessing.contrast = showroom.contrast ?? 1.08;
1590
1667
  imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
@@ -1608,7 +1685,7 @@ export default class BabylonJSController {
1608
1685
  }
1609
1686
 
1610
1687
  const showroom = this.#config.look?.showroom || {};
1611
- const timeOfDay = clamp01(this.#settings.lightingTimeOfDay ?? 0.5);
1688
+ const timeOfDay = clamp01(this.#settings.lightingTimeOfDay ? this.#settings.lightingTimeOfDay ?? 0.5 : 0.5);
1612
1689
  const profile = getLightingCycleProfile(timeOfDay);
1613
1690
  const daylight = profile.dayFactor;
1614
1691
  const peakLight = daylight > 0 ? Math.pow(daylight, 0.72) : 0;
@@ -1624,12 +1701,15 @@ export default class BabylonJSController {
1624
1701
  imageProcessing.colorCurvesEnabled = false;
1625
1702
  }
1626
1703
 
1704
+ // Night end stays in low linear values (a dark blue sky); the day end is the calibrated
1705
+ // HDR off-white. Both are linear already, so no toLinearSpace() on either.
1706
+ const bgDay = showroom.backgroundDay ?? this.#config.look.showroom.backgroundDay;
1627
1707
  this.#scene.clearColor = new Color4(
1628
- lerp(0.035, 0.98, daylight),
1629
- lerp(0.045, 0.98, daylight),
1630
- lerp(0.085, 0.98, daylight),
1708
+ lerp(0.000627, bgDay[0], daylight),
1709
+ lerp(0.001089, bgDay[1], daylight),
1710
+ lerp(0.004413, bgDay[2], daylight),
1631
1711
  1,
1632
- ).toLinearSpace();
1712
+ );
1633
1713
 
1634
1714
  if ("environmentIntensity" in this.#scene) {
1635
1715
  this.#scene.environmentIntensity = lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
@@ -1701,6 +1781,78 @@ export default class BabylonJSController {
1701
1781
  this.#engine.setHardwareScalingLevel(scalingLevel);
1702
1782
  }
1703
1783
 
1784
+ /**
1785
+ * World-space bounds of the loaded model's renderable geometry.
1786
+ * @private
1787
+ * @returns {{center: Vector3, radius: number}|null} Centre and bounding-sphere radius, or null when nothing renderable is loaded.
1788
+ */
1789
+ #getModelBounds() {
1790
+ const meshes = (this.#containers?.model?.assetContainer?.meshes ?? []).filter((mesh) => (mesh?.getTotalVertices?.() ?? 0) > 0);
1791
+ if (meshes.length === 0) {
1792
+ return null;
1793
+ }
1794
+
1795
+ let min = null;
1796
+ let max = null;
1797
+ meshes.forEach((mesh) => {
1798
+ mesh.computeWorldMatrix(true);
1799
+ const box = mesh.getBoundingInfo?.()?.boundingBox;
1800
+ if (!box) {
1801
+ return;
1802
+ }
1803
+ min = min ? Vector3.Minimize(min, box.minimumWorld) : box.minimumWorld.clone();
1804
+ max = max ? Vector3.Maximize(max, box.maximumWorld) : box.maximumWorld.clone();
1805
+ });
1806
+
1807
+ if (!min || !max) {
1808
+ return null;
1809
+ }
1810
+
1811
+ const radius = Vector3.Distance(min, max) / 2;
1812
+ if (!Number.isFinite(radius) || radius <= 0) {
1813
+ return null;
1814
+ }
1815
+
1816
+ return { center: Vector3.Center(min, max), radius };
1817
+ }
1818
+
1819
+ /**
1820
+ * Frames the default camera on the loaded model: aims it at the model's centre and pulls it
1821
+ * back far enough for the whole model to fit the viewport.
1822
+ *
1823
+ * Only ever applied to the viewer's own camera — a camera authored in the model or the scene
1824
+ * is a composition choice and is left untouched. Orbit angles are preserved so a model reload
1825
+ * (an option or dimension change) re-centres without snapping the user's viewpoint back.
1826
+ * @private
1827
+ * @param {Camera|null|undefined} camera - The camera about to become active.
1828
+ * @returns {void}
1829
+ */
1830
+ #fitDefaultCameraToModel(camera) {
1831
+ if (camera !== this.#camera || !(camera instanceof ArcRotateCamera)) {
1832
+ return;
1833
+ }
1834
+
1835
+ const bounds = this.#getModelBounds();
1836
+ if (!bounds) {
1837
+ return;
1838
+ }
1839
+
1840
+ // fovMode is FOVMODE_HORIZONTAL_FIXED (see #applyShowroomCameraFraming), so camera.fov is
1841
+ // the horizontal angle and the vertical one follows the aspect ratio. Fitting to the
1842
+ // horizontal angle alone clips the model top and bottom on any viewport wider than tall.
1843
+ const aspect = this.#engine?.getAspectRatio?.(camera) || 1;
1844
+ const horizontalFov = camera.fov;
1845
+ const verticalFov = 2 * Math.atan(Math.tan(horizontalFov / 2) / aspect);
1846
+ const limitingFov = Math.min(horizontalFov, verticalFov);
1847
+ const distance = (bounds.radius / Math.sin(limitingFov / 2)) * this.#config.defaultCameraFraming.margin;
1848
+
1849
+ camera.setTarget(bounds.center);
1850
+ camera.radius = distance;
1851
+ // The 5-20 m defaults were sized for a room; left in place they clamp the fit itself.
1852
+ camera.lowerRadiusLimit = distance * this.#config.defaultCameraFraming.minRadiusFactor;
1853
+ camera.upperRadiusLimit = distance * this.#config.defaultCameraFraming.maxRadiusFactor;
1854
+ }
1855
+
1704
1856
  /**
1705
1857
  * Keeps the showroom camera framing stable when the viewport aspect ratio changes.
1706
1858
  * The 3D showroom is meant to feel compositionally stable, so we prefer a horizontal-fixed
@@ -1931,7 +2083,7 @@ export default class BabylonJSController {
1931
2083
  }
1932
2084
 
1933
2085
  /**
1934
- * Attaches the `prefviewer-animation-changed` listener to the nearest `pref-viewer-3d` host.
2086
+ * Attaches the `pref-viewer-3d-animation-changed` listener to the nearest `pref-viewer-3d` host.
1935
2087
  * Removes any previous registration first to avoid duplicate callbacks across reload cycles.
1936
2088
  * @private
1937
2089
  * @returns {boolean} True when the listener is attached, false when no host is available.
@@ -1942,12 +2094,12 @@ export default class BabylonJSController {
1942
2094
  return false;
1943
2095
  }
1944
2096
  this.#detachAnimationChangedListener();
1945
- this.#prefViewer3D.addEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
2097
+ this.#prefViewer3D.addEventListener("pref-viewer-3d-animation-changed", this.#handlers.onAnimationGroupChanged);
1946
2098
  return true;
1947
2099
  }
1948
2100
 
1949
2101
  /**
1950
- * Detaches the `prefviewer-animation-changed` listener from the cached `pref-viewer-3d` host.
2102
+ * Detaches the `pref-viewer-3d-animation-changed` listener from the cached `pref-viewer-3d` host.
1951
2103
  * @private
1952
2104
  * @returns {boolean} True when a host exists and the listener removal was attempted, false otherwise.
1953
2105
  */
@@ -1955,7 +2107,7 @@ export default class BabylonJSController {
1955
2107
  if (!this.#prefViewer3D) {
1956
2108
  return false;
1957
2109
  }
1958
- this.#prefViewer3D.removeEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
2110
+ this.#prefViewer3D.removeEventListener("pref-viewer-3d-animation-changed", this.#handlers.onAnimationGroupChanged);
1959
2111
  return true;
1960
2112
  }
1961
2113
 
@@ -1988,12 +2140,12 @@ export default class BabylonJSController {
1988
2140
  */
1989
2141
  #onMouseWheel(event, pickInfo) {
1990
2142
  event.preventDefault();
1991
-
2143
+
1992
2144
  // OPTIMIZATION: Mark activity for idle mode
1993
2145
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1994
2146
  this.#state.render.lastActivityAt = now;
1995
2147
  this.#state.render.isIdle = false;
1996
-
2148
+
1997
2149
  const camera = this.#scene?.activeCamera;
1998
2150
  if (!camera) {
1999
2151
  return false;
@@ -2028,7 +2180,7 @@ export default class BabylonJSController {
2028
2180
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2029
2181
  this.#state.render.lastActivityAt = now;
2030
2182
  this.#state.render.isIdle = false;
2031
-
2183
+
2032
2184
  if (this.#babylonJSAnimationController) {
2033
2185
  if (event.button !== 2 || !pickInfo || !pickInfo.pickedMesh) {
2034
2186
  this.#babylonJSAnimationController.hideMenu();
@@ -2053,7 +2205,7 @@ export default class BabylonJSController {
2053
2205
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2054
2206
  this.#state.render.lastActivityAt = now;
2055
2207
  this.#state.render.isIdle = false;
2056
-
2208
+
2057
2209
  const camera = this.#scene?.activeCamera;
2058
2210
  if (camera && !camera.metadata?.locked) {
2059
2211
  this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
@@ -2275,6 +2427,7 @@ export default class BabylonJSController {
2275
2427
  }
2276
2428
  }
2277
2429
  this.#applyShowroomCameraFraming(camera);
2430
+ this.#fitDefaultCameraToModel(camera);
2278
2431
  this.#scene.activeCamera?.detachControl();
2279
2432
  camera.detachControl();
2280
2433
  if (!cameraState.locked) {
@@ -2701,20 +2854,34 @@ export default class BabylonJSController {
2701
2854
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
2702
2855
  this.#setMaxSimultaneousLights();
2703
2856
  this.#enhanceTextureQuality();
2704
- this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
2705
- // Apply stored highlight settings so fresh page loads respect persisted state
2706
- this.#setHighlightConfig({
2707
- enabled: this.#settings.highlightEnabled,
2708
- color: this.#settings.highlightColor,
2709
- });
2710
- if (this.#babylonJSAnimationController?.hasAnimations?.()) {
2711
- this.#attachAnimationChangedListener();
2712
- }
2857
+ this.#syncAnimationControllerAfterModelLoad();
2858
+
2713
2859
  await this.#startRender();
2714
2860
  });
2715
2861
  return detail;
2716
2862
  }
2717
2863
 
2864
+ /**
2865
+ * Creates or disposes the animation controller after a model load according to current settings.
2866
+ * @private
2867
+ * @returns {void}
2868
+ */
2869
+ #syncAnimationControllerAfterModelLoad() {
2870
+ if (this.#settings.showAnimationMenu && this.#containers.model.assetContainer) {
2871
+ this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
2872
+ // Apply stored highlight settings so fresh page loads respect persisted state
2873
+ this.#setHighlightConfig({
2874
+ enabled: this.#settings.highlightEnabled,
2875
+ color: this.#settings.highlightColor,
2876
+ });
2877
+ if (this.#babylonJSAnimationController?.hasAnimations?.()) {
2878
+ this.#attachAnimationChangedListener();
2879
+ }
2880
+ } else {
2881
+ this.#disposeAnimationController();
2882
+ }
2883
+ }
2884
+
2718
2885
  /**
2719
2886
  * Reinstalls every camera-sensitive post-process (default pipeline, SSAO, shadows) after loads or option changes.
2720
2887
  * Ensures the active camera always owns fresh pipelines so render quality remains consistent across reload cycles.
@@ -2968,14 +3135,14 @@ export default class BabylonJSController {
2968
3135
  this.#configureDracoCompression();
2969
3136
  this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false }, true);
2970
3137
  this.#engine.disableUniformBuffers = true;
2971
-
3138
+
2972
3139
  // OPTIMIZATION: Detect hardware tier and apply quality settings
2973
3140
  const detectedTier = this.#detectHardwareTier();
2974
3141
  this.#applyQualitySettings(detectedTier);
2975
-
3142
+
2976
3143
  // OPTIMIZATION: Initialize idle tracking
2977
3144
  this.#state.render.lastActivityAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2978
-
3145
+
2979
3146
  this.#scene = new Scene(this.#engine);
2980
3147
 
2981
3148
  // Activate the rendering of geometry data into a G-buffer, essential for advanced effects like deferred shading,
@@ -2996,6 +3163,7 @@ export default class BabylonJSController {
2996
3163
  this.#scene.skipPointerUpPicking = true;
2997
3164
 
2998
3165
  this.#createCamera();
3166
+ await this.#ensureEnvironmentBRDFTextureReady();
2999
3167
  this.#enableInteraction();
3000
3168
  await this.#createXRExperience();
3001
3169
  }
@@ -3151,6 +3319,182 @@ export default class BabylonJSController {
3151
3319
  return { ...this.#settings };
3152
3320
  }
3153
3321
 
3322
+ /**
3323
+ * Returns a diagnostic snapshot of the current Babylon.js runtime state.
3324
+ * Intended for integration troubleshooting and should not mutate scene state.
3325
+ * @public
3326
+ * @returns {object} Debug information for canvas, engine, scene, containers, render loop and settings.
3327
+ */
3328
+ getDebugInfo() {
3329
+ const vectorToJSON = (value) => value ? { x: value.x, y: value.y, z: value.z } : null;
3330
+ const rect = this.#canvas?.getBoundingClientRect?.();
3331
+ const camera = this.#scene?.activeCamera ?? null;
3332
+ const sceneMeshes = this.#scene?.meshes ?? [];
3333
+ const sceneMaterials = this.#scene?.materials ?? [];
3334
+ const sceneTextures = this.#scene?.textures ?? [];
3335
+ const activeMeshes = this.#scene?.getActiveMeshes?.();
3336
+ const modelAssetContainer = this.#containers?.model?.assetContainer ?? null;
3337
+ const modelMeshes = modelAssetContainer?.meshes ?? [];
3338
+ const enabledModelMeshes = modelMeshes.filter((mesh) => mesh.isEnabled?.() !== false && mesh.isVisible !== false);
3339
+ const textureToJSON = (texture) => ({
3340
+ name: texture?.name ?? null,
3341
+ url: texture?.url ?? null,
3342
+ isReady: texture?.isReady?.() ?? null,
3343
+ hasTexture: Boolean(texture),
3344
+ className: texture?.getClassName?.() ?? texture?.constructor?.name ?? null,
3345
+ });
3346
+ const summarizeMaterial = (material) => {
3347
+ const relatedMeshes = sceneMeshes.filter((mesh) => mesh.material === material);
3348
+ const sampleMesh = relatedMeshes[0] ?? null;
3349
+ const activeTextures = material?.getActiveTextures?.() ?? [];
3350
+ let isReadyWithoutMesh = null;
3351
+ let isReadyWithMesh = null;
3352
+ try {
3353
+ isReadyWithoutMesh = typeof material?.isReady === "function" ? material.isReady() : null;
3354
+ } catch (error) {
3355
+ isReadyWithoutMesh = `error: ${error?.message ?? error}`;
3356
+ }
3357
+ try {
3358
+ isReadyWithMesh = typeof material?.isReady === "function" && sampleMesh ? material.isReady(sampleMesh) : null;
3359
+ } catch (error) {
3360
+ isReadyWithMesh = `error: ${error?.message ?? error}`;
3361
+ }
3362
+
3363
+ return {
3364
+ name: material?.name ?? null,
3365
+ id: material?.id ?? null,
3366
+ className: material?.getClassName?.() ?? material?.constructor?.name ?? null,
3367
+ isReadyWithoutMesh,
3368
+ isReadyWithMesh,
3369
+ relatedMeshes: relatedMeshes.length,
3370
+ sampleMesh: sampleMesh ? {
3371
+ name: sampleMesh.name,
3372
+ id: sampleMesh.id,
3373
+ enabled: sampleMesh.isEnabled?.() ?? null,
3374
+ isVisible: sampleMesh.isVisible ?? null,
3375
+ hasVertices: sampleMesh.getTotalVertices?.() ?? null,
3376
+ hasIndices: sampleMesh.getTotalIndices?.() ?? null,
3377
+ } : null,
3378
+ textures: activeTextures.map(textureToJSON),
3379
+ notReadyTextures: activeTextures.filter((texture) => texture?.isReady?.() === false).map(textureToJSON),
3380
+ forceDepthWrite: material?.forceDepthWrite ?? null,
3381
+ transparencyMode: material?.transparencyMode ?? null,
3382
+ alpha: material?.alpha ?? null,
3383
+ backFaceCulling: material?.backFaceCulling ?? null,
3384
+ disableLighting: material?.disableLighting ?? null,
3385
+ };
3386
+ };
3387
+ const materialDiagnostics = sceneMaterials.slice(0, 25).map(summarizeMaterial);
3388
+ const notReadyMaterials = materialDiagnostics
3389
+ .filter((material) => material.isReadyWithoutMesh === false || material.isReadyWithMesh === false || material.notReadyTextures.length > 0)
3390
+ .slice(0, 25);
3391
+ const notReadyTextures = sceneTextures
3392
+ .filter((texture) => texture?.isReady?.() === false)
3393
+ .slice(0, 25)
3394
+ .map(textureToJSON);
3395
+ const notReadyMeshes = sceneMeshes
3396
+ .filter((mesh) => typeof mesh.isReady === "function" && !mesh.isReady())
3397
+ .slice(0, 25)
3398
+ .map((mesh) => ({
3399
+ name: mesh.name,
3400
+ id: mesh.id,
3401
+ enabled: mesh.isEnabled?.() ?? null,
3402
+ isVisible: mesh.isVisible ?? null,
3403
+ material: mesh.material?.name ?? null,
3404
+ }));
3405
+ const modelBounds = enabledModelMeshes.length && this.#scene?.getWorldExtends
3406
+ ? this.#scene.getWorldExtends((mesh) => enabledModelMeshes.includes(mesh))
3407
+ : null;
3408
+ const summarizeContainer = (container) => ({
3409
+ show: container?.state?.show ?? null,
3410
+ visible: container?.state?.visible ?? null,
3411
+ mustBeShown: container?.state?.mustBeShown ?? null,
3412
+ isPending: container?.state?.isPending ?? null,
3413
+ isSuccess: container?.state?.isSuccess ?? null,
3414
+ hasAssetContainer: Boolean(container?.assetContainer),
3415
+ meshes: container?.assetContainer?.meshes?.length ?? 0,
3416
+ enabledMeshes: (container?.assetContainer?.meshes ?? []).filter((mesh) => mesh.isEnabled?.() !== false && mesh.isVisible !== false).length,
3417
+ materials: container?.assetContainer?.materials?.length ?? 0,
3418
+ cameras: container?.assetContainer?.cameras?.map((item) => ({
3419
+ name: item.name,
3420
+ id: item.id,
3421
+ className: item.getClassName?.() ?? item.constructor?.name ?? null,
3422
+ position: vectorToJSON(item.position),
3423
+ target: vectorToJSON(item.target),
3424
+ })) ?? [],
3425
+ animationGroups: container?.assetContainer?.animationGroups?.length ?? 0,
3426
+ });
3427
+
3428
+ return {
3429
+ canvas: {
3430
+ clientWidth: this.#canvas?.clientWidth ?? null,
3431
+ clientHeight: this.#canvas?.clientHeight ?? null,
3432
+ width: this.#canvas?.width ?? null,
3433
+ height: this.#canvas?.height ?? null,
3434
+ boundingWidth: rect?.width ?? null,
3435
+ boundingHeight: rect?.height ?? null,
3436
+ },
3437
+ engine: {
3438
+ renderWidth: this.#engine?.getRenderWidth?.() ?? null,
3439
+ renderHeight: this.#engine?.getRenderHeight?.() ?? null,
3440
+ hardwareScalingLevel: this.#engine?.getHardwareScalingLevel?.() ?? null,
3441
+ isPointerLock: this.#engine?.isPointerLock ?? null,
3442
+ isDisposed: this.#engine?.isDisposed ?? null,
3443
+ contextLost: this.#engine?.isContextLost?.() ?? null,
3444
+ },
3445
+ scene: {
3446
+ uid: this.#scene?.uid ?? null,
3447
+ isReady: this.#scene?.isReady?.() ?? null,
3448
+ meshes: sceneMeshes.length,
3449
+ visibleMeshes: activeMeshes?.length ?? activeMeshes?.data?.length ?? null,
3450
+ materials: sceneMaterials.length,
3451
+ textures: sceneTextures.length,
3452
+ lights: this.#scene?.lights?.length ?? 0,
3453
+ animationGroups: this.#scene?.animationGroups?.length ?? 0,
3454
+ activeCamera: camera ? {
3455
+ name: camera.name,
3456
+ id: camera.id,
3457
+ className: camera.getClassName?.() ?? camera.constructor?.name ?? null,
3458
+ position: vectorToJSON(camera.position),
3459
+ target: vectorToJSON(camera.target),
3460
+ radius: camera.radius ?? null,
3461
+ alpha: camera.alpha ?? null,
3462
+ beta: camera.beta ?? null,
3463
+ } : null,
3464
+ clearColor: this.#scene?.clearColor ? {
3465
+ r: this.#scene.clearColor.r,
3466
+ g: this.#scene.clearColor.g,
3467
+ b: this.#scene.clearColor.b,
3468
+ a: this.#scene.clearColor.a,
3469
+ } : null,
3470
+ environmentTexture: this.#scene?.environmentTexture ? {
3471
+ name: this.#scene.environmentTexture.name,
3472
+ isReady: this.#scene.environmentTexture.isReady?.() ?? null,
3473
+ } : null,
3474
+ notReadyMaterials,
3475
+ notReadyTextures,
3476
+ materialDiagnostics,
3477
+ notReadyMeshes,
3478
+ },
3479
+ containers: {
3480
+ model: summarizeContainer(this.#containers.model),
3481
+ environment: summarizeContainer(this.#containers.environment),
3482
+ materials: summarizeContainer(this.#containers.materials),
3483
+ },
3484
+ modelBounds: modelBounds ? {
3485
+ min: vectorToJSON(modelBounds.min),
3486
+ max: vectorToJSON(modelBounds.max),
3487
+ } : null,
3488
+ render: { ...this.#state.render },
3489
+ settings: { ...this.#settings },
3490
+ pipelines: {
3491
+ default: Boolean(this.#renderPipelines.default),
3492
+ ssao: Boolean(this.#renderPipelines.ssao),
3493
+ iblShadows: Boolean(this.#renderPipelines.iblShadows),
3494
+ },
3495
+ };
3496
+ }
3497
+
3154
3498
  /**
3155
3499
  * Loads all asset containers (model, environment, materials, etc.) and adds them to the scene.
3156
3500
  * Applies material and camera options, sets visibility, and initializes lights and shadows.
@@ -3203,14 +3547,42 @@ export default class BabylonJSController {
3203
3547
  }
3204
3548
 
3205
3549
  /**
3206
- * Applies lighting and highlight configuration without forcing a full scene reload.
3550
+ * Applies animation-menu, highlight, and time-of-day lighting configuration without forcing a full scene reload.
3551
+ * Values are merged into the persisted render settings snapshot unless `config.persist === false`.
3552
+ * When `showLightingTimeOfDay` is set to false and no explicit `lightingTimeOfDay` is supplied,
3553
+ * the active lighting time is reset to `DEFAULT_RENDER_SETTINGS.lightingTimeOfDay` (0.5).
3207
3554
  * @public
3208
- * @param {{lightingTimeOfDay?:number, highlightColor?:string, highlightEnabled?:boolean, showAnimationMenu?:boolean}} config - Lighting/highlight configuration.
3555
+ * @param {object} config - Immediate render/illumination configuration.
3556
+ * @param {boolean|string} [config.showAnimationMenu] - Enables/disables the animation controls when animations are available.
3557
+ * @param {boolean|string|null} [config.highlightEnabled] - Enables/disables hover highlight; string "false" is normalized to false, null is ignored.
3558
+ * @param {string|null} [config.highlightColor] - Hex color used by hover highlight; null is ignored.
3559
+ * @param {boolean|string} [config.showLightingTimeOfDay] - Shows/hides the time-of-day control and can reset time to the default when hidden.
3560
+ * @param {number} [config.lightingTimeOfDay] - Normalized time of day in the 0..1 range.
3561
+ * @param {boolean} [config.persist=true] - False for transient updates such as lighting playback frames.
3209
3562
  * @returns {void}
3210
3563
  */
3211
3564
  setIlluminationConfig(config = {}) {
3212
3565
  let shouldApplyLighting = false;
3213
3566
 
3567
+ if (config.showLightingTimeOfDay !== undefined) {
3568
+ const showLighting = typeof config.showLightingTimeOfDay === "string"
3569
+ ? config.showLightingTimeOfDay.toLowerCase() !== "false"
3570
+ : Boolean(config.showLightingTimeOfDay);
3571
+ if (this.#settings.showLightingTimeOfDay !== showLighting) {
3572
+ shouldApplyLighting = true;
3573
+ }
3574
+ this.#settings.showLightingTimeOfDay = showLighting;
3575
+
3576
+ const hasExplicitLightingTime = typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay);
3577
+ if (!showLighting && !hasExplicitLightingTime) {
3578
+ const defaultTime = BabylonJSController.DEFAULT_RENDER_SETTINGS.lightingTimeOfDay;
3579
+ if (this.#settings.lightingTimeOfDay !== defaultTime) {
3580
+ this.#settings.lightingTimeOfDay = defaultTime;
3581
+ shouldApplyLighting = true;
3582
+ }
3583
+ }
3584
+ }
3585
+
3214
3586
  if (typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay)) {
3215
3587
  const normalizedTime = clamp01(config.lightingTimeOfDay);
3216
3588
  if (this.#settings.lightingTimeOfDay !== normalizedTime) {
@@ -3220,8 +3592,11 @@ export default class BabylonJSController {
3220
3592
  }
3221
3593
 
3222
3594
  if (config.showAnimationMenu !== undefined) {
3223
- // Handle show-animation-menu via the 3D component's parent
3224
- // This is handled in pref-viewer-3d.js attributeChangedCallback
3595
+ const showMenu = typeof config.showAnimationMenu === "string"
3596
+ ? config.showAnimationMenu.toLowerCase() !== "false"
3597
+ : Boolean(config.showAnimationMenu);
3598
+ this.#settings.showAnimationMenu = showMenu;
3599
+ this.#syncAnimationControllerAfterModelLoad();
3225
3600
  }
3226
3601
 
3227
3602
  // Handle highlight color for animation hover
@@ -3242,7 +3617,7 @@ export default class BabylonJSController {
3242
3617
  }
3243
3618
  this.#setHighlightConfig({
3244
3619
  color: config.highlightColor ?? undefined,
3245
- enabled,
3620
+ enabled: enabled,
3246
3621
  });
3247
3622
  }
3248
3623
 
@@ -3281,11 +3656,13 @@ export default class BabylonJSController {
3281
3656
  }
3282
3657
 
3283
3658
  /**
3284
- * Merges incoming render flags with the current configuration, persists them, and marks
3659
+ * Merges reload-oriented render flags with the current configuration, persists them, and marks
3285
3660
  * all dependent loaders/options as pending when something actually changed.
3661
+ * Immediate settings such as highlight, animation menu, and time-of-day lighting are handled by
3662
+ * `setIlluminationConfig()` before this method receives the remaining reload settings.
3286
3663
  * @public
3287
- * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of render toggles to apply.
3288
- * @returns {{changed:boolean, settings:{antiAliasingEnabled:boolean, ambientOcclusionEnabled:boolean, iblEnabled:boolean, shadowsEnabled:boolean}}}
3664
+ * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of reload render toggles to apply.
3665
+ * @returns {{changed:boolean, settings:object}} Current full render settings snapshot and whether a reload is required.
3289
3666
  * @description
3290
3667
  * Callers can inspect the `changed` flag to decide whether to trigger a reload with
3291
3668
  * `reloadWithCurrentSettings()` or simply reuse the returned snapshot.