@preference-sl/pref-viewer 2.17.0-beta.0 → 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.
@@ -11,15 +11,12 @@ import BabylonJSAnimationController from "./babylonjs-animation-controller.js";
11
11
  import BabylonJSViewCubeController from "./babylonjs-view-cube-controller.js";
12
12
  import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
13
13
  import BabylonJSCameraZoomController from "./babylonjs-camera-zoom-controller.js";
14
+ import BabylonJSSelectionController from "./babylonjs-selection-controller.js";
14
15
  import OpeningAnimation from "./babylonjs-animation-opening.js";
15
- import { translate } from "./localization/i18n.js";
16
+ import I18n from "./localization/i18n.js";
16
17
  import { PrefViewerStyleVariables } from "./styles.js";
17
- import { clamp01, lerp, getAdaptiveHardwareScalingLevel, getLightingCycleProfile } from "./render-math.js";
18
- import { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlassMaterial, isPrefViewerTransparentRenderOrderMaterial } from "./material-utils.js";
19
-
20
- // Re-export so existing consumers (tests, other modules) can keep importing from this file.
21
- export { getAdaptiveHardwareScalingLevel, getLightingCycleProfile } from "./render-math.js";
22
- export { markPrefViewerSimplifiedTransmissionMaterial, isPrefViewerPhysicalGlassMaterial, isPrefViewerTransparentRenderOrderMaterial } from "./material-utils.js";
18
+ import RenderMath from "./render-math.js";
19
+ import MaterialUtils from "./material-utils.js";
23
20
 
24
21
  /**
25
22
  * BabylonJSController coordinates the PrefViewer 3D runtime: it bootstraps Babylon.js, manages asset containers,
@@ -126,6 +123,7 @@ export default class BabylonJSController {
126
123
 
127
124
  #gltfResolver = null; // GLTFResolver instance
128
125
  #babylonJSAnimationController = null; // AnimationController instance
126
+ #selectionController = null;
129
127
 
130
128
  #renderPipelines = {
131
129
  default: null,
@@ -136,6 +134,7 @@ export default class BabylonJSController {
136
134
  #handlers = {
137
135
  onAnimationGroupChanged: null,
138
136
  onKeyUp: null,
137
+ onPointerCancel: null,
139
138
  onPointerObservable: null,
140
139
  onResize: null,
141
140
  renderLoop: null,
@@ -151,6 +150,7 @@ export default class BabylonJSController {
151
150
  lastMovePickX: NaN,
152
151
  lastMovePickY: NaN,
153
152
  lastPickedMeshId: null,
153
+ pointerDown: null,
154
154
  },
155
155
  // Render loop state balances performance with responsiveness.
156
156
  render: {
@@ -219,6 +219,12 @@ export default class BabylonJSController {
219
219
  pointerPicking: {
220
220
  movePickIntervalMs: 50, // Cap expensive scene.pick calls to ~20 Hz while moving the pointer
221
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,
222
228
  },
223
229
  // Hardware-adaptive quality settings (applied based on GPU capabilities)
224
230
  quality: {
@@ -320,6 +326,7 @@ export default class BabylonJSController {
320
326
  * @private
321
327
  * @param {WebGLRenderingContext} gl - WebGL context
322
328
  * @param {string} rendererName - GPU renderer name
329
+ * @param {boolean} [conservative=false] - Uses a cautious fallback when the GPU renderer is unknown.
323
330
  * @returns {string} Hardware tier
324
331
  */
325
332
  #detectDesktopTier(gl, rendererName, conservative = false) {
@@ -429,6 +436,7 @@ export default class BabylonJSController {
429
436
  * Applies quality settings based on detected hardware tier.
430
437
  * @private
431
438
  * @param {string} tier - Hardware tier: 'low', 'medium', or 'high'
439
+ * @returns {void}
432
440
  */
433
441
  #applyQualitySettings(tier) {
434
442
  const qualityPresets = {
@@ -538,6 +546,16 @@ export default class BabylonJSController {
538
546
  };
539
547
  });
540
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
+ );
541
559
  this.#loadStoredRenderSettings();
542
560
  this.#bindHandlers();
543
561
  }
@@ -550,6 +568,7 @@ export default class BabylonJSController {
550
568
  #bindHandlers() {
551
569
  this.#handlers.onAnimationGroupChanged = this.#onAnimationGroupChanged.bind(this);
552
570
  this.#handlers.onKeyUp = this.#onKeyUp.bind(this);
571
+ this.#handlers.onPointerCancel = this.#onPointerCancel.bind(this);
553
572
  this.#handlers.onPointerObservable = this.#onPointerObservable.bind(this);
554
573
  this.#handlers.onResize = this.#onResize.bind(this);
555
574
  this.#handlers.renderLoop = this.#renderLoop.bind(this);
@@ -716,6 +735,11 @@ export default class BabylonJSController {
716
735
 
717
736
  try {
718
737
  await new Promise((resolve) => {
738
+ /**
739
+ * Polls BRDF readiness until ready, disposed or the preparation timeout elapses.
740
+ * @private
741
+ * @returns {void}
742
+ */
719
743
  const tick = () => {
720
744
  if (!this.#scene || !this.#engine || brdfTexture.isDisposed?.()) {
721
745
  resolve(false);
@@ -1395,7 +1419,7 @@ export default class BabylonJSController {
1395
1419
  * Configures pipeline options for resolution, sampling, opacity, and debugging.
1396
1420
  * Only applies if the scene has an environment texture set.
1397
1421
  * @private
1398
- * @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.
1399
1423
  */
1400
1424
  async #initializeIBLShadows() {
1401
1425
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
@@ -1524,7 +1548,7 @@ export default class BabylonJSController {
1524
1548
  * Configures soft shadows for the built-in directional light used when no HDR environment is present.
1525
1549
  * Uses hardware-adaptive quality settings.
1526
1550
  * @private
1527
- * @returns {void}
1551
+ * @returns {Promise<void>}
1528
1552
  */
1529
1553
  async #initializeDefaultLightShadows() {
1530
1554
  if (!this.#dirLight) {
@@ -1550,7 +1574,7 @@ export default class BabylonJSController {
1550
1574
  * Keeps only the generator bound to the built-in `PrefViewerDirLight` so its shadows persist,
1551
1575
  * then adds generators for every directional or spot light coming from the environment asset container.
1552
1576
  * @private
1553
- * @returns {void}
1577
+ * @returns {Promise<void>}
1554
1578
  */
1555
1579
  async #initializeEnvironmentShadows() {
1556
1580
  this.#shadowGen = this.#shadowGen.filter((generator) => {
@@ -1599,7 +1623,7 @@ export default class BabylonJSController {
1599
1623
  * Disposes every active shadow generator plus the IBL shadow pipeline to avoid stale casters across reloads.
1600
1624
  * Clears the cached `#shadowGen` array so subsequent loads can rebuild fresh generators.
1601
1625
  * @private
1602
- * @returns {void}
1626
+ * @returns {Promise<void>}
1603
1627
  */
1604
1628
  async #disableShadows() {
1605
1629
  this.#shadowGen.forEach((shadowGenerator) => {
@@ -1612,10 +1636,10 @@ export default class BabylonJSController {
1612
1636
  /**
1613
1637
  * Initializes shadows for the Babylon.js scene.
1614
1638
  * @private
1615
- * @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.
1616
1640
  * @description
1617
- * If no environment texture is set, initializes IBL shadows.
1618
- * 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.
1619
1643
  */
1620
1644
  async #initializeShadows() {
1621
1645
  if (!this.#renderSettings.shadowsEnabled) {
@@ -1726,16 +1750,16 @@ export default class BabylonJSController {
1726
1750
  }
1727
1751
 
1728
1752
  const showroom = BabylonJSController.config.look?.showroom || {};
1729
- const timeOfDay = clamp01(this.#renderSettings.lightingTimeOfDay ? this.#renderSettings.lightingTimeOfDay ?? 0.5 : 0.5);
1730
- const profile = getLightingCycleProfile(timeOfDay);
1753
+ const timeOfDay = RenderMath.clamp01(this.#renderSettings.lightingTimeOfDay ? this.#renderSettings.lightingTimeOfDay ?? 0.5 : 0.5);
1754
+ const profile = RenderMath.getLightingCycleProfile(timeOfDay);
1731
1755
  const daylight = profile.dayFactor;
1732
1756
  const peakLight = daylight > 0 ? Math.pow(daylight, 0.72) : 0;
1733
1757
  const twilight = 1 - peakLight;
1734
1758
 
1735
1759
  if (this.#scene.imageProcessingConfiguration) {
1736
1760
  const imageProcessing = this.#scene.imageProcessingConfiguration;
1737
- imageProcessing.exposure = lerp(0.78, showroom.exposure ?? 0.92, peakLight);
1738
- 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);
1739
1763
  imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
1740
1764
  imageProcessing.toneMappingType = showroom.toneMappingType ?? (ImageProcessingConfiguration?.TONEMAPPING_ACES ?? 0);
1741
1765
  imageProcessing.vignetteEnabled = false;
@@ -1746,57 +1770,57 @@ export default class BabylonJSController {
1746
1770
  // HDR off-white. Both are linear already, so no toLinearSpace() on either.
1747
1771
  const bgDay = showroom.backgroundDay ?? BabylonJSController.config.look.showroom.backgroundDay;
1748
1772
  this.#scene.clearColor = new Color4(
1749
- lerp(0.000627, bgDay[0], daylight),
1750
- lerp(0.001089, bgDay[1], daylight),
1751
- 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),
1752
1776
  1,
1753
1777
  );
1754
1778
 
1755
1779
  if ("environmentIntensity" in this.#scene) {
1756
- this.#scene.environmentIntensity = lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
1780
+ this.#scene.environmentIntensity = RenderMath.lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
1757
1781
  }
1758
1782
 
1759
1783
  if (this.#hemiLight) {
1760
1784
  this.#hemiLight.direction = new Vector3(
1761
- lerp(-0.45, -0.8, peakLight),
1762
- lerp(0.45, 0.95, peakLight),
1763
- 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),
1764
1788
  );
1765
- this.#hemiLight.intensity = lerp(0.18, showroom.hemiLightIntensity ?? 0.52, peakLight);
1789
+ this.#hemiLight.intensity = RenderMath.lerp(0.18, showroom.hemiLightIntensity ?? 0.52, peakLight);
1766
1790
  this.#hemiLight.diffuse = new Color3(
1767
- lerp(0.42, 0.92, peakLight),
1768
- lerp(0.48, 0.96, peakLight),
1769
- 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),
1770
1794
  );
1771
1795
  this.#hemiLight.groundColor = new Color3(
1772
- lerp(0.08, 0.3, twilight),
1773
- lerp(0.08, 0.28, twilight),
1774
- 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),
1775
1799
  );
1776
1800
  }
1777
1801
 
1778
1802
  if (this.#dirLight) {
1779
1803
  this.#dirLight.direction = new Vector3(profile.sunDirection.x, profile.sunDirection.y, profile.sunDirection.z);
1780
- this.#dirLight.intensity = lerp(0.02, showroom.dirLightIntensity ?? 0.56, peakLight);
1804
+ this.#dirLight.intensity = RenderMath.lerp(0.02, showroom.dirLightIntensity ?? 0.56, peakLight);
1781
1805
  this.#dirLight.diffuse = new Color3(
1782
- lerp(0.28, 1.0, peakLight),
1783
- lerp(0.3, 0.97, peakLight),
1784
- 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),
1785
1809
  );
1786
1810
  }
1787
1811
 
1788
1812
  if (this.#moonLight) {
1789
1813
  this.#moonLight.direction = new Vector3(profile.moonDirection.x, profile.moonDirection.y, profile.moonDirection.z);
1790
- this.#moonLight.intensity = lerp(0.44, 0.03, peakLight);
1814
+ this.#moonLight.intensity = RenderMath.lerp(0.44, 0.03, peakLight);
1791
1815
  this.#moonLight.diffuse = new Color3(0.58, 0.7, 1.0);
1792
1816
  }
1793
1817
 
1794
1818
  if (this.#cameraLight) {
1795
- this.#cameraLight.intensity = lerp(0.06, showroom.cameraLightIntensity ?? 0.16, peakLight);
1819
+ this.#cameraLight.intensity = RenderMath.lerp(0.06, showroom.cameraLightIntensity ?? 0.16, peakLight);
1796
1820
  this.#cameraLight.diffuse = new Color3(
1797
- lerp(0.35, 1.0, peakLight),
1798
- lerp(0.35, 0.97, peakLight),
1799
- 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),
1800
1824
  );
1801
1825
  }
1802
1826
 
@@ -1818,7 +1842,7 @@ export default class BabylonJSController {
1818
1842
 
1819
1843
  const pixelRatio = typeof window !== "undefined" ? window.devicePixelRatio : 1;
1820
1844
  const baseScaling = BabylonJSController.config.quality.hardwareScaling || 1;
1821
- const scalingLevel = getAdaptiveHardwareScalingLevel(baseScaling, pixelRatio);
1845
+ const scalingLevel = RenderMath.getAdaptiveHardwareScalingLevel(baseScaling, pixelRatio);
1822
1846
  this.#engine.setHardwareScalingLevel(scalingLevel);
1823
1847
  }
1824
1848
 
@@ -1901,6 +1925,7 @@ export default class BabylonJSController {
1901
1925
  this.#state.pointerPicking.lastMovePickX = NaN;
1902
1926
  this.#state.pointerPicking.lastMovePickY = NaN;
1903
1927
  this.#state.pointerPicking.lastPickedMeshId = null;
1928
+ this.#state.pointerPicking.pointerDown = null;
1904
1929
  }
1905
1930
 
1906
1931
  /**
@@ -1948,6 +1973,7 @@ export default class BabylonJSController {
1948
1973
  #enableInteraction() {
1949
1974
  if (this.#canvas) {
1950
1975
  this.#canvas.addEventListener("keyup", this.#handlers.onKeyUp);
1976
+ this.#canvas.addEventListener("pointercancel", this.#handlers.onPointerCancel);
1951
1977
  }
1952
1978
  if (this.#scene) {
1953
1979
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
@@ -1966,6 +1992,7 @@ export default class BabylonJSController {
1966
1992
  #disableInteraction() {
1967
1993
  if (this.#canvas) {
1968
1994
  this.#canvas.removeEventListener("keyup", this.#handlers.onKeyUp);
1995
+ this.#canvas.removeEventListener("pointercancel", this.#handlers.onPointerCancel);
1969
1996
  }
1970
1997
  if (this.#scene !== null) {
1971
1998
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
@@ -2188,13 +2215,13 @@ export default class BabylonJSController {
2188
2215
  }
2189
2216
 
2190
2217
  /**
2191
- * Handles pointer up events on the Babylon.js scene.
2192
- * OPTIMIZATION: Tracks activity for idle mode.
2193
- * @private
2194
- * @param {PointerEvent} event - The pointer up event.
2195
- * @param {PickInfo} pickInfo - The result of the scene pick operation.
2196
- * @returns {void}
2197
- */
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
+ */
2198
2225
  #onPointerUp(event, pickInfo) {
2199
2226
  // OPTIMIZATION: Mark activity for idle mode
2200
2227
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
@@ -2241,6 +2268,49 @@ export default class BabylonJSController {
2241
2268
  }
2242
2269
  }
2243
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
+
2244
2314
  /**
2245
2315
  * Handles pointer events observed on the Babylon.js scene.
2246
2316
  * Uses pointer-move sampling to avoid running expensive `scene.pick()` on every event.
@@ -2250,6 +2320,11 @@ export default class BabylonJSController {
2250
2320
  */
2251
2321
  #onPointerObservable(info) {
2252
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
+ }
2253
2328
  let pickInfo = null;
2254
2329
  let lastPickedMeshId = null;
2255
2330
  if (this.#shouldPickOnPointerMove()) {
@@ -2258,7 +2333,17 @@ export default class BabylonJSController {
2258
2333
  }
2259
2334
  this.#onPointerMove(info.event, pickInfo);
2260
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;
2261
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
+ }
2262
2347
  const pickInfo = info.event?.button === 2 ? this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY) : null;
2263
2348
  if (pickInfo) {
2264
2349
  this.#state.pointerPicking.lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
@@ -2269,6 +2354,149 @@ export default class BabylonJSController {
2269
2354
  }
2270
2355
  }
2271
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
+
2272
2500
  /**
2273
2501
  * Handles canvas resize notifications.
2274
2502
  * Resizes the Babylon engine and requests a short on-demand render burst so camera-dependent
@@ -2282,23 +2510,10 @@ export default class BabylonJSController {
2282
2510
  }
2283
2511
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2284
2512
  const elapsed = now - this.#state.resize.lastAppliedAt;
2285
- const applyResize = () => {
2286
- this.#state.resize.timeoutId = null;
2287
- this.#state.resize.isScheduled = false;
2288
- if (!this.#engine) {
2289
- return;
2290
- }
2291
- this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2292
- console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
2293
- this.#applyShowroomCameraFraming();
2294
- this.#engine.resize();
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 });
2298
- };
2513
+
2299
2514
 
2300
2515
  if (elapsed >= BabylonJSController.config.resize.throttleMs) {
2301
- applyResize();
2516
+ this.#applyResize(elapsed);
2302
2517
  return;
2303
2518
  }
2304
2519
 
@@ -2309,7 +2524,7 @@ export default class BabylonJSController {
2309
2524
  this.#state.resize.isScheduled = true;
2310
2525
  const waitMs = Math.max(0, BabylonJSController.config.resize.throttleMs - elapsed);
2311
2526
  this.#state.resize.timeoutId = setTimeout(() => {
2312
- applyResize();
2527
+ this.#applyResize(elapsed);
2313
2528
  }, waitMs);
2314
2529
  }
2315
2530
 
@@ -2396,6 +2611,7 @@ export default class BabylonJSController {
2396
2611
  * Cancels pending cube navigation and applies configured camera selection and locking.
2397
2612
  * Configures unlocked orbital cameras for proportional native wheel input, rebinds the model zoom guard
2398
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.
2399
2615
  * @private
2400
2616
  * @returns {boolean} True if camera options were set successfully, false otherwise.
2401
2617
  */
@@ -2418,31 +2634,22 @@ export default class BabylonJSController {
2418
2634
  }
2419
2635
 
2420
2636
  if (!camera) {
2421
- // If a new camera (different from the default) was tried and not found, search for the current one
2422
- if (cameraState.isPending && cameraState.update.value !== null && cameraState.value !== null && cameraState.update.value !== cameraState.value) {
2423
- camera = modelContainer.assetContainer?.cameras.find((thisCamera) => thisCamera.name === cameraState.value) || environmentContainer.assetContainer?.cameras.find((thisCamera) => thisCamera.name === cameraState.value) || null;
2424
- }
2425
- if (camera) {
2426
- // If the current camera (different from the default) was found, use it
2427
- camera.metadata = { locked: cameraState.locked };
2428
- cameraState.setSuccess(false);
2429
- } else {
2430
- // If no camera was found, use the default camera
2431
- camera = this.#camera;
2432
- cameraState.value = null;
2433
- cameraState.locked = this.#camera.metadata.locked;
2434
- cameraState.setSuccess(false);
2435
- }
2637
+ camera = this.#camera;
2638
+ camera.metadata = { ...camera.metadata, locked: false };
2639
+ cameraState.value = null;
2640
+ cameraState.locked = false;
2641
+ cameraState.setSuccess(false);
2436
2642
  } else {
2437
2643
  // If the requested camera was found, use it
2438
2644
  if (cameraName !== null) {
2439
2645
  camera.metadata = { locked: cameraState.isPending ? cameraState.update.locked : cameraState.locked };
2440
2646
  } else {
2441
- // 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 };
2442
2649
  if (cameraState.isPending) {
2443
- cameraState.update.locked = this.#camera.metadata.locked;
2650
+ cameraState.update.locked = false;
2444
2651
  } else {
2445
- cameraState.locked = this.#camera.metadata.locked;
2652
+ cameraState.locked = false;
2446
2653
  }
2447
2654
  }
2448
2655
  if (cameraState.isPending) {
@@ -2469,7 +2676,7 @@ export default class BabylonJSController {
2469
2676
  * Applies pending image-based lighting (IBL) option updates.
2470
2677
  * Marks the IBL state as successful, recreates lights so the new environment takes effect, and reports whether anything changed.
2471
2678
  * @private
2472
- * @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.
2473
2680
  * @description
2474
2681
  * Delegates to `#createLights()`, which executes the full IBL URL-to-texture lifecycle:
2475
2682
  * `cachedUrl -> HDRCubeTexture -> #hdrTexture clone -> consumeCachedUrl(true)`.
@@ -2618,6 +2825,7 @@ export default class BabylonJSController {
2618
2825
  * Stops every animation group on the provided asset container to guarantee new loads start from a clean state.
2619
2826
  * @private
2620
2827
  * @param {AssetContainer} assetContainer - Container whose animation groups should be halted.
2828
+ * @returns {void}
2621
2829
  */
2622
2830
  #assetContainer_stopAnimations(assetContainer) {
2623
2831
  if (!assetContainer.animationGroups || assetContainer.animationGroups.length === 0) {
@@ -2679,7 +2887,7 @@ export default class BabylonJSController {
2679
2887
  mat.subSurface.isTranslucencyEnabled = false;
2680
2888
  mat.alpha = Math.max(0.28, 1.0 - intensity * 0.65);
2681
2889
  mat.transparencyMode = PBRMaterial.PBRMATERIAL_ALPHABLEND;
2682
- markPrefViewerSimplifiedTransmissionMaterial(mat);
2890
+ MaterialUtils.markPrefViewerSimplifiedTransmissionMaterial(mat);
2683
2891
  }
2684
2892
 
2685
2893
  /**
@@ -2717,7 +2925,7 @@ export default class BabylonJSController {
2717
2925
  /**
2718
2926
  * Stops the Babylon.js render loop for the current scene.
2719
2927
  * @private
2720
- * @returns {void}
2928
+ * @returns {Promise<void>}
2721
2929
  */
2722
2930
  async #stopRender() {
2723
2931
  this.#stopEngineRenderLoop();
@@ -2789,6 +2997,9 @@ export default class BabylonJSController {
2789
2997
  compileMaterials: true,
2790
2998
  loadAllMaterials: true,
2791
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",
2792
3003
  extensionOptions: {
2793
3004
  ExtrasAsMetadata: { enabled: true },
2794
3005
  },
@@ -2822,6 +3033,7 @@ export default class BabylonJSController {
2822
3033
  */
2823
3034
  async #loadContainers(force = false) {
2824
3035
  this.#viewCube?.cancel();
3036
+ this.#resetPointerPickingState();
2825
3037
  this.#detachAnimationChangedListener();
2826
3038
  await this.#stopRender();
2827
3039
 
@@ -2891,6 +3103,7 @@ export default class BabylonJSController {
2891
3103
  this.#setMaxSimultaneousLights();
2892
3104
  this.#enhanceTextureQuality();
2893
3105
  this.#syncAnimationControllerAfterModelLoad();
3106
+ this.#selectionController?.refresh();
2894
3107
 
2895
3108
  await this.#startRender();
2896
3109
  });
@@ -2922,7 +3135,7 @@ export default class BabylonJSController {
2922
3135
  * Reinstalls every camera-sensitive post-process (default pipeline, SSAO, shadows) after loads or option changes.
2923
3136
  * Ensures the active camera always owns fresh pipelines so render quality remains consistent across reload cycles.
2924
3137
  * @private
2925
- * @returns {void}
3138
+ * @returns {Promise<void>}
2926
3139
  */
2927
3140
  async #loadCameraDependentEffects() {
2928
3141
  await this.#initializeVisualImprovements();
@@ -2934,7 +3147,7 @@ export default class BabylonJSController {
2934
3147
  * Shuts down every post-process tied to the active camera before stopping the render loop.
2935
3148
  * Ensures AA, SSAO, and shadow resources detach cleanly so reloads rebuild from scratch.
2936
3149
  * @private
2937
- * @returns {void}
3150
+ * @returns {Promise<void>}
2938
3151
  */
2939
3152
  async #unloadCameraDependentEffects() {
2940
3153
  await this.#disableVisualImprovements();
@@ -2975,19 +3188,13 @@ export default class BabylonJSController {
2975
3188
  return;
2976
3189
  }
2977
3190
 
2978
- const getNodeName = (data) => {
2979
- if (data && typeof data === "string" && data.includes("/")) {
2980
- data = data.slice(data.lastIndexOf("/") + 1);
2981
- return data;
2982
- }
2983
- return false;
2984
- };
3191
+
2985
3192
 
2986
3193
  let applyNewTranslation = false;
2987
3194
  let applyOldTranslation = false;
2988
3195
  let undoOldTranslation = false;
2989
- const newTranslationNodeName = getNodeName(newMetadata?.customProperties?.innerFloorPath);
2990
- const oldTranslationNodeName = getNodeName(oldMetadata?.customProperties?.innerFloorPath);
3196
+ const newTranslationNodeName = BabylonJSController.#getNodeName(newMetadata?.customProperties?.innerFloorPath);
3197
+ const oldTranslationNodeName = BabylonJSController.#getNodeName(oldMetadata?.customProperties?.innerFloorPath);
2991
3198
 
2992
3199
  if (this.#containers.environment.state.isSuccess) {
2993
3200
  // If environment is being reloaded:
@@ -3100,7 +3307,7 @@ export default class BabylonJSController {
3100
3307
  }
3101
3308
 
3102
3309
  const locale = this.#prefViewer.culture;
3103
- const texts = translate("prefViewer.downloadDialog", locale ? { locale } : undefined) || {};
3310
+ const texts = I18n.translate("prefViewer.downloadDialog", locale ? { locale } : undefined) || {};
3104
3311
  const header = texts.title || "Download 3D Scene";
3105
3312
  const content = `
3106
3313
  <form slot="content" id="download-dialog-form" style="display:flex;flex-direction:column;gap:16px;">
@@ -3262,6 +3469,7 @@ export default class BabylonJSController {
3262
3469
 
3263
3470
  this.#disablingPromises.general = (async () => {
3264
3471
  this.#disableInteraction();
3472
+ this.#selectionController?.dispose();
3265
3473
  this.#disposeAnimationController();
3266
3474
  this.#disposeGLTFResolver();
3267
3475
  try {
@@ -3312,6 +3520,16 @@ export default class BabylonJSController {
3312
3520
  this.#viewCube?.sync();
3313
3521
  }
3314
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
+
3315
3533
  /**
3316
3534
  * Downloads the current scene, model, or environment as a GLB file.
3317
3535
  * @public
@@ -3431,7 +3649,7 @@ export default class BabylonJSController {
3431
3649
  * @returns {object} Debug information for canvas, engine, scene, containers, render loop and settings.
3432
3650
  */
3433
3651
  getDebugInfo() {
3434
- const vectorToJSON = (value) => value ? { x: value.x, y: value.y, z: value.z } : null;
3652
+
3435
3653
  const rect = this.#canvas?.getBoundingClientRect?.();
3436
3654
  const camera = this.#scene?.activeCamera ?? null;
3437
3655
  const sceneMeshes = this.#scene?.meshes ?? [];
@@ -3441,62 +3659,16 @@ export default class BabylonJSController {
3441
3659
  const modelAssetContainer = this.#containers?.model?.assetContainer ?? null;
3442
3660
  const modelMeshes = modelAssetContainer?.meshes ?? [];
3443
3661
  const enabledModelMeshes = modelMeshes.filter((mesh) => mesh.isEnabled?.() !== false && mesh.isVisible !== false);
3444
- const textureToJSON = (texture) => ({
3445
- name: texture?.name ?? null,
3446
- url: texture?.url ?? null,
3447
- isReady: texture?.isReady?.() ?? null,
3448
- hasTexture: Boolean(texture),
3449
- className: texture?.getClassName?.() ?? texture?.constructor?.name ?? null,
3450
- });
3451
- const summarizeMaterial = (material) => {
3452
- const relatedMeshes = sceneMeshes.filter((mesh) => mesh.material === material);
3453
- const sampleMesh = relatedMeshes[0] ?? null;
3454
- const activeTextures = material?.getActiveTextures?.() ?? [];
3455
- let isReadyWithoutMesh = null;
3456
- let isReadyWithMesh = null;
3457
- try {
3458
- isReadyWithoutMesh = typeof material?.isReady === "function" ? material.isReady() : null;
3459
- } catch (error) {
3460
- isReadyWithoutMesh = `error: ${error?.message ?? error}`;
3461
- }
3462
- try {
3463
- isReadyWithMesh = typeof material?.isReady === "function" && sampleMesh ? material.isReady(sampleMesh) : null;
3464
- } catch (error) {
3465
- isReadyWithMesh = `error: ${error?.message ?? error}`;
3466
- }
3467
3662
 
3468
- return {
3469
- name: material?.name ?? null,
3470
- id: material?.id ?? null,
3471
- className: material?.getClassName?.() ?? material?.constructor?.name ?? null,
3472
- isReadyWithoutMesh,
3473
- isReadyWithMesh,
3474
- relatedMeshes: relatedMeshes.length,
3475
- sampleMesh: sampleMesh ? {
3476
- name: sampleMesh.name,
3477
- id: sampleMesh.id,
3478
- enabled: sampleMesh.isEnabled?.() ?? null,
3479
- isVisible: sampleMesh.isVisible ?? null,
3480
- hasVertices: sampleMesh.getTotalVertices?.() ?? null,
3481
- hasIndices: sampleMesh.getTotalIndices?.() ?? null,
3482
- } : null,
3483
- textures: activeTextures.map(textureToJSON),
3484
- notReadyTextures: activeTextures.filter((texture) => texture?.isReady?.() === false).map(textureToJSON),
3485
- forceDepthWrite: material?.forceDepthWrite ?? null,
3486
- transparencyMode: material?.transparencyMode ?? null,
3487
- alpha: material?.alpha ?? null,
3488
- backFaceCulling: material?.backFaceCulling ?? null,
3489
- disableLighting: material?.disableLighting ?? null,
3490
- };
3491
- };
3492
- const materialDiagnostics = sceneMaterials.slice(0, 25).map(summarizeMaterial);
3663
+
3664
+ const materialDiagnostics = sceneMaterials.slice(0, 25).map((material) => BabylonJSController.#summarizeMaterial(material, sceneMeshes));
3493
3665
  const notReadyMaterials = materialDiagnostics
3494
3666
  .filter((material) => material.isReadyWithoutMesh === false || material.isReadyWithMesh === false || material.notReadyTextures.length > 0)
3495
3667
  .slice(0, 25);
3496
3668
  const notReadyTextures = sceneTextures
3497
3669
  .filter((texture) => texture?.isReady?.() === false)
3498
3670
  .slice(0, 25)
3499
- .map(textureToJSON);
3671
+ .map(BabylonJSController.#textureToJSON);
3500
3672
  const notReadyMeshes = sceneMeshes
3501
3673
  .filter((mesh) => typeof mesh.isReady === "function" && !mesh.isReady())
3502
3674
  .slice(0, 25)
@@ -3510,25 +3682,7 @@ export default class BabylonJSController {
3510
3682
  const modelBounds = enabledModelMeshes.length && this.#scene?.getWorldExtends
3511
3683
  ? this.#scene.getWorldExtends((mesh) => enabledModelMeshes.includes(mesh))
3512
3684
  : null;
3513
- const summarizeContainer = (container) => ({
3514
- show: container?.state?.show ?? null,
3515
- visible: container?.state?.visible ?? null,
3516
- mustBeShown: container?.state?.mustBeShown ?? null,
3517
- isPending: container?.state?.isPending ?? null,
3518
- isSuccess: container?.state?.isSuccess ?? null,
3519
- hasAssetContainer: Boolean(container?.assetContainer),
3520
- meshes: container?.assetContainer?.meshes?.length ?? 0,
3521
- enabledMeshes: (container?.assetContainer?.meshes ?? []).filter((mesh) => mesh.isEnabled?.() !== false && mesh.isVisible !== false).length,
3522
- materials: container?.assetContainer?.materials?.length ?? 0,
3523
- cameras: container?.assetContainer?.cameras?.map((item) => ({
3524
- name: item.name,
3525
- id: item.id,
3526
- className: item.getClassName?.() ?? item.constructor?.name ?? null,
3527
- position: vectorToJSON(item.position),
3528
- target: vectorToJSON(item.target),
3529
- })) ?? [],
3530
- animationGroups: container?.assetContainer?.animationGroups?.length ?? 0,
3531
- });
3685
+
3532
3686
 
3533
3687
  return {
3534
3688
  canvas: {
@@ -3560,8 +3714,8 @@ export default class BabylonJSController {
3560
3714
  name: camera.name,
3561
3715
  id: camera.id,
3562
3716
  className: camera.getClassName?.() ?? camera.constructor?.name ?? null,
3563
- position: vectorToJSON(camera.position),
3564
- target: vectorToJSON(camera.target),
3717
+ position: BabylonJSController.#vectorToJSON(camera.position),
3718
+ target: BabylonJSController.#vectorToJSON(camera.target),
3565
3719
  radius: camera.radius ?? null,
3566
3720
  alpha: camera.alpha ?? null,
3567
3721
  beta: camera.beta ?? null,
@@ -3582,13 +3736,13 @@ export default class BabylonJSController {
3582
3736
  notReadyMeshes,
3583
3737
  },
3584
3738
  containers: {
3585
- model: summarizeContainer(this.#containers.model),
3586
- environment: summarizeContainer(this.#containers.environment),
3587
- 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),
3588
3742
  },
3589
3743
  modelBounds: modelBounds ? {
3590
- min: vectorToJSON(modelBounds.min),
3591
- max: vectorToJSON(modelBounds.max),
3744
+ min: BabylonJSController.#vectorToJSON(modelBounds.min),
3745
+ max: BabylonJSController.#vectorToJSON(modelBounds.max),
3592
3746
  } : null,
3593
3747
  render: { ...this.#state.render },
3594
3748
  settings: { ...this.#renderSettings },
@@ -3604,7 +3758,8 @@ export default class BabylonJSController {
3604
3758
  * Loads all asset containers (model, environment, materials, etc.) and adds them to the scene.
3605
3759
  * Applies material and camera options, sets visibility, and initializes lights and shadows.
3606
3760
  * @public
3607
- * @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.
3608
3763
  */
3609
3764
  async load(force = false) {
3610
3765
  return await this.#loadContainers(force);
@@ -3640,7 +3795,7 @@ export default class BabylonJSController {
3640
3795
  * Reapplies image-based lighting configuration (HDR URL, intensity, shadow mode).
3641
3796
  * Stops rendering, pushes pending IBL state into the scene, rebuilds camera-dependent effects, then resumes rendering.
3642
3797
  * @public
3643
- * @returns {boolean} True if IBL options were set successfully, false otherwise.
3798
+ * @returns {Promise<boolean>} True if IBL options were set successfully, false otherwise.
3644
3799
  */
3645
3800
  async setIBLOptions() {
3646
3801
  await this.#stopRender();
@@ -3689,7 +3844,7 @@ export default class BabylonJSController {
3689
3844
  }
3690
3845
 
3691
3846
  if (typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay)) {
3692
- const normalizedTime = clamp01(config.lightingTimeOfDay);
3847
+ const normalizedTime = RenderMath.clamp01(config.lightingTimeOfDay);
3693
3848
  if (this.#renderSettings.lightingTimeOfDay !== normalizedTime) {
3694
3849
  this.#renderSettings.lightingTimeOfDay = normalizedTime;
3695
3850
  shouldApplyLighting = true;