@preference-sl/pref-viewer 2.16.0-beta.9 → 2.16.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.
@@ -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";
@@ -10,104 +10,13 @@ import { MaterialData } from "./pref-viewer-3d-data.js";
10
10
  import BabylonJSAnimationController from "./babylonjs-animation-controller.js";
11
11
  import OpeningAnimation from "./babylonjs-animation-opening.js";
12
12
  import { translate } from "./localization/i18n.js";
13
+ import { PrefViewerStyleVariables } from "./styles.js";
14
+ import { clamp01, lerp, getAdaptiveHardwareScalingLevel, getLightingCycleProfile } from "./render-math.js";
15
+ import { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlassMaterial, isPrefViewerTransparentRenderOrderMaterial } from "./material-utils.js";
13
16
 
14
- export function getAdaptiveHardwareScalingLevel(baseScaling = 1, pixelRatio = 1) {
15
- const safeBaseScaling = Number.isFinite(baseScaling) && baseScaling > 0 ? baseScaling : 1;
16
- const safePixelRatio = Number.isFinite(pixelRatio) && pixelRatio > 0 ? pixelRatio : 1;
17
- return safeBaseScaling / safePixelRatio;
18
- }
19
-
20
- function clamp01(value) {
21
- if (!Number.isFinite(value)) {
22
- return 0.5;
23
- }
24
- return Math.min(1, Math.max(0, value));
25
- }
26
-
27
- function lerp(from, to, amount) {
28
- const t = clamp01(amount);
29
- return from + (to - from) * t;
30
- }
31
-
32
- function smoothstep(edge0, edge1, x) {
33
- if (edge0 === edge1) {
34
- return x >= edge1 ? 1 : 0;
35
- }
36
- const t = clamp01((x - edge0) / (edge1 - edge0));
37
- return t * t * (3 - 2 * t);
38
- }
39
-
40
- function bellCurve(x) {
41
- const clamped = clamp01(x);
42
- return Math.sin(clamped * Math.PI);
43
- }
44
-
45
- function normalizeDirection(x, y, z) {
46
- const length = Math.hypot(x, y, z) || 1;
47
- return {
48
- x: x / length,
49
- y: y / length,
50
- z: z / length,
51
- };
52
- }
53
-
54
- const PREF_VIEWER_SIMPLIFIED_TRANSMISSION_METADATA_KEY = "prefViewerSimplifiedTransmission";
55
-
56
- export function markPrefViewerSimplifiedTransmissionMaterial(mat) {
57
- if (!mat) return mat;
58
- mat.metadata = {
59
- ...(mat.metadata ?? {}),
60
- [PREF_VIEWER_SIMPLIFIED_TRANSMISSION_METADATA_KEY]: true,
61
- };
62
- return mat;
63
- }
64
-
65
- export function isPrefViewerTransparentRenderOrderMaterial(mat) {
66
- if (!mat) return false;
67
- const isTransmissive = isPrefViewerPhysicalGlassMaterial(mat);
68
- const isSimplifiedTransmission = mat.metadata?.[PREF_VIEWER_SIMPLIFIED_TRANSMISSION_METADATA_KEY] === true;
69
- const alpha = typeof mat.alpha === "number" ? mat.alpha : 1;
70
- const isAlphaBlended =
71
- alpha < 1 ||
72
- mat.transparencyMode === PBRMaterial.PBRMATERIAL_ALPHABLEND ||
73
- mat.transparencyMode === PBRMaterial.PBRMATERIAL_ALPHATESTANDBLEND ||
74
- mat.needAlphaBlending?.() === true ||
75
- mat.needAlphaTesting?.() === true;
76
-
77
- return Boolean(isTransmissive || isSimplifiedTransmission || isAlphaBlended);
78
- }
79
-
80
- export function isPrefViewerPhysicalGlassMaterial(mat) {
81
- return Boolean(mat?.subSurface?.isRefractionEnabled || mat?.subSurface?.isTranslucencyEnabled);
82
- }
83
-
84
- export function getLightingCycleProfile(timeOfDay = 0.5) {
85
- const normalizedTime = clamp01(timeOfDay);
86
- const timeHours = normalizedTime * 24;
87
- const sunriseHour = 6.25;
88
- const sunsetHour = 20.25;
89
- const daylightProgress = clamp01((timeHours - sunriseHour) / (sunsetHour - sunriseHour));
90
- const dayFactor = timeHours >= sunriseHour && timeHours <= sunsetHour ? Math.pow(bellCurve(daylightProgress), 0.88) : 0;
91
- const nightFactor = 1 - dayFactor;
92
- const solarArc = dayFactor > 0 ? Math.pow(dayFactor, 0.72) : 0;
93
- const solarAzimuth = lerp(-Math.PI * 0.82, Math.PI * 0.82, daylightProgress);
94
- const solarElevation = lerp(-0.18, 1.18, solarArc);
95
- const sunPhase = normalizedTime * Math.PI * 2 - Math.PI / 2;
96
- const sunDirection = normalizeDirection(-Math.cos(sunPhase), -Math.sin(sunPhase), -0.35);
97
- const moonDirection = normalizeDirection(-sunDirection.x, -sunDirection.y, -sunDirection.z);
98
-
99
- return {
100
- timeOfDay: normalizedTime,
101
- timeHours,
102
- sunPhase,
103
- solarAzimuth,
104
- solarElevation,
105
- dayFactor,
106
- nightFactor,
107
- sunDirection,
108
- moonDirection,
109
- };
110
- }
17
+ // Re-export so existing consumers (tests, other modules) can keep importing from this file.
18
+ export { getAdaptiveHardwareScalingLevel, getLightingCycleProfile } from "./render-math.js";
19
+ export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlassMaterial, isPrefViewerTransparentRenderOrderMaterial } from "./material-utils.js";
111
20
 
112
21
  /**
113
22
  * BabylonJSController coordinates the PrefViewer 3D runtime: it bootstraps Babylon.js, manages asset containers,
@@ -130,9 +39,10 @@ export function getLightingCycleProfile(timeOfDay = 0.5) {
130
39
  *
131
40
  * Runtime Flow
132
41
  * 1. Instantiate with `new BabylonJSController(canvas, containers, options)`.
133
- * 2. Call `enable()` to configure Draco, spin up the engine/scene, attach interaction + XR hooks, and start the render loop.
42
+ * 2. Call `enable()` to configure Draco, spin up the engine/scene, and attach interaction + XR hooks. The render loop
43
+ * does not start here; it starts automatically after the first `load()` completes.
134
44
  * 3. When PrefViewer marks containers/options pending, invoke `load()` (or `reloadWithCurrentSettings()`) so the controller
135
- * fetches sources, rebuilds containers, and reattaches pipelines.
45
+ * fetches sources, rebuilds containers, reattaches pipelines, and starts the render loop.
136
46
  * 4. Use `scheduleRenderSettingsReload()` to merge/persist toggles; when it reports `changed: true`, call
137
47
  * `reloadWithCurrentSettings()` to apply the staged settings.
138
48
  * 5. Use `setContainerVisibility`, `setMaterialOptions`, `setCameraOptions`, or `setIBLOptions` for targeted updates; these
@@ -144,15 +54,19 @@ export function getLightingCycleProfile(timeOfDay = 0.5) {
144
54
  * Public API Highlights
145
55
  * - constructor(canvas, containers, options)
146
56
  * - enable() / disable()
147
- * - load() / reloadWithCurrentSettings()
57
+ * - load(force?) / reloadWithCurrentSettings()
148
58
  * - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
149
59
  * - getRenderSettings() / scheduleRenderSettingsReload(settings)
60
+ * - getDebugInfo()
150
61
  * - setContainerVisibility(name, show)
151
62
  * - setMaterialOptions() / setCameraOptions() / setIBLOptions()
63
+ * - setIlluminationConfig(config)
64
+ * - getAnimations() / playAnimation(name, action) / setAnimationProgress(name, progress) / setAnimationLoop(name, loop)
152
65
  *
153
66
  * Key Invariants
154
- * - Asset containers must expose `setPendingWithCurrentStorage`/`setPending` before calling load/reload; the controller
155
- * reads those flags to resolve fresh sources and avoids touching the DOM until data is ready.
67
+ * - Asset containers must expose `setPendingWithCurrentStorage` before calling load/reload; the controller reads that
68
+ * flag to resolve fresh sources. Option objects (camera, materials, IBL) expose `setPendingWithCurrent` for the same
69
+ * purpose. Neither set of flags touches the DOM — they only gate resolver calls.
156
70
  * - Camera-dependent pipelines (DefaultRenderingPipeline, SSAO, IBL shadows, directional shadow generators) are rebuilt
157
71
  * only after the active camera and environment textures are ready; render-loop restarts gate those transitions.
158
72
  * - `show-model`/`show-scene` DOM attributes reflect container visibility; there are no direct `showModel()/hideModel()` APIs.
@@ -183,10 +97,13 @@ export default class BabylonJSController {
183
97
  ambientOcclusionEnabled: true,
184
98
  iblEnabled: true,
185
99
  shadowsEnabled: false,
186
- lightingTimeOfDay: 0.5,
187
- // Highlight settings
100
+ // Animation menu and highlight settings
101
+ showAnimationMenu: true,
188
102
  highlightEnabled: true,
189
- highlightColor: "#ff6700",
103
+ highlightColor: PrefViewerStyleVariables.colorPrimary,
104
+ // Lighting time of day (0=midnight, 0.5=noon, 1=midnight)
105
+ showLightingTimeOfDay: true,
106
+ lightingTimeOfDay: 0.5,
190
107
  };
191
108
 
192
109
  // Canvas HTML element
@@ -207,8 +124,8 @@ export default class BabylonJSController {
207
124
  #shadowGen = [];
208
125
  #XRExperience = null;
209
126
  #canvasResizeObserver = null;
210
-
211
- #hdrTexture = null; // reusable in-memory HDR source cloned into scene.environmentTexture across reloads
127
+
128
+ #hdrTexture = null; // Reusable in-memory HDR source cloned into scene.environmentTexture across reloads
212
129
 
213
130
  #containers = {};
214
131
  #options = {};
@@ -261,30 +178,35 @@ export default class BabylonJSController {
261
178
  // Runtime configuration constants (tuning knobs, not per-frame state).
262
179
  #config = {
263
180
  pointerPicking: {
264
- movePickIntervalMs: 50, // cap expensive scene.pick calls to ~20 Hz while moving the pointer
265
- movePickMinDistancePx: 2, // skip picks for sub-pixel jitter
181
+ movePickIntervalMs: 50, // Cap expensive scene.pick calls to ~20 Hz while moving the pointer
182
+ movePickMinDistancePx: 2, // Skip picks for sub-pixel jitter
266
183
  },
267
184
  render: {
268
185
  burstFramesBase: 2,
269
- burstFramesEnhanced: 32, // when AA/SSAO/IBL is enabled, more frames are needed to reach stable output
186
+ burstFramesEnhanced: 32, // When AA/SSAO/IBL is enabled, more frames are needed to reach stable output
270
187
  interactionMs: 250,
271
188
  animationMs: 200,
272
189
  idleThrottleMs: 1000 / 8, // Reduced from 15 FPS to 8 FPS for idle - reduces GPU/CPU load
273
190
  idleThresholdMs: 3000, // Time before going to true idle (no renders)
274
191
  },
275
192
  resize: {
276
- throttleMs: 50, // cap resize work to ~20 Hz while dragging/resizing containers
193
+ throttleMs: 50, // Cap resize work to ~20 Hz while dragging/resizing containers
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
277
199
  },
278
200
  // Hardware-adaptive quality settings (applied based on GPU capabilities)
279
201
  quality: {
280
202
  // Settings for different hardware tiers: 'low', 'medium', 'high'
281
203
  // Applied automatically based on GPU detected capabilities
282
- tier: 'medium', // default, will be updated after hardware detection
283
- shadowMapSize: 1024, // Standard shadow generator resolution
284
- iblShadowResolution: 1, // IBL shadow resolution exponent (0=1024, 1=2048, 2=4096)
285
- iblSampleDirections: 4, // IBL shadow quality
286
- shadowBlurKernel: 16, // Shadow blur amount
287
- ssaoEnabled: true, // Screen Space Ambient Occlusion
204
+ tier: 'medium', // Default, will be updated after hardware detection
205
+ shadowMapSize: 1024, // Standard shadow generator resolution
206
+ iblShadowResolution: 1, // IBL shadow resolution exponent (0=1024, 1=2048, 2=4096)
207
+ iblSampleDirections: 4, // IBL shadow quality
208
+ shadowBlurKernel: 16, // Shadow blur amount
209
+ ssaoEnabled: true, // Screen Space Ambient Occlusion
288
210
  },
289
211
  look: {
290
212
  showroom: {
@@ -296,6 +218,24 @@ export default class BabylonJSController {
296
218
  hemiLightIntensity: 0.52,
297
219
  dirLightIntensity: 0.56,
298
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],
299
239
  },
300
240
  },
301
241
  };
@@ -309,18 +249,18 @@ export default class BabylonJSController {
309
249
  #detectHardwareTier() {
310
250
  // First, try to get WebGL info to help identify the device
311
251
  const gl = this.#engine?.getRenderingCanvas()?.getContext?.('webgl2') || this.#engine?.getRenderingCanvas()?.getContext?.('webgl');
312
-
252
+
313
253
  const debugInfo = gl?.getExtension('WEBGL_debug_renderer_info');
314
254
  const rendererName = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : '';
315
255
  const isAngleRenderer = /^ANGLE\s*\(/i.test(rendererName);
316
-
256
+
317
257
  // If we have WebGL renderer info and it looks like a desktop GPU, skip mobile detection
318
258
  // Desktop GPUs typically have "NVIDIA", "AMD", "Intel" in the name
319
259
  const isDesktopGPU = /NVIDIA|AMD|Intel|Radeon|GeForce|Quadro/i.test(rendererName);
320
-
260
+
321
261
  // Also check if it's explicitly a mobile GPU
322
262
  const isMobileGPU = /Adreno|Mali|Apple GPU/i.test(rendererName);
323
-
263
+
324
264
  // If we detect a desktop GPU, go directly to desktop detection
325
265
  if (isDesktopGPU && !isMobileGPU) {
326
266
  if (isAngleRenderer) {
@@ -339,7 +279,7 @@ export default class BabylonJSController {
339
279
 
340
280
  // If no WebGL info or unclear, fall back to user agent detection
341
281
  const mobileInfo = this.#detectMobileInfo();
342
-
282
+
343
283
  // Only treat as mobile if we have strong mobile indicators
344
284
  if (mobileInfo.isMobile && !mobileInfo.isDesktopLike) {
345
285
  if (mobileInfo.isHighEnd) {
@@ -366,15 +306,15 @@ export default class BabylonJSController {
366
306
  #detectDesktopTier(gl, rendererName, conservative = false) {
367
307
  // Check WebGL capabilities
368
308
  const maxTextureSize = gl?.getParameter(gl.MAX_TEXTURE_SIZE) || 8192;
369
-
309
+
370
310
  // Detect high-end GPUs
371
311
  const isAppleSilicon = /Apple|M1|M2|M3|M4/i.test(rendererName);
372
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);
373
313
  const isAmdHighEnd = /RX (6|7|8|9)[0-9]{2,}|RX Vega|Radeon Pro|WX [0-9]/i.test(rendererName);
374
314
  const isIntelIntegrated = /Intel.*UHD|Intel.*Iris|Mesa|llvmpipe/i.test(rendererName);
375
-
315
+
376
316
  let tier = conservative ? 'medium' : 'high'; // Default to conservative desktop when ANGLE is in the path
377
-
317
+
378
318
  if (isIntelIntegrated || maxTextureSize < 8192) {
379
319
  tier = 'medium';
380
320
  } else if (!conservative && (isAppleSilicon || isNvidiaHighEnd || isAmdHighEnd || maxTextureSize >= 16384)) {
@@ -382,7 +322,7 @@ export default class BabylonJSController {
382
322
  } else if (conservative && maxTextureSize >= 16384) {
383
323
  tier = 'high';
384
324
  }
385
-
325
+
386
326
  console.log(`[PrefViewer] Desktop tier: ${tier} (GPU: ${rendererName || 'unknown'}, MaxTexture: ${maxTextureSize})`);
387
327
  return tier;
388
328
  }
@@ -398,48 +338,48 @@ export default class BabylonJSController {
398
338
  const screenWidth = window.screen?.width || 0;
399
339
  const screenHeight = window.screen?.height || 0;
400
340
  const pixelRatio = window.devicePixelRatio || 1;
401
-
341
+
402
342
  // Detect device type
403
343
  const isIPhone = /iPhone/i.test(userAgent);
404
344
  const isIPad = /iPad/i.test(userAgent);
405
345
  const isAndroid = /Android/i.test(userAgent);
406
346
  const isMobile = /Mobile/i.test(userAgent);
407
-
347
+
408
348
  // High-end mobile patterns (recent flagship devices)
409
349
  const highEndPatterns = [
410
350
  /iPhone (1[4-9]|SE)/i, // iPhone 14+
411
- /iPad Pro/i, // iPad Pro
412
- /SM-S91[0-9]/i, // Samsung S21+
413
- /SM-S9[0-9]/i, // Samsung S20+
414
- /Pixel [7-9]/i, // Pixel 7+
415
- /OnePlus [8-9|10|11]/i, // OnePlus 8+
351
+ /iPad Pro/i, // iPad Pro
352
+ /SM-S91[0-9]/i, // Samsung S21+
353
+ /SM-S9[0-9]/i, // Samsung S20+
354
+ /Pixel [7-9]/i, // Pixel 7+
355
+ /OnePlus [8-9|10|11]/i, // OnePlus 8+
416
356
  /Xiaomi (1[2-4]|Mi 1[1-4])/i, // Xiaomi 12+
417
- /Huawei (P50|Mate 4|)/i, // Huawei P50+
357
+ /Huawei (P50|Mate 4|)/i, // Huawei P50+
418
358
  ];
419
-
359
+
420
360
  // Mid-range patterns
421
361
  const midRangePatterns = [
422
- /iPhone (1[0-3]|SE)/i, // iPhone 10-13
362
+ /iPhone (1[0-3]|SE)/i, // iPhone 10-13
423
363
  /iPad Air/i, // iPad Air
424
- /SM-A[0-9]/i, // Samsung A series
425
- /Redmi Note/i, // Redmi Note
426
- /Pixel [4-6]/i, // Pixel 4-6
427
- /Moto G/i, // Moto G
364
+ /SM-A[0-9]/i, // Samsung A series
365
+ /Redmi Note/i, // Redmi Note
366
+ /Pixel [4-6]/i, // Pixel 4-6
367
+ /Moto G/i, // Moto G
428
368
  ];
429
-
369
+
430
370
  const isHighEnd = highEndPatterns.some(p => p.test(userAgent));
431
371
  const isMidRange = midRangePatterns.some(p => p.test(userAgent));
432
-
372
+
433
373
  // Detect tablet ONLY if explicitly in user agent (not just screen size)
434
374
  // Many desktops have large screens and would incorrectly be detected as tablets
435
375
  const isTablet = /iPad|Android.*Tablet|Nexus 9|Nexus 10/i.test(userAgent);
436
-
376
+
437
377
  // Detect if mobile (phone form factor) - must have explicit mobile indicators
438
378
  const isMobilePhone = /Android.*Mobile|iPhone|iPod/i.test(userAgent);
439
-
379
+
440
380
  // Check if it looks like a desktop/laptop (has "Windows", "Mac", "Linux" but no mobile indicators)
441
381
  const isDesktopLike = /Windows NT|Macintosh|Linux|X11/i.test(userAgent) && !isMobilePhone && !isTablet;
442
-
382
+
443
383
  // Device name for logging
444
384
  let deviceName = 'Unknown';
445
385
  if (isIPhone) {
@@ -451,7 +391,7 @@ export default class BabylonJSController {
451
391
  const match = userAgent.match(/Android.*;.*(.+?)\)/);
452
392
  deviceName = match ? match[1].trim() : 'Android';
453
393
  }
454
-
394
+
455
395
  return {
456
396
  isMobile: isMobilePhone && !isDesktopLike,
457
397
  isTablet: isTablet && !isDesktopLike,
@@ -476,13 +416,13 @@ export default class BabylonJSController {
476
416
  // Mobile low-end (old phones, budget devices)
477
417
  low: {
478
418
  shadowMapSize: 256, // Very small for speed
479
- iblShadowResolution: -1, // Disable IBL shadows
419
+ iblShadowResolution: -1, // Disable IBL shadows
480
420
  iblSampleDirections: 1, // Minimal sampling
481
421
  shadowBlurKernel: 0, // No blur
482
422
  ssaoEnabled: false,
483
423
  antialiasingEnabled: false,
484
- hardwareScaling: 2.5, // Aggressive downscaling
485
- maxModelResolution: 512, // Low-res model loading
424
+ hardwareScaling: 2.5, // Aggressive downscaling
425
+ maxModelResolution: 512, // Low-res model loading
486
426
  environmentTextureSize: 512,
487
427
  },
488
428
  // Mobile high-end / Tablet / Desktop low
@@ -500,7 +440,7 @@ export default class BabylonJSController {
500
440
  // Desktop mid-range / High-end mobile
501
441
  high: {
502
442
  shadowMapSize: 1024,
503
- iblShadowResolution: 0, // 1024x1024 - good balance
443
+ iblShadowResolution: 0, // 1024x1024 - good balance
504
444
  iblSampleDirections: 4, // Quality sampling
505
445
  shadowBlurKernel: 8, // Sharper shadows for product clarity
506
446
  ssaoEnabled: false,
@@ -512,9 +452,9 @@ export default class BabylonJSController {
512
452
  // Desktop high-end (RTX, Apple Silicon, etc.)
513
453
  ultra: {
514
454
  shadowMapSize: 2048,
515
- iblShadowResolution: 1, // 2048x2048
455
+ iblShadowResolution: 1, // 2048x2048
516
456
  iblSampleDirections: 8, // Maximum quality
517
- shadowBlurKernel: 16, // Preserve clarity over softness
457
+ shadowBlurKernel: 16, // Preserve clarity over softness
518
458
  ssaoEnabled: false,
519
459
  antialiasingEnabled: true,
520
460
  hardwareScaling: 1.0, // Native resolution
@@ -532,10 +472,10 @@ export default class BabylonJSController {
532
472
  };
533
473
 
534
474
  const preset = presetMap[tier] || qualityPresets.medium;
535
-
475
+
536
476
  // Apply preset to config
537
- this.#config.quality = {
538
- tier,
477
+ this.#config.quality = {
478
+ tier,
539
479
  ...preset,
540
480
  // Legacy compatibility
541
481
  shadowMapSize: preset.shadowMapSize,
@@ -729,6 +669,55 @@ export default class BabylonJSController {
729
669
  }
730
670
  }
731
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
+
732
721
  /**
733
722
  * Starts Babylon's engine render loop if it is not already running.
734
723
  * @private
@@ -756,7 +745,7 @@ export default class BabylonJSController {
756
745
  this.#state.render.isLoopRunning = false;
757
746
  return true;
758
747
  }
759
-
748
+
760
749
  /**
761
750
  * Resets transient render-loop bookkeeping so the next render request starts from a clean baseline.
762
751
  * Clears queued burst frames, ends any active continuous-render window, and drops the idle-throttle timestamp.
@@ -891,12 +880,12 @@ export default class BabylonJSController {
891
880
  }
892
881
 
893
882
  const shouldRender = animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
894
-
883
+
895
884
  // Reset idle flag if we need to render
896
885
  if (shouldRender) {
897
886
  this.#state.render.isIdle = false;
898
887
  }
899
-
888
+
900
889
  return shouldRender;
901
890
  }
902
891
 
@@ -932,7 +921,7 @@ export default class BabylonJSController {
932
921
  if (!continuous && this.#state.render.lastRenderAt > 0 && now - this.#state.render.lastRenderAt < this.#config.render.idleThrottleMs) {
933
922
  return;
934
923
  }
935
-
924
+
936
925
  this.#scene.render();
937
926
  this.#state.render.lastRenderAt = now;
938
927
 
@@ -1658,81 +1647,6 @@ export default class BabylonJSController {
1658
1647
  });
1659
1648
  }
1660
1649
 
1661
- /**
1662
- * Fixes cross-GLTF transparency ordering. Detects transparent/transmissive meshes
1663
- * (alpha-blended, alpha-tested, simplified environment transmission, or
1664
- * KHR_materials_transmission) across all loaded containers and moves them to
1665
- * renderingGroupId=1 with depth-write disabled.
1666
- * This ensures opaque scene geometry (e.g. lamp cups behind a model window) is
1667
- * written to the depth buffer first (group 0) so the glass layer composites on top
1668
- * without occluding it.
1669
- * @private
1670
- * @returns {void}
1671
- */
1672
- #applyTransmittanceRenderOrder() {
1673
- if (!this.#scene) return;
1674
-
1675
- const envMeshIds = new Set(
1676
- this.#containers.environment?.assetContainer?.meshes.map((m) => m.uniqueId) ?? []
1677
- );
1678
- const modelMeshIds = new Set(
1679
- this.#containers.model?.assetContainer?.meshes.map((m) => m.uniqueId) ?? []
1680
- );
1681
-
1682
- // Map each material to the model meshes using it — needed to detect shared materials
1683
- // before modifying an env mesh's material (must clone to avoid breaking model glass).
1684
- const matToModelMeshes = new Map();
1685
- this.#scene.meshes.forEach((mesh) => {
1686
- if (!modelMeshIds.has(mesh.uniqueId) || !mesh.material) return;
1687
- if (!matToModelMeshes.has(mesh.material)) matToModelMeshes.set(mesh.material, new Set());
1688
- matToModelMeshes.get(mesh.material).add(mesh.uniqueId);
1689
- });
1690
-
1691
- this.#scene.meshes.forEach((mesh) => {
1692
- const mat = mesh.material;
1693
- if (!mat) return;
1694
- const isTransmissive = isPrefViewerPhysicalGlassMaterial(mat);
1695
- const isTransparentLike = isPrefViewerTransparentRenderOrderMaterial(mat);
1696
- if (!isTransparentLike) return;
1697
-
1698
- if (envMeshIds.has(mesh.uniqueId)) {
1699
- // Environment mesh carrying a transmissive material introduced by a material swap
1700
- // (initial-load materials are pre-simplified in #assetContainer_simplifyTransmissiveMaterials).
1701
- // Clone if shared with a model mesh so the model's physical glass is not affected.
1702
- let targetMat = mat;
1703
- if (matToModelMeshes.has(mat)) {
1704
- targetMat = mat.clone(`${mat.name}_env_simplified`);
1705
- mesh.material = targetMat;
1706
- }
1707
- if (isTransmissive) {
1708
- this.#simplifyTransmissiveMaterial(targetMat);
1709
- }
1710
- mesh.renderingGroupId = 1;
1711
- targetMat.disableDepthWrite = true;
1712
- targetMat.alphaIndex = 0;
1713
- } else if (modelMeshIds.has(mesh.uniqueId)) {
1714
- // Model transparent materials render after all group-0 geometry.
1715
- mesh.renderingGroupId = 1;
1716
- mat.disableDepthWrite = true;
1717
-
1718
- // Physical model glass keeps transmission, but strips volume absorption (tintColor)
1719
- // and roughness because those blur/darken transmitted geometry. Do not apply this
1720
- // tuning to plain alpha-blended model materials: render-order and physical glass
1721
- // material tuning are separate concerns.
1722
- if (isTransmissive && mat.subSurface) {
1723
- mat.subSurface.tintColor = Color3.White();
1724
- mat.roughness = 0;
1725
- }
1726
- mat.alphaIndex = 1;
1727
- } else {
1728
- // Unknown container (future-proof safe default).
1729
- mesh.renderingGroupId = 1;
1730
- mat.disableDepthWrite = true;
1731
- mat.alphaIndex = 1;
1732
- }
1733
- });
1734
- }
1735
-
1736
1650
  /**
1737
1651
  * Applies the showroom tonemapping curve and lighting bias used by the 3D viewer.
1738
1652
  * @private
@@ -1746,7 +1660,8 @@ export default class BabylonJSController {
1746
1660
  const showroom = this.#config.look?.showroom || {};
1747
1661
  const imageProcessing = this.#scene.imageProcessingConfiguration;
1748
1662
 
1749
- 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);
1750
1665
  imageProcessing.exposure = showroom.exposure ?? 0.95;
1751
1666
  imageProcessing.contrast = showroom.contrast ?? 1.08;
1752
1667
  imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
@@ -1770,7 +1685,7 @@ export default class BabylonJSController {
1770
1685
  }
1771
1686
 
1772
1687
  const showroom = this.#config.look?.showroom || {};
1773
- const timeOfDay = clamp01(this.#settings.lightingTimeOfDay ?? 0.5);
1688
+ const timeOfDay = clamp01(this.#settings.lightingTimeOfDay ? this.#settings.lightingTimeOfDay ?? 0.5 : 0.5);
1774
1689
  const profile = getLightingCycleProfile(timeOfDay);
1775
1690
  const daylight = profile.dayFactor;
1776
1691
  const peakLight = daylight > 0 ? Math.pow(daylight, 0.72) : 0;
@@ -1786,12 +1701,15 @@ export default class BabylonJSController {
1786
1701
  imageProcessing.colorCurvesEnabled = false;
1787
1702
  }
1788
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;
1789
1707
  this.#scene.clearColor = new Color4(
1790
- lerp(0.035, 0.98, daylight),
1791
- lerp(0.045, 0.98, daylight),
1792
- 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),
1793
1711
  1,
1794
- ).toLinearSpace();
1712
+ );
1795
1713
 
1796
1714
  if ("environmentIntensity" in this.#scene) {
1797
1715
  this.#scene.environmentIntensity = lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
@@ -1863,6 +1781,78 @@ export default class BabylonJSController {
1863
1781
  this.#engine.setHardwareScalingLevel(scalingLevel);
1864
1782
  }
1865
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
+
1866
1856
  /**
1867
1857
  * Keeps the showroom camera framing stable when the viewport aspect ratio changes.
1868
1858
  * The 3D showroom is meant to feel compositionally stable, so we prefer a horizontal-fixed
@@ -2093,7 +2083,7 @@ export default class BabylonJSController {
2093
2083
  }
2094
2084
 
2095
2085
  /**
2096
- * 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.
2097
2087
  * Removes any previous registration first to avoid duplicate callbacks across reload cycles.
2098
2088
  * @private
2099
2089
  * @returns {boolean} True when the listener is attached, false when no host is available.
@@ -2104,12 +2094,12 @@ export default class BabylonJSController {
2104
2094
  return false;
2105
2095
  }
2106
2096
  this.#detachAnimationChangedListener();
2107
- this.#prefViewer3D.addEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
2097
+ this.#prefViewer3D.addEventListener("pref-viewer-3d-animation-changed", this.#handlers.onAnimationGroupChanged);
2108
2098
  return true;
2109
2099
  }
2110
2100
 
2111
2101
  /**
2112
- * 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.
2113
2103
  * @private
2114
2104
  * @returns {boolean} True when a host exists and the listener removal was attempted, false otherwise.
2115
2105
  */
@@ -2117,7 +2107,7 @@ export default class BabylonJSController {
2117
2107
  if (!this.#prefViewer3D) {
2118
2108
  return false;
2119
2109
  }
2120
- this.#prefViewer3D.removeEventListener("prefviewer-animation-changed", this.#handlers.onAnimationGroupChanged);
2110
+ this.#prefViewer3D.removeEventListener("pref-viewer-3d-animation-changed", this.#handlers.onAnimationGroupChanged);
2121
2111
  return true;
2122
2112
  }
2123
2113
 
@@ -2150,12 +2140,12 @@ export default class BabylonJSController {
2150
2140
  */
2151
2141
  #onMouseWheel(event, pickInfo) {
2152
2142
  event.preventDefault();
2153
-
2143
+
2154
2144
  // OPTIMIZATION: Mark activity for idle mode
2155
2145
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2156
2146
  this.#state.render.lastActivityAt = now;
2157
2147
  this.#state.render.isIdle = false;
2158
-
2148
+
2159
2149
  const camera = this.#scene?.activeCamera;
2160
2150
  if (!camera) {
2161
2151
  return false;
@@ -2190,7 +2180,7 @@ export default class BabylonJSController {
2190
2180
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2191
2181
  this.#state.render.lastActivityAt = now;
2192
2182
  this.#state.render.isIdle = false;
2193
-
2183
+
2194
2184
  if (this.#babylonJSAnimationController) {
2195
2185
  if (event.button !== 2 || !pickInfo || !pickInfo.pickedMesh) {
2196
2186
  this.#babylonJSAnimationController.hideMenu();
@@ -2215,7 +2205,7 @@ export default class BabylonJSController {
2215
2205
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2216
2206
  this.#state.render.lastActivityAt = now;
2217
2207
  this.#state.render.isIdle = false;
2218
-
2208
+
2219
2209
  const camera = this.#scene?.activeCamera;
2220
2210
  if (camera && !camera.metadata?.locked) {
2221
2211
  this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
@@ -2437,6 +2427,7 @@ export default class BabylonJSController {
2437
2427
  }
2438
2428
  }
2439
2429
  this.#applyShowroomCameraFraming(camera);
2430
+ this.#fitDefaultCameraToModel(camera);
2440
2431
  this.#scene.activeCamera?.detachControl();
2441
2432
  camera.detachControl();
2442
2433
  if (!cameraState.locked) {
@@ -2863,21 +2854,34 @@ export default class BabylonJSController {
2863
2854
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
2864
2855
  this.#setMaxSimultaneousLights();
2865
2856
  this.#enhanceTextureQuality();
2866
- this.#applyTransmittanceRenderOrder();
2867
- this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
2868
- // Apply stored highlight settings so fresh page loads respect persisted state
2869
- this.#setHighlightConfig({
2870
- enabled: this.#settings.highlightEnabled,
2871
- color: this.#settings.highlightColor,
2872
- });
2873
- if (this.#babylonJSAnimationController?.hasAnimations?.()) {
2874
- this.#attachAnimationChangedListener();
2875
- }
2857
+ this.#syncAnimationControllerAfterModelLoad();
2858
+
2876
2859
  await this.#startRender();
2877
2860
  });
2878
2861
  return detail;
2879
2862
  }
2880
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
+
2881
2885
  /**
2882
2886
  * Reinstalls every camera-sensitive post-process (default pipeline, SSAO, shadows) after loads or option changes.
2883
2887
  * Ensures the active camera always owns fresh pipelines so render quality remains consistent across reload cycles.
@@ -3131,14 +3135,14 @@ export default class BabylonJSController {
3131
3135
  this.#configureDracoCompression();
3132
3136
  this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false }, true);
3133
3137
  this.#engine.disableUniformBuffers = true;
3134
-
3138
+
3135
3139
  // OPTIMIZATION: Detect hardware tier and apply quality settings
3136
3140
  const detectedTier = this.#detectHardwareTier();
3137
3141
  this.#applyQualitySettings(detectedTier);
3138
-
3142
+
3139
3143
  // OPTIMIZATION: Initialize idle tracking
3140
3144
  this.#state.render.lastActivityAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
3141
-
3145
+
3142
3146
  this.#scene = new Scene(this.#engine);
3143
3147
 
3144
3148
  // Activate the rendering of geometry data into a G-buffer, essential for advanced effects like deferred shading,
@@ -3159,6 +3163,7 @@ export default class BabylonJSController {
3159
3163
  this.#scene.skipPointerUpPicking = true;
3160
3164
 
3161
3165
  this.#createCamera();
3166
+ await this.#ensureEnvironmentBRDFTextureReady();
3162
3167
  this.#enableInteraction();
3163
3168
  await this.#createXRExperience();
3164
3169
  }
@@ -3314,6 +3319,182 @@ export default class BabylonJSController {
3314
3319
  return { ...this.#settings };
3315
3320
  }
3316
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
+
3317
3498
  /**
3318
3499
  * Loads all asset containers (model, environment, materials, etc.) and adds them to the scene.
3319
3500
  * Applies material and camera options, sets visibility, and initializes lights and shadows.
@@ -3346,7 +3527,6 @@ export default class BabylonJSController {
3346
3527
  setMaterialOptions() {
3347
3528
  this.#stopRender();
3348
3529
  const materialsOptionsSetted = this.#setOptions_Materials();
3349
- this.#applyTransmittanceRenderOrder();
3350
3530
  this.#startRender();
3351
3531
  return materialsOptionsSetted;
3352
3532
  }
@@ -3367,14 +3547,42 @@ export default class BabylonJSController {
3367
3547
  }
3368
3548
 
3369
3549
  /**
3370
- * 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).
3371
3554
  * @public
3372
- * @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.
3373
3562
  * @returns {void}
3374
3563
  */
3375
3564
  setIlluminationConfig(config = {}) {
3376
3565
  let shouldApplyLighting = false;
3377
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
+
3378
3586
  if (typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay)) {
3379
3587
  const normalizedTime = clamp01(config.lightingTimeOfDay);
3380
3588
  if (this.#settings.lightingTimeOfDay !== normalizedTime) {
@@ -3384,8 +3592,11 @@ export default class BabylonJSController {
3384
3592
  }
3385
3593
 
3386
3594
  if (config.showAnimationMenu !== undefined) {
3387
- // Handle show-animation-menu via the 3D component's parent
3388
- // 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();
3389
3600
  }
3390
3601
 
3391
3602
  // Handle highlight color for animation hover
@@ -3406,7 +3617,7 @@ export default class BabylonJSController {
3406
3617
  }
3407
3618
  this.#setHighlightConfig({
3408
3619
  color: config.highlightColor ?? undefined,
3409
- enabled,
3620
+ enabled: enabled,
3410
3621
  });
3411
3622
  }
3412
3623
 
@@ -3445,11 +3656,13 @@ export default class BabylonJSController {
3445
3656
  }
3446
3657
 
3447
3658
  /**
3448
- * 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
3449
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.
3450
3663
  * @public
3451
- * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of render toggles to apply.
3452
- * @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.
3453
3666
  * @description
3454
3667
  * Callers can inspect the `changed` flag to decide whether to trigger a reload with
3455
3668
  * `reloadWithCurrentSettings()` or simply reuse the returned snapshot.