@preference-sl/pref-viewer 2.16.2 → 2.17.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
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";
1
+ import { ArcRotateCamera, AssetContainer, BRDFTextureTools, Camera, Color3, Color4, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, ImageProcessingConfiguration, LoadAssetContainerAsync, Material, Matrix, 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";
@@ -8,15 +8,15 @@ import JSZip from "jszip";
8
8
  import GLTFResolver from "./gltf-resolver.js";
9
9
  import { MaterialData } from "./pref-viewer-3d-data.js";
10
10
  import BabylonJSAnimationController from "./babylonjs-animation-controller.js";
11
+ import BabylonJSViewCubeController from "./babylonjs-view-cube-controller.js";
12
+ import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
13
+ import BabylonJSCameraZoomController from "./babylonjs-camera-zoom-controller.js";
14
+ import BabylonJSSelectionController from "./babylonjs-selection-controller.js";
11
15
  import OpeningAnimation from "./babylonjs-animation-opening.js";
12
- import { translate } from "./localization/i18n.js";
16
+ import I18n from "./localization/i18n.js";
13
17
  import { PrefViewerStyleVariables } from "./styles.js";
14
- import { clamp01, lerp, getAdaptiveHardwareScalingLevel, getLightingCycleProfile } from "./render-math.js";
15
- import { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlassMaterial, isPrefViewerTransparentRenderOrderMaterial } from "./material-utils.js";
16
-
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";
18
+ import RenderMath from "./render-math.js";
19
+ import MaterialUtils from "./material-utils.js";
20
20
 
21
21
  /**
22
22
  * BabylonJSController coordinates the PrefViewer 3D runtime: it bootstraps Babylon.js, manages asset containers,
@@ -34,11 +34,12 @@ export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlass
34
34
  * - Manages keyboard/pointer/wheel handlers, animation menus, and WebXR so PrefViewer menus and DOM attributes stay
35
35
  * synchronized with Babylon state, using sampled pointer picking and last-picked tracking to reduce raycast/highlight
36
36
  * cost on dense scenes.
37
+ * - Lazily owns the ViewCube overlay and binds a model-only orbital zoom guard independently of cube visibility.
37
38
  * - Generates GLB, glTF+ZIP, or USDZ exports with timestamped names and localized dialog copy.
38
39
  * - Translates metadata (inner floor offsets, cast/receive shadows, camera locks) into scene adjustments after reloads.
39
40
  *
40
41
  * Runtime Flow
41
- * 1. Instantiate with `new BabylonJSController(canvas, containers, options)`.
42
+ * 1. Instantiate with `new BabylonJSController(canvas, containers, options, viewCubeConfig?)`.
42
43
  * 2. Call `enable()` to configure Draco, spin up the engine/scene, and attach interaction + XR hooks. The render loop
43
44
  * does not start here; it starts automatically after the first `load()` completes.
44
45
  * 3. When PrefViewer marks containers/options pending, invoke `load()` (or `reloadWithCurrentSettings()`) so the controller
@@ -52,7 +53,8 @@ export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlass
52
53
  * shared GLTF resolver, which closes its internal IndexedDB handle.
53
54
  *
54
55
  * Public API Highlights
55
- * - constructor(canvas, containers, options)
56
+ * - constructor(canvas, containers, options, viewCubeConfig?)
57
+ * - setViewCubeEnabled(enabled) / refreshViewCube()
56
58
  * - enable() / disable()
57
59
  * - load(force?) / reloadWithCurrentSettings()
58
60
  * - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
@@ -78,8 +80,9 @@ export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlass
78
80
  * - Browser-only features guard `window`, localStorage, and XR APIs before use so the controller is safe to construct
79
81
  * in SSR/Node contexts (though functionality activates only in browsers).
80
82
  * - Pointer-pick lifecycle: hover/highlight raycasts are sampled on POINTERMOVE (time + distance thresholds), wheel
81
- * input avoids picks entirely, and right-click POINTERUP performs an on-demand pick for context-menu targeting.
82
- * - Runtime bookkeeping is split into mutable `#state` and tuning constants under `#config` to keep behavior changes
83
+ * input avoids interaction picks; post-input orbital zoom protection separately probes only model meshes.
84
+ * Right-click POINTERUP performs an on-demand interaction pick for context-menu targeting.
85
+ * - Runtime bookkeeping is split into mutable `#state` and shared tuning settings under `BabylonJSController.config` to keep behavior changes
83
86
  * explicit and reduce cross-field drift.
84
87
  * - Resize lifecycle: canvas resize notifications are throttled (with trailing execution) before calling `engine.resize()`
85
88
  * and any queued resize callback is canceled during teardown.
@@ -87,25 +90,6 @@ export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlass
87
90
  * during XR exit and engine disposal.
88
91
  */
89
92
  export default class BabylonJSController {
90
- #RENDER_SETTINGS_STORAGE_KEY = "pref-viewer/render-settings";
91
-
92
- // Default render settings
93
- // Add here new render toggles that require persistence here, and remember to update the matching labels
94
- // under translations.prefViewer.menu3d.switches in ./localization/translations.js.
95
- static DEFAULT_RENDER_SETTINGS = {
96
- antiAliasingEnabled: true,
97
- ambientOcclusionEnabled: true,
98
- iblEnabled: true,
99
- shadowsEnabled: false,
100
- // Animation menu and highlight settings
101
- showAnimationMenu: true,
102
- highlightEnabled: true,
103
- highlightColor: PrefViewerStyleVariables.colorPrimary,
104
- // Lighting time of day (0=midnight, 0.5=noon, 1=midnight)
105
- showLightingTimeOfDay: true,
106
- lightingTimeOfDay: 0.5,
107
- };
108
-
109
93
  // Canvas HTML element
110
94
  #canvas = null;
111
95
 
@@ -128,10 +112,18 @@ export default class BabylonJSController {
128
112
  #hdrTexture = null; // Reusable in-memory HDR source cloned into scene.environmentTexture across reloads
129
113
 
130
114
  #containers = {};
115
+
116
+ #viewCube = null;
117
+ #cameraZoom = null;
118
+ #viewCubeConfig = {};
119
+ #viewCubeEnabled = true;
120
+ #viewCubeSuspended = true;
121
+
131
122
  #options = {};
132
123
 
133
124
  #gltfResolver = null; // GLTFResolver instance
134
125
  #babylonJSAnimationController = null; // AnimationController instance
126
+ #selectionController = null;
135
127
 
136
128
  #renderPipelines = {
137
129
  default: null,
@@ -142,12 +134,13 @@ export default class BabylonJSController {
142
134
  #handlers = {
143
135
  onAnimationGroupChanged: null,
144
136
  onKeyUp: null,
137
+ onPointerCancel: null,
145
138
  onPointerObservable: null,
146
139
  onResize: null,
147
140
  renderLoop: null,
148
141
  };
149
142
 
150
- #settings = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
143
+ #renderSettings = { ...BabylonJSController.config.render.defaultSettings };
151
144
 
152
145
  // Runtime mutable state (changes while the app is running).
153
146
  #state = {
@@ -157,6 +150,7 @@ export default class BabylonJSController {
157
150
  lastMovePickX: NaN,
158
151
  lastMovePickY: NaN,
159
152
  lastPickedMeshId: null,
153
+ pointerDown: null,
160
154
  },
161
155
  // Render loop state balances performance with responsiveness.
162
156
  render: {
@@ -175,38 +169,22 @@ export default class BabylonJSController {
175
169
  },
176
170
  };
177
171
 
178
- // Runtime configuration constants (tuning knobs, not per-frame state).
179
- #config = {
180
- pointerPicking: {
181
- movePickIntervalMs: 50, // Cap expensive scene.pick calls to ~20 Hz while moving the pointer
182
- movePickMinDistancePx: 2, // Skip picks for sub-pixel jitter
183
- },
184
- render: {
185
- burstFramesBase: 2,
186
- burstFramesEnhanced: 32, // When AA/SSAO/IBL is enabled, more frames are needed to reach stable output
187
- interactionMs: 250,
188
- animationMs: 200,
189
- idleThrottleMs: 1000 / 8, // Reduced from 15 FPS to 8 FPS for idle - reduces GPU/CPU load
190
- idleThresholdMs: 3000, // Time before going to true idle (no renders)
191
- },
192
- resize: {
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
172
+ /**
173
+ * Shared runtime tuning settings accessible to cooperating controller classes.
174
+ * All instances use this object, including the quality preset updated by hardware detection.
175
+ * render.defaultSettings supplies defaults for each instance's persisted render settings snapshot.
176
+ * Per-instance render and interaction bookkeeping remains in #state.
177
+ * @public
178
+ * @static
179
+ * @type {object}
180
+ */
181
+ static config = {
182
+ camera: {
183
+ wheelDeltaPercentage: 0.015,
184
+ fitMarginFactor: 0.15, // Total free viewport fraction: 0.15 leaves 85% for the model
198
185
  maxRadiusFactor: 4, // Pull back past the whole model
199
- },
200
- // Hardware-adaptive quality settings (applied based on GPU capabilities)
201
- quality: {
202
- // Settings for different hardware tiers: 'low', 'medium', 'high'
203
- // Applied automatically based on GPU detected capabilities
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
186
+ minRadiusFactor: 0.25, // ViewCube minimum radius relative to transition distance
187
+ transitionDuration: 350, // ViewCube camera transition duration in milliseconds
210
188
  },
211
189
  look: {
212
190
  showroom: {
@@ -238,6 +216,53 @@ export default class BabylonJSController {
238
216
  backgroundDay: [1.424, 1.424, 1.424],
239
217
  },
240
218
  },
219
+ pointerPicking: {
220
+ movePickIntervalMs: 50, // Cap expensive scene.pick calls to ~20 Hz while moving the pointer
221
+ movePickMinDistancePx: 2, // Skip picks for sub-pixel jitter
222
+ clickMaxDistancePx: 6,
223
+ },
224
+ selection: {
225
+ selectableColor: [1, 1, 0],
226
+ selectedColor: [1, 0, 0],
227
+ overlayAlpha: 0.5,
228
+ },
229
+ // Hardware-adaptive quality settings (applied based on GPU capabilities)
230
+ quality: {
231
+ // Settings for different hardware tiers: 'low', 'medium', 'high'
232
+ // Applied automatically based on GPU detected capabilities
233
+ tier: 'medium', // Default, will be updated after hardware detection
234
+ shadowMapSize: 1024, // Standard shadow generator resolution
235
+ iblShadowResolution: 1, // IBL shadow resolution exponent (0=1024, 1=2048, 2=4096)
236
+ iblSampleDirections: 4, // IBL shadow quality
237
+ shadowBlurKernel: 16, // Shadow blur amount
238
+ ssaoEnabled: true, // Screen Space Ambient Occlusion
239
+ },
240
+ render: {
241
+ // Default render settings. Add new persisted render toggles here and update their translation labels.
242
+ defaultSettings: {
243
+ antiAliasingEnabled: true,
244
+ ambientOcclusionEnabled: true,
245
+ iblEnabled: true,
246
+ shadowsEnabled: false,
247
+ // Animation menu and highlight settings
248
+ showAnimationMenu: true,
249
+ highlightEnabled: true,
250
+ highlightColor: PrefViewerStyleVariables.colorPrimary,
251
+ // Lighting time of day (0=midnight, 0.5=noon, 1=midnight)
252
+ showLightingTimeOfDay: true,
253
+ lightingTimeOfDay: 0.5,
254
+ },
255
+ burstFramesBase: 2,
256
+ burstFramesEnhanced: 32, // When AA/SSAO/IBL is enabled, more frames are needed to reach stable output
257
+ interactionMs: 250,
258
+ animationMs: 200,
259
+ idleThrottleMs: 1000 / 8, // Reduced from 15 FPS to 8 FPS for idle - reduces GPU/CPU load
260
+ idleThresholdMs: 3000, // Time before going to true idle (no renders)
261
+ },
262
+ resize: {
263
+ throttleMs: 50, // Cap resize work to ~20 Hz while dragging/resizing containers
264
+ },
265
+ storageKey: "pref-viewer/render-settings",
241
266
  };
242
267
 
243
268
  /**
@@ -301,6 +326,7 @@ export default class BabylonJSController {
301
326
  * @private
302
327
  * @param {WebGLRenderingContext} gl - WebGL context
303
328
  * @param {string} rendererName - GPU renderer name
329
+ * @param {boolean} [conservative=false] - Uses a cautious fallback when the GPU renderer is unknown.
304
330
  * @returns {string} Hardware tier
305
331
  */
306
332
  #detectDesktopTier(gl, rendererName, conservative = false) {
@@ -410,6 +436,7 @@ export default class BabylonJSController {
410
436
  * Applies quality settings based on detected hardware tier.
411
437
  * @private
412
438
  * @param {string} tier - Hardware tier: 'low', 'medium', or 'high'
439
+ * @returns {void}
413
440
  */
414
441
  #applyQualitySettings(tier) {
415
442
  const qualityPresets = {
@@ -474,7 +501,7 @@ export default class BabylonJSController {
474
501
  const preset = presetMap[tier] || qualityPresets.medium;
475
502
 
476
503
  // Apply preset to config
477
- this.#config.quality = {
504
+ BabylonJSController.config.quality = {
478
505
  tier,
479
506
  ...preset,
480
507
  // Legacy compatibility
@@ -498,14 +525,20 @@ export default class BabylonJSController {
498
525
  * @param {HTMLCanvasElement|null} canvas - The canvas element to render the scene on.
499
526
  * @param {object} containers - An object containing container states for model, environment, materials, etc.
500
527
  * @param {object} options - Configuration options for the Babylon.js scene and assets.
528
+ * @param {object} [viewCubeConfig={}] - Internal overlay integration options.
529
+ * @param {boolean} [viewCubeConfig.enabled=true] - Desired activation; creation waits for a live scene and camera.
530
+ * @param {function(): string} [viewCubeConfig.getCulture] - Current UI culture for cube labels.
531
+ * @param {function(number, number): void} [viewCubeConfig.onLayout] - Measured cube SVG height and scene viewport height in CSS pixels; (0, 0) on disposal.
501
532
  * @returns {BabylonJSController}
502
533
  * @description
503
534
  * - Assigns the provided canvas to the controller.
504
535
  * - Initializes each container with its state and sets assetContainer to null.
505
536
  * - Stores the provided options for later use in scene setup and asset loading.
506
537
  */
507
- constructor(canvas = null, containers = {}, options = {}) {
538
+ constructor(canvas = null, containers = {}, options = {}, viewCubeConfig = {}) {
508
539
  this.#canvas = canvas;
540
+ this.#viewCubeConfig = viewCubeConfig;
541
+ this.#viewCubeEnabled = viewCubeConfig.enabled !== false;
509
542
  Object.keys(containers).forEach((key) => {
510
543
  this.#containers[key] = {
511
544
  assetContainer: null,
@@ -513,6 +546,16 @@ export default class BabylonJSController {
513
546
  };
514
547
  });
515
548
  this.#options = options;
549
+ const selectionConfig = BabylonJSController.config.selection;
550
+ this.#selectionController = new BabylonJSSelectionController(
551
+ () => this.#containers.model?.assetContainer?.meshes ?? [],
552
+ {
553
+ selectableColor: new Color3(...selectionConfig.selectableColor),
554
+ selectedColor: new Color3(...selectionConfig.selectedColor),
555
+ overlayAlpha: selectionConfig.overlayAlpha,
556
+ requestRender: () => this.#requestRender({ frames: 1 }),
557
+ },
558
+ );
516
559
  this.#loadStoredRenderSettings();
517
560
  this.#bindHandlers();
518
561
  }
@@ -525,6 +568,7 @@ export default class BabylonJSController {
525
568
  #bindHandlers() {
526
569
  this.#handlers.onAnimationGroupChanged = this.#onAnimationGroupChanged.bind(this);
527
570
  this.#handlers.onKeyUp = this.#onKeyUp.bind(this);
571
+ this.#handlers.onPointerCancel = this.#onPointerCancel.bind(this);
528
572
  this.#handlers.onPointerObservable = this.#onPointerObservable.bind(this);
529
573
  this.#handlers.onResize = this.#onResize.bind(this);
530
574
  this.#handlers.renderLoop = this.#renderLoop.bind(this);
@@ -563,18 +607,18 @@ export default class BabylonJSController {
563
607
  let changed = false;
564
608
  Object.keys(settings).forEach((key) => {
565
609
  // Handle boolean settings
566
- if (typeof settings[key] === "boolean" && this.#settings[key] !== settings[key]) {
567
- this.#settings[key] = settings[key];
610
+ if (typeof settings[key] === "boolean" && this.#renderSettings[key] !== settings[key]) {
611
+ this.#renderSettings[key] = settings[key];
568
612
  changed = true;
569
613
  }
570
614
  // Handle string settings (like illuminationColor)
571
- if (typeof settings[key] === "string" && this.#settings[key] !== settings[key]) {
572
- this.#settings[key] = settings[key];
615
+ if (typeof settings[key] === "string" && this.#renderSettings[key] !== settings[key]) {
616
+ this.#renderSettings[key] = settings[key];
573
617
  changed = true;
574
618
  }
575
619
  // Handle numeric settings such as lightingTimeOfDay
576
- if (typeof settings[key] === "number" && Number.isFinite(settings[key]) && this.#settings[key] !== settings[key]) {
577
- this.#settings[key] = settings[key];
620
+ if (typeof settings[key] === "number" && Number.isFinite(settings[key]) && this.#renderSettings[key] !== settings[key]) {
621
+ this.#renderSettings[key] = settings[key];
578
622
  changed = true;
579
623
  }
580
624
  });
@@ -597,23 +641,23 @@ export default class BabylonJSController {
597
641
  return;
598
642
  }
599
643
  try {
600
- const serialized = window.localStorage.getItem(this.#RENDER_SETTINGS_STORAGE_KEY);
644
+ const serialized = window.localStorage.getItem(BabylonJSController.config.storageKey);
601
645
  if (!serialized) {
602
646
  return;
603
647
  }
604
648
  const parsed = JSON.parse(serialized);
605
- Object.keys(BabylonJSController.DEFAULT_RENDER_SETTINGS).forEach((key) => {
649
+ Object.keys(BabylonJSController.config.render.defaultSettings).forEach((key) => {
606
650
  // Handle boolean settings
607
651
  if (typeof parsed?.[key] === "boolean") {
608
- this.#settings[key] = parsed[key];
652
+ this.#renderSettings[key] = parsed[key];
609
653
  }
610
654
  // Handle string settings (like illuminationColor)
611
655
  if (typeof parsed?.[key] === "string") {
612
- this.#settings[key] = parsed[key];
656
+ this.#renderSettings[key] = parsed[key];
613
657
  }
614
658
  // Handle numeric settings such as lightingTimeOfDay
615
659
  if (typeof parsed?.[key] === "number" && Number.isFinite(parsed[key])) {
616
- this.#settings[key] = parsed[key];
660
+ this.#renderSettings[key] = parsed[key];
617
661
  }
618
662
  });
619
663
  } catch (error) {
@@ -632,7 +676,7 @@ export default class BabylonJSController {
632
676
  return;
633
677
  }
634
678
  try {
635
- window.localStorage.setItem(this.#RENDER_SETTINGS_STORAGE_KEY, JSON.stringify(this.#settings));
679
+ window.localStorage.setItem(BabylonJSController.config.storageKey, JSON.stringify(this.#renderSettings));
636
680
  } catch (error) {
637
681
  console.warn("PrefViewer: unable to store render settings", error);
638
682
  }
@@ -687,10 +731,15 @@ export default class BabylonJSController {
687
731
 
688
732
  const timeoutMs = 2000;
689
733
  const startedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
690
- this.#requestRender({ frames: this.#config.render.burstFramesEnhanced, continuousMs: timeoutMs });
734
+ this.#requestRender({ frames: BabylonJSController.config.render.burstFramesEnhanced, continuousMs: timeoutMs });
691
735
 
692
736
  try {
693
737
  await new Promise((resolve) => {
738
+ /**
739
+ * Polls BRDF readiness until ready, disposed or the preparation timeout elapses.
740
+ * @private
741
+ * @returns {void}
742
+ */
694
743
  const tick = () => {
695
744
  if (!this.#scene || !this.#engine || brdfTexture.isDisposed?.()) {
696
745
  resolve(false);
@@ -844,7 +893,7 @@ export default class BabylonJSController {
844
893
 
845
894
  /**
846
895
  * Evaluates whether the renderer should stay in continuous mode.
847
- * XR always forces continuous rendering; animation/camera motion also extends the
896
+ * Pending ViewCube transitions and active XR force continuous rendering; animation/camera motion extends the
848
897
  * continuous deadline window to avoid abrupt stop-start behavior.
849
898
  * OPTIMIZATION: Added true idle mode - stops rendering completely after idleThresholdMs of inactivity.
850
899
  * @private
@@ -852,6 +901,9 @@ export default class BabylonJSController {
852
901
  * @returns {boolean} True when continuous rendering should remain active.
853
902
  */
854
903
  #shouldRenderContinuously(now) {
904
+ if (this.#viewCube?.isAnimating) {
905
+ return true;
906
+ }
855
907
  const inXR = this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR;
856
908
  if (inXR) {
857
909
  this.#state.render.isIdle = false;
@@ -863,19 +915,19 @@ export default class BabylonJSController {
863
915
  const cameraInMotion = this.#isCameraInMotion();
864
916
 
865
917
  if (animationRunning) {
866
- this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.animationMs);
918
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + BabylonJSController.config.render.animationMs);
867
919
  this.#state.render.isIdle = false;
868
920
  this.#state.render.lastActivityAt = now;
869
921
  }
870
922
  if (cameraInMotion) {
871
- this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.interactionMs);
923
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + BabylonJSController.config.render.interactionMs);
872
924
  this.#state.render.isIdle = false;
873
925
  this.#state.render.lastActivityAt = now;
874
926
  }
875
927
 
876
928
  // OPTIMIZATION: True idle mode - if no activity for idleThresholdMs, stop rendering completely
877
929
  const timeSinceActivity = now - this.#state.render.lastActivityAt;
878
- if (timeSinceActivity > this.#config.render.idleThresholdMs) {
930
+ if (timeSinceActivity > BabylonJSController.config.render.idleThresholdMs) {
879
931
  this.#state.render.isIdle = true;
880
932
  }
881
933
 
@@ -891,7 +943,8 @@ export default class BabylonJSController {
891
943
 
892
944
  /**
893
945
  * Render loop callback for Babylon.js.
894
- * Runs only while scene state is dirty, interactive motion is active, animations are running, or XR is active.
946
+ * Runs while scene state is dirty, interactive motion/animations are active, or XR/ViewCube navigation requires it.
947
+ * Suspends dynamic zoom constraints during cube framing, advances its transition, renders and synchronizes its indicator.
895
948
  * It self-stops when the scene becomes idle (true idle mode - no renders at all).
896
949
  * OPTIMIZATION: Added true idle mode that completely stops rendering after idleThresholdMs.
897
950
  * @private
@@ -918,11 +971,16 @@ export default class BabylonJSController {
918
971
  return;
919
972
  }
920
973
 
921
- if (!continuous && this.#state.render.lastRenderAt > 0 && now - this.#state.render.lastRenderAt < this.#config.render.idleThrottleMs) {
974
+ if (!continuous && this.#state.render.lastRenderAt > 0 && now - this.#state.render.lastRenderAt < BabylonJSController.config.render.idleThrottleMs) {
922
975
  return;
923
976
  }
924
977
 
978
+ if (this.#viewCube?.isAnimating) {
979
+ this.#cameraZoom?.update();
980
+ }
981
+ this.#viewCube?.tick(now);
925
982
  this.#scene.render();
983
+ this.#viewCube?.sync();
926
984
  this.#state.render.lastRenderAt = now;
927
985
 
928
986
  if (this.#state.render.dirtyFrames > 0) {
@@ -1005,15 +1063,20 @@ export default class BabylonJSController {
1005
1063
  }
1006
1064
 
1007
1065
  /**
1008
- * Creates and configures the main ArcRotateCamera for the Babylon.js scene.
1066
+ * Creates the default ArcRotateCamera with unrestricted beta, upright orbit and proportional native wheel zoom.
1067
+ * Starts at the exact Inner orientation (+Z, alpha/beta pi/2), matching the ViewCube before loading.
1068
+ * Starts without a fixed lower radius; the independent model zoom guard supplies the dynamic floor.
1069
+ * Imported-camera configuration is applied separately and is not reset here.
1009
1070
  * @private
1010
1071
  * @returns {void}
1011
1072
  */
1012
1073
  #createCamera() {
1013
- this.#camera = new ArcRotateCamera("defaultCamera", (3 * Math.PI) / 2, Math.PI * 0.47, 10, Vector3.Zero(), this.#scene);
1014
- this.#camera.upperBetaLimit = Math.PI * 0.48;
1015
- this.#camera.lowerBetaLimit = Math.PI * 0.25;
1016
- this.#camera.lowerRadiusLimit = 5;
1074
+ this.#camera = new ArcRotateCamera("defaultCamera", Math.PI / 2, Math.PI / 2, 10, Vector3.Zero(), this.#scene);
1075
+ this.#camera.upperBetaLimit = null;
1076
+ this.#camera.lowerBetaLimit = null;
1077
+ this.#camera.allowUpsideDown = false;
1078
+ this.#camera.wheelDeltaPercentage = BabylonJSController.config.camera.wheelDeltaPercentage;
1079
+ this.#camera.lowerRadiusLimit = null;
1017
1080
  this.#camera.upperRadiusLimit = 20;
1018
1081
  this.#camera.metadata = { locked: false };
1019
1082
  this.#applyShowroomCameraFraming(this.#camera);
@@ -1040,7 +1103,7 @@ export default class BabylonJSController {
1040
1103
 
1041
1104
  let lightsChanged = false;
1042
1105
 
1043
- const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
1106
+ const iblEnabled = this.#renderSettings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
1044
1107
 
1045
1108
  if (iblEnabled) {
1046
1109
  lightsChanged = await this.#initializeEnvironmentTexture();
@@ -1120,7 +1183,7 @@ export default class BabylonJSController {
1120
1183
  return false;
1121
1184
  }
1122
1185
 
1123
- if (!this.#settings.ambientOcclusionEnabled) {
1186
+ if (!this.#renderSettings.ambientOcclusionEnabled) {
1124
1187
  return false;
1125
1188
  }
1126
1189
  const supportedPipelines = pipelineManager.supportedPipelines;
@@ -1221,7 +1284,7 @@ export default class BabylonJSController {
1221
1284
  return false;
1222
1285
  }
1223
1286
 
1224
- if (!this.#settings.antiAliasingEnabled) {
1287
+ if (!this.#renderSettings.antiAliasingEnabled) {
1225
1288
  return false;
1226
1289
  }
1227
1290
  const supportedPipelines = pipelineManager.supportedPipelines;
@@ -1283,7 +1346,7 @@ export default class BabylonJSController {
1283
1346
  let hdrTexture = null;
1284
1347
  if (this.#options.ibl?.cachedUrl) {
1285
1348
  const hdrTextureURI = this.#options.ibl.cachedUrl;
1286
- const envTextureSize = this.#config.quality.environmentTextureSize || 1024;
1349
+ const envTextureSize = BabylonJSController.config.quality.environmentTextureSize || 1024;
1287
1350
  hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, envTextureSize, false, false, false, true, undefined, undefined, false, true, true);
1288
1351
  } else if (this.#hdrTexture && this.#options.ibl?.valid === true) {
1289
1352
  hdrTexture = this.#hdrTexture.clone();
@@ -1356,7 +1419,7 @@ export default class BabylonJSController {
1356
1419
  * Configures pipeline options for resolution, sampling, opacity, and debugging.
1357
1420
  * Only applies if the scene has an environment texture set.
1358
1421
  * @private
1359
- * @returns {Promise<void|boolean>} Returns false if no environment texture is set; otherwise void.
1422
+ * @returns {Promise<boolean>} True when the IBL shadow pipeline is initialized; false when unavailable or setup fails.
1360
1423
  */
1361
1424
  async #initializeIBLShadows() {
1362
1425
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
@@ -1411,8 +1474,8 @@ export default class BabylonJSController {
1411
1474
 
1412
1475
  // Use hardware-adaptive quality settings
1413
1476
  const pipelineOptions = {
1414
- resolutionExp: this.#config.quality.iblShadowResolution, // Adaptive: 0=1024, 1=2048, 2=4096
1415
- sampleDirections: this.#config.quality.iblSampleDirections, // Adaptive: 2=fast, 4=quality
1477
+ resolutionExp: BabylonJSController.config.quality.iblShadowResolution, // Adaptive: 0=1024, 1=2048, 2=4096
1478
+ sampleDirections: BabylonJSController.config.quality.iblSampleDirections, // Adaptive: 2=fast, 4=quality
1416
1479
  ssShadowsEnabled: true,
1417
1480
  shadowRemanence: 0.85,
1418
1481
  triPlanarVoxelization: true,
@@ -1485,7 +1548,7 @@ export default class BabylonJSController {
1485
1548
  * Configures soft shadows for the built-in directional light used when no HDR environment is present.
1486
1549
  * Uses hardware-adaptive quality settings.
1487
1550
  * @private
1488
- * @returns {void}
1551
+ * @returns {Promise<void>}
1489
1552
  */
1490
1553
  async #initializeDefaultLightShadows() {
1491
1554
  if (!this.#dirLight) {
@@ -1493,11 +1556,11 @@ export default class BabylonJSController {
1493
1556
  }
1494
1557
  this.#dirLight.autoUpdateExtends = false;
1495
1558
  // Use quality-adaptive shadow map size
1496
- const shadowMapSize = this.#config.quality.shadowMapSize;
1559
+ const shadowMapSize = BabylonJSController.config.quality.shadowMapSize;
1497
1560
  const shadowGenerator = new ShadowGenerator(shadowMapSize, this.#dirLight);
1498
1561
  shadowGenerator.useBlurExponentialShadowMap = true;
1499
1562
  // Use quality-adaptive blur kernel
1500
- shadowGenerator.blurKernel = this.#config.quality.shadowBlurKernel;
1563
+ shadowGenerator.blurKernel = BabylonJSController.config.quality.shadowBlurKernel;
1501
1564
  shadowGenerator.darkness = 0.5;
1502
1565
  shadowGenerator.bias = 0.0005;
1503
1566
  shadowGenerator.normalBias = 0.02;
@@ -1511,7 +1574,7 @@ export default class BabylonJSController {
1511
1574
  * Keeps only the generator bound to the built-in `PrefViewerDirLight` so its shadows persist,
1512
1575
  * then adds generators for every directional or spot light coming from the environment asset container.
1513
1576
  * @private
1514
- * @returns {void}
1577
+ * @returns {Promise<void>}
1515
1578
  */
1516
1579
  async #initializeEnvironmentShadows() {
1517
1580
  this.#shadowGen = this.#shadowGen.filter((generator) => {
@@ -1560,7 +1623,7 @@ export default class BabylonJSController {
1560
1623
  * Disposes every active shadow generator plus the IBL shadow pipeline to avoid stale casters across reloads.
1561
1624
  * Clears the cached `#shadowGen` array so subsequent loads can rebuild fresh generators.
1562
1625
  * @private
1563
- * @returns {void}
1626
+ * @returns {Promise<void>}
1564
1627
  */
1565
1628
  async #disableShadows() {
1566
1629
  this.#shadowGen.forEach((shadowGenerator) => {
@@ -1573,19 +1636,19 @@ export default class BabylonJSController {
1573
1636
  /**
1574
1637
  * Initializes shadows for the Babylon.js scene.
1575
1638
  * @private
1576
- * @returns {void|true} Returns true if IBL shadows are initialized, otherwise void.
1639
+ * @returns {Promise<void|false>} Resolves after shadow setup, or false when shadows are disabled.
1577
1640
  * @description
1578
- * If no environment texture is set, initializes IBL shadows.
1579
- * Otherwise, sets up shadow casting and receiving for all relevant meshes using the shadow generator.
1641
+ * Uses IBL shadows only when IBL is enabled with a valid source and its shadow option is active.
1642
+ * Otherwise initializes default-light shadows, then adds shadow support for environment lights.
1580
1643
  */
1581
1644
  async #initializeShadows() {
1582
- if (!this.#settings.shadowsEnabled) {
1645
+ if (!this.#renderSettings.shadowsEnabled) {
1583
1646
  return false;
1584
1647
  }
1585
1648
 
1586
1649
  this.#ensureMeshesReceiveShadows();
1587
1650
 
1588
- const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
1651
+ const iblEnabled = this.#renderSettings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
1589
1652
  const iblShadowsEnabled = iblEnabled && this.#options.ibl.shadows;
1590
1653
 
1591
1654
  if (iblShadowsEnabled) {
@@ -1603,7 +1666,9 @@ export default class BabylonJSController {
1603
1666
  * @returns {void}
1604
1667
  */
1605
1668
  #setMaxSimultaneousLights() {
1606
- if (!this.#scene) return;
1669
+ if (!this.#scene) {
1670
+ return;
1671
+ }
1607
1672
  let lightsNumber = 1; // At least one light coming from the environment texture contribution
1608
1673
  this.#scene.lights.forEach((light) => {
1609
1674
  if (light.isEnabled()) {
@@ -1657,10 +1722,10 @@ export default class BabylonJSController {
1657
1722
  return;
1658
1723
  }
1659
1724
 
1660
- const showroom = this.#config.look?.showroom || {};
1725
+ const showroom = BabylonJSController.config.look?.showroom || {};
1661
1726
  const imageProcessing = this.#scene.imageProcessingConfiguration;
1662
1727
 
1663
- const bg = showroom.backgroundDay ?? this.#config.look.showroom.backgroundDay;
1728
+ const bg = showroom.backgroundDay ?? BabylonJSController.config.look.showroom.backgroundDay;
1664
1729
  this.#scene.clearColor = new Color4(bg[0], bg[1], bg[2], 1);
1665
1730
  imageProcessing.exposure = showroom.exposure ?? 0.95;
1666
1731
  imageProcessing.contrast = showroom.contrast ?? 1.08;
@@ -1684,17 +1749,17 @@ export default class BabylonJSController {
1684
1749
  return false;
1685
1750
  }
1686
1751
 
1687
- const showroom = this.#config.look?.showroom || {};
1688
- const timeOfDay = clamp01(this.#settings.lightingTimeOfDay ? this.#settings.lightingTimeOfDay ?? 0.5 : 0.5);
1689
- const profile = getLightingCycleProfile(timeOfDay);
1752
+ const showroom = BabylonJSController.config.look?.showroom || {};
1753
+ const timeOfDay = RenderMath.clamp01(this.#renderSettings.lightingTimeOfDay ? this.#renderSettings.lightingTimeOfDay ?? 0.5 : 0.5);
1754
+ const profile = RenderMath.getLightingCycleProfile(timeOfDay);
1690
1755
  const daylight = profile.dayFactor;
1691
1756
  const peakLight = daylight > 0 ? Math.pow(daylight, 0.72) : 0;
1692
1757
  const twilight = 1 - peakLight;
1693
1758
 
1694
1759
  if (this.#scene.imageProcessingConfiguration) {
1695
1760
  const imageProcessing = this.#scene.imageProcessingConfiguration;
1696
- imageProcessing.exposure = lerp(0.78, showroom.exposure ?? 0.92, peakLight);
1697
- imageProcessing.contrast = lerp(0.98, showroom.contrast ?? 1.02, peakLight);
1761
+ imageProcessing.exposure = RenderMath.lerp(0.78, showroom.exposure ?? 0.92, peakLight);
1762
+ imageProcessing.contrast = RenderMath.lerp(0.98, showroom.contrast ?? 1.02, peakLight);
1698
1763
  imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
1699
1764
  imageProcessing.toneMappingType = showroom.toneMappingType ?? (ImageProcessingConfiguration?.TONEMAPPING_ACES ?? 0);
1700
1765
  imageProcessing.vignetteEnabled = false;
@@ -1703,64 +1768,64 @@ export default class BabylonJSController {
1703
1768
 
1704
1769
  // Night end stays in low linear values (a dark blue sky); the day end is the calibrated
1705
1770
  // HDR off-white. Both are linear already, so no toLinearSpace() on either.
1706
- const bgDay = showroom.backgroundDay ?? this.#config.look.showroom.backgroundDay;
1771
+ const bgDay = showroom.backgroundDay ?? BabylonJSController.config.look.showroom.backgroundDay;
1707
1772
  this.#scene.clearColor = new Color4(
1708
- lerp(0.000627, bgDay[0], daylight),
1709
- lerp(0.001089, bgDay[1], daylight),
1710
- lerp(0.004413, bgDay[2], daylight),
1773
+ RenderMath.lerp(0.000627, bgDay[0], daylight),
1774
+ RenderMath.lerp(0.001089, bgDay[1], daylight),
1775
+ RenderMath.lerp(0.004413, bgDay[2], daylight),
1711
1776
  1,
1712
1777
  );
1713
1778
 
1714
1779
  if ("environmentIntensity" in this.#scene) {
1715
- this.#scene.environmentIntensity = lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
1780
+ this.#scene.environmentIntensity = RenderMath.lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
1716
1781
  }
1717
1782
 
1718
1783
  if (this.#hemiLight) {
1719
1784
  this.#hemiLight.direction = new Vector3(
1720
- lerp(-0.45, -0.8, peakLight),
1721
- lerp(0.45, 0.95, peakLight),
1722
- lerp(-0.75, -0.35, peakLight),
1785
+ RenderMath.lerp(-0.45, -0.8, peakLight),
1786
+ RenderMath.lerp(0.45, 0.95, peakLight),
1787
+ RenderMath.lerp(-0.75, -0.35, peakLight),
1723
1788
  );
1724
- this.#hemiLight.intensity = lerp(0.18, showroom.hemiLightIntensity ?? 0.52, peakLight);
1789
+ this.#hemiLight.intensity = RenderMath.lerp(0.18, showroom.hemiLightIntensity ?? 0.52, peakLight);
1725
1790
  this.#hemiLight.diffuse = new Color3(
1726
- lerp(0.42, 0.92, peakLight),
1727
- lerp(0.48, 0.96, peakLight),
1728
- lerp(0.62, 0.98, peakLight),
1791
+ RenderMath.lerp(0.42, 0.92, peakLight),
1792
+ RenderMath.lerp(0.48, 0.96, peakLight),
1793
+ RenderMath.lerp(0.62, 0.98, peakLight),
1729
1794
  );
1730
1795
  this.#hemiLight.groundColor = new Color3(
1731
- lerp(0.08, 0.3, twilight),
1732
- lerp(0.08, 0.28, twilight),
1733
- lerp(0.1, 0.22, twilight),
1796
+ RenderMath.lerp(0.08, 0.3, twilight),
1797
+ RenderMath.lerp(0.08, 0.28, twilight),
1798
+ RenderMath.lerp(0.1, 0.22, twilight),
1734
1799
  );
1735
1800
  }
1736
1801
 
1737
1802
  if (this.#dirLight) {
1738
1803
  this.#dirLight.direction = new Vector3(profile.sunDirection.x, profile.sunDirection.y, profile.sunDirection.z);
1739
- this.#dirLight.intensity = lerp(0.02, showroom.dirLightIntensity ?? 0.56, peakLight);
1804
+ this.#dirLight.intensity = RenderMath.lerp(0.02, showroom.dirLightIntensity ?? 0.56, peakLight);
1740
1805
  this.#dirLight.diffuse = new Color3(
1741
- lerp(0.28, 1.0, peakLight),
1742
- lerp(0.3, 0.97, peakLight),
1743
- lerp(0.34, 0.9, peakLight),
1806
+ RenderMath.lerp(0.28, 1.0, peakLight),
1807
+ RenderMath.lerp(0.3, 0.97, peakLight),
1808
+ RenderMath.lerp(0.34, 0.9, peakLight),
1744
1809
  );
1745
1810
  }
1746
1811
 
1747
1812
  if (this.#moonLight) {
1748
1813
  this.#moonLight.direction = new Vector3(profile.moonDirection.x, profile.moonDirection.y, profile.moonDirection.z);
1749
- this.#moonLight.intensity = lerp(0.44, 0.03, peakLight);
1814
+ this.#moonLight.intensity = RenderMath.lerp(0.44, 0.03, peakLight);
1750
1815
  this.#moonLight.diffuse = new Color3(0.58, 0.7, 1.0);
1751
1816
  }
1752
1817
 
1753
1818
  if (this.#cameraLight) {
1754
- this.#cameraLight.intensity = lerp(0.06, showroom.cameraLightIntensity ?? 0.16, peakLight);
1819
+ this.#cameraLight.intensity = RenderMath.lerp(0.06, showroom.cameraLightIntensity ?? 0.16, peakLight);
1755
1820
  this.#cameraLight.diffuse = new Color3(
1756
- lerp(0.35, 1.0, peakLight),
1757
- lerp(0.35, 0.97, peakLight),
1758
- lerp(0.36, 0.92, peakLight),
1821
+ RenderMath.lerp(0.35, 1.0, peakLight),
1822
+ RenderMath.lerp(0.35, 0.97, peakLight),
1823
+ RenderMath.lerp(0.36, 0.92, peakLight),
1759
1824
  );
1760
1825
  }
1761
1826
 
1762
1827
  this.#scene.markAllMaterialsAsDirty?.(Material.LightDirtyFlag ?? Material.TextureDirtyFlag);
1763
- this.#requestRender({ frames: 2, continuousMs: this.#config.render.interactionMs });
1828
+ this.#requestRender({ frames: 2, continuousMs: BabylonJSController.config.render.interactionMs });
1764
1829
  return true;
1765
1830
  }
1766
1831
 
@@ -1776,81 +1841,62 @@ export default class BabylonJSController {
1776
1841
  }
1777
1842
 
1778
1843
  const pixelRatio = typeof window !== "undefined" ? window.devicePixelRatio : 1;
1779
- const baseScaling = this.#config.quality.hardwareScaling || 1;
1780
- const scalingLevel = getAdaptiveHardwareScalingLevel(baseScaling, pixelRatio);
1844
+ const baseScaling = BabylonJSController.config.quality.hardwareScaling || 1;
1845
+ const scalingLevel = RenderMath.getAdaptiveHardwareScalingLevel(baseScaling, pixelRatio);
1781
1846
  this.#engine.setHardwareScalingLevel(scalingLevel);
1782
1847
  }
1783
1848
 
1784
1849
  /**
1785
- * World-space bounds of the loaded model's renderable geometry.
1850
+ * Frames the default camera using the shared model-framing calculation.
1851
+ * Preserves its current orientation and projection; excludes environment geometry.
1852
+ * Applies the destination immediately, without a ViewCube transition.
1853
+ * The default camera has no parent; imported cameras retain their authored framing.
1786
1854
  * @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.
1855
+ * @param {Camera|null|undefined} camera - Camera about to become active.
1828
1856
  * @returns {void}
1829
1857
  */
1830
1858
  #fitDefaultCameraToModel(camera) {
1831
1859
  if (camera !== this.#camera || !(camera instanceof ArcRotateCamera)) {
1832
1860
  return;
1833
1861
  }
1834
-
1835
- const bounds = this.#getModelBounds();
1862
+ const bounds = BabylonJSCameraFraming.getModelBounds(this.#containers.model?.assetContainer);
1836
1863
  if (!bounds) {
1837
1864
  return;
1838
1865
  }
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;
1866
+ camera.getViewMatrix(true);
1867
+ const direction = camera.globalPosition.subtract(camera.getTarget());
1868
+ if (direction.lengthSquared() <= 1e-12) {
1869
+ return;
1870
+ }
1871
+ direction.normalize();
1872
+ const fit = BabylonJSCameraFraming.calculateCameraFit(camera, bounds, direction, {
1873
+ width: this.#canvas.clientWidth,
1874
+ height: this.#canvas.clientHeight,
1875
+ });
1876
+ if (!fit) {
1877
+ return;
1878
+ }
1879
+ // Geometry protection supplies the minimum zoom radius.
1880
+ camera.lowerRadiusLimit = null;
1881
+ camera.upperRadiusLimit = fit.distance * BabylonJSController.config.camera.maxRadiusFactor;
1882
+ camera.upVector = Vector3.Up();
1883
+ camera.setTarget(fit.target);
1884
+ // Match ViewCube's destination orientation, including its small pole correction.
1885
+ const rotationMatrix = Matrix.Identity();
1886
+ fit.rotation.toRotationMatrix(rotationMatrix);
1887
+ const forward = Vector3.TransformNormal(
1888
+ new Vector3(0, 0, camera.getScene().useRightHandedSystem ? -1 : 1),
1889
+ rotationMatrix
1890
+ );
1891
+ camera.setPosition(fit.target.subtract(forward.scale(fit.distance)));
1892
+ if (camera.mode === Camera.ORTHOGRAPHIC_CAMERA) {
1893
+ camera.orthoLeft = -fit.halfWidth;
1894
+ camera.orthoRight = fit.halfWidth;
1895
+ camera.orthoBottom = -fit.halfHeight;
1896
+ camera.orthoTop = fit.halfHeight;
1897
+ }
1898
+ camera.minZ = Math.min(camera.minZ > 0 ? camera.minZ : Infinity, Math.max(fit.radius * 0.001, 0.00001));
1899
+ camera.maxZ = Math.max(camera.maxZ, fit.distance + fit.radius * 2);
1854
1900
  }
1855
1901
 
1856
1902
  /**
@@ -1879,6 +1925,7 @@ export default class BabylonJSController {
1879
1925
  this.#state.pointerPicking.lastMovePickX = NaN;
1880
1926
  this.#state.pointerPicking.lastMovePickY = NaN;
1881
1927
  this.#state.pointerPicking.lastPickedMeshId = null;
1928
+ this.#state.pointerPicking.pointerDown = null;
1882
1929
  }
1883
1930
 
1884
1931
  /**
@@ -1896,7 +1943,7 @@ export default class BabylonJSController {
1896
1943
  const x = this.#scene.pointerX;
1897
1944
  const y = this.#scene.pointerY;
1898
1945
  const state = this.#state.pointerPicking;
1899
- const config = this.#config.pointerPicking;
1946
+ const config = BabylonJSController.config.pointerPicking;
1900
1947
 
1901
1948
  if (!Number.isFinite(state.lastMovePickX) || !Number.isFinite(state.lastMovePickY)) {
1902
1949
  state.lastMovePickX = x;
@@ -1926,6 +1973,7 @@ export default class BabylonJSController {
1926
1973
  #enableInteraction() {
1927
1974
  if (this.#canvas) {
1928
1975
  this.#canvas.addEventListener("keyup", this.#handlers.onKeyUp);
1976
+ this.#canvas.addEventListener("pointercancel", this.#handlers.onPointerCancel);
1929
1977
  }
1930
1978
  if (this.#scene) {
1931
1979
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
@@ -1944,6 +1992,7 @@ export default class BabylonJSController {
1944
1992
  #disableInteraction() {
1945
1993
  if (this.#canvas) {
1946
1994
  this.#canvas.removeEventListener("keyup", this.#handlers.onKeyUp);
1995
+ this.#canvas.removeEventListener("pointercancel", this.#handlers.onPointerCancel);
1947
1996
  }
1948
1997
  if (this.#scene !== null) {
1949
1998
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
@@ -2065,7 +2114,7 @@ export default class BabylonJSController {
2065
2114
  * @returns {void}
2066
2115
  */
2067
2116
  #onAnimationGroupPlay() {
2068
- this.#requestRender({ frames: 1, continuousMs: this.#config.render.animationMs });
2117
+ this.#requestRender({ frames: 1, continuousMs: BabylonJSController.config.render.animationMs });
2069
2118
  }
2070
2119
 
2071
2120
  /**
@@ -2074,11 +2123,11 @@ export default class BabylonJSController {
2074
2123
  * @returns {void}
2075
2124
  */
2076
2125
  #onAnimationGroupStop() {
2077
- if (this.#settings.iblEnabled && this.#renderPipelines.iblShadows) {
2126
+ if (this.#renderSettings.iblEnabled && this.#renderPipelines.iblShadows) {
2078
2127
  this.#renderPipelines.iblShadows.updateVoxelization();
2079
2128
  this.#scene?.postProcessRenderPipelineManager?.update();
2080
2129
  }
2081
- const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2130
+ const frames = this.#renderSettings.antiAliasingEnabled || this.#renderSettings.ambientOcclusionEnabled || this.#renderSettings.iblEnabled ? BabylonJSController.config.render.burstFramesEnhanced : BabylonJSController.config.render.burstFramesBase;
2082
2131
  this.#requestRender({ frames: frames });
2083
2132
  }
2084
2133
 
@@ -2131,7 +2180,8 @@ export default class BabylonJSController {
2131
2180
  }
2132
2181
 
2133
2182
  /**
2134
- * Handles mouse wheel events on the Babylon.js canvas for zooming the camera.
2183
+ * Wakes rendering for native orbital wheel zoom and handles wheel movement for target cameras.
2184
+ * ArcRotateCamera owns proportional wheel zoom; do not add a second radius offset here.
2135
2185
  * OPTIMIZATION: Tracks activity for idle mode.
2136
2186
  * @private
2137
2187
  * @param {WheelEvent} event - The mouse wheel event.
@@ -2151,10 +2201,7 @@ export default class BabylonJSController {
2151
2201
  return false;
2152
2202
  }
2153
2203
  if (!camera.metadata?.locked) {
2154
- if (camera instanceof ArcRotateCamera) {
2155
- camera.wheelPrecision = camera.wheelPrecision || 3.0;
2156
- camera.inertialRadiusOffset -= event.deltaY * camera.wheelPrecision * 0.001;
2157
- } else if (camera instanceof FreeCamera || camera instanceof UniversalCamera) {
2204
+ if (camera instanceof FreeCamera || camera instanceof UniversalCamera) {
2158
2205
  camera.wheelPrecision = camera.wheelPrecision || 3.0;
2159
2206
  const zoomSpeed = -event.deltaY * camera.wheelPrecision * 0.001;
2160
2207
 
@@ -2163,18 +2210,18 @@ export default class BabylonJSController {
2163
2210
  const movementVector = direction.scale(zoomSpeed);
2164
2211
  camera.position = camera.position.add(movementVector);
2165
2212
  }
2166
- this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
2213
+ this.#requestRender({ frames: 1, continuousMs: BabylonJSController.config.render.interactionMs });
2167
2214
  }
2168
2215
  }
2169
2216
 
2170
2217
  /**
2171
- * Handles pointer up events on the Babylon.js scene.
2172
- * OPTIMIZATION: Tracks activity for idle mode.
2173
- * @private
2174
- * @param {PointerEvent} event - The pointer up event.
2175
- * @param {PickInfo} pickInfo - The result of the scene pick operation.
2176
- * @returns {void}
2177
- */
2218
+ * Handles pointer up events on the Babylon.js scene.
2219
+ * OPTIMIZATION: Tracks activity for idle mode.
2220
+ * @private
2221
+ * @param {PointerEvent} event - The pointer up event.
2222
+ * @param {PickInfo} pickInfo - The result of the scene pick operation.
2223
+ * @returns {void}
2224
+ */
2178
2225
  #onPointerUp(event, pickInfo) {
2179
2226
  // OPTIMIZATION: Mark activity for idle mode
2180
2227
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
@@ -2208,19 +2255,62 @@ export default class BabylonJSController {
2208
2255
 
2209
2256
  const camera = this.#scene?.activeCamera;
2210
2257
  if (camera && !camera.metadata?.locked) {
2211
- this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
2258
+ this.#requestRender({ frames: 1, continuousMs: BabylonJSController.config.render.interactionMs });
2212
2259
  }
2213
2260
  if (this.#babylonJSAnimationController && pickInfo) {
2214
2261
  const pickedMeshId = pickInfo?.pickedMesh?.id || null;
2215
2262
  if (this.#state.pointerPicking.lastPickedMeshId !== pickedMeshId) {
2216
2263
  const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
2217
2264
  if (highlightResult.changed) {
2218
- this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
2265
+ this.#requestRender({ frames: 1, continuousMs: BabylonJSController.config.render.interactionMs });
2219
2266
  }
2220
2267
  }
2221
2268
  }
2222
2269
  }
2223
2270
 
2271
+ /**
2272
+ * Invalidates an incomplete click gesture when the browser cancels its pointer.
2273
+ * @private
2274
+ * @returns {void}
2275
+ */
2276
+ #onPointerCancel() {
2277
+ this.#state.pointerPicking.pointerDown = null;
2278
+ }
2279
+
2280
+ /**
2281
+ * Emits the internal 3D click contract for every model mesh crossed by the pointer ray.
2282
+ * @private
2283
+ * @returns {void}
2284
+ */
2285
+ #emitModelMeshClick() {
2286
+ const modelMeshes = this.#containers.model?.assetContainer?.meshes ?? [];
2287
+ const acceptedMeshes = BabylonJSSelectionController.getModelMeshSet(modelMeshes);
2288
+ if (!this.#scene || acceptedMeshes.size === 0) {
2289
+ return;
2290
+ }
2291
+ const ray = this.#scene.createPickingRay(
2292
+ this.#scene.pointerX,
2293
+ this.#scene.pointerY,
2294
+ Matrix.Identity(),
2295
+ this.#scene.activeCamera,
2296
+ false,
2297
+ );
2298
+ const picks = this.#scene.multiPickWithRay(ray, (mesh) => BabylonJSSelectionController.isPickableModelMesh(mesh, acceptedMeshes)) ?? [];
2299
+ const ids = BabylonJSSelectionController.collectPickedMeshIds(picks, modelMeshes);
2300
+ if (ids.length === 0) {
2301
+ return;
2302
+ }
2303
+ this.#getPrefViewer3DComponent();
2304
+ const CustomEventType = this.#prefViewer3D?.ownerDocument?.defaultView?.CustomEvent ?? globalThis.CustomEvent;
2305
+ if (this.#prefViewer3D && CustomEventType) {
2306
+ this.#prefViewer3D.dispatchEvent(new CustomEventType("pref-viewer-3d-element-click", {
2307
+ bubbles: true,
2308
+ composed: true,
2309
+ detail: { ids },
2310
+ }));
2311
+ }
2312
+ }
2313
+
2224
2314
  /**
2225
2315
  * Handles pointer events observed on the Babylon.js scene.
2226
2316
  * Uses pointer-move sampling to avoid running expensive `scene.pick()` on every event.
@@ -2230,6 +2320,11 @@ export default class BabylonJSController {
2230
2320
  */
2231
2321
  #onPointerObservable(info) {
2232
2322
  if (info.type === PointerEventTypes.POINTERMOVE) {
2323
+ const start = this.#state.pointerPicking.pointerDown;
2324
+ if (start && (info.event?.pointerId !== start.pointerId || (info.event?.buttons & 1) === 0
2325
+ || Math.hypot(info.event?.clientX - start.x, info.event?.clientY - start.y) > BabylonJSController.config.pointerPicking.clickMaxDistancePx)) {
2326
+ start.dragged = true;
2327
+ }
2233
2328
  let pickInfo = null;
2234
2329
  let lastPickedMeshId = null;
2235
2330
  if (this.#shouldPickOnPointerMove()) {
@@ -2238,7 +2333,17 @@ export default class BabylonJSController {
2238
2333
  }
2239
2334
  this.#onPointerMove(info.event, pickInfo);
2240
2335
  this.#state.pointerPicking.lastPickedMeshId = lastPickedMeshId;
2336
+ } else if (info.type === PointerEventTypes.POINTERDOWN) {
2337
+ this.#state.pointerPicking.pointerDown = info.event?.button === 0 && !this.#state.pointerPicking.pointerDown
2338
+ ? { x: info.event.clientX, y: info.event.clientY, button: info.event.button, pointerId: info.event.pointerId }
2339
+ : null;
2241
2340
  } else if (info.type === PointerEventTypes.POINTERUP) {
2341
+ const pointerDown = this.#state.pointerPicking.pointerDown;
2342
+ this.#state.pointerPicking.pointerDown = null;
2343
+ const pointerUp = { x: info.event?.clientX, y: info.event?.clientY, button: info.event?.button, pointerId: info.event?.pointerId };
2344
+ if (BabylonJSSelectionController.isPrimaryClickGesture(pointerDown, pointerUp, BabylonJSController.config.pointerPicking.clickMaxDistancePx)) {
2345
+ this.#emitModelMeshClick();
2346
+ }
2242
2347
  const pickInfo = info.event?.button === 2 ? this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY) : null;
2243
2348
  if (pickInfo) {
2244
2349
  this.#state.pointerPicking.lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
@@ -2249,6 +2354,149 @@ export default class BabylonJSController {
2249
2354
  }
2250
2355
  }
2251
2356
 
2357
+ /**
2358
+ * Applies a pending engine resize, cancels cube navigation and wakes rendering.
2359
+ * @private
2360
+ * @param {number} elapsed - Time since the previous resize, retained for diagnostics.
2361
+ * @returns {void}
2362
+ */
2363
+ #applyResize(elapsed) {
2364
+ this.#state.resize.timeoutId = null;
2365
+ this.#state.resize.isScheduled = false;
2366
+ if (!this.#engine) {
2367
+ return;
2368
+ }
2369
+ this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2370
+ console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
2371
+ this.#applyShowroomCameraFraming();
2372
+ this.#engine.resize();
2373
+ const needsEnhancedBurst = this.#renderSettings.antiAliasingEnabled || this.#renderSettings.ambientOcclusionEnabled || this.#renderSettings.iblEnabled || this.#renderSettings.shadowsEnabled;
2374
+ const frames = needsEnhancedBurst ? BabylonJSController.config.render.burstFramesEnhanced : BabylonJSController.config.render.burstFramesBase;
2375
+ this.#requestRender({ frames, continuousMs: BabylonJSController.config.render.interactionMs });
2376
+ }
2377
+
2378
+ /**
2379
+ * Extracts the final segment of a slash-containing metadata path, returning false otherwise.
2380
+ * @private
2381
+ * @static
2382
+ * @param {unknown} data - Metadata path candidate.
2383
+ * @returns {string|false}
2384
+ */
2385
+ static #getNodeName(data) {
2386
+ if (data && typeof data === "string" && data.includes("/")) {
2387
+ data = data.slice(data.lastIndexOf("/") + 1);
2388
+ return data;
2389
+ }
2390
+ return false;
2391
+ }
2392
+
2393
+ /**
2394
+ * Converts a vector to serializable coordinates, returning null for an absent value.
2395
+ * @private
2396
+ * @static
2397
+ * @param {Vector3|null|undefined} value - Vector to summarize.
2398
+ * @returns {{x:number,y:number,z:number}|null}
2399
+ */
2400
+ static #vectorToJSON(value) {
2401
+ return value ? { x: value.x, y: value.y, z: value.z } : null;
2402
+ }
2403
+
2404
+ /**
2405
+ * Summarizes texture identity and readiness without including its contents.
2406
+ * @private
2407
+ * @static
2408
+ * @param {object|null|undefined} texture - Babylon texture to inspect.
2409
+ * @returns {object}
2410
+ */
2411
+ static #textureToJSON(texture) {
2412
+ return {
2413
+ name: texture?.name ?? null,
2414
+ url: texture?.url ?? null,
2415
+ isReady: texture?.isReady?.() ?? null,
2416
+ hasTexture: Boolean(texture),
2417
+ className: texture?.getClassName?.() ?? texture?.constructor?.name ?? null,
2418
+ };
2419
+ }
2420
+
2421
+ /**
2422
+ * Summarizes material readiness, related geometry and texture diagnostics, capturing readiness errors.
2423
+ * @private
2424
+ * @static
2425
+ * @param {Material} material - Material to inspect.
2426
+ * @param {Array<object>} sceneMeshes - Current scene mesh snapshot used to resolve material owners.
2427
+ * @returns {object}
2428
+ */
2429
+ static #summarizeMaterial(material, sceneMeshes) {
2430
+ const relatedMeshes = sceneMeshes.filter((mesh) => mesh.material === material);
2431
+ const sampleMesh = relatedMeshes[0] ?? null;
2432
+ const activeTextures = material?.getActiveTextures?.() ?? [];
2433
+ let isReadyWithoutMesh = null;
2434
+ let isReadyWithMesh = null;
2435
+ try {
2436
+ isReadyWithoutMesh = typeof material?.isReady === "function" ? material.isReady() : null;
2437
+ } catch (error) {
2438
+ isReadyWithoutMesh = `error: ${error?.message ?? error}`;
2439
+ }
2440
+ try {
2441
+ isReadyWithMesh = typeof material?.isReady === "function" && sampleMesh ? material.isReady(sampleMesh) : null;
2442
+ } catch (error) {
2443
+ isReadyWithMesh = `error: ${error?.message ?? error}`;
2444
+ }
2445
+
2446
+ return {
2447
+ name: material?.name ?? null,
2448
+ id: material?.id ?? null,
2449
+ className: material?.getClassName?.() ?? material?.constructor?.name ?? null,
2450
+ isReadyWithoutMesh,
2451
+ isReadyWithMesh,
2452
+ relatedMeshes: relatedMeshes.length,
2453
+ sampleMesh: sampleMesh ? {
2454
+ name: sampleMesh.name,
2455
+ id: sampleMesh.id,
2456
+ enabled: sampleMesh.isEnabled?.() ?? null,
2457
+ isVisible: sampleMesh.isVisible ?? null,
2458
+ hasVertices: sampleMesh.getTotalVertices?.() ?? null,
2459
+ hasIndices: sampleMesh.getTotalIndices?.() ?? null,
2460
+ } : null,
2461
+ textures: activeTextures.map(BabylonJSController.#textureToJSON),
2462
+ notReadyTextures: activeTextures.filter((texture) => texture?.isReady?.() === false).map(BabylonJSController.#textureToJSON),
2463
+ forceDepthWrite: material?.forceDepthWrite ?? null,
2464
+ transparencyMode: material?.transparencyMode ?? null,
2465
+ alpha: material?.alpha ?? null,
2466
+ backFaceCulling: material?.backFaceCulling ?? null,
2467
+ disableLighting: material?.disableLighting ?? null,
2468
+ };
2469
+ }
2470
+
2471
+ /**
2472
+ * Summarizes container state and counts of its assets.
2473
+ * @private
2474
+ * @static
2475
+ * @param {object|null|undefined} container - Viewer container and its state.
2476
+ * @returns {object}
2477
+ */
2478
+ static #summarizeContainer(container) {
2479
+ return {
2480
+ show: container?.state?.show ?? null,
2481
+ visible: container?.state?.visible ?? null,
2482
+ mustBeShown: container?.state?.mustBeShown ?? null,
2483
+ isPending: container?.state?.isPending ?? null,
2484
+ isSuccess: container?.state?.isSuccess ?? null,
2485
+ hasAssetContainer: Boolean(container?.assetContainer),
2486
+ meshes: container?.assetContainer?.meshes?.length ?? 0,
2487
+ enabledMeshes: (container?.assetContainer?.meshes ?? []).filter((mesh) => mesh.isEnabled?.() !== false && mesh.isVisible !== false).length,
2488
+ materials: container?.assetContainer?.materials?.length ?? 0,
2489
+ cameras: container?.assetContainer?.cameras?.map((item) => ({
2490
+ name: item.name,
2491
+ id: item.id,
2492
+ className: item.getClassName?.() ?? item.constructor?.name ?? null,
2493
+ position: BabylonJSController.#vectorToJSON(item.position),
2494
+ target: BabylonJSController.#vectorToJSON(item.target),
2495
+ })) ?? [],
2496
+ animationGroups: container?.assetContainer?.animationGroups?.length ?? 0,
2497
+ };
2498
+ }
2499
+
2252
2500
  /**
2253
2501
  * Handles canvas resize notifications.
2254
2502
  * Resizes the Babylon engine and requests a short on-demand render burst so camera-dependent
@@ -2262,23 +2510,10 @@ export default class BabylonJSController {
2262
2510
  }
2263
2511
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2264
2512
  const elapsed = now - this.#state.resize.lastAppliedAt;
2265
- const applyResize = () => {
2266
- this.#state.resize.timeoutId = null;
2267
- this.#state.resize.isScheduled = false;
2268
- if (!this.#engine) {
2269
- return;
2270
- }
2271
- this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2272
- console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
2273
- this.#applyShowroomCameraFraming();
2274
- this.#engine.resize();
2275
- const needsEnhancedBurst = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled || this.#settings.shadowsEnabled;
2276
- const frames = needsEnhancedBurst ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2277
- this.#requestRender({ frames, continuousMs: this.#config.render.interactionMs });
2278
- };
2279
2513
 
2280
- if (elapsed >= this.#config.resize.throttleMs) {
2281
- applyResize();
2514
+
2515
+ if (elapsed >= BabylonJSController.config.resize.throttleMs) {
2516
+ this.#applyResize(elapsed);
2282
2517
  return;
2283
2518
  }
2284
2519
 
@@ -2287,9 +2522,9 @@ export default class BabylonJSController {
2287
2522
  }
2288
2523
 
2289
2524
  this.#state.resize.isScheduled = true;
2290
- const waitMs = Math.max(0, this.#config.resize.throttleMs - elapsed);
2525
+ const waitMs = Math.max(0, BabylonJSController.config.resize.throttleMs - elapsed);
2291
2526
  this.#state.resize.timeoutId = setTimeout(() => {
2292
- applyResize();
2527
+ this.#applyResize(elapsed);
2293
2528
  }, waitMs);
2294
2529
  }
2295
2530
 
@@ -2373,11 +2608,15 @@ export default class BabylonJSController {
2373
2608
  }
2374
2609
 
2375
2610
  /**
2376
- * Applies camera options from the configuration to the active camera.
2611
+ * Cancels pending cube navigation and applies configured camera selection and locking.
2612
+ * Configures unlocked orbital cameras for proportional native wheel input, rebinds the model zoom guard
2613
+ * and synchronizes the cube after selecting the active camera.
2614
+ * A missing named camera falls back to the unlocked default camera, rather than retaining an imported view.
2377
2615
  * @private
2378
2616
  * @returns {boolean} True if camera options were set successfully, false otherwise.
2379
2617
  */
2380
2618
  #setOptions_Camera() {
2619
+ this.#viewCube?.cancel();
2381
2620
  const cameraState = this.#options.camera;
2382
2621
  const modelContainer = this.#containers.model;
2383
2622
  const environmentContainer = this.#containers.environment;
@@ -2395,31 +2634,22 @@ export default class BabylonJSController {
2395
2634
  }
2396
2635
 
2397
2636
  if (!camera) {
2398
- // If a new camera (different from the default) was tried and not found, search for the current one
2399
- if (cameraState.isPending && cameraState.update.value !== null && cameraState.value !== null && cameraState.update.value !== cameraState.value) {
2400
- camera = modelContainer.assetContainer?.cameras.find((thisCamera) => thisCamera.name === cameraState.value) || environmentContainer.assetContainer?.cameras.find((thisCamera) => thisCamera.name === cameraState.value) || null;
2401
- }
2402
- if (camera) {
2403
- // If the current camera (different from the default) was found, use it
2404
- camera.metadata = { locked: cameraState.locked };
2405
- cameraState.setSuccess(false);
2406
- } else {
2407
- // If no camera was found, use the default camera
2408
- camera = this.#camera;
2409
- cameraState.value = null;
2410
- cameraState.locked = this.#camera.metadata.locked;
2411
- cameraState.setSuccess(false);
2412
- }
2637
+ camera = this.#camera;
2638
+ camera.metadata = { ...camera.metadata, locked: false };
2639
+ cameraState.value = null;
2640
+ cameraState.locked = false;
2641
+ cameraState.setSuccess(false);
2413
2642
  } else {
2414
2643
  // If the requested camera was found, use it
2415
2644
  if (cameraName !== null) {
2416
2645
  camera.metadata = { locked: cameraState.isPending ? cameraState.update.locked : cameraState.locked };
2417
2646
  } else {
2418
- // If it's the default camera, maintain its lock state
2647
+ // Selecting the default camera always restores interactive navigation.
2648
+ camera.metadata = { ...camera.metadata, locked: false };
2419
2649
  if (cameraState.isPending) {
2420
- cameraState.update.locked = this.#camera.metadata.locked;
2650
+ cameraState.update.locked = false;
2421
2651
  } else {
2422
- cameraState.locked = this.#camera.metadata.locked;
2652
+ cameraState.locked = false;
2423
2653
  }
2424
2654
  }
2425
2655
  if (cameraState.isPending) {
@@ -2431,9 +2661,14 @@ export default class BabylonJSController {
2431
2661
  this.#scene.activeCamera?.detachControl();
2432
2662
  camera.detachControl();
2433
2663
  if (!cameraState.locked) {
2664
+ if (camera instanceof ArcRotateCamera) {
2665
+ camera.wheelDeltaPercentage = BabylonJSController.config.camera.wheelDeltaPercentage;
2666
+ }
2434
2667
  camera.attachControl(this.#canvas, true);
2435
2668
  }
2436
2669
  this.#scene.activeCamera = camera;
2670
+ this.#cameraZoom?.setCamera(camera);
2671
+ this.#viewCube?.sync();
2437
2672
  return true;
2438
2673
  }
2439
2674
 
@@ -2441,7 +2676,7 @@ export default class BabylonJSController {
2441
2676
  * Applies pending image-based lighting (IBL) option updates.
2442
2677
  * Marks the IBL state as successful, recreates lights so the new environment takes effect, and reports whether anything changed.
2443
2678
  * @private
2444
- * @returns {boolean} True when lights were refreshed due to pending IBL changes, otherwise false.
2679
+ * @returns {Promise<boolean>} True when lights were refreshed due to pending IBL changes, otherwise false.
2445
2680
  * @description
2446
2681
  * Delegates to `#createLights()`, which executes the full IBL URL-to-texture lifecycle:
2447
2682
  * `cachedUrl -> HDRCubeTexture -> #hdrTexture clone -> consumeCachedUrl(true)`.
@@ -2590,6 +2825,7 @@ export default class BabylonJSController {
2590
2825
  * Stops every animation group on the provided asset container to guarantee new loads start from a clean state.
2591
2826
  * @private
2592
2827
  * @param {AssetContainer} assetContainer - Container whose animation groups should be halted.
2828
+ * @returns {void}
2593
2829
  */
2594
2830
  #assetContainer_stopAnimations(assetContainer) {
2595
2831
  if (!assetContainer.animationGroups || assetContainer.animationGroups.length === 0) {
@@ -2628,8 +2864,12 @@ export default class BabylonJSController {
2628
2864
  */
2629
2865
  #assetContainer_simplifyTransmissiveMaterials(assetContainer) {
2630
2866
  assetContainer.materials?.forEach((mat) => {
2631
- if (!(mat instanceof PBRMaterial)) return;
2632
- if (!mat.subSurface?.isRefractionEnabled && !mat.subSurface?.isTranslucencyEnabled) return;
2867
+ if (!(mat instanceof PBRMaterial)) {
2868
+ return;
2869
+ }
2870
+ if (!mat.subSurface?.isRefractionEnabled && !mat.subSurface?.isTranslucencyEnabled) {
2871
+ return;
2872
+ }
2633
2873
  this.#simplifyTransmissiveMaterial(mat);
2634
2874
  });
2635
2875
  }
@@ -2647,7 +2887,7 @@ export default class BabylonJSController {
2647
2887
  mat.subSurface.isTranslucencyEnabled = false;
2648
2888
  mat.alpha = Math.max(0.28, 1.0 - intensity * 0.65);
2649
2889
  mat.transparencyMode = PBRMaterial.PBRMATERIAL_ALPHABLEND;
2650
- markPrefViewerSimplifiedTransmissionMaterial(mat);
2890
+ MaterialUtils.markPrefViewerSimplifiedTransmissionMaterial(mat);
2651
2891
  }
2652
2892
 
2653
2893
  /**
@@ -2685,7 +2925,7 @@ export default class BabylonJSController {
2685
2925
  /**
2686
2926
  * Stops the Babylon.js render loop for the current scene.
2687
2927
  * @private
2688
- * @returns {void}
2928
+ * @returns {Promise<void>}
2689
2929
  */
2690
2930
  async #stopRender() {
2691
2931
  this.#stopEngineRenderLoop();
@@ -2700,11 +2940,13 @@ export default class BabylonJSController {
2700
2940
  * @returns {Promise<void>}
2701
2941
  */
2702
2942
  async #startRender() {
2703
- if (!this.#scene) return;
2943
+ if (!this.#scene) {
2944
+ return;
2945
+ }
2704
2946
  await this.#loadCameraDependentEffects();
2705
2947
  await this.#scene.whenReadyAsync();
2706
- const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2707
- this.#requestRender({ frames: frames, continuousMs: this.#config.render.interactionMs });
2948
+ const frames = this.#renderSettings.antiAliasingEnabled || this.#renderSettings.ambientOcclusionEnabled || this.#renderSettings.iblEnabled ? BabylonJSController.config.render.burstFramesEnhanced : BabylonJSController.config.render.burstFramesBase;
2949
+ this.#requestRender({ frames: frames, continuousMs: BabylonJSController.config.render.interactionMs });
2708
2950
  }
2709
2951
 
2710
2952
  /**
@@ -2755,6 +2997,9 @@ export default class BabylonJSController {
2755
2997
  compileMaterials: true,
2756
2998
  loadAllMaterials: true,
2757
2999
  loadOnlyMaterials: container.state.name === "materials",
3000
+ // InstancedMesh overlays render through their source mesh. Regular model
3001
+ // meshes keep shared materials while allowing independent semantic colors.
3002
+ createInstances: container.state.name !== "model",
2758
3003
  extensionOptions: {
2759
3004
  ExtrasAsMetadata: { enabled: true },
2760
3005
  },
@@ -2776,15 +3021,19 @@ export default class BabylonJSController {
2776
3021
  }
2777
3022
 
2778
3023
  /**
2779
- * Loads all asset containers (model, environment, materials, etc.) and adds them to the scene.
3024
+ * Cancels pending cube navigation, loads asset containers and adds replacements to the scene.
3025
+ * Current model selection is resolved by the zoom guard on subsequent input updates.
2780
3026
  * @private
3027
+ * @param {boolean} [force=false] - Whether to bypass existing asset cache metadata.
2781
3028
  * @returns {Promise<{success: boolean, error: any}>} Resolves to an object indicating if loading succeeded and any error encountered.
2782
3029
  * @description
2783
3030
  * Waits for all containers to load in parallel, then replaces or adds them to the scene as needed.
2784
- * Applies material and camera options, sets wall/floor visibility, and initializes lights and shadows.
3031
+ * Applies material options and wall/floor visibility before camera fitting, then initializes lights and shadows.
2785
3032
  * Returns an object with success status and error details.
2786
3033
  */
2787
3034
  async #loadContainers(force = false) {
3035
+ this.#viewCube?.cancel();
3036
+ this.#resetPointerPickingState();
2788
3037
  this.#detachAnimationChangedListener();
2789
3038
  await this.#stopRender();
2790
3039
 
@@ -2837,9 +3086,10 @@ export default class BabylonJSController {
2837
3086
  });
2838
3087
 
2839
3088
  this.#setOptions_Materials();
3089
+ // Apply effective geometry visibility before calculating the camera fit.
3090
+ this.#setVisibilityOfWallAndFloorInModel();
2840
3091
  this.#setOptions_Camera();
2841
3092
  await this.#setOptions_IBL();
2842
- this.#setVisibilityOfWallAndFloorInModel();
2843
3093
  detail.success = true;
2844
3094
  })
2845
3095
  .catch((error) => {
@@ -2853,6 +3103,7 @@ export default class BabylonJSController {
2853
3103
  this.#setMaxSimultaneousLights();
2854
3104
  this.#enhanceTextureQuality();
2855
3105
  this.#syncAnimationControllerAfterModelLoad();
3106
+ this.#selectionController?.refresh();
2856
3107
 
2857
3108
  await this.#startRender();
2858
3109
  });
@@ -2865,12 +3116,12 @@ export default class BabylonJSController {
2865
3116
  * @returns {void}
2866
3117
  */
2867
3118
  #syncAnimationControllerAfterModelLoad() {
2868
- if (this.#settings.showAnimationMenu && this.#containers.model.assetContainer) {
3119
+ if (this.#renderSettings.showAnimationMenu && this.#containers.model.assetContainer) {
2869
3120
  this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
2870
3121
  // Apply stored highlight settings so fresh page loads respect persisted state
2871
3122
  this.#setHighlightConfig({
2872
- enabled: this.#settings.highlightEnabled,
2873
- color: this.#settings.highlightColor,
3123
+ enabled: this.#renderSettings.highlightEnabled,
3124
+ color: this.#renderSettings.highlightColor,
2874
3125
  });
2875
3126
  if (this.#babylonJSAnimationController?.hasAnimations?.()) {
2876
3127
  this.#attachAnimationChangedListener();
@@ -2884,7 +3135,7 @@ export default class BabylonJSController {
2884
3135
  * Reinstalls every camera-sensitive post-process (default pipeline, SSAO, shadows) after loads or option changes.
2885
3136
  * Ensures the active camera always owns fresh pipelines so render quality remains consistent across reload cycles.
2886
3137
  * @private
2887
- * @returns {void}
3138
+ * @returns {Promise<void>}
2888
3139
  */
2889
3140
  async #loadCameraDependentEffects() {
2890
3141
  await this.#initializeVisualImprovements();
@@ -2896,7 +3147,7 @@ export default class BabylonJSController {
2896
3147
  * Shuts down every post-process tied to the active camera before stopping the render loop.
2897
3148
  * Ensures AA, SSAO, and shadow resources detach cleanly so reloads rebuild from scratch.
2898
3149
  * @private
2899
- * @returns {void}
3150
+ * @returns {Promise<void>}
2900
3151
  */
2901
3152
  async #unloadCameraDependentEffects() {
2902
3153
  await this.#disableVisualImprovements();
@@ -2937,19 +3188,13 @@ export default class BabylonJSController {
2937
3188
  return;
2938
3189
  }
2939
3190
 
2940
- const getNodeName = (data) => {
2941
- if (data && typeof data === "string" && data.includes("/")) {
2942
- data = data.slice(data.lastIndexOf("/") + 1);
2943
- return data;
2944
- }
2945
- return false;
2946
- };
3191
+
2947
3192
 
2948
3193
  let applyNewTranslation = false;
2949
3194
  let applyOldTranslation = false;
2950
3195
  let undoOldTranslation = false;
2951
- const newTranslationNodeName = getNodeName(newMetadata?.customProperties?.innerFloorPath);
2952
- const oldTranslationNodeName = getNodeName(oldMetadata?.customProperties?.innerFloorPath);
3196
+ const newTranslationNodeName = BabylonJSController.#getNodeName(newMetadata?.customProperties?.innerFloorPath);
3197
+ const oldTranslationNodeName = BabylonJSController.#getNodeName(oldMetadata?.customProperties?.innerFloorPath);
2953
3198
 
2954
3199
  if (this.#containers.environment.state.isSuccess) {
2955
3200
  // If environment is being reloaded:
@@ -3062,7 +3307,7 @@ export default class BabylonJSController {
3062
3307
  }
3063
3308
 
3064
3309
  const locale = this.#prefViewer.culture;
3065
- const texts = translate("prefViewer.downloadDialog", locale ? { locale } : undefined) || {};
3310
+ const texts = I18n.translate("prefViewer.downloadDialog", locale ? { locale } : undefined) || {};
3066
3311
  const header = texts.title || "Download 3D Scene";
3067
3312
  const content = `
3068
3313
  <form slot="content" id="download-dialog-form" style="display:flex;flex-direction:column;gap:16px;">
@@ -3122,10 +3367,34 @@ export default class BabylonJSController {
3122
3367
  * ---------------------------
3123
3368
  */
3124
3369
 
3370
+ /**
3371
+ * Creates the enabled cube once a live scene/camera exists and teardown is not in progress.
3372
+ * Resolves camera/model state through callbacks; does not reload assets or camera options.
3373
+ * @private
3374
+ * @returns {void}
3375
+ */
3376
+ #initializeViewCube() {
3377
+ if (!this.#viewCubeEnabled || this.#viewCubeSuspended || this.#viewCube || !this.#engine || !this.#scene?.activeCamera) {
3378
+ return;
3379
+ }
3380
+ this.#viewCube = new BabylonJSViewCubeController({
3381
+ canvas: this.#canvas,
3382
+ getCamera: () => this.#scene?.activeCamera,
3383
+ isLocked: (camera) => !camera || camera.metadata?.locked === true || this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR,
3384
+ getBounds: () => BabylonJSCameraFraming.getModelBounds(this.#containers.model?.assetContainer),
3385
+ getViewport: () => ({ width: this.#canvas.clientWidth, height: this.#canvas.clientHeight }),
3386
+ getCulture: this.#viewCubeConfig.getCulture,
3387
+ onLayout: this.#viewCubeConfig.onLayout,
3388
+ requestRender: () => this.#requestRender({ frames: 2 }),
3389
+ });
3390
+ }
3391
+
3125
3392
  /**
3126
3393
  * Initializes the Babylon.js engine, scene, camera, lights, and interaction handlers.
3127
- * Configures Draco compression, sets up XR experience, and starts the render loop.
3128
- * Observes canvas resize events to update the engine.
3394
+ * Configures Draco compression and sets up XR; rendering starts through the existing load/request scheduler.
3395
+ * Observes canvas resize events, binds model-only orbital zoom protection and adaptive default-camera near clipping,
3396
+ * and lazily creates the requested cube. Imported camera clipping is preserved by the host policy.
3397
+ * Aborts remaining initialization if the scene was replaced/disposed during texture readiness.
3129
3398
  * @public
3130
3399
  * @returns {Promise<void>}
3131
3400
  */
@@ -3161,26 +3430,46 @@ export default class BabylonJSController {
3161
3430
  this.#scene.skipPointerUpPicking = true;
3162
3431
 
3163
3432
  this.#createCamera();
3433
+ this.#cameraZoom = new BabylonJSCameraZoomController(
3434
+ () => this.#containers.model?.state?.isVisible ? this.#containers.model.assetContainer : null,
3435
+ (camera) => camera.metadata?.locked === true || this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR,
3436
+ () => this.#viewCube?.isAnimating === true,
3437
+ (camera) => camera === this.#camera,
3438
+ );
3439
+ this.#cameraZoom.setCamera(this.#camera);
3440
+ this.#viewCubeSuspended = false;
3441
+ this.#initializeViewCube();
3442
+ const enabledScene = this.#scene;
3164
3443
  await this.#ensureEnvironmentBRDFTextureReady();
3444
+ if (!this.#engine || this.#scene !== enabledScene) {
3445
+ return;
3446
+ }
3165
3447
  this.#enableInteraction();
3166
3448
  await this.#createXRExperience();
3167
3449
  }
3168
3450
 
3169
3451
  /**
3170
3452
  * Disposes the Babylon.js engine and disconnects the canvas resize observer.
3171
- * Cleans up all scene, camera, light, XR, and GLTF resolver resources.
3453
+ * Releases the zoom observer and cube overlay before scene teardown, restoring the zoom camera
3454
+ * floor and suspending cube creation until the next enable. Cleans up light, XR and resolver resources.
3172
3455
  * The teardown is asynchronous: it waits for XR/session-dependent shutdown work
3173
3456
  * before disposing the engine, and coalesces concurrent calls into one in-flight promise.
3174
3457
  * @public
3175
3458
  * @returns {Promise<void>}
3176
3459
  */
3177
3460
  async disable() {
3461
+ this.#cameraZoom?.dispose();
3462
+ this.#cameraZoom = null;
3463
+ this.#viewCubeSuspended = true;
3464
+ this.#viewCube?.dispose();
3465
+ this.#viewCube = null;
3178
3466
  if (this.#disablingPromises.general) {
3179
3467
  return await this.#disablingPromises.general;
3180
3468
  }
3181
3469
 
3182
3470
  this.#disablingPromises.general = (async () => {
3183
3471
  this.#disableInteraction();
3472
+ this.#selectionController?.dispose();
3184
3473
  this.#disposeAnimationController();
3185
3474
  this.#disposeGLTFResolver();
3186
3475
  try {
@@ -3205,6 +3494,42 @@ export default class BabylonJSController {
3205
3494
  }
3206
3495
  }
3207
3496
 
3497
+ /**
3498
+ * Lazily creates the cube when enabled and releases all cube resources when disabled.
3499
+ * Stores activation until a live scene is available; never reloads assets or camera options.
3500
+ * @public
3501
+ * @param {boolean} enabled - Whether the visible 3D viewer should own a ViewCube.
3502
+ * @returns {void}
3503
+ */
3504
+ setViewCubeEnabled(enabled) {
3505
+ this.#viewCubeEnabled = Boolean(enabled);
3506
+ if (this.#viewCubeEnabled) {
3507
+ this.#initializeViewCube();
3508
+ } else {
3509
+ this.#viewCube?.dispose();
3510
+ this.#viewCube = null;
3511
+ }
3512
+ }
3513
+
3514
+ /**
3515
+ * Synchronizes the existing cube after culture or camera-state changes without creating an inactive cube.
3516
+ * @public
3517
+ * @returns {void}
3518
+ */
3519
+ refreshViewCube() {
3520
+ this.#viewCube?.sync();
3521
+ }
3522
+
3523
+ /**
3524
+ * Applies the host-owned semantic selection state to the current model meshes.
3525
+ * @public
3526
+ * @param {object} state - Canonical PrefViewer selection snapshot.
3527
+ * @returns {void}
3528
+ */
3529
+ applySelectionState(state = {}) {
3530
+ this.#selectionController?.applySelectionState(state);
3531
+ }
3532
+
3208
3533
  /**
3209
3534
  * Downloads the current scene, model, or environment as a GLB file.
3210
3535
  * @public
@@ -3314,7 +3639,7 @@ export default class BabylonJSController {
3314
3639
  * @returns {{antiAliasingEnabled:boolean, ambientOcclusionEnabled:boolean, iblEnabled:boolean, shadowsEnabled:boolean}}
3315
3640
  */
3316
3641
  getRenderSettings() {
3317
- return { ...this.#settings };
3642
+ return { ...this.#renderSettings };
3318
3643
  }
3319
3644
 
3320
3645
  /**
@@ -3324,7 +3649,7 @@ export default class BabylonJSController {
3324
3649
  * @returns {object} Debug information for canvas, engine, scene, containers, render loop and settings.
3325
3650
  */
3326
3651
  getDebugInfo() {
3327
- const vectorToJSON = (value) => value ? { x: value.x, y: value.y, z: value.z } : null;
3652
+
3328
3653
  const rect = this.#canvas?.getBoundingClientRect?.();
3329
3654
  const camera = this.#scene?.activeCamera ?? null;
3330
3655
  const sceneMeshes = this.#scene?.meshes ?? [];
@@ -3334,62 +3659,16 @@ export default class BabylonJSController {
3334
3659
  const modelAssetContainer = this.#containers?.model?.assetContainer ?? null;
3335
3660
  const modelMeshes = modelAssetContainer?.meshes ?? [];
3336
3661
  const enabledModelMeshes = modelMeshes.filter((mesh) => mesh.isEnabled?.() !== false && mesh.isVisible !== false);
3337
- const textureToJSON = (texture) => ({
3338
- name: texture?.name ?? null,
3339
- url: texture?.url ?? null,
3340
- isReady: texture?.isReady?.() ?? null,
3341
- hasTexture: Boolean(texture),
3342
- className: texture?.getClassName?.() ?? texture?.constructor?.name ?? null,
3343
- });
3344
- const summarizeMaterial = (material) => {
3345
- const relatedMeshes = sceneMeshes.filter((mesh) => mesh.material === material);
3346
- const sampleMesh = relatedMeshes[0] ?? null;
3347
- const activeTextures = material?.getActiveTextures?.() ?? [];
3348
- let isReadyWithoutMesh = null;
3349
- let isReadyWithMesh = null;
3350
- try {
3351
- isReadyWithoutMesh = typeof material?.isReady === "function" ? material.isReady() : null;
3352
- } catch (error) {
3353
- isReadyWithoutMesh = `error: ${error?.message ?? error}`;
3354
- }
3355
- try {
3356
- isReadyWithMesh = typeof material?.isReady === "function" && sampleMesh ? material.isReady(sampleMesh) : null;
3357
- } catch (error) {
3358
- isReadyWithMesh = `error: ${error?.message ?? error}`;
3359
- }
3360
3662
 
3361
- return {
3362
- name: material?.name ?? null,
3363
- id: material?.id ?? null,
3364
- className: material?.getClassName?.() ?? material?.constructor?.name ?? null,
3365
- isReadyWithoutMesh,
3366
- isReadyWithMesh,
3367
- relatedMeshes: relatedMeshes.length,
3368
- sampleMesh: sampleMesh ? {
3369
- name: sampleMesh.name,
3370
- id: sampleMesh.id,
3371
- enabled: sampleMesh.isEnabled?.() ?? null,
3372
- isVisible: sampleMesh.isVisible ?? null,
3373
- hasVertices: sampleMesh.getTotalVertices?.() ?? null,
3374
- hasIndices: sampleMesh.getTotalIndices?.() ?? null,
3375
- } : null,
3376
- textures: activeTextures.map(textureToJSON),
3377
- notReadyTextures: activeTextures.filter((texture) => texture?.isReady?.() === false).map(textureToJSON),
3378
- forceDepthWrite: material?.forceDepthWrite ?? null,
3379
- transparencyMode: material?.transparencyMode ?? null,
3380
- alpha: material?.alpha ?? null,
3381
- backFaceCulling: material?.backFaceCulling ?? null,
3382
- disableLighting: material?.disableLighting ?? null,
3383
- };
3384
- };
3385
- const materialDiagnostics = sceneMaterials.slice(0, 25).map(summarizeMaterial);
3663
+
3664
+ const materialDiagnostics = sceneMaterials.slice(0, 25).map((material) => BabylonJSController.#summarizeMaterial(material, sceneMeshes));
3386
3665
  const notReadyMaterials = materialDiagnostics
3387
3666
  .filter((material) => material.isReadyWithoutMesh === false || material.isReadyWithMesh === false || material.notReadyTextures.length > 0)
3388
3667
  .slice(0, 25);
3389
3668
  const notReadyTextures = sceneTextures
3390
3669
  .filter((texture) => texture?.isReady?.() === false)
3391
3670
  .slice(0, 25)
3392
- .map(textureToJSON);
3671
+ .map(BabylonJSController.#textureToJSON);
3393
3672
  const notReadyMeshes = sceneMeshes
3394
3673
  .filter((mesh) => typeof mesh.isReady === "function" && !mesh.isReady())
3395
3674
  .slice(0, 25)
@@ -3403,25 +3682,7 @@ export default class BabylonJSController {
3403
3682
  const modelBounds = enabledModelMeshes.length && this.#scene?.getWorldExtends
3404
3683
  ? this.#scene.getWorldExtends((mesh) => enabledModelMeshes.includes(mesh))
3405
3684
  : null;
3406
- const summarizeContainer = (container) => ({
3407
- show: container?.state?.show ?? null,
3408
- visible: container?.state?.visible ?? null,
3409
- mustBeShown: container?.state?.mustBeShown ?? null,
3410
- isPending: container?.state?.isPending ?? null,
3411
- isSuccess: container?.state?.isSuccess ?? null,
3412
- hasAssetContainer: Boolean(container?.assetContainer),
3413
- meshes: container?.assetContainer?.meshes?.length ?? 0,
3414
- enabledMeshes: (container?.assetContainer?.meshes ?? []).filter((mesh) => mesh.isEnabled?.() !== false && mesh.isVisible !== false).length,
3415
- materials: container?.assetContainer?.materials?.length ?? 0,
3416
- cameras: container?.assetContainer?.cameras?.map((item) => ({
3417
- name: item.name,
3418
- id: item.id,
3419
- className: item.getClassName?.() ?? item.constructor?.name ?? null,
3420
- position: vectorToJSON(item.position),
3421
- target: vectorToJSON(item.target),
3422
- })) ?? [],
3423
- animationGroups: container?.assetContainer?.animationGroups?.length ?? 0,
3424
- });
3685
+
3425
3686
 
3426
3687
  return {
3427
3688
  canvas: {
@@ -3453,8 +3714,8 @@ export default class BabylonJSController {
3453
3714
  name: camera.name,
3454
3715
  id: camera.id,
3455
3716
  className: camera.getClassName?.() ?? camera.constructor?.name ?? null,
3456
- position: vectorToJSON(camera.position),
3457
- target: vectorToJSON(camera.target),
3717
+ position: BabylonJSController.#vectorToJSON(camera.position),
3718
+ target: BabylonJSController.#vectorToJSON(camera.target),
3458
3719
  radius: camera.radius ?? null,
3459
3720
  alpha: camera.alpha ?? null,
3460
3721
  beta: camera.beta ?? null,
@@ -3475,16 +3736,16 @@ export default class BabylonJSController {
3475
3736
  notReadyMeshes,
3476
3737
  },
3477
3738
  containers: {
3478
- model: summarizeContainer(this.#containers.model),
3479
- environment: summarizeContainer(this.#containers.environment),
3480
- materials: summarizeContainer(this.#containers.materials),
3739
+ model: BabylonJSController.#summarizeContainer(this.#containers.model),
3740
+ environment: BabylonJSController.#summarizeContainer(this.#containers.environment),
3741
+ materials: BabylonJSController.#summarizeContainer(this.#containers.materials),
3481
3742
  },
3482
3743
  modelBounds: modelBounds ? {
3483
- min: vectorToJSON(modelBounds.min),
3484
- max: vectorToJSON(modelBounds.max),
3744
+ min: BabylonJSController.#vectorToJSON(modelBounds.min),
3745
+ max: BabylonJSController.#vectorToJSON(modelBounds.max),
3485
3746
  } : null,
3486
3747
  render: { ...this.#state.render },
3487
- settings: { ...this.#settings },
3748
+ settings: { ...this.#renderSettings },
3488
3749
  pipelines: {
3489
3750
  default: Boolean(this.#renderPipelines.default),
3490
3751
  ssao: Boolean(this.#renderPipelines.ssao),
@@ -3497,7 +3758,8 @@ export default class BabylonJSController {
3497
3758
  * Loads all asset containers (model, environment, materials, etc.) and adds them to the scene.
3498
3759
  * Applies material and camera options, sets visibility, and initializes lights and shadows.
3499
3760
  * @public
3500
- * @returns {Promise<boolean>} Resolves to true if loading succeeds, false otherwise.
3761
+ * @param {boolean} [force=false] - Bypasses asset cache metadata checks when reloading.
3762
+ * @returns {Promise<{success:boolean,error:*}>} Result of loading and preparing the asset containers.
3501
3763
  */
3502
3764
  async load(force = false) {
3503
3765
  return await this.#loadContainers(force);
@@ -3533,7 +3795,7 @@ export default class BabylonJSController {
3533
3795
  * Reapplies image-based lighting configuration (HDR URL, intensity, shadow mode).
3534
3796
  * Stops rendering, pushes pending IBL state into the scene, rebuilds camera-dependent effects, then resumes rendering.
3535
3797
  * @public
3536
- * @returns {boolean} True if IBL options were set successfully, false otherwise.
3798
+ * @returns {Promise<boolean>} True if IBL options were set successfully, false otherwise.
3537
3799
  */
3538
3800
  async setIBLOptions() {
3539
3801
  await this.#stopRender();
@@ -3548,7 +3810,7 @@ export default class BabylonJSController {
3548
3810
  * Applies animation-menu, highlight, and time-of-day lighting configuration without forcing a full scene reload.
3549
3811
  * Values are merged into the persisted render settings snapshot unless `config.persist === false`.
3550
3812
  * When `showLightingTimeOfDay` is set to false and no explicit `lightingTimeOfDay` is supplied,
3551
- * the active lighting time is reset to `DEFAULT_RENDER_SETTINGS.lightingTimeOfDay` (0.5).
3813
+ * the active lighting time is reset to `BabylonJSController.config.render.defaultSettings.lightingTimeOfDay` (0.5).
3552
3814
  * @public
3553
3815
  * @param {object} config - Immediate render/illumination configuration.
3554
3816
  * @param {boolean|string} [config.showAnimationMenu] - Enables/disables the animation controls when animations are available.
@@ -3566,25 +3828,25 @@ export default class BabylonJSController {
3566
3828
  const showLighting = typeof config.showLightingTimeOfDay === "string"
3567
3829
  ? config.showLightingTimeOfDay.toLowerCase() !== "false"
3568
3830
  : Boolean(config.showLightingTimeOfDay);
3569
- if (this.#settings.showLightingTimeOfDay !== showLighting) {
3831
+ if (this.#renderSettings.showLightingTimeOfDay !== showLighting) {
3570
3832
  shouldApplyLighting = true;
3571
3833
  }
3572
- this.#settings.showLightingTimeOfDay = showLighting;
3834
+ this.#renderSettings.showLightingTimeOfDay = showLighting;
3573
3835
 
3574
3836
  const hasExplicitLightingTime = typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay);
3575
3837
  if (!showLighting && !hasExplicitLightingTime) {
3576
- const defaultTime = BabylonJSController.DEFAULT_RENDER_SETTINGS.lightingTimeOfDay;
3577
- if (this.#settings.lightingTimeOfDay !== defaultTime) {
3578
- this.#settings.lightingTimeOfDay = defaultTime;
3838
+ const defaultTime = BabylonJSController.config.render.defaultSettings.lightingTimeOfDay;
3839
+ if (this.#renderSettings.lightingTimeOfDay !== defaultTime) {
3840
+ this.#renderSettings.lightingTimeOfDay = defaultTime;
3579
3841
  shouldApplyLighting = true;
3580
3842
  }
3581
3843
  }
3582
3844
  }
3583
3845
 
3584
3846
  if (typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay)) {
3585
- const normalizedTime = clamp01(config.lightingTimeOfDay);
3586
- if (this.#settings.lightingTimeOfDay !== normalizedTime) {
3587
- this.#settings.lightingTimeOfDay = normalizedTime;
3847
+ const normalizedTime = RenderMath.clamp01(config.lightingTimeOfDay);
3848
+ if (this.#renderSettings.lightingTimeOfDay !== normalizedTime) {
3849
+ this.#renderSettings.lightingTimeOfDay = normalizedTime;
3588
3850
  shouldApplyLighting = true;
3589
3851
  }
3590
3852
  }
@@ -3593,7 +3855,7 @@ export default class BabylonJSController {
3593
3855
  const showMenu = typeof config.showAnimationMenu === "string"
3594
3856
  ? config.showAnimationMenu.toLowerCase() !== "false"
3595
3857
  : Boolean(config.showAnimationMenu);
3596
- this.#settings.showAnimationMenu = showMenu;
3858
+ this.#renderSettings.showAnimationMenu = showMenu;
3597
3859
  this.#syncAnimationControllerAfterModelLoad();
3598
3860
  }
3599
3861
 
@@ -3606,12 +3868,12 @@ export default class BabylonJSController {
3606
3868
  } else if (enabled === null) {
3607
3869
  enabled = undefined;
3608
3870
  }
3609
- // Keep #settings in sync so getRenderSettings() always reflects the real state
3871
+ // Keep #renderSettings in sync so getRenderSettings() always reflects the real state
3610
3872
  if (enabled !== undefined) {
3611
- this.#settings.highlightEnabled = enabled;
3873
+ this.#renderSettings.highlightEnabled = enabled;
3612
3874
  }
3613
3875
  if (config.highlightColor !== undefined && config.highlightColor !== null) {
3614
- this.#settings.highlightColor = config.highlightColor;
3876
+ this.#renderSettings.highlightColor = config.highlightColor;
3615
3877
  }
3616
3878
  this.#setHighlightConfig({
3617
3879
  color: config.highlightColor ?? undefined,