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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { ArcRotateCamera, AssetContainer, 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,6 +8,9 @@ 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";
11
14
  import OpeningAnimation from "./babylonjs-animation-opening.js";
12
15
  import { translate } from "./localization/i18n.js";
13
16
  import { PrefViewerStyleVariables } from "./styles.js";
@@ -34,11 +37,12 @@ export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlass
34
37
  * - Manages keyboard/pointer/wheel handlers, animation menus, and WebXR so PrefViewer menus and DOM attributes stay
35
38
  * synchronized with Babylon state, using sampled pointer picking and last-picked tracking to reduce raycast/highlight
36
39
  * cost on dense scenes.
40
+ * - Lazily owns the ViewCube overlay and binds a model-only orbital zoom guard independently of cube visibility.
37
41
  * - Generates GLB, glTF+ZIP, or USDZ exports with timestamped names and localized dialog copy.
38
42
  * - Translates metadata (inner floor offsets, cast/receive shadows, camera locks) into scene adjustments after reloads.
39
43
  *
40
44
  * Runtime Flow
41
- * 1. Instantiate with `new BabylonJSController(canvas, containers, options)`.
45
+ * 1. Instantiate with `new BabylonJSController(canvas, containers, options, viewCubeConfig?)`.
42
46
  * 2. Call `enable()` to configure Draco, spin up the engine/scene, and attach interaction + XR hooks. The render loop
43
47
  * does not start here; it starts automatically after the first `load()` completes.
44
48
  * 3. When PrefViewer marks containers/options pending, invoke `load()` (or `reloadWithCurrentSettings()`) so the controller
@@ -52,7 +56,8 @@ export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlass
52
56
  * shared GLTF resolver, which closes its internal IndexedDB handle.
53
57
  *
54
58
  * Public API Highlights
55
- * - constructor(canvas, containers, options)
59
+ * - constructor(canvas, containers, options, viewCubeConfig?)
60
+ * - setViewCubeEnabled(enabled) / refreshViewCube()
56
61
  * - enable() / disable()
57
62
  * - load(force?) / reloadWithCurrentSettings()
58
63
  * - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
@@ -78,8 +83,9 @@ export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlass
78
83
  * - Browser-only features guard `window`, localStorage, and XR APIs before use so the controller is safe to construct
79
84
  * in SSR/Node contexts (though functionality activates only in browsers).
80
85
  * - 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
86
+ * input avoids interaction picks; post-input orbital zoom protection separately probes only model meshes.
87
+ * Right-click POINTERUP performs an on-demand interaction pick for context-menu targeting.
88
+ * - Runtime bookkeeping is split into mutable `#state` and shared tuning settings under `BabylonJSController.config` to keep behavior changes
83
89
  * explicit and reduce cross-field drift.
84
90
  * - Resize lifecycle: canvas resize notifications are throttled (with trailing execution) before calling `engine.resize()`
85
91
  * and any queued resize callback is canceled during teardown.
@@ -87,25 +93,6 @@ export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlass
87
93
  * during XR exit and engine disposal.
88
94
  */
89
95
  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
96
  // Canvas HTML element
110
97
  #canvas = null;
111
98
 
@@ -128,6 +115,13 @@ export default class BabylonJSController {
128
115
  #hdrTexture = null; // Reusable in-memory HDR source cloned into scene.environmentTexture across reloads
129
116
 
130
117
  #containers = {};
118
+
119
+ #viewCube = null;
120
+ #cameraZoom = null;
121
+ #viewCubeConfig = {};
122
+ #viewCubeEnabled = true;
123
+ #viewCubeSuspended = true;
124
+
131
125
  #options = {};
132
126
 
133
127
  #gltfResolver = null; // GLTFResolver instance
@@ -147,7 +141,7 @@ export default class BabylonJSController {
147
141
  renderLoop: null,
148
142
  };
149
143
 
150
- #settings = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
144
+ #renderSettings = { ...BabylonJSController.config.render.defaultSettings };
151
145
 
152
146
  // Runtime mutable state (changes while the app is running).
153
147
  #state = {
@@ -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,47 @@ 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
+ },
223
+ // Hardware-adaptive quality settings (applied based on GPU capabilities)
224
+ quality: {
225
+ // Settings for different hardware tiers: 'low', 'medium', 'high'
226
+ // Applied automatically based on GPU detected capabilities
227
+ tier: 'medium', // Default, will be updated after hardware detection
228
+ shadowMapSize: 1024, // Standard shadow generator resolution
229
+ iblShadowResolution: 1, // IBL shadow resolution exponent (0=1024, 1=2048, 2=4096)
230
+ iblSampleDirections: 4, // IBL shadow quality
231
+ shadowBlurKernel: 16, // Shadow blur amount
232
+ ssaoEnabled: true, // Screen Space Ambient Occlusion
233
+ },
234
+ render: {
235
+ // Default render settings. Add new persisted render toggles here and update their translation labels.
236
+ defaultSettings: {
237
+ antiAliasingEnabled: true,
238
+ ambientOcclusionEnabled: true,
239
+ iblEnabled: true,
240
+ shadowsEnabled: false,
241
+ // Animation menu and highlight settings
242
+ showAnimationMenu: true,
243
+ highlightEnabled: true,
244
+ highlightColor: PrefViewerStyleVariables.colorPrimary,
245
+ // Lighting time of day (0=midnight, 0.5=noon, 1=midnight)
246
+ showLightingTimeOfDay: true,
247
+ lightingTimeOfDay: 0.5,
248
+ },
249
+ burstFramesBase: 2,
250
+ burstFramesEnhanced: 32, // When AA/SSAO/IBL is enabled, more frames are needed to reach stable output
251
+ interactionMs: 250,
252
+ animationMs: 200,
253
+ idleThrottleMs: 1000 / 8, // Reduced from 15 FPS to 8 FPS for idle - reduces GPU/CPU load
254
+ idleThresholdMs: 3000, // Time before going to true idle (no renders)
255
+ },
256
+ resize: {
257
+ throttleMs: 50, // Cap resize work to ~20 Hz while dragging/resizing containers
258
+ },
259
+ storageKey: "pref-viewer/render-settings",
241
260
  };
242
261
 
243
262
  /**
@@ -474,7 +493,7 @@ export default class BabylonJSController {
474
493
  const preset = presetMap[tier] || qualityPresets.medium;
475
494
 
476
495
  // Apply preset to config
477
- this.#config.quality = {
496
+ BabylonJSController.config.quality = {
478
497
  tier,
479
498
  ...preset,
480
499
  // Legacy compatibility
@@ -498,14 +517,20 @@ export default class BabylonJSController {
498
517
  * @param {HTMLCanvasElement|null} canvas - The canvas element to render the scene on.
499
518
  * @param {object} containers - An object containing container states for model, environment, materials, etc.
500
519
  * @param {object} options - Configuration options for the Babylon.js scene and assets.
520
+ * @param {object} [viewCubeConfig={}] - Internal overlay integration options.
521
+ * @param {boolean} [viewCubeConfig.enabled=true] - Desired activation; creation waits for a live scene and camera.
522
+ * @param {function(): string} [viewCubeConfig.getCulture] - Current UI culture for cube labels.
523
+ * @param {function(number, number): void} [viewCubeConfig.onLayout] - Measured cube SVG height and scene viewport height in CSS pixels; (0, 0) on disposal.
501
524
  * @returns {BabylonJSController}
502
525
  * @description
503
526
  * - Assigns the provided canvas to the controller.
504
527
  * - Initializes each container with its state and sets assetContainer to null.
505
528
  * - Stores the provided options for later use in scene setup and asset loading.
506
529
  */
507
- constructor(canvas = null, containers = {}, options = {}) {
530
+ constructor(canvas = null, containers = {}, options = {}, viewCubeConfig = {}) {
508
531
  this.#canvas = canvas;
532
+ this.#viewCubeConfig = viewCubeConfig;
533
+ this.#viewCubeEnabled = viewCubeConfig.enabled !== false;
509
534
  Object.keys(containers).forEach((key) => {
510
535
  this.#containers[key] = {
511
536
  assetContainer: null,
@@ -563,18 +588,18 @@ export default class BabylonJSController {
563
588
  let changed = false;
564
589
  Object.keys(settings).forEach((key) => {
565
590
  // Handle boolean settings
566
- if (typeof settings[key] === "boolean" && this.#settings[key] !== settings[key]) {
567
- this.#settings[key] = settings[key];
591
+ if (typeof settings[key] === "boolean" && this.#renderSettings[key] !== settings[key]) {
592
+ this.#renderSettings[key] = settings[key];
568
593
  changed = true;
569
594
  }
570
595
  // Handle string settings (like illuminationColor)
571
- if (typeof settings[key] === "string" && this.#settings[key] !== settings[key]) {
572
- this.#settings[key] = settings[key];
596
+ if (typeof settings[key] === "string" && this.#renderSettings[key] !== settings[key]) {
597
+ this.#renderSettings[key] = settings[key];
573
598
  changed = true;
574
599
  }
575
600
  // 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];
601
+ if (typeof settings[key] === "number" && Number.isFinite(settings[key]) && this.#renderSettings[key] !== settings[key]) {
602
+ this.#renderSettings[key] = settings[key];
578
603
  changed = true;
579
604
  }
580
605
  });
@@ -597,23 +622,23 @@ export default class BabylonJSController {
597
622
  return;
598
623
  }
599
624
  try {
600
- const serialized = window.localStorage.getItem(this.#RENDER_SETTINGS_STORAGE_KEY);
625
+ const serialized = window.localStorage.getItem(BabylonJSController.config.storageKey);
601
626
  if (!serialized) {
602
627
  return;
603
628
  }
604
629
  const parsed = JSON.parse(serialized);
605
- Object.keys(BabylonJSController.DEFAULT_RENDER_SETTINGS).forEach((key) => {
630
+ Object.keys(BabylonJSController.config.render.defaultSettings).forEach((key) => {
606
631
  // Handle boolean settings
607
632
  if (typeof parsed?.[key] === "boolean") {
608
- this.#settings[key] = parsed[key];
633
+ this.#renderSettings[key] = parsed[key];
609
634
  }
610
635
  // Handle string settings (like illuminationColor)
611
636
  if (typeof parsed?.[key] === "string") {
612
- this.#settings[key] = parsed[key];
637
+ this.#renderSettings[key] = parsed[key];
613
638
  }
614
639
  // Handle numeric settings such as lightingTimeOfDay
615
640
  if (typeof parsed?.[key] === "number" && Number.isFinite(parsed[key])) {
616
- this.#settings[key] = parsed[key];
641
+ this.#renderSettings[key] = parsed[key];
617
642
  }
618
643
  });
619
644
  } catch (error) {
@@ -632,7 +657,7 @@ export default class BabylonJSController {
632
657
  return;
633
658
  }
634
659
  try {
635
- window.localStorage.setItem(this.#RENDER_SETTINGS_STORAGE_KEY, JSON.stringify(this.#settings));
660
+ window.localStorage.setItem(BabylonJSController.config.storageKey, JSON.stringify(this.#renderSettings));
636
661
  } catch (error) {
637
662
  console.warn("PrefViewer: unable to store render settings", error);
638
663
  }
@@ -687,7 +712,7 @@ export default class BabylonJSController {
687
712
 
688
713
  const timeoutMs = 2000;
689
714
  const startedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
690
- this.#requestRender({ frames: this.#config.render.burstFramesEnhanced, continuousMs: timeoutMs });
715
+ this.#requestRender({ frames: BabylonJSController.config.render.burstFramesEnhanced, continuousMs: timeoutMs });
691
716
 
692
717
  try {
693
718
  await new Promise((resolve) => {
@@ -844,7 +869,7 @@ export default class BabylonJSController {
844
869
 
845
870
  /**
846
871
  * Evaluates whether the renderer should stay in continuous mode.
847
- * XR always forces continuous rendering; animation/camera motion also extends the
872
+ * Pending ViewCube transitions and active XR force continuous rendering; animation/camera motion extends the
848
873
  * continuous deadline window to avoid abrupt stop-start behavior.
849
874
  * OPTIMIZATION: Added true idle mode - stops rendering completely after idleThresholdMs of inactivity.
850
875
  * @private
@@ -852,6 +877,9 @@ export default class BabylonJSController {
852
877
  * @returns {boolean} True when continuous rendering should remain active.
853
878
  */
854
879
  #shouldRenderContinuously(now) {
880
+ if (this.#viewCube?.isAnimating) {
881
+ return true;
882
+ }
855
883
  const inXR = this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR;
856
884
  if (inXR) {
857
885
  this.#state.render.isIdle = false;
@@ -863,19 +891,19 @@ export default class BabylonJSController {
863
891
  const cameraInMotion = this.#isCameraInMotion();
864
892
 
865
893
  if (animationRunning) {
866
- this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.animationMs);
894
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + BabylonJSController.config.render.animationMs);
867
895
  this.#state.render.isIdle = false;
868
896
  this.#state.render.lastActivityAt = now;
869
897
  }
870
898
  if (cameraInMotion) {
871
- this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + this.#config.render.interactionMs);
899
+ this.#state.render.continuousUntil = Math.max(this.#state.render.continuousUntil, now + BabylonJSController.config.render.interactionMs);
872
900
  this.#state.render.isIdle = false;
873
901
  this.#state.render.lastActivityAt = now;
874
902
  }
875
903
 
876
904
  // OPTIMIZATION: True idle mode - if no activity for idleThresholdMs, stop rendering completely
877
905
  const timeSinceActivity = now - this.#state.render.lastActivityAt;
878
- if (timeSinceActivity > this.#config.render.idleThresholdMs) {
906
+ if (timeSinceActivity > BabylonJSController.config.render.idleThresholdMs) {
879
907
  this.#state.render.isIdle = true;
880
908
  }
881
909
 
@@ -891,7 +919,8 @@ export default class BabylonJSController {
891
919
 
892
920
  /**
893
921
  * 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.
922
+ * Runs while scene state is dirty, interactive motion/animations are active, or XR/ViewCube navigation requires it.
923
+ * Suspends dynamic zoom constraints during cube framing, advances its transition, renders and synchronizes its indicator.
895
924
  * It self-stops when the scene becomes idle (true idle mode - no renders at all).
896
925
  * OPTIMIZATION: Added true idle mode that completely stops rendering after idleThresholdMs.
897
926
  * @private
@@ -918,11 +947,16 @@ export default class BabylonJSController {
918
947
  return;
919
948
  }
920
949
 
921
- if (!continuous && this.#state.render.lastRenderAt > 0 && now - this.#state.render.lastRenderAt < this.#config.render.idleThrottleMs) {
950
+ if (!continuous && this.#state.render.lastRenderAt > 0 && now - this.#state.render.lastRenderAt < BabylonJSController.config.render.idleThrottleMs) {
922
951
  return;
923
952
  }
924
953
 
954
+ if (this.#viewCube?.isAnimating) {
955
+ this.#cameraZoom?.update();
956
+ }
957
+ this.#viewCube?.tick(now);
925
958
  this.#scene.render();
959
+ this.#viewCube?.sync();
926
960
  this.#state.render.lastRenderAt = now;
927
961
 
928
962
  if (this.#state.render.dirtyFrames > 0) {
@@ -1005,15 +1039,20 @@ export default class BabylonJSController {
1005
1039
  }
1006
1040
 
1007
1041
  /**
1008
- * Creates and configures the main ArcRotateCamera for the Babylon.js scene.
1042
+ * Creates the default ArcRotateCamera with unrestricted beta, upright orbit and proportional native wheel zoom.
1043
+ * Starts at the exact Inner orientation (+Z, alpha/beta pi/2), matching the ViewCube before loading.
1044
+ * Starts without a fixed lower radius; the independent model zoom guard supplies the dynamic floor.
1045
+ * Imported-camera configuration is applied separately and is not reset here.
1009
1046
  * @private
1010
1047
  * @returns {void}
1011
1048
  */
1012
1049
  #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;
1050
+ this.#camera = new ArcRotateCamera("defaultCamera", Math.PI / 2, Math.PI / 2, 10, Vector3.Zero(), this.#scene);
1051
+ this.#camera.upperBetaLimit = null;
1052
+ this.#camera.lowerBetaLimit = null;
1053
+ this.#camera.allowUpsideDown = false;
1054
+ this.#camera.wheelDeltaPercentage = BabylonJSController.config.camera.wheelDeltaPercentage;
1055
+ this.#camera.lowerRadiusLimit = null;
1017
1056
  this.#camera.upperRadiusLimit = 20;
1018
1057
  this.#camera.metadata = { locked: false };
1019
1058
  this.#applyShowroomCameraFraming(this.#camera);
@@ -1040,7 +1079,7 @@ export default class BabylonJSController {
1040
1079
 
1041
1080
  let lightsChanged = false;
1042
1081
 
1043
- const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
1082
+ const iblEnabled = this.#renderSettings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
1044
1083
 
1045
1084
  if (iblEnabled) {
1046
1085
  lightsChanged = await this.#initializeEnvironmentTexture();
@@ -1120,7 +1159,7 @@ export default class BabylonJSController {
1120
1159
  return false;
1121
1160
  }
1122
1161
 
1123
- if (!this.#settings.ambientOcclusionEnabled) {
1162
+ if (!this.#renderSettings.ambientOcclusionEnabled) {
1124
1163
  return false;
1125
1164
  }
1126
1165
  const supportedPipelines = pipelineManager.supportedPipelines;
@@ -1221,7 +1260,7 @@ export default class BabylonJSController {
1221
1260
  return false;
1222
1261
  }
1223
1262
 
1224
- if (!this.#settings.antiAliasingEnabled) {
1263
+ if (!this.#renderSettings.antiAliasingEnabled) {
1225
1264
  return false;
1226
1265
  }
1227
1266
  const supportedPipelines = pipelineManager.supportedPipelines;
@@ -1283,7 +1322,7 @@ export default class BabylonJSController {
1283
1322
  let hdrTexture = null;
1284
1323
  if (this.#options.ibl?.cachedUrl) {
1285
1324
  const hdrTextureURI = this.#options.ibl.cachedUrl;
1286
- const envTextureSize = this.#config.quality.environmentTextureSize || 1024;
1325
+ const envTextureSize = BabylonJSController.config.quality.environmentTextureSize || 1024;
1287
1326
  hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, envTextureSize, false, false, false, true, undefined, undefined, false, true, true);
1288
1327
  } else if (this.#hdrTexture && this.#options.ibl?.valid === true) {
1289
1328
  hdrTexture = this.#hdrTexture.clone();
@@ -1411,8 +1450,8 @@ export default class BabylonJSController {
1411
1450
 
1412
1451
  // Use hardware-adaptive quality settings
1413
1452
  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
1453
+ resolutionExp: BabylonJSController.config.quality.iblShadowResolution, // Adaptive: 0=1024, 1=2048, 2=4096
1454
+ sampleDirections: BabylonJSController.config.quality.iblSampleDirections, // Adaptive: 2=fast, 4=quality
1416
1455
  ssShadowsEnabled: true,
1417
1456
  shadowRemanence: 0.85,
1418
1457
  triPlanarVoxelization: true,
@@ -1493,11 +1532,11 @@ export default class BabylonJSController {
1493
1532
  }
1494
1533
  this.#dirLight.autoUpdateExtends = false;
1495
1534
  // Use quality-adaptive shadow map size
1496
- const shadowMapSize = this.#config.quality.shadowMapSize;
1535
+ const shadowMapSize = BabylonJSController.config.quality.shadowMapSize;
1497
1536
  const shadowGenerator = new ShadowGenerator(shadowMapSize, this.#dirLight);
1498
1537
  shadowGenerator.useBlurExponentialShadowMap = true;
1499
1538
  // Use quality-adaptive blur kernel
1500
- shadowGenerator.blurKernel = this.#config.quality.shadowBlurKernel;
1539
+ shadowGenerator.blurKernel = BabylonJSController.config.quality.shadowBlurKernel;
1501
1540
  shadowGenerator.darkness = 0.5;
1502
1541
  shadowGenerator.bias = 0.0005;
1503
1542
  shadowGenerator.normalBias = 0.02;
@@ -1579,13 +1618,13 @@ export default class BabylonJSController {
1579
1618
  * Otherwise, sets up shadow casting and receiving for all relevant meshes using the shadow generator.
1580
1619
  */
1581
1620
  async #initializeShadows() {
1582
- if (!this.#settings.shadowsEnabled) {
1621
+ if (!this.#renderSettings.shadowsEnabled) {
1583
1622
  return false;
1584
1623
  }
1585
1624
 
1586
1625
  this.#ensureMeshesReceiveShadows();
1587
1626
 
1588
- const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
1627
+ const iblEnabled = this.#renderSettings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
1589
1628
  const iblShadowsEnabled = iblEnabled && this.#options.ibl.shadows;
1590
1629
 
1591
1630
  if (iblShadowsEnabled) {
@@ -1603,7 +1642,9 @@ export default class BabylonJSController {
1603
1642
  * @returns {void}
1604
1643
  */
1605
1644
  #setMaxSimultaneousLights() {
1606
- if (!this.#scene) return;
1645
+ if (!this.#scene) {
1646
+ return;
1647
+ }
1607
1648
  let lightsNumber = 1; // At least one light coming from the environment texture contribution
1608
1649
  this.#scene.lights.forEach((light) => {
1609
1650
  if (light.isEnabled()) {
@@ -1657,10 +1698,10 @@ export default class BabylonJSController {
1657
1698
  return;
1658
1699
  }
1659
1700
 
1660
- const showroom = this.#config.look?.showroom || {};
1701
+ const showroom = BabylonJSController.config.look?.showroom || {};
1661
1702
  const imageProcessing = this.#scene.imageProcessingConfiguration;
1662
1703
 
1663
- const bg = showroom.backgroundDay ?? this.#config.look.showroom.backgroundDay;
1704
+ const bg = showroom.backgroundDay ?? BabylonJSController.config.look.showroom.backgroundDay;
1664
1705
  this.#scene.clearColor = new Color4(bg[0], bg[1], bg[2], 1);
1665
1706
  imageProcessing.exposure = showroom.exposure ?? 0.95;
1666
1707
  imageProcessing.contrast = showroom.contrast ?? 1.08;
@@ -1684,8 +1725,8 @@ export default class BabylonJSController {
1684
1725
  return false;
1685
1726
  }
1686
1727
 
1687
- const showroom = this.#config.look?.showroom || {};
1688
- const timeOfDay = clamp01(this.#settings.lightingTimeOfDay ? this.#settings.lightingTimeOfDay ?? 0.5 : 0.5);
1728
+ const showroom = BabylonJSController.config.look?.showroom || {};
1729
+ const timeOfDay = clamp01(this.#renderSettings.lightingTimeOfDay ? this.#renderSettings.lightingTimeOfDay ?? 0.5 : 0.5);
1689
1730
  const profile = getLightingCycleProfile(timeOfDay);
1690
1731
  const daylight = profile.dayFactor;
1691
1732
  const peakLight = daylight > 0 ? Math.pow(daylight, 0.72) : 0;
@@ -1703,7 +1744,7 @@ export default class BabylonJSController {
1703
1744
 
1704
1745
  // Night end stays in low linear values (a dark blue sky); the day end is the calibrated
1705
1746
  // HDR off-white. Both are linear already, so no toLinearSpace() on either.
1706
- const bgDay = showroom.backgroundDay ?? this.#config.look.showroom.backgroundDay;
1747
+ const bgDay = showroom.backgroundDay ?? BabylonJSController.config.look.showroom.backgroundDay;
1707
1748
  this.#scene.clearColor = new Color4(
1708
1749
  lerp(0.000627, bgDay[0], daylight),
1709
1750
  lerp(0.001089, bgDay[1], daylight),
@@ -1760,7 +1801,7 @@ export default class BabylonJSController {
1760
1801
  }
1761
1802
 
1762
1803
  this.#scene.markAllMaterialsAsDirty?.(Material.LightDirtyFlag ?? Material.TextureDirtyFlag);
1763
- this.#requestRender({ frames: 2, continuousMs: this.#config.render.interactionMs });
1804
+ this.#requestRender({ frames: 2, continuousMs: BabylonJSController.config.render.interactionMs });
1764
1805
  return true;
1765
1806
  }
1766
1807
 
@@ -1776,81 +1817,62 @@ export default class BabylonJSController {
1776
1817
  }
1777
1818
 
1778
1819
  const pixelRatio = typeof window !== "undefined" ? window.devicePixelRatio : 1;
1779
- const baseScaling = this.#config.quality.hardwareScaling || 1;
1820
+ const baseScaling = BabylonJSController.config.quality.hardwareScaling || 1;
1780
1821
  const scalingLevel = getAdaptiveHardwareScalingLevel(baseScaling, pixelRatio);
1781
1822
  this.#engine.setHardwareScalingLevel(scalingLevel);
1782
1823
  }
1783
1824
 
1784
1825
  /**
1785
- * World-space bounds of the loaded model's renderable geometry.
1826
+ * Frames the default camera using the shared model-framing calculation.
1827
+ * Preserves its current orientation and projection; excludes environment geometry.
1828
+ * Applies the destination immediately, without a ViewCube transition.
1829
+ * The default camera has no parent; imported cameras retain their authored framing.
1786
1830
  * @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.
1831
+ * @param {Camera|null|undefined} camera - Camera about to become active.
1828
1832
  * @returns {void}
1829
1833
  */
1830
1834
  #fitDefaultCameraToModel(camera) {
1831
1835
  if (camera !== this.#camera || !(camera instanceof ArcRotateCamera)) {
1832
1836
  return;
1833
1837
  }
1834
-
1835
- const bounds = this.#getModelBounds();
1838
+ const bounds = BabylonJSCameraFraming.getModelBounds(this.#containers.model?.assetContainer);
1836
1839
  if (!bounds) {
1837
1840
  return;
1838
1841
  }
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;
1842
+ camera.getViewMatrix(true);
1843
+ const direction = camera.globalPosition.subtract(camera.getTarget());
1844
+ if (direction.lengthSquared() <= 1e-12) {
1845
+ return;
1846
+ }
1847
+ direction.normalize();
1848
+ const fit = BabylonJSCameraFraming.calculateCameraFit(camera, bounds, direction, {
1849
+ width: this.#canvas.clientWidth,
1850
+ height: this.#canvas.clientHeight,
1851
+ });
1852
+ if (!fit) {
1853
+ return;
1854
+ }
1855
+ // Geometry protection supplies the minimum zoom radius.
1856
+ camera.lowerRadiusLimit = null;
1857
+ camera.upperRadiusLimit = fit.distance * BabylonJSController.config.camera.maxRadiusFactor;
1858
+ camera.upVector = Vector3.Up();
1859
+ camera.setTarget(fit.target);
1860
+ // Match ViewCube's destination orientation, including its small pole correction.
1861
+ const rotationMatrix = Matrix.Identity();
1862
+ fit.rotation.toRotationMatrix(rotationMatrix);
1863
+ const forward = Vector3.TransformNormal(
1864
+ new Vector3(0, 0, camera.getScene().useRightHandedSystem ? -1 : 1),
1865
+ rotationMatrix
1866
+ );
1867
+ camera.setPosition(fit.target.subtract(forward.scale(fit.distance)));
1868
+ if (camera.mode === Camera.ORTHOGRAPHIC_CAMERA) {
1869
+ camera.orthoLeft = -fit.halfWidth;
1870
+ camera.orthoRight = fit.halfWidth;
1871
+ camera.orthoBottom = -fit.halfHeight;
1872
+ camera.orthoTop = fit.halfHeight;
1873
+ }
1874
+ camera.minZ = Math.min(camera.minZ > 0 ? camera.minZ : Infinity, Math.max(fit.radius * 0.001, 0.00001));
1875
+ camera.maxZ = Math.max(camera.maxZ, fit.distance + fit.radius * 2);
1854
1876
  }
1855
1877
 
1856
1878
  /**
@@ -1896,7 +1918,7 @@ export default class BabylonJSController {
1896
1918
  const x = this.#scene.pointerX;
1897
1919
  const y = this.#scene.pointerY;
1898
1920
  const state = this.#state.pointerPicking;
1899
- const config = this.#config.pointerPicking;
1921
+ const config = BabylonJSController.config.pointerPicking;
1900
1922
 
1901
1923
  if (!Number.isFinite(state.lastMovePickX) || !Number.isFinite(state.lastMovePickY)) {
1902
1924
  state.lastMovePickX = x;
@@ -2065,7 +2087,7 @@ export default class BabylonJSController {
2065
2087
  * @returns {void}
2066
2088
  */
2067
2089
  #onAnimationGroupPlay() {
2068
- this.#requestRender({ frames: 1, continuousMs: this.#config.render.animationMs });
2090
+ this.#requestRender({ frames: 1, continuousMs: BabylonJSController.config.render.animationMs });
2069
2091
  }
2070
2092
 
2071
2093
  /**
@@ -2074,11 +2096,11 @@ export default class BabylonJSController {
2074
2096
  * @returns {void}
2075
2097
  */
2076
2098
  #onAnimationGroupStop() {
2077
- if (this.#settings.iblEnabled && this.#renderPipelines.iblShadows) {
2099
+ if (this.#renderSettings.iblEnabled && this.#renderPipelines.iblShadows) {
2078
2100
  this.#renderPipelines.iblShadows.updateVoxelization();
2079
2101
  this.#scene?.postProcessRenderPipelineManager?.update();
2080
2102
  }
2081
- const frames = this.#settings.antiAliasingEnabled || this.#settings.ambientOcclusionEnabled || this.#settings.iblEnabled ? this.#config.render.burstFramesEnhanced : this.#config.render.burstFramesBase;
2103
+ const frames = this.#renderSettings.antiAliasingEnabled || this.#renderSettings.ambientOcclusionEnabled || this.#renderSettings.iblEnabled ? BabylonJSController.config.render.burstFramesEnhanced : BabylonJSController.config.render.burstFramesBase;
2082
2104
  this.#requestRender({ frames: frames });
2083
2105
  }
2084
2106
 
@@ -2131,7 +2153,8 @@ export default class BabylonJSController {
2131
2153
  }
2132
2154
 
2133
2155
  /**
2134
- * Handles mouse wheel events on the Babylon.js canvas for zooming the camera.
2156
+ * Wakes rendering for native orbital wheel zoom and handles wheel movement for target cameras.
2157
+ * ArcRotateCamera owns proportional wheel zoom; do not add a second radius offset here.
2135
2158
  * OPTIMIZATION: Tracks activity for idle mode.
2136
2159
  * @private
2137
2160
  * @param {WheelEvent} event - The mouse wheel event.
@@ -2151,10 +2174,7 @@ export default class BabylonJSController {
2151
2174
  return false;
2152
2175
  }
2153
2176
  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) {
2177
+ if (camera instanceof FreeCamera || camera instanceof UniversalCamera) {
2158
2178
  camera.wheelPrecision = camera.wheelPrecision || 3.0;
2159
2179
  const zoomSpeed = -event.deltaY * camera.wheelPrecision * 0.001;
2160
2180
 
@@ -2163,7 +2183,7 @@ export default class BabylonJSController {
2163
2183
  const movementVector = direction.scale(zoomSpeed);
2164
2184
  camera.position = camera.position.add(movementVector);
2165
2185
  }
2166
- this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
2186
+ this.#requestRender({ frames: 1, continuousMs: BabylonJSController.config.render.interactionMs });
2167
2187
  }
2168
2188
  }
2169
2189
 
@@ -2208,14 +2228,14 @@ export default class BabylonJSController {
2208
2228
 
2209
2229
  const camera = this.#scene?.activeCamera;
2210
2230
  if (camera && !camera.metadata?.locked) {
2211
- this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
2231
+ this.#requestRender({ frames: 1, continuousMs: BabylonJSController.config.render.interactionMs });
2212
2232
  }
2213
2233
  if (this.#babylonJSAnimationController && pickInfo) {
2214
2234
  const pickedMeshId = pickInfo?.pickedMesh?.id || null;
2215
2235
  if (this.#state.pointerPicking.lastPickedMeshId !== pickedMeshId) {
2216
2236
  const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
2217
2237
  if (highlightResult.changed) {
2218
- this.#requestRender({ frames: 1, continuousMs: this.#config.render.interactionMs });
2238
+ this.#requestRender({ frames: 1, continuousMs: BabylonJSController.config.render.interactionMs });
2219
2239
  }
2220
2240
  }
2221
2241
  }
@@ -2272,12 +2292,12 @@ export default class BabylonJSController {
2272
2292
  console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
2273
2293
  this.#applyShowroomCameraFraming();
2274
2294
  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 });
2295
+ const needsEnhancedBurst = this.#renderSettings.antiAliasingEnabled || this.#renderSettings.ambientOcclusionEnabled || this.#renderSettings.iblEnabled || this.#renderSettings.shadowsEnabled;
2296
+ const frames = needsEnhancedBurst ? BabylonJSController.config.render.burstFramesEnhanced : BabylonJSController.config.render.burstFramesBase;
2297
+ this.#requestRender({ frames, continuousMs: BabylonJSController.config.render.interactionMs });
2278
2298
  };
2279
2299
 
2280
- if (elapsed >= this.#config.resize.throttleMs) {
2300
+ if (elapsed >= BabylonJSController.config.resize.throttleMs) {
2281
2301
  applyResize();
2282
2302
  return;
2283
2303
  }
@@ -2287,7 +2307,7 @@ export default class BabylonJSController {
2287
2307
  }
2288
2308
 
2289
2309
  this.#state.resize.isScheduled = true;
2290
- const waitMs = Math.max(0, this.#config.resize.throttleMs - elapsed);
2310
+ const waitMs = Math.max(0, BabylonJSController.config.resize.throttleMs - elapsed);
2291
2311
  this.#state.resize.timeoutId = setTimeout(() => {
2292
2312
  applyResize();
2293
2313
  }, waitMs);
@@ -2373,11 +2393,14 @@ export default class BabylonJSController {
2373
2393
  }
2374
2394
 
2375
2395
  /**
2376
- * Applies camera options from the configuration to the active camera.
2396
+ * Cancels pending cube navigation and applies configured camera selection and locking.
2397
+ * Configures unlocked orbital cameras for proportional native wheel input, rebinds the model zoom guard
2398
+ * and synchronizes the cube after selecting the active camera.
2377
2399
  * @private
2378
2400
  * @returns {boolean} True if camera options were set successfully, false otherwise.
2379
2401
  */
2380
2402
  #setOptions_Camera() {
2403
+ this.#viewCube?.cancel();
2381
2404
  const cameraState = this.#options.camera;
2382
2405
  const modelContainer = this.#containers.model;
2383
2406
  const environmentContainer = this.#containers.environment;
@@ -2431,9 +2454,14 @@ export default class BabylonJSController {
2431
2454
  this.#scene.activeCamera?.detachControl();
2432
2455
  camera.detachControl();
2433
2456
  if (!cameraState.locked) {
2457
+ if (camera instanceof ArcRotateCamera) {
2458
+ camera.wheelDeltaPercentage = BabylonJSController.config.camera.wheelDeltaPercentage;
2459
+ }
2434
2460
  camera.attachControl(this.#canvas, true);
2435
2461
  }
2436
2462
  this.#scene.activeCamera = camera;
2463
+ this.#cameraZoom?.setCamera(camera);
2464
+ this.#viewCube?.sync();
2437
2465
  return true;
2438
2466
  }
2439
2467
 
@@ -2628,8 +2656,12 @@ export default class BabylonJSController {
2628
2656
  */
2629
2657
  #assetContainer_simplifyTransmissiveMaterials(assetContainer) {
2630
2658
  assetContainer.materials?.forEach((mat) => {
2631
- if (!(mat instanceof PBRMaterial)) return;
2632
- if (!mat.subSurface?.isRefractionEnabled && !mat.subSurface?.isTranslucencyEnabled) return;
2659
+ if (!(mat instanceof PBRMaterial)) {
2660
+ return;
2661
+ }
2662
+ if (!mat.subSurface?.isRefractionEnabled && !mat.subSurface?.isTranslucencyEnabled) {
2663
+ return;
2664
+ }
2633
2665
  this.#simplifyTransmissiveMaterial(mat);
2634
2666
  });
2635
2667
  }
@@ -2700,11 +2732,13 @@ export default class BabylonJSController {
2700
2732
  * @returns {Promise<void>}
2701
2733
  */
2702
2734
  async #startRender() {
2703
- if (!this.#scene) return;
2735
+ if (!this.#scene) {
2736
+ return;
2737
+ }
2704
2738
  await this.#loadCameraDependentEffects();
2705
2739
  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 });
2740
+ const frames = this.#renderSettings.antiAliasingEnabled || this.#renderSettings.ambientOcclusionEnabled || this.#renderSettings.iblEnabled ? BabylonJSController.config.render.burstFramesEnhanced : BabylonJSController.config.render.burstFramesBase;
2741
+ this.#requestRender({ frames: frames, continuousMs: BabylonJSController.config.render.interactionMs });
2708
2742
  }
2709
2743
 
2710
2744
  /**
@@ -2776,15 +2810,18 @@ export default class BabylonJSController {
2776
2810
  }
2777
2811
 
2778
2812
  /**
2779
- * Loads all asset containers (model, environment, materials, etc.) and adds them to the scene.
2813
+ * Cancels pending cube navigation, loads asset containers and adds replacements to the scene.
2814
+ * Current model selection is resolved by the zoom guard on subsequent input updates.
2780
2815
  * @private
2816
+ * @param {boolean} [force=false] - Whether to bypass existing asset cache metadata.
2781
2817
  * @returns {Promise<{success: boolean, error: any}>} Resolves to an object indicating if loading succeeded and any error encountered.
2782
2818
  * @description
2783
2819
  * 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.
2820
+ * Applies material options and wall/floor visibility before camera fitting, then initializes lights and shadows.
2785
2821
  * Returns an object with success status and error details.
2786
2822
  */
2787
2823
  async #loadContainers(force = false) {
2824
+ this.#viewCube?.cancel();
2788
2825
  this.#detachAnimationChangedListener();
2789
2826
  await this.#stopRender();
2790
2827
 
@@ -2837,9 +2874,10 @@ export default class BabylonJSController {
2837
2874
  });
2838
2875
 
2839
2876
  this.#setOptions_Materials();
2877
+ // Apply effective geometry visibility before calculating the camera fit.
2878
+ this.#setVisibilityOfWallAndFloorInModel();
2840
2879
  this.#setOptions_Camera();
2841
2880
  await this.#setOptions_IBL();
2842
- this.#setVisibilityOfWallAndFloorInModel();
2843
2881
  detail.success = true;
2844
2882
  })
2845
2883
  .catch((error) => {
@@ -2865,12 +2903,12 @@ export default class BabylonJSController {
2865
2903
  * @returns {void}
2866
2904
  */
2867
2905
  #syncAnimationControllerAfterModelLoad() {
2868
- if (this.#settings.showAnimationMenu && this.#containers.model.assetContainer) {
2906
+ if (this.#renderSettings.showAnimationMenu && this.#containers.model.assetContainer) {
2869
2907
  this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
2870
2908
  // Apply stored highlight settings so fresh page loads respect persisted state
2871
2909
  this.#setHighlightConfig({
2872
- enabled: this.#settings.highlightEnabled,
2873
- color: this.#settings.highlightColor,
2910
+ enabled: this.#renderSettings.highlightEnabled,
2911
+ color: this.#renderSettings.highlightColor,
2874
2912
  });
2875
2913
  if (this.#babylonJSAnimationController?.hasAnimations?.()) {
2876
2914
  this.#attachAnimationChangedListener();
@@ -3122,10 +3160,34 @@ export default class BabylonJSController {
3122
3160
  * ---------------------------
3123
3161
  */
3124
3162
 
3163
+ /**
3164
+ * Creates the enabled cube once a live scene/camera exists and teardown is not in progress.
3165
+ * Resolves camera/model state through callbacks; does not reload assets or camera options.
3166
+ * @private
3167
+ * @returns {void}
3168
+ */
3169
+ #initializeViewCube() {
3170
+ if (!this.#viewCubeEnabled || this.#viewCubeSuspended || this.#viewCube || !this.#engine || !this.#scene?.activeCamera) {
3171
+ return;
3172
+ }
3173
+ this.#viewCube = new BabylonJSViewCubeController({
3174
+ canvas: this.#canvas,
3175
+ getCamera: () => this.#scene?.activeCamera,
3176
+ isLocked: (camera) => !camera || camera.metadata?.locked === true || this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR,
3177
+ getBounds: () => BabylonJSCameraFraming.getModelBounds(this.#containers.model?.assetContainer),
3178
+ getViewport: () => ({ width: this.#canvas.clientWidth, height: this.#canvas.clientHeight }),
3179
+ getCulture: this.#viewCubeConfig.getCulture,
3180
+ onLayout: this.#viewCubeConfig.onLayout,
3181
+ requestRender: () => this.#requestRender({ frames: 2 }),
3182
+ });
3183
+ }
3184
+
3125
3185
  /**
3126
3186
  * 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.
3187
+ * Configures Draco compression and sets up XR; rendering starts through the existing load/request scheduler.
3188
+ * Observes canvas resize events, binds model-only orbital zoom protection and adaptive default-camera near clipping,
3189
+ * and lazily creates the requested cube. Imported camera clipping is preserved by the host policy.
3190
+ * Aborts remaining initialization if the scene was replaced/disposed during texture readiness.
3129
3191
  * @public
3130
3192
  * @returns {Promise<void>}
3131
3193
  */
@@ -3161,20 +3223,39 @@ export default class BabylonJSController {
3161
3223
  this.#scene.skipPointerUpPicking = true;
3162
3224
 
3163
3225
  this.#createCamera();
3226
+ this.#cameraZoom = new BabylonJSCameraZoomController(
3227
+ () => this.#containers.model?.state?.isVisible ? this.#containers.model.assetContainer : null,
3228
+ (camera) => camera.metadata?.locked === true || this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR,
3229
+ () => this.#viewCube?.isAnimating === true,
3230
+ (camera) => camera === this.#camera,
3231
+ );
3232
+ this.#cameraZoom.setCamera(this.#camera);
3233
+ this.#viewCubeSuspended = false;
3234
+ this.#initializeViewCube();
3235
+ const enabledScene = this.#scene;
3164
3236
  await this.#ensureEnvironmentBRDFTextureReady();
3237
+ if (!this.#engine || this.#scene !== enabledScene) {
3238
+ return;
3239
+ }
3165
3240
  this.#enableInteraction();
3166
3241
  await this.#createXRExperience();
3167
3242
  }
3168
3243
 
3169
3244
  /**
3170
3245
  * Disposes the Babylon.js engine and disconnects the canvas resize observer.
3171
- * Cleans up all scene, camera, light, XR, and GLTF resolver resources.
3246
+ * Releases the zoom observer and cube overlay before scene teardown, restoring the zoom camera
3247
+ * floor and suspending cube creation until the next enable. Cleans up light, XR and resolver resources.
3172
3248
  * The teardown is asynchronous: it waits for XR/session-dependent shutdown work
3173
3249
  * before disposing the engine, and coalesces concurrent calls into one in-flight promise.
3174
3250
  * @public
3175
3251
  * @returns {Promise<void>}
3176
3252
  */
3177
3253
  async disable() {
3254
+ this.#cameraZoom?.dispose();
3255
+ this.#cameraZoom = null;
3256
+ this.#viewCubeSuspended = true;
3257
+ this.#viewCube?.dispose();
3258
+ this.#viewCube = null;
3178
3259
  if (this.#disablingPromises.general) {
3179
3260
  return await this.#disablingPromises.general;
3180
3261
  }
@@ -3205,6 +3286,32 @@ export default class BabylonJSController {
3205
3286
  }
3206
3287
  }
3207
3288
 
3289
+ /**
3290
+ * Lazily creates the cube when enabled and releases all cube resources when disabled.
3291
+ * Stores activation until a live scene is available; never reloads assets or camera options.
3292
+ * @public
3293
+ * @param {boolean} enabled - Whether the visible 3D viewer should own a ViewCube.
3294
+ * @returns {void}
3295
+ */
3296
+ setViewCubeEnabled(enabled) {
3297
+ this.#viewCubeEnabled = Boolean(enabled);
3298
+ if (this.#viewCubeEnabled) {
3299
+ this.#initializeViewCube();
3300
+ } else {
3301
+ this.#viewCube?.dispose();
3302
+ this.#viewCube = null;
3303
+ }
3304
+ }
3305
+
3306
+ /**
3307
+ * Synchronizes the existing cube after culture or camera-state changes without creating an inactive cube.
3308
+ * @public
3309
+ * @returns {void}
3310
+ */
3311
+ refreshViewCube() {
3312
+ this.#viewCube?.sync();
3313
+ }
3314
+
3208
3315
  /**
3209
3316
  * Downloads the current scene, model, or environment as a GLB file.
3210
3317
  * @public
@@ -3314,7 +3421,7 @@ export default class BabylonJSController {
3314
3421
  * @returns {{antiAliasingEnabled:boolean, ambientOcclusionEnabled:boolean, iblEnabled:boolean, shadowsEnabled:boolean}}
3315
3422
  */
3316
3423
  getRenderSettings() {
3317
- return { ...this.#settings };
3424
+ return { ...this.#renderSettings };
3318
3425
  }
3319
3426
 
3320
3427
  /**
@@ -3484,7 +3591,7 @@ export default class BabylonJSController {
3484
3591
  max: vectorToJSON(modelBounds.max),
3485
3592
  } : null,
3486
3593
  render: { ...this.#state.render },
3487
- settings: { ...this.#settings },
3594
+ settings: { ...this.#renderSettings },
3488
3595
  pipelines: {
3489
3596
  default: Boolean(this.#renderPipelines.default),
3490
3597
  ssao: Boolean(this.#renderPipelines.ssao),
@@ -3548,7 +3655,7 @@ export default class BabylonJSController {
3548
3655
  * Applies animation-menu, highlight, and time-of-day lighting configuration without forcing a full scene reload.
3549
3656
  * Values are merged into the persisted render settings snapshot unless `config.persist === false`.
3550
3657
  * 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).
3658
+ * the active lighting time is reset to `BabylonJSController.config.render.defaultSettings.lightingTimeOfDay` (0.5).
3552
3659
  * @public
3553
3660
  * @param {object} config - Immediate render/illumination configuration.
3554
3661
  * @param {boolean|string} [config.showAnimationMenu] - Enables/disables the animation controls when animations are available.
@@ -3566,16 +3673,16 @@ export default class BabylonJSController {
3566
3673
  const showLighting = typeof config.showLightingTimeOfDay === "string"
3567
3674
  ? config.showLightingTimeOfDay.toLowerCase() !== "false"
3568
3675
  : Boolean(config.showLightingTimeOfDay);
3569
- if (this.#settings.showLightingTimeOfDay !== showLighting) {
3676
+ if (this.#renderSettings.showLightingTimeOfDay !== showLighting) {
3570
3677
  shouldApplyLighting = true;
3571
3678
  }
3572
- this.#settings.showLightingTimeOfDay = showLighting;
3679
+ this.#renderSettings.showLightingTimeOfDay = showLighting;
3573
3680
 
3574
3681
  const hasExplicitLightingTime = typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay);
3575
3682
  if (!showLighting && !hasExplicitLightingTime) {
3576
- const defaultTime = BabylonJSController.DEFAULT_RENDER_SETTINGS.lightingTimeOfDay;
3577
- if (this.#settings.lightingTimeOfDay !== defaultTime) {
3578
- this.#settings.lightingTimeOfDay = defaultTime;
3683
+ const defaultTime = BabylonJSController.config.render.defaultSettings.lightingTimeOfDay;
3684
+ if (this.#renderSettings.lightingTimeOfDay !== defaultTime) {
3685
+ this.#renderSettings.lightingTimeOfDay = defaultTime;
3579
3686
  shouldApplyLighting = true;
3580
3687
  }
3581
3688
  }
@@ -3583,8 +3690,8 @@ export default class BabylonJSController {
3583
3690
 
3584
3691
  if (typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay)) {
3585
3692
  const normalizedTime = clamp01(config.lightingTimeOfDay);
3586
- if (this.#settings.lightingTimeOfDay !== normalizedTime) {
3587
- this.#settings.lightingTimeOfDay = normalizedTime;
3693
+ if (this.#renderSettings.lightingTimeOfDay !== normalizedTime) {
3694
+ this.#renderSettings.lightingTimeOfDay = normalizedTime;
3588
3695
  shouldApplyLighting = true;
3589
3696
  }
3590
3697
  }
@@ -3593,7 +3700,7 @@ export default class BabylonJSController {
3593
3700
  const showMenu = typeof config.showAnimationMenu === "string"
3594
3701
  ? config.showAnimationMenu.toLowerCase() !== "false"
3595
3702
  : Boolean(config.showAnimationMenu);
3596
- this.#settings.showAnimationMenu = showMenu;
3703
+ this.#renderSettings.showAnimationMenu = showMenu;
3597
3704
  this.#syncAnimationControllerAfterModelLoad();
3598
3705
  }
3599
3706
 
@@ -3606,12 +3713,12 @@ export default class BabylonJSController {
3606
3713
  } else if (enabled === null) {
3607
3714
  enabled = undefined;
3608
3715
  }
3609
- // Keep #settings in sync so getRenderSettings() always reflects the real state
3716
+ // Keep #renderSettings in sync so getRenderSettings() always reflects the real state
3610
3717
  if (enabled !== undefined) {
3611
- this.#settings.highlightEnabled = enabled;
3718
+ this.#renderSettings.highlightEnabled = enabled;
3612
3719
  }
3613
3720
  if (config.highlightColor !== undefined && config.highlightColor !== null) {
3614
- this.#settings.highlightColor = config.highlightColor;
3721
+ this.#renderSettings.highlightColor = config.highlightColor;
3615
3722
  }
3616
3723
  this.#setHighlightConfig({
3617
3724
  color: config.highlightColor ?? undefined,