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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.13.1-beta.0",
3
+ "version": "2.14.0-beta.2",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -141,9 +141,11 @@ export default class BabylonJSController {
141
141
  // Render loop state balances performance with responsiveness.
142
142
  render: {
143
143
  isLoopRunning: false,
144
+ isIdle: false,
144
145
  dirtyFrames: 0,
145
146
  continuousUntil: 0,
146
147
  lastRenderAt: 0,
148
+ lastActivityAt: 0,
147
149
  },
148
150
  // Resize state batches frequent ResizeObserver notifications.
149
151
  resize: {
@@ -164,13 +166,259 @@ export default class BabylonJSController {
164
166
  burstFramesEnhanced: 32, // when AA/SSAO/IBL is enabled, more frames are needed to reach stable output
165
167
  interactionMs: 250,
166
168
  animationMs: 200,
167
- idleThrottleMs: 1000 / 15,
169
+ idleThrottleMs: 1000 / 8, // Reduced from 15 FPS to 8 FPS for idle - reduces GPU/CPU load
170
+ idleThresholdMs: 3000, // Time before going to true idle (no renders)
168
171
  },
169
172
  resize: {
170
173
  throttleMs: 50, // cap resize work to ~20 Hz while dragging/resizing containers
171
174
  },
175
+ // Hardware-adaptive quality settings (applied based on GPU capabilities)
176
+ quality: {
177
+ // Settings for different hardware tiers: 'low', 'medium', 'high'
178
+ // Applied automatically based on GPU detected capabilities
179
+ tier: 'medium', // default, will be updated after hardware detection
180
+ shadowMapSize: 1024, // Standard shadow generator resolution
181
+ iblShadowResolution: 1, // IBL shadow resolution exponent (0=1024, 1=2048, 2=4096)
182
+ iblSampleDirections: 4, // IBL shadow quality
183
+ shadowBlurKernel: 16, // Shadow blur amount
184
+ ssaoEnabled: true, // Screen Space Ambient Occlusion
185
+ },
172
186
  };
173
187
 
188
+ /**
189
+ * Detects hardware capabilities and determines optimal quality tier.
190
+ * Uses WebGL renderer info, device type, and battery status to classify hardware.
191
+ * @private
192
+ * @returns {string} Hardware tier: 'low', 'medium', 'high', or 'ultra'
193
+ */
194
+ #detectHardwareTier() {
195
+ // First, try to get WebGL info to help identify the device
196
+ const gl = this.#engine?.getRenderingCanvas()?.getContext?.('webgl2') || this.#engine?.getRenderingCanvas()?.getContext?.('webgl');
197
+
198
+ const debugInfo = gl?.getExtension('WEBGL_debug_renderer_info');
199
+ const rendererName = debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : '';
200
+
201
+ // If we have WebGL renderer info and it looks like a desktop GPU, skip mobile detection
202
+ // Desktop GPUs typically have "NVIDIA", "AMD", "Intel" in the name
203
+ const isDesktopGPU = /NVIDIA|AMD|Intel|Radeon|GeForce|Quadro/i.test(rendererName);
204
+
205
+ // Also check if it's explicitly a mobile GPU
206
+ const isMobileGPU = /Adreno|Mali|Apple GPU/i.test(rendererName);
207
+
208
+ // If we detect a desktop GPU, go directly to desktop detection
209
+ if (isDesktopGPU && !isMobileGPU) {
210
+ console.log(`[PrefViewer] Desktop GPU detected: ${rendererName}`);
211
+ return this.#detectDesktopTier(gl, rendererName);
212
+ }
213
+
214
+ // If we detect a mobile GPU, it's definitely mobile
215
+ if (isMobileGPU) {
216
+ console.log(`[PrefViewer] Mobile GPU detected: ${rendererName} - Quality tier: low`);
217
+ return 'low';
218
+ }
219
+
220
+ // If no WebGL info or unclear, fall back to user agent detection
221
+ const mobileInfo = this.#detectMobileInfo();
222
+
223
+ // Only treat as mobile if we have strong mobile indicators
224
+ if (mobileInfo.isMobile && !mobileInfo.isDesktopLike) {
225
+ if (mobileInfo.isHighEnd) {
226
+ console.log(`[PrefViewer] High-end mobile detected: ${mobileInfo.deviceName} - Quality tier: medium`);
227
+ return 'medium';
228
+ } else {
229
+ console.log(`[PrefViewer] Mobile detected: ${mobileInfo.deviceName} - Quality tier: low`);
230
+ return 'low';
231
+ }
232
+ }
233
+
234
+ // If still unclear, default to desktop detection
235
+ console.log(`[PrefViewer] Using desktop GPU detection (unknown renderer: ${rendererName || 'none'})`);
236
+ return this.#detectDesktopTier(gl, rendererName);
237
+ }
238
+
239
+ /**
240
+ * Detects hardware tier for desktop GPUs based on renderer name and WebGL capabilities.
241
+ * @private
242
+ * @param {WebGLRenderingContext} gl - WebGL context
243
+ * @param {string} rendererName - GPU renderer name
244
+ * @returns {string} Hardware tier
245
+ */
246
+ #detectDesktopTier(gl, rendererName) {
247
+ // Check WebGL capabilities
248
+ const maxTextureSize = gl?.getParameter(gl.MAX_TEXTURE_SIZE) || 8192;
249
+
250
+ // Detect high-end GPUs
251
+ const isAppleSilicon = /Apple|M1|M2|M3|M4/i.test(rendererName);
252
+ 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);
253
+ const isAmdHighEnd = /RX (6|7|8|9)[0-9]{2,}|RX Vega|Radeon Pro|WX [0-9]/i.test(rendererName);
254
+ const isIntelIntegrated = /Intel.*UHD|Intel.*Iris|Mesa|llvmpipe/i.test(rendererName);
255
+
256
+ let tier = 'high'; // Default to high for desktop
257
+
258
+ if (isIntelIntegrated || maxTextureSize < 8192) {
259
+ tier = 'medium';
260
+ } else if (isAppleSilicon || isNvidiaHighEnd || isAmdHighEnd || maxTextureSize >= 16384) {
261
+ tier = 'ultra';
262
+ }
263
+
264
+ console.log(`[PrefViewer] Desktop tier: ${tier} (GPU: ${rendererName || 'unknown'}, MaxTexture: ${maxTextureSize})`);
265
+ return tier;
266
+ }
267
+
268
+ /**
269
+ * Detects mobile device type and capabilities.
270
+ * Analyzes user agent, screen specs, and device performance indicators.
271
+ * @private
272
+ * @returns {Object} Mobile device info with tier classification
273
+ */
274
+ #detectMobileInfo() {
275
+ const userAgent = navigator.userAgent || '';
276
+ const screenWidth = window.screen?.width || 0;
277
+ const screenHeight = window.screen?.height || 0;
278
+ const pixelRatio = window.devicePixelRatio || 1;
279
+
280
+ // Detect device type
281
+ const isIPhone = /iPhone/i.test(userAgent);
282
+ const isIPad = /iPad/i.test(userAgent);
283
+ const isAndroid = /Android/i.test(userAgent);
284
+ const isMobile = /Mobile/i.test(userAgent);
285
+
286
+ // High-end mobile patterns (recent flagship devices)
287
+ const highEndPatterns = [
288
+ /iPhone (1[4-9]|SE)/i, // iPhone 14+
289
+ /iPad Pro/i, // iPad Pro
290
+ /SM-S91[0-9]/i, // Samsung S21+
291
+ /SM-S9[0-9]/i, // Samsung S20+
292
+ /Pixel [7-9]/i, // Pixel 7+
293
+ /OnePlus [8-9|10|11]/i, // OnePlus 8+
294
+ /Xiaomi (1[2-4]|Mi 1[1-4])/i, // Xiaomi 12+
295
+ /Huawei (P50|Mate 4|)/i, // Huawei P50+
296
+ ];
297
+
298
+ // Mid-range patterns
299
+ const midRangePatterns = [
300
+ /iPhone (1[0-3]|SE)/i, // iPhone 10-13
301
+ /iPad Air/i, // iPad Air
302
+ /SM-A[0-9]/i, // Samsung A series
303
+ /Redmi Note/i, // Redmi Note
304
+ /Pixel [4-6]/i, // Pixel 4-6
305
+ /Moto G/i, // Moto G
306
+ ];
307
+
308
+ const isHighEnd = highEndPatterns.some(p => p.test(userAgent));
309
+ const isMidRange = midRangePatterns.some(p => p.test(userAgent));
310
+
311
+ // Detect tablet ONLY if explicitly in user agent (not just screen size)
312
+ // Many desktops have large screens and would incorrectly be detected as tablets
313
+ const isTablet = /iPad|Android.*Tablet|Nexus 9|Nexus 10/i.test(userAgent);
314
+
315
+ // Detect if mobile (phone form factor) - must have explicit mobile indicators
316
+ const isMobilePhone = /Android.*Mobile|iPhone|iPod/i.test(userAgent);
317
+
318
+ // Check if it looks like a desktop/laptop (has "Windows", "Mac", "Linux" but no mobile indicators)
319
+ const isDesktopLike = /Windows NT|Macintosh|Linux|X11/i.test(userAgent) && !isMobilePhone && !isTablet;
320
+
321
+ // Device name for logging
322
+ let deviceName = 'Unknown';
323
+ if (isIPhone) {
324
+ const match = userAgent.match(/iPhone.*OS (\d+)/);
325
+ deviceName = match ? `iPhone OS ${match[1]}` : 'iPhone';
326
+ } else if (isIPad) {
327
+ deviceName = 'iPad';
328
+ } else if (isAndroid) {
329
+ const match = userAgent.match(/Android.*;.*(.+?)\)/);
330
+ deviceName = match ? match[1].trim() : 'Android';
331
+ }
332
+
333
+ return {
334
+ isMobile: isMobilePhone && !isDesktopLike,
335
+ isTablet: isTablet && !isDesktopLike,
336
+ isDesktopLike,
337
+ isHighEnd: isHighEnd || (isTablet && pixelRatio >= 2),
338
+ isMidRange: isMidRange,
339
+ isLowEnd: !isHighEnd && !isMidRange,
340
+ deviceName,
341
+ screenWidth,
342
+ screenHeight,
343
+ pixelRatio,
344
+ };
345
+ }
346
+
347
+ /**
348
+ * Applies quality settings based on detected hardware tier.
349
+ * @private
350
+ * @param {string} tier - Hardware tier: 'low', 'medium', or 'high'
351
+ */
352
+ #applyQualitySettings(tier) {
353
+ const qualityPresets = {
354
+ // Mobile low-end (old phones, budget devices)
355
+ low: {
356
+ shadowMapSize: 256, // Very small for speed
357
+ iblShadowResolution: -1, // Disable IBL shadows
358
+ iblSampleDirections: 1, // Minimal sampling
359
+ shadowBlurKernel: 0, // No blur
360
+ ssaoEnabled: false,
361
+ antialiasingEnabled: false,
362
+ hardwareScaling: 2.5, // Aggressive downscaling
363
+ maxModelResolution: 512, // Low-res model loading
364
+ },
365
+ // Mobile high-end / Tablet / Desktop low
366
+ medium: {
367
+ shadowMapSize: 512,
368
+ iblShadowResolution: 0, // 1024x1024
369
+ iblSampleDirections: 2, // Fast sampling
370
+ shadowBlurKernel: 4, // Light blur
371
+ ssaoEnabled: false, // Disable for mobile battery
372
+ antialiasingEnabled: true,
373
+ hardwareScaling: 2.0,
374
+ maxModelResolution: 1024,
375
+ },
376
+ // Desktop mid-range / High-end mobile
377
+ high: {
378
+ shadowMapSize: 1024,
379
+ iblShadowResolution: 0, // 1024x1024 - good balance
380
+ iblSampleDirections: 4, // Quality sampling
381
+ shadowBlurKernel: 16, // Smooth shadows
382
+ ssaoEnabled: true,
383
+ antialiasingEnabled: true,
384
+ hardwareScaling: 1.5,
385
+ maxModelResolution: 2048,
386
+ },
387
+ // Desktop high-end (RTX, Apple Silicon, etc.)
388
+ ultra: {
389
+ shadowMapSize: 2048,
390
+ iblShadowResolution: 1, // 2048x2048
391
+ iblSampleDirections: 8, // Maximum quality
392
+ shadowBlurKernel: 32, // Ultra smooth
393
+ ssaoEnabled: true,
394
+ antialiasingEnabled: true,
395
+ hardwareScaling: 1.0, // Native resolution
396
+ maxModelResolution: 4096,
397
+ },
398
+ };
399
+
400
+ // Map tier to preset with fallback
401
+ const presetMap = {
402
+ 'low': qualityPresets.low,
403
+ 'medium': qualityPresets.medium,
404
+ 'high': qualityPresets.high,
405
+ 'ultra': qualityPresets.ultra,
406
+ };
407
+
408
+ const preset = presetMap[tier] || qualityPresets.medium;
409
+
410
+ // Apply preset to config
411
+ this.#config.quality = {
412
+ tier,
413
+ ...preset,
414
+ // Legacy compatibility
415
+ shadowMapSize: preset.shadowMapSize,
416
+ iblShadowResolution: preset.iblShadowResolution,
417
+ iblSampleDirections: preset.iblSampleDirections,
418
+ shadowBlurKernel: preset.shadowBlurKernel,
419
+ };
420
+ }
421
+
174
422
  // Promises to track async disable() lifecycle when XR and general teardown may run concurrently; ensures idempotent disable calls are safe and callers can await full teardown completion.
175
423
  #disablingPromises = {
176
424
  xr: null,
@@ -463,6 +711,7 @@ export default class BabylonJSController {
463
711
  * Evaluates whether the renderer should stay in continuous mode.
464
712
  * XR always forces continuous rendering; animation/camera motion also extends the
465
713
  * continuous deadline window to avoid abrupt stop-start behavior.
714
+ * OPTIMIZATION: Added true idle mode - stops rendering completely after idleThresholdMs of inactivity.
466
715
  * @private
467
716
  * @param {number} now - Current high-resolution timestamp.
468
717
  * @returns {boolean} True when continuous rendering should remain active.
@@ -470,6 +719,8 @@ export default class BabylonJSController {
470
719
  #shouldRenderContinuously(now) {
471
720
  const inXR = this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR;
472
721
  if (inXR) {
722
+ this.#state.render.isIdle = false;
723
+ this.#state.render.lastActivityAt = now;
473
724
  return true;
474
725
  }
475
726
 
@@ -478,18 +729,36 @@ export default class BabylonJSController {
478
729
 
479
730
  if (animationRunning) {
480
731
  this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.animationMs);
732
+ this.#state.render.isIdle = false;
733
+ this.#state.render.lastActivityAt = now;
481
734
  }
482
735
  if (cameraInMotion) {
483
736
  this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.interactionMs);
737
+ this.#state.render.isIdle = false;
738
+ this.#state.render.lastActivityAt = now;
739
+ }
740
+
741
+ // OPTIMIZATION: True idle mode - if no activity for idleThresholdMs, stop rendering completely
742
+ const timeSinceActivity = now - this.#state.render.lastActivityAt;
743
+ if (timeSinceActivity > this.#config.render.idleThresholdMs) {
744
+ this.#state.render.isIdle = true;
484
745
  }
485
746
 
486
- return animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
747
+ const shouldRender = animationRunning || cameraInMotion || this.#state.render.continuousUntil > now;
748
+
749
+ // Reset idle flag if we need to render
750
+ if (shouldRender) {
751
+ this.#state.render.isIdle = false;
752
+ }
753
+
754
+ return shouldRender;
487
755
  }
488
756
 
489
757
  /**
490
758
  * Render loop callback for Babylon.js.
491
759
  * Runs only while scene state is dirty, interactive motion is active, animations are running, or XR is active.
492
- * It self-stops when the scene becomes idle.
760
+ * It self-stops when the scene becomes idle (true idle mode - no renders at all).
761
+ * OPTIMIZATION: Added true idle mode that completely stops rendering after idleThresholdMs.
493
762
  * @private
494
763
  * @returns {void}
495
764
  */
@@ -503,6 +772,12 @@ export default class BabylonJSController {
503
772
  const continuous = this.#shouldRenderContinuously(now);
504
773
  const needsRender = continuous || this.#state.render.dirtyFrames > 0;
505
774
 
775
+ // OPTIMIZATION: True idle mode - stop rendering completely when idle
776
+ if (this.#state.render.isIdle && !needsRender) {
777
+ this.#stopEngineRenderLoop();
778
+ return;
779
+ }
780
+
506
781
  if (!needsRender) {
507
782
  this.#stopEngineRenderLoop();
508
783
  return;
@@ -1035,9 +1310,10 @@ export default class BabylonJSController {
1035
1310
 
1036
1311
  const pipelineName = "PrefViewerIblShadowsRenderPipeline";
1037
1312
 
1313
+ // Use hardware-adaptive quality settings
1038
1314
  const pipelineOptions = {
1039
- resolutionExp: 1, // Higher resolution for better shadow quality (recomended 8)
1040
- sampleDirections: 4, // More sample directions for smoother shadows (recomended 4)
1315
+ resolutionExp: this.#config.quality.iblShadowResolution, // Adaptive: 0=1024, 1=2048, 2=4096
1316
+ sampleDirections: this.#config.quality.iblSampleDirections, // Adaptive: 2=fast, 4=quality
1041
1317
  ssShadowsEnabled: true,
1042
1318
  shadowRemanence: 0.85,
1043
1319
  triPlanarVoxelization: true,
@@ -1108,6 +1384,7 @@ export default class BabylonJSController {
1108
1384
 
1109
1385
  /**
1110
1386
  * Configures soft shadows for the built-in directional light used when no HDR environment is present.
1387
+ * Uses hardware-adaptive quality settings.
1111
1388
  * @private
1112
1389
  * @returns {void}
1113
1390
  */
@@ -1116,9 +1393,12 @@ export default class BabylonJSController {
1116
1393
  return;
1117
1394
  }
1118
1395
  this.#dirLight.autoUpdateExtends = false;
1119
- const shadowGenerator = new ShadowGenerator(1024, this.#dirLight);
1396
+ // Use quality-adaptive shadow map size
1397
+ const shadowMapSize = this.#config.quality.shadowMapSize;
1398
+ const shadowGenerator = new ShadowGenerator(shadowMapSize, this.#dirLight);
1120
1399
  shadowGenerator.useBlurExponentialShadowMap = true;
1121
- shadowGenerator.blurKernel = 16;
1400
+ // Use quality-adaptive blur kernel
1401
+ shadowGenerator.blurKernel = this.#config.quality.shadowBlurKernel;
1122
1402
  shadowGenerator.darkness = 0.5;
1123
1403
  shadowGenerator.bias = 0.0005;
1124
1404
  shadowGenerator.normalBias = 0.02;
@@ -1499,6 +1779,7 @@ export default class BabylonJSController {
1499
1779
 
1500
1780
  /**
1501
1781
  * Handles mouse wheel events on the Babylon.js canvas for zooming the camera.
1782
+ * OPTIMIZATION: Tracks activity for idle mode.
1502
1783
  * @private
1503
1784
  * @param {WheelEvent} event - The mouse wheel event.
1504
1785
  * @param {Object} pickInfo - The result of the scene pick operation (not used in this method).
@@ -1506,6 +1787,12 @@ export default class BabylonJSController {
1506
1787
  */
1507
1788
  #onMouseWheel(event, pickInfo) {
1508
1789
  event.preventDefault();
1790
+
1791
+ // OPTIMIZATION: Mark activity for idle mode
1792
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1793
+ this.#state.render.lastActivityAt = now;
1794
+ this.#state.render.isIdle = false;
1795
+
1509
1796
  const camera = this.#scene?.activeCamera;
1510
1797
  if (!camera) {
1511
1798
  return false;
@@ -1527,14 +1814,20 @@ export default class BabylonJSController {
1527
1814
  }
1528
1815
  }
1529
1816
 
1530
- /**
1817
+ /**
1531
1818
  * Handles pointer up events on the Babylon.js scene.
1819
+ * OPTIMIZATION: Tracks activity for idle mode.
1532
1820
  * @private
1533
1821
  * @param {PointerEvent} event - The pointer up event.
1534
1822
  * @param {PickInfo} pickInfo - The result of the scene pick operation.
1535
1823
  * @returns {void}
1536
1824
  */
1537
1825
  #onPointerUp(event, pickInfo) {
1826
+ // OPTIMIZATION: Mark activity for idle mode
1827
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1828
+ this.#state.render.lastActivityAt = now;
1829
+ this.#state.render.isIdle = false;
1830
+
1538
1831
  if (this.#babylonJSAnimationController) {
1539
1832
  if (event.button !== 2 || !pickInfo || !pickInfo.pickedMesh) {
1540
1833
  this.#babylonJSAnimationController.hideMenu();
@@ -1548,12 +1841,18 @@ export default class BabylonJSController {
1548
1841
 
1549
1842
  /**
1550
1843
  * Handles pointer move events on the Babylon.js scene.
1844
+ * OPTIMIZATION: Tracks activity for idle mode.
1551
1845
  * @private
1552
1846
  * @param {PointerEvent} event - The pointer move event.
1553
1847
  * @param {PickInfo|null} pickInfo - The sampled result of the scene pick operation (may be null when sampling skips a raycast).
1554
1848
  * @returns {void}
1555
1849
  */
1556
1850
  #onPointerMove(event, pickInfo) {
1851
+ // OPTIMIZATION: Mark activity for idle mode
1852
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1853
+ this.#state.render.lastActivityAt = now;
1854
+ this.#state.render.isIdle = false;
1855
+
1557
1856
  const camera = this.#scene?.activeCamera;
1558
1857
  if (camera && !camera.metadata?.locked) {
1559
1858
  this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
@@ -2420,6 +2719,20 @@ export default class BabylonJSController {
2420
2719
  this.#configureDracoCompression();
2421
2720
  this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false });
2422
2721
  this.#engine.disableUniformBuffers = true;
2722
+
2723
+ // OPTIMIZATION: Detect hardware tier and apply quality settings
2724
+ const detectedTier = this.#detectHardwareTier();
2725
+ this.#applyQualitySettings(detectedTier);
2726
+
2727
+ // OPTIMIZATION: Apply hardware scaling from quality preset
2728
+ const pixelRatio = typeof window !== 'undefined' ? window.devicePixelRatio : 1;
2729
+ const baseScaling = this.#config.quality.hardwareScaling || 1;
2730
+ const scalingLevel = pixelRatio > 1 ? baseScaling * pixelRatio : baseScaling;
2731
+ this.#engine.setHardwareScalingLevel(scalingLevel);
2732
+
2733
+ // OPTIMIZATION: Initialize idle tracking
2734
+ this.#state.render.lastActivityAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2735
+
2423
2736
  this.#scene = new Scene(this.#engine);
2424
2737
 
2425
2738
  // Activate the rendering of geometry data into a G-buffer, essential for advanced effects like deferred shading,
@@ -594,6 +594,11 @@ export default class PrefViewer3D extends HTMLElement {
594
594
 
595
595
  const loadDetail = await this.#babylonJSController.load(forceReload);
596
596
 
597
+ // Apply initial render settings if provided in options
598
+ if (config.options?.render) {
599
+ await this.applyRenderSettings(config.options.render);
600
+ }
601
+
597
602
  return { ...loadDetail, load: this.#onLoaded() };
598
603
  }
599
604
 
@@ -622,6 +627,12 @@ export default class PrefViewer3D extends HTMLElement {
622
627
  if (needUpdateIBL) {
623
628
  someSetted = someSetted || (await this.#babylonJSController.setIBLOptions());
624
629
  }
630
+
631
+ // Apply render settings if provided
632
+ if (options.render) {
633
+ await this.applyRenderSettings(options.render);
634
+ }
635
+
625
636
  const detail = this.#onSetOptions();
626
637
  return { success: someSetted, detail: detail };
627
638
  }
@@ -106,7 +106,7 @@ export default class PrefViewer extends HTMLElement {
106
106
  * @returns {string[]} Array of attribute names to observe.
107
107
  */
108
108
  static get observedAttributes() {
109
- return ["config", "culture", "drawing", "materials", "mode", "model", "scene", "options", "show-model", "show-scene"];
109
+ return ["config", "culture", "drawing", "materials", "mode", "model", "scene", "options", "show-model", "show-scene", "show-menu"];
110
110
  }
111
111
 
112
112
  /**
@@ -174,6 +174,10 @@ export default class PrefViewer extends HTMLElement {
174
174
  const showScene = value.toLowerCase() === "true";
175
175
  showScene ? this.showScene() : this.hideScene();
176
176
  break;
177
+ case "show-menu":
178
+ // Recreate menu to apply show/hide setting
179
+ this.#createMenu3D();
180
+ break;
177
181
  }
178
182
  }
179
183
 
@@ -271,6 +275,7 @@ export default class PrefViewer extends HTMLElement {
271
275
  /**
272
276
  * Creates (or recreates) the PrefViewerMenu3D element, wires hover/apply handlers, and syncs it with the 3D component.
273
277
  * When a menu already exists it is removed so the DOM stays clean.
278
+ * Respects the "show-menu" attribute to determine if menu should be visible.
274
279
  * @private
275
280
  * @returns {void}
276
281
  */
@@ -282,6 +287,16 @@ export default class PrefViewer extends HTMLElement {
282
287
  this.#menu3D.removeEventListener("pref-viewer-menu-3d-apply", this.#handlers.onMenuApply);
283
288
  this.#menu3D.remove();
284
289
  }
290
+
291
+ // Check if menu should be shown (default: true if not specified)
292
+ const showMenuAttr = this.getAttribute("show-menu");
293
+ const showMenu = showMenuAttr === null || showMenuAttr === "true";
294
+
295
+ if (!showMenu) {
296
+ this.#menu3D = null;
297
+ return;
298
+ }
299
+
285
300
  this.#menu3D = document.createElement("pref-viewer-menu-3d");
286
301
  if (typeof this.#menu3D.setCulture === "function") {
287
302
  this.#menu3D.setCulture(this.#culture);