@preference-sl/pref-viewer 2.16.0-beta.10 → 2.16.0-beta.12

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 = {};
@@ -222,18 +226,18 @@ export default class BabylonJSController {
222
226
  #detectHardwareTier() {
223
227
  // First, try to get WebGL info to help identify the device
224
228
  const gl = this.#engine?.getRenderingCanvas()?.getContext?.('webgl2') || this.#engine?.getRenderingCanvas()?.getContext?.('webgl');
225
-
229
+
226
230
  const debugInfo = gl?.getExtension('WEBGL_debug_renderer_info');
227
231
  const rendererName = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : '';
228
232
  const isAngleRenderer = /^ANGLE\s*\(/i.test(rendererName);
229
-
233
+
230
234
  // If we have WebGL renderer info and it looks like a desktop GPU, skip mobile detection
231
235
  // Desktop GPUs typically have "NVIDIA", "AMD", "Intel" in the name
232
236
  const isDesktopGPU = /NVIDIA|AMD|Intel|Radeon|GeForce|Quadro/i.test(rendererName);
233
-
237
+
234
238
  // Also check if it's explicitly a mobile GPU
235
239
  const isMobileGPU = /Adreno|Mali|Apple GPU/i.test(rendererName);
236
-
240
+
237
241
  // If we detect a desktop GPU, go directly to desktop detection
238
242
  if (isDesktopGPU && !isMobileGPU) {
239
243
  if (isAngleRenderer) {
@@ -252,7 +256,7 @@ export default class BabylonJSController {
252
256
 
253
257
  // If no WebGL info or unclear, fall back to user agent detection
254
258
  const mobileInfo = this.#detectMobileInfo();
255
-
259
+
256
260
  // Only treat as mobile if we have strong mobile indicators
257
261
  if (mobileInfo.isMobile && !mobileInfo.isDesktopLike) {
258
262
  if (mobileInfo.isHighEnd) {
@@ -279,15 +283,15 @@ export default class BabylonJSController {
279
283
  #detectDesktopTier(gl, rendererName, conservative = false) {
280
284
  // Check WebGL capabilities
281
285
  const maxTextureSize = gl?.getParameter(gl.MAX_TEXTURE_SIZE) || 8192;
282
-
286
+
283
287
  // Detect high-end GPUs
284
288
  const isAppleSilicon = /Apple|M1|M2|M3|M4/i.test(rendererName);
285
289
  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
290
  const isAmdHighEnd = /RX (6|7|8|9)[0-9]{2,}|RX Vega|Radeon Pro|WX [0-9]/i.test(rendererName);
287
291
  const isIntelIntegrated = /Intel.*UHD|Intel.*Iris|Mesa|llvmpipe/i.test(rendererName);
288
-
292
+
289
293
  let tier = conservative ? 'medium' : 'high'; // Default to conservative desktop when ANGLE is in the path
290
-
294
+
291
295
  if (isIntelIntegrated || maxTextureSize < 8192) {
292
296
  tier = 'medium';
293
297
  } else if (!conservative && (isAppleSilicon || isNvidiaHighEnd || isAmdHighEnd || maxTextureSize >= 16384)) {
@@ -295,7 +299,7 @@ export default class BabylonJSController {
295
299
  } else if (conservative && maxTextureSize >= 16384) {
296
300
  tier = 'high';
297
301
  }
298
-
302
+
299
303
  console.log(`[PrefViewer] Desktop tier: ${tier} (GPU: ${rendererName || 'unknown'}, MaxTexture: ${maxTextureSize})`);
300
304
  return tier;
301
305
  }
@@ -311,13 +315,13 @@ export default class BabylonJSController {
311
315
  const screenWidth = window.screen?.width || 0;
312
316
  const screenHeight = window.screen?.height || 0;
313
317
  const pixelRatio = window.devicePixelRatio || 1;
314
-
318
+
315
319
  // Detect device type
316
320
  const isIPhone = /iPhone/i.test(userAgent);
317
321
  const isIPad = /iPad/i.test(userAgent);
318
322
  const isAndroid = /Android/i.test(userAgent);
319
323
  const isMobile = /Mobile/i.test(userAgent);
320
-
324
+
321
325
  // High-end mobile patterns (recent flagship devices)
322
326
  const highEndPatterns = [
323
327
  /iPhone (1[4-9]|SE)/i, // iPhone 14+
@@ -329,7 +333,7 @@ export default class BabylonJSController {
329
333
  /Xiaomi (1[2-4]|Mi 1[1-4])/i, // Xiaomi 12+
330
334
  /Huawei (P50|Mate 4|)/i, // Huawei P50+
331
335
  ];
332
-
336
+
333
337
  // Mid-range patterns
334
338
  const midRangePatterns = [
335
339
  /iPhone (1[0-3]|SE)/i, // iPhone 10-13
@@ -339,20 +343,20 @@ export default class BabylonJSController {
339
343
  /Pixel [4-6]/i, // Pixel 4-6
340
344
  /Moto G/i, // Moto G
341
345
  ];
342
-
346
+
343
347
  const isHighEnd = highEndPatterns.some(p => p.test(userAgent));
344
348
  const isMidRange = midRangePatterns.some(p => p.test(userAgent));
345
-
349
+
346
350
  // Detect tablet ONLY if explicitly in user agent (not just screen size)
347
351
  // Many desktops have large screens and would incorrectly be detected as tablets
348
352
  const isTablet = /iPad|Android.*Tablet|Nexus 9|Nexus 10/i.test(userAgent);
349
-
353
+
350
354
  // Detect if mobile (phone form factor) - must have explicit mobile indicators
351
355
  const isMobilePhone = /Android.*Mobile|iPhone|iPod/i.test(userAgent);
352
-
356
+
353
357
  // Check if it looks like a desktop/laptop (has "Windows", "Mac", "Linux" but no mobile indicators)
354
358
  const isDesktopLike = /Windows NT|Macintosh|Linux|X11/i.test(userAgent) && !isMobilePhone && !isTablet;
355
-
359
+
356
360
  // Device name for logging
357
361
  let deviceName = 'Unknown';
358
362
  if (isIPhone) {
@@ -364,7 +368,7 @@ export default class BabylonJSController {
364
368
  const match = userAgent.match(/Android.*;.*(.+?)\)/);
365
369
  deviceName = match ? match[1].trim() : 'Android';
366
370
  }
367
-
371
+
368
372
  return {
369
373
  isMobile: isMobilePhone && !isDesktopLike,
370
374
  isTablet: isTablet && !isDesktopLike,
@@ -445,10 +449,10 @@ export default class BabylonJSController {
445
449
  };
446
450
 
447
451
  const preset = presetMap[tier] || qualityPresets.medium;
448
-
452
+
449
453
  // Apply preset to config
450
- this.#config.quality = {
451
- tier,
454
+ this.#config.quality = {
455
+ tier,
452
456
  ...preset,
453
457
  // Legacy compatibility
454
458
  shadowMapSize: preset.shadowMapSize,
@@ -642,6 +646,55 @@ export default class BabylonJSController {
642
646
  }
643
647
  }
644
648
 
649
+ /**
650
+ * Preloads Babylon's internal BRDF lookup texture required by PBR materials.
651
+ * In render-on-demand hosts the RGBD expansion can stall unless a few renders are kept alive.
652
+ * @private
653
+ * @returns {Promise<boolean>} True when the texture is ready, false when it timed out or is unavailable.
654
+ */
655
+ async #ensureEnvironmentBRDFTextureReady() {
656
+ if (!this.#scene || !this.#engine) {
657
+ return false;
658
+ }
659
+
660
+ const brdfTexture = BRDFTextureTools.GetEnvironmentBRDFTexture(this.#scene);
661
+ if (!brdfTexture || brdfTexture.isReady?.()) {
662
+ return true;
663
+ }
664
+
665
+ const timeoutMs = 2000;
666
+ const startedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
667
+ this.#requestRender({ frames: this.#config.render.burstFramesEnhanced, continuousMs: timeoutMs });
668
+
669
+ try {
670
+ await new Promise((resolve) => {
671
+ const tick = () => {
672
+ if (!this.#scene || !this.#engine || brdfTexture.isDisposed?.()) {
673
+ resolve(false);
674
+ return;
675
+ }
676
+ if (brdfTexture.isReady?.()) {
677
+ resolve(true);
678
+ return;
679
+ }
680
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
681
+ if (now - startedAt >= timeoutMs) {
682
+ resolve(false);
683
+ return;
684
+ }
685
+ this.#requestRender({ frames: 1, continuousMs: 100 });
686
+ setTimeout(tick, 16);
687
+ };
688
+ tick();
689
+ });
690
+ } catch (error) {
691
+ console.warn("PrefViewer: Error preparing Babylon BRDF texture:", error);
692
+ }
693
+
694
+ this.#requestRender({ frames: 2 });
695
+ return brdfTexture.isReady?.() ?? false;
696
+ }
697
+
645
698
  /**
646
699
  * Starts Babylon's engine render loop if it is not already running.
647
700
  * @private
@@ -669,7 +722,7 @@ export default class BabylonJSController {
669
722
  this.#state.render.isLoopRunning = false;
670
723
  return true;
671
724
  }
672
-
725
+
673
726
  /**
674
727
  * Resets transient render-loop bookkeeping so the next render request starts from a clean baseline.
675
728
  * Clears queued burst frames, ends any active continuous-render window, and drops the idle-throttle timestamp.
@@ -804,12 +857,12 @@ export default class BabylonJSController {
804
857
  }
805
858
 
806
859
  const shouldRender = animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
807
-
860
+
808
861
  // Reset idle flag if we need to render
809
862
  if (shouldRender) {
810
863
  this.#state.render.isIdle = false;
811
864
  }
812
-
865
+
813
866
  return shouldRender;
814
867
  }
815
868
 
@@ -845,7 +898,7 @@ export default class BabylonJSController {
845
898
  if (!continuous && this.#state.render.lastRenderAt > 0 && now - this.#state.render.lastRenderAt < this.#config.render.idleThrottleMs) {
846
899
  return;
847
900
  }
848
-
901
+
849
902
  this.#scene.render();
850
903
  this.#state.render.lastRenderAt = now;
851
904
 
@@ -1570,7 +1623,7 @@ export default class BabylonJSController {
1570
1623
  }
1571
1624
  });
1572
1625
  }
1573
-
1626
+
1574
1627
  /**
1575
1628
  * Applies the showroom tonemapping curve and lighting bias used by the 3D viewer.
1576
1629
  * @private
@@ -1608,7 +1661,7 @@ export default class BabylonJSController {
1608
1661
  }
1609
1662
 
1610
1663
  const showroom = this.#config.look?.showroom || {};
1611
- const timeOfDay = clamp01(this.#settings.lightingTimeOfDay ?? 0.5);
1664
+ const timeOfDay = clamp01(this.#settings.lightingTimeOfDay ? this.#settings.lightingTimeOfDay ?? 0.5 : 0.5);
1612
1665
  const profile = getLightingCycleProfile(timeOfDay);
1613
1666
  const daylight = profile.dayFactor;
1614
1667
  const peakLight = daylight > 0 ? Math.pow(daylight, 0.72) : 0;
@@ -1931,7 +1984,7 @@ export default class BabylonJSController {
1931
1984
  }
1932
1985
 
1933
1986
  /**
1934
- * Attaches the `prefviewer-animation-changed` listener to the nearest `pref-viewer-3d` host.
1987
+ * Attaches the `pref-viewer-3d-animation-changed` listener to the nearest `pref-viewer-3d` host.
1935
1988
  * Removes any previous registration first to avoid duplicate callbacks across reload cycles.
1936
1989
  * @private
1937
1990
  * @returns {boolean} True when the listener is attached, false when no host is available.
@@ -1942,12 +1995,12 @@ export default class BabylonJSController {
1942
1995
  return false;
1943
1996
  }
1944
1997
  this.#detachAnimationChangedListener();
1945
- this.#prefViewer3D.addEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
1998
+ this.#prefViewer3D.addEventListener("pref-viewer-3d-animation-changed", this.#handlers.onAnimationGroupChanged);
1946
1999
  return true;
1947
2000
  }
1948
2001
 
1949
2002
  /**
1950
- * Detaches the `prefviewer-animation-changed` listener from the cached `pref-viewer-3d` host.
2003
+ * Detaches the `pref-viewer-3d-animation-changed` listener from the cached `pref-viewer-3d` host.
1951
2004
  * @private
1952
2005
  * @returns {boolean} True when a host exists and the listener removal was attempted, false otherwise.
1953
2006
  */
@@ -1955,7 +2008,7 @@ export default class BabylonJSController {
1955
2008
  if (!this.#prefViewer3D) {
1956
2009
  return false;
1957
2010
  }
1958
- this.#prefViewer3D.removeEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
2011
+ this.#prefViewer3D.removeEventListener("pref-viewer-3d-animation-changed", this.#handlers.onAnimationGroupChanged);
1959
2012
  return true;
1960
2013
  }
1961
2014
 
@@ -1988,12 +2041,12 @@ export default class BabylonJSController {
1988
2041
  */
1989
2042
  #onMouseWheel(event, pickInfo) {
1990
2043
  event.preventDefault();
1991
-
2044
+
1992
2045
  // OPTIMIZATION: Mark activity for idle mode
1993
2046
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1994
2047
  this.#state.render.lastActivityAt = now;
1995
2048
  this.#state.render.isIdle = false;
1996
-
2049
+
1997
2050
  const camera = this.#scene?.activeCamera;
1998
2051
  if (!camera) {
1999
2052
  return false;
@@ -2028,7 +2081,7 @@ export default class BabylonJSController {
2028
2081
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2029
2082
  this.#state.render.lastActivityAt = now;
2030
2083
  this.#state.render.isIdle = false;
2031
-
2084
+
2032
2085
  if (this.#babylonJSAnimationController) {
2033
2086
  if (event.button !== 2 || !pickInfo || !pickInfo.pickedMesh) {
2034
2087
  this.#babylonJSAnimationController.hideMenu();
@@ -2053,7 +2106,7 @@ export default class BabylonJSController {
2053
2106
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2054
2107
  this.#state.render.lastActivityAt = now;
2055
2108
  this.#state.render.isIdle = false;
2056
-
2109
+
2057
2110
  const camera = this.#scene?.activeCamera;
2058
2111
  if (camera && !camera.metadata?.locked) {
2059
2112
  this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
@@ -2701,20 +2754,34 @@ export default class BabylonJSController {
2701
2754
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
2702
2755
  this.#setMaxSimultaneousLights();
2703
2756
  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
- }
2757
+ this.#syncAnimationControllerAfterModelLoad();
2758
+
2713
2759
  await this.#startRender();
2714
2760
  });
2715
2761
  return detail;
2716
2762
  }
2717
2763
 
2764
+ /**
2765
+ * Creates or disposes the animation controller after a model load according to current settings.
2766
+ * @private
2767
+ * @returns {void}
2768
+ */
2769
+ #syncAnimationControllerAfterModelLoad() {
2770
+ if (this.#settings.showAnimationMenu && this.#containers.model.assetContainer) {
2771
+ this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
2772
+ // Apply stored highlight settings so fresh page loads respect persisted state
2773
+ this.#setHighlightConfig({
2774
+ enabled: this.#settings.highlightEnabled,
2775
+ color: this.#settings.highlightColor,
2776
+ });
2777
+ if (this.#babylonJSAnimationController?.hasAnimations?.()) {
2778
+ this.#attachAnimationChangedListener();
2779
+ }
2780
+ } else {
2781
+ this.#disposeAnimationController();
2782
+ }
2783
+ }
2784
+
2718
2785
  /**
2719
2786
  * Reinstalls every camera-sensitive post-process (default pipeline, SSAO, shadows) after loads or option changes.
2720
2787
  * Ensures the active camera always owns fresh pipelines so render quality remains consistent across reload cycles.
@@ -2968,14 +3035,14 @@ export default class BabylonJSController {
2968
3035
  this.#configureDracoCompression();
2969
3036
  this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false }, true);
2970
3037
  this.#engine.disableUniformBuffers = true;
2971
-
3038
+
2972
3039
  // OPTIMIZATION: Detect hardware tier and apply quality settings
2973
3040
  const detectedTier = this.#detectHardwareTier();
2974
3041
  this.#applyQualitySettings(detectedTier);
2975
-
3042
+
2976
3043
  // OPTIMIZATION: Initialize idle tracking
2977
3044
  this.#state.render.lastActivityAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2978
-
3045
+
2979
3046
  this.#scene = new Scene(this.#engine);
2980
3047
 
2981
3048
  // Activate the rendering of geometry data into a G-buffer, essential for advanced effects like deferred shading,
@@ -2996,6 +3063,7 @@ export default class BabylonJSController {
2996
3063
  this.#scene.skipPointerUpPicking = true;
2997
3064
 
2998
3065
  this.#createCamera();
3066
+ await this.#ensureEnvironmentBRDFTextureReady();
2999
3067
  this.#enableInteraction();
3000
3068
  await this.#createXRExperience();
3001
3069
  }
@@ -3151,6 +3219,182 @@ export default class BabylonJSController {
3151
3219
  return { ...this.#settings };
3152
3220
  }
3153
3221
 
3222
+ /**
3223
+ * Returns a diagnostic snapshot of the current Babylon.js runtime state.
3224
+ * Intended for integration troubleshooting and should not mutate scene state.
3225
+ * @public
3226
+ * @returns {object} Debug information for canvas, engine, scene, containers, render loop and settings.
3227
+ */
3228
+ getDebugInfo() {
3229
+ const vectorToJSON = (value) => value ? { x: value.x, y: value.y, z: value.z } : null;
3230
+ const rect = this.#canvas?.getBoundingClientRect?.();
3231
+ const camera = this.#scene?.activeCamera ?? null;
3232
+ const sceneMeshes = this.#scene?.meshes ?? [];
3233
+ const sceneMaterials = this.#scene?.materials ?? [];
3234
+ const sceneTextures = this.#scene?.textures ?? [];
3235
+ const activeMeshes = this.#scene?.getActiveMeshes?.();
3236
+ const modelAssetContainer = this.#containers?.model?.assetContainer ?? null;
3237
+ const modelMeshes = modelAssetContainer?.meshes ?? [];
3238
+ const enabledModelMeshes = modelMeshes.filter((mesh) => mesh.isEnabled?.() !== false && mesh.isVisible !== false);
3239
+ const textureToJSON = (texture) => ({
3240
+ name: texture?.name ?? null,
3241
+ url: texture?.url ?? null,
3242
+ isReady: texture?.isReady?.() ?? null,
3243
+ hasTexture: Boolean(texture),
3244
+ className: texture?.getClassName?.() ?? texture?.constructor?.name ?? null,
3245
+ });
3246
+ const summarizeMaterial = (material) => {
3247
+ const relatedMeshes = sceneMeshes.filter((mesh) => mesh.material === material);
3248
+ const sampleMesh = relatedMeshes[0] ?? null;
3249
+ const activeTextures = material?.getActiveTextures?.() ?? [];
3250
+ let isReadyWithoutMesh = null;
3251
+ let isReadyWithMesh = null;
3252
+ try {
3253
+ isReadyWithoutMesh = typeof material?.isReady === "function" ? material.isReady() : null;
3254
+ } catch (error) {
3255
+ isReadyWithoutMesh = `error: ${error?.message ?? error}`;
3256
+ }
3257
+ try {
3258
+ isReadyWithMesh = typeof material?.isReady === "function" && sampleMesh ? material.isReady(sampleMesh) : null;
3259
+ } catch (error) {
3260
+ isReadyWithMesh = `error: ${error?.message ?? error}`;
3261
+ }
3262
+
3263
+ return {
3264
+ name: material?.name ?? null,
3265
+ id: material?.id ?? null,
3266
+ className: material?.getClassName?.() ?? material?.constructor?.name ?? null,
3267
+ isReadyWithoutMesh,
3268
+ isReadyWithMesh,
3269
+ relatedMeshes: relatedMeshes.length,
3270
+ sampleMesh: sampleMesh ? {
3271
+ name: sampleMesh.name,
3272
+ id: sampleMesh.id,
3273
+ enabled: sampleMesh.isEnabled?.() ?? null,
3274
+ isVisible: sampleMesh.isVisible ?? null,
3275
+ hasVertices: sampleMesh.getTotalVertices?.() ?? null,
3276
+ hasIndices: sampleMesh.getTotalIndices?.() ?? null,
3277
+ } : null,
3278
+ textures: activeTextures.map(textureToJSON),
3279
+ notReadyTextures: activeTextures.filter((texture) => texture?.isReady?.() === false).map(textureToJSON),
3280
+ forceDepthWrite: material?.forceDepthWrite ?? null,
3281
+ transparencyMode: material?.transparencyMode ?? null,
3282
+ alpha: material?.alpha ?? null,
3283
+ backFaceCulling: material?.backFaceCulling ?? null,
3284
+ disableLighting: material?.disableLighting ?? null,
3285
+ };
3286
+ };
3287
+ const materialDiagnostics = sceneMaterials.slice(0, 25).map(summarizeMaterial);
3288
+ const notReadyMaterials = materialDiagnostics
3289
+ .filter((material) => material.isReadyWithoutMesh === false || material.isReadyWithMesh === false || material.notReadyTextures.length > 0)
3290
+ .slice(0, 25);
3291
+ const notReadyTextures = sceneTextures
3292
+ .filter((texture) => texture?.isReady?.() === false)
3293
+ .slice(0, 25)
3294
+ .map(textureToJSON);
3295
+ const notReadyMeshes = sceneMeshes
3296
+ .filter((mesh) => typeof mesh.isReady === "function" && !mesh.isReady())
3297
+ .slice(0, 25)
3298
+ .map((mesh) => ({
3299
+ name: mesh.name,
3300
+ id: mesh.id,
3301
+ enabled: mesh.isEnabled?.() ?? null,
3302
+ isVisible: mesh.isVisible ?? null,
3303
+ material: mesh.material?.name ?? null,
3304
+ }));
3305
+ const modelBounds = enabledModelMeshes.length && this.#scene?.getWorldExtends
3306
+ ? this.#scene.getWorldExtends((mesh) => enabledModelMeshes.includes(mesh))
3307
+ : null;
3308
+ const summarizeContainer = (container) => ({
3309
+ show: container?.state?.show ?? null,
3310
+ visible: container?.state?.visible ?? null,
3311
+ mustBeShown: container?.state?.mustBeShown ?? null,
3312
+ isPending: container?.state?.isPending ?? null,
3313
+ isSuccess: container?.state?.isSuccess ?? null,
3314
+ hasAssetContainer: Boolean(container?.assetContainer),
3315
+ meshes: container?.assetContainer?.meshes?.length ?? 0,
3316
+ enabledMeshes: (container?.assetContainer?.meshes ?? []).filter((mesh) => mesh.isEnabled?.() !== false && mesh.isVisible !== false).length,
3317
+ materials: container?.assetContainer?.materials?.length ?? 0,
3318
+ cameras: container?.assetContainer?.cameras?.map((item) => ({
3319
+ name: item.name,
3320
+ id: item.id,
3321
+ className: item.getClassName?.() ?? item.constructor?.name ?? null,
3322
+ position: vectorToJSON(item.position),
3323
+ target: vectorToJSON(item.target),
3324
+ })) ?? [],
3325
+ animationGroups: container?.assetContainer?.animationGroups?.length ?? 0,
3326
+ });
3327
+
3328
+ return {
3329
+ canvas: {
3330
+ clientWidth: this.#canvas?.clientWidth ?? null,
3331
+ clientHeight: this.#canvas?.clientHeight ?? null,
3332
+ width: this.#canvas?.width ?? null,
3333
+ height: this.#canvas?.height ?? null,
3334
+ boundingWidth: rect?.width ?? null,
3335
+ boundingHeight: rect?.height ?? null,
3336
+ },
3337
+ engine: {
3338
+ renderWidth: this.#engine?.getRenderWidth?.() ?? null,
3339
+ renderHeight: this.#engine?.getRenderHeight?.() ?? null,
3340
+ hardwareScalingLevel: this.#engine?.getHardwareScalingLevel?.() ?? null,
3341
+ isPointerLock: this.#engine?.isPointerLock ?? null,
3342
+ isDisposed: this.#engine?.isDisposed ?? null,
3343
+ contextLost: this.#engine?.isContextLost?.() ?? null,
3344
+ },
3345
+ scene: {
3346
+ uid: this.#scene?.uid ?? null,
3347
+ isReady: this.#scene?.isReady?.() ?? null,
3348
+ meshes: sceneMeshes.length,
3349
+ visibleMeshes: activeMeshes?.length ?? activeMeshes?.data?.length ?? null,
3350
+ materials: sceneMaterials.length,
3351
+ textures: sceneTextures.length,
3352
+ lights: this.#scene?.lights?.length ?? 0,
3353
+ animationGroups: this.#scene?.animationGroups?.length ?? 0,
3354
+ activeCamera: camera ? {
3355
+ name: camera.name,
3356
+ id: camera.id,
3357
+ className: camera.getClassName?.() ?? camera.constructor?.name ?? null,
3358
+ position: vectorToJSON(camera.position),
3359
+ target: vectorToJSON(camera.target),
3360
+ radius: camera.radius ?? null,
3361
+ alpha: camera.alpha ?? null,
3362
+ beta: camera.beta ?? null,
3363
+ } : null,
3364
+ clearColor: this.#scene?.clearColor ? {
3365
+ r: this.#scene.clearColor.r,
3366
+ g: this.#scene.clearColor.g,
3367
+ b: this.#scene.clearColor.b,
3368
+ a: this.#scene.clearColor.a,
3369
+ } : null,
3370
+ environmentTexture: this.#scene?.environmentTexture ? {
3371
+ name: this.#scene.environmentTexture.name,
3372
+ isReady: this.#scene.environmentTexture.isReady?.() ?? null,
3373
+ } : null,
3374
+ notReadyMaterials,
3375
+ notReadyTextures,
3376
+ materialDiagnostics,
3377
+ notReadyMeshes,
3378
+ },
3379
+ containers: {
3380
+ model: summarizeContainer(this.#containers.model),
3381
+ environment: summarizeContainer(this.#containers.environment),
3382
+ materials: summarizeContainer(this.#containers.materials),
3383
+ },
3384
+ modelBounds: modelBounds ? {
3385
+ min: vectorToJSON(modelBounds.min),
3386
+ max: vectorToJSON(modelBounds.max),
3387
+ } : null,
3388
+ render: { ...this.#state.render },
3389
+ settings: { ...this.#settings },
3390
+ pipelines: {
3391
+ default: Boolean(this.#renderPipelines.default),
3392
+ ssao: Boolean(this.#renderPipelines.ssao),
3393
+ iblShadows: Boolean(this.#renderPipelines.iblShadows),
3394
+ },
3395
+ };
3396
+ }
3397
+
3154
3398
  /**
3155
3399
  * Loads all asset containers (model, environment, materials, etc.) and adds them to the scene.
3156
3400
  * Applies material and camera options, sets visibility, and initializes lights and shadows.
@@ -3203,14 +3447,42 @@ export default class BabylonJSController {
3203
3447
  }
3204
3448
 
3205
3449
  /**
3206
- * Applies lighting and highlight configuration without forcing a full scene reload.
3450
+ * Applies animation-menu, highlight, and time-of-day lighting configuration without forcing a full scene reload.
3451
+ * Values are merged into the persisted render settings snapshot unless `config.persist === false`.
3452
+ * When `showLightingTimeOfDay` is set to false and no explicit `lightingTimeOfDay` is supplied,
3453
+ * the active lighting time is reset to `DEFAULT_RENDER_SETTINGS.lightingTimeOfDay` (0.5).
3207
3454
  * @public
3208
- * @param {{lightingTimeOfDay?:number, highlightColor?:string, highlightEnabled?:boolean, showAnimationMenu?:boolean}} config - Lighting/highlight configuration.
3455
+ * @param {object} config - Immediate render/illumination configuration.
3456
+ * @param {boolean|string} [config.showAnimationMenu] - Enables/disables the animation controls when animations are available.
3457
+ * @param {boolean|string|null} [config.highlightEnabled] - Enables/disables hover highlight; string "false" is normalized to false, null is ignored.
3458
+ * @param {string|null} [config.highlightColor] - Hex color used by hover highlight; null is ignored.
3459
+ * @param {boolean|string} [config.showLightingTimeOfDay] - Shows/hides the time-of-day control and can reset time to the default when hidden.
3460
+ * @param {number} [config.lightingTimeOfDay] - Normalized time of day in the 0..1 range.
3461
+ * @param {boolean} [config.persist=true] - False for transient updates such as lighting playback frames.
3209
3462
  * @returns {void}
3210
3463
  */
3211
3464
  setIlluminationConfig(config = {}) {
3212
3465
  let shouldApplyLighting = false;
3213
3466
 
3467
+ if (config.showLightingTimeOfDay !== undefined) {
3468
+ const showLighting = typeof config.showLightingTimeOfDay === "string"
3469
+ ? config.showLightingTimeOfDay.toLowerCase() !== "false"
3470
+ : Boolean(config.showLightingTimeOfDay);
3471
+ if (this.#settings.showLightingTimeOfDay !== showLighting) {
3472
+ shouldApplyLighting = true;
3473
+ }
3474
+ this.#settings.showLightingTimeOfDay = showLighting;
3475
+
3476
+ const hasExplicitLightingTime = typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay);
3477
+ if (!showLighting && !hasExplicitLightingTime) {
3478
+ const defaultTime = BabylonJSController.DEFAULT_RENDER_SETTINGS.lightingTimeOfDay;
3479
+ if (this.#settings.lightingTimeOfDay !== defaultTime) {
3480
+ this.#settings.lightingTimeOfDay = defaultTime;
3481
+ shouldApplyLighting = true;
3482
+ }
3483
+ }
3484
+ }
3485
+
3214
3486
  if (typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay)) {
3215
3487
  const normalizedTime = clamp01(config.lightingTimeOfDay);
3216
3488
  if (this.#settings.lightingTimeOfDay !== normalizedTime) {
@@ -3220,8 +3492,11 @@ export default class BabylonJSController {
3220
3492
  }
3221
3493
 
3222
3494
  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
3495
+ const showMenu = typeof config.showAnimationMenu === "string"
3496
+ ? config.showAnimationMenu.toLowerCase() !== "false"
3497
+ : Boolean(config.showAnimationMenu);
3498
+ this.#settings.showAnimationMenu = showMenu;
3499
+ this.#syncAnimationControllerAfterModelLoad();
3225
3500
  }
3226
3501
 
3227
3502
  // Handle highlight color for animation hover
@@ -3242,7 +3517,7 @@ export default class BabylonJSController {
3242
3517
  }
3243
3518
  this.#setHighlightConfig({
3244
3519
  color: config.highlightColor ?? undefined,
3245
- enabled,
3520
+ enabled: enabled,
3246
3521
  });
3247
3522
  }
3248
3523
 
@@ -3281,11 +3556,13 @@ export default class BabylonJSController {
3281
3556
  }
3282
3557
 
3283
3558
  /**
3284
- * Merges incoming render flags with the current configuration, persists them, and marks
3559
+ * Merges reload-oriented render flags with the current configuration, persists them, and marks
3285
3560
  * all dependent loaders/options as pending when something actually changed.
3561
+ * Immediate settings such as highlight, animation menu, and time-of-day lighting are handled by
3562
+ * `setIlluminationConfig()` before this method receives the remaining reload settings.
3286
3563
  * @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}}}
3564
+ * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of reload render toggles to apply.
3565
+ * @returns {{changed:boolean, settings:object}} Current full render settings snapshot and whether a reload is required.
3289
3566
  * @description
3290
3567
  * Callers can inspect the `changed` flag to decide whether to trigger a reload with
3291
3568
  * `reloadWithCurrentSettings()` or simply reuse the returned snapshot.