@preference-sl/pref-viewer 2.18.0 → 3.0.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.
Files changed (35) hide show
  1. package/README.md +267 -31
  2. package/index.d.ts +75 -6
  3. package/package.json +8 -2
  4. package/src/babylonjs-animation-controller.js +18 -10
  5. package/src/babylonjs-animation-opening-menu.js +80 -14
  6. package/src/babylonjs-animation-opening.js +3 -3
  7. package/src/babylonjs-ar-floor-bounds.js +169 -0
  8. package/src/babylonjs-ar-glass.js +128 -0
  9. package/src/babylonjs-ar-placement-feedback.js +449 -0
  10. package/src/babylonjs-ar-placement.js +1038 -0
  11. package/src/babylonjs-camera-wheel-motion.js +35 -2
  12. package/src/babylonjs-controller.js +507 -125
  13. package/src/babylonjs-orbital-pointers-input.js +186 -0
  14. package/src/{render-math.js → babylonjs-render-math.js} +11 -11
  15. package/src/babylonjs-selection-controller.js +4 -4
  16. package/src/babylonjs-view-cube-canvas-controller.js +1287 -0
  17. package/src/babylonjs-view-cube-canvas-orbit-math.js +220 -0
  18. package/src/babylonjs-view-cube-canvas-renderer.js +775 -0
  19. package/src/babylonjs-view-cube-controller.js +74 -1103
  20. package/src/babylonjs-view-cube-svg-controller.js +1012 -0
  21. package/src/file-storage.js +7 -4
  22. package/src/gltf-resolver.js +13 -8
  23. package/src/localization/translations.js +12 -0
  24. package/src/panzoom-controller.js +4 -4
  25. package/src/pref-viewer-2d.js +2 -2
  26. package/src/pref-viewer-3d.js +55 -9
  27. package/src/pref-viewer-controls-presentation.js +292 -0
  28. package/src/pref-viewer-dialog.js +86 -7
  29. package/src/pref-viewer-menu-3d.js +134 -17
  30. package/src/pref-viewer-style-controller.js +320 -0
  31. package/src/pref-viewer.js +133 -109
  32. package/src/quick-look-action.js +280 -0
  33. package/src/styles.js +702 -98
  34. package/src/babylonjs-view-cube-renderer.js +0 -548
  35. package/src/view-cube-orbit-math.js +0 -199
@@ -1,4 +1,5 @@
1
- import { ArcRotateCamera, AssetContainer, BRDFTextureTools, Camera, Color3, Color4, CubeTexture, 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";
1
+ import { ArcRotateCamera, AssetContainer, BRDFTextureTools, Camera, Color3, Color4, CubeTexture, DefaultRenderingPipeline, DirectionalLight, Engine, FreeCamera, HDRCubeTexture, HemisphericLight, IblShadowsRenderPipeline, ImageProcessingConfiguration, LoadAssetContainerAsync, Material, Matrix, PBRMaterial, PointerEventTypes, PointLight, RenderTargetTexture, Scene, ShadowGenerator, SpotLight, SSAORenderingPipeline, Texture, Tools, UniversalCamera, Vector3, WebXRDefaultExperience, WebXRSessionManager, WebXRState, WhenTextureReadyAsync } from "@babylonjs/core";
2
+ import BabylonJSARPlacement from "./babylonjs-ar-placement.js";
2
3
  import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression.js";
3
4
  import "@babylonjs/loaders";
4
5
  import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_draco_mesh_compression.js";
@@ -11,13 +12,15 @@ import BabylonJSAnimationController from "./babylonjs-animation-controller.js";
11
12
  import BabylonJSViewCubeController from "./babylonjs-view-cube-controller.js";
12
13
  import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
13
14
  import BabylonJSCameraZoomController from "./babylonjs-camera-zoom-controller.js";
15
+ import BabylonJSOrbitalPointersInput from "./babylonjs-orbital-pointers-input.js";
14
16
  import BabylonJSCameraWheelMotion from "./babylonjs-camera-wheel-motion.js";
15
17
  import BabylonJSSelectionController from "./babylonjs-selection-controller.js";
16
18
  import OpeningAnimation from "./babylonjs-animation-opening.js";
17
19
  import I18n from "./localization/i18n.js";
18
20
  import { PrefViewerStyleVariables } from "./styles.js";
19
- import RenderMath from "./render-math.js";
21
+ import BabylonJSRenderMath from "./babylonjs-render-math.js";
20
22
  import MaterialUtils from "./material-utils.js";
23
+ import QuickLookAction from "./quick-look-action.js";
21
24
 
22
25
  /**
23
26
  * BabylonJSController coordinates the PrefViewer 3D runtime: it bootstraps Babylon.js, manages asset containers,
@@ -35,8 +38,12 @@ import MaterialUtils from "./material-utils.js";
35
38
  * - Manages keyboard/pointer/wheel handlers, animation menus, and WebXR so PrefViewer menus and DOM attributes stay
36
39
  * synchronized with Babylon state, using sampled pointer picking and last-picked tracking to reduce raycast/highlight
37
40
  * cost on dense scenes.
41
+ * - Configures distance-proportional orbital wheel/pinch zoom, with focal-enabled touch pinch following the two-finger midpoint.
38
42
  * - Lazily owns the ViewCube overlay and binds a model-only orbital zoom guard independently of cube visibility.
39
- * - Generates GLB, glTF+ZIP, or USDZ exports with timestamped names and localized dialog copy.
43
+ * - Generates GLB, glTF+ZIP, or USDZ exports with timestamped names and localized, density-aware download dialogs.
44
+ * - Shares cube-and-scan-frame AR artwork between native XR and Quick Look controls.
45
+ * - Owns metric WebXR floor placement or an eligible Safari Quick Look action. Prepared native content tracks
46
+ * completed model/material state; obsolete asynchronous work cannot publish into a replacement lifecycle.
40
47
  * - Translates metadata (inner floor offsets, cast/receive shadows, camera locks) into scene adjustments after reloads.
41
48
  *
42
49
  * Runtime Flow
@@ -55,7 +62,7 @@ import MaterialUtils from "./material-utils.js";
55
62
  *
56
63
  * Public API Highlights
57
64
  * - constructor(canvas, containers, options, viewCubeConfig?)
58
- * - setViewCubeEnabled(enabled) / refreshViewCube()
65
+ * - setViewCubeEnabled(enabled) / setViewCubeRenderer(renderer) / refreshViewCube()
59
66
  * - enable() / disable()
60
67
  * - load(force?) / reloadWithCurrentSettings()
61
68
  * - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
@@ -82,13 +89,20 @@ import MaterialUtils from "./material-utils.js";
82
89
  * in SSR/Node contexts (though functionality activates only in browsers).
83
90
  * - Pointer-pick lifecycle: hover/highlight raycasts are sampled on POINTERMOVE (time + distance thresholds), wheel
84
91
  * input avoids interaction picks; post-input orbital zoom protection separately probes only model meshes.
85
- * Right-click POINTERUP performs an on-demand interaction pick for context-menu targeting.
92
+ * Right-click POINTERUP and a stationary touch hold perform on-demand animation-menu picking.
93
+ * Touch holds consume release selection and are cancelled by navigation, interruption or teardown.
94
+ * Touch feedback lasts only while a hold is pending; touch-generated hover cannot retain animation highlights.
86
95
  * - Runtime bookkeeping is split into mutable `#state` and shared tuning settings under `BabylonJSController.config` to keep behavior changes
87
96
  * explicit and reduce cross-field drift.
88
97
  * - Resize lifecycle: canvas resize notifications are throttled (with trailing execution) before calling `engine.resize()`
89
98
  * and any queued resize callback is canceled during teardown.
90
99
  * - Teardown lifecycle: concurrent `disable()` calls are coalesced into a single in-flight promise to avoid races
91
100
  * during XR exit and engine disposal.
101
+ * - AR routing is selected internally from confirmed WebXR capability and eligible Safari browser signals.
102
+ * - XR lifecycle: entry wakes an idle engine and keeps its frame pump running through session transitions;
103
+ * otherwise Babylon cannot request native frames or deliver floor hit tests to placement.
104
+ * Transient AR gesture feedback is excluded from all scene/model download formats.
105
+ * Optional native plane detection exposes contours for initial footprint fitting when the runtime supports it.
92
106
  */
93
107
  export default class BabylonJSController {
94
108
  // Canvas HTML element
@@ -108,6 +122,11 @@ export default class BabylonJSController {
108
122
  #cameraLight = null;
109
123
  #shadowGen = [];
110
124
  #XRExperience = null;
125
+ #arPlacement = null;
126
+ #xrGeneration = 0;
127
+ #quickLookAction = null;
128
+ #quickLookLoads = 0;
129
+ #quickLookValid = { model: false, materials: true };
111
130
  #canvasResizeObserver = null;
112
131
 
113
132
  #hdrTexture = null; // Reusable in-memory HDR source cloned into scene.environmentTexture across reloads
@@ -152,6 +171,9 @@ export default class BabylonJSController {
152
171
  lastMovePickY: NaN,
153
172
  lastPickedMeshId: null,
154
173
  pointerDown: null,
174
+ touchPointers: new Set(),
175
+ longPressTimeout: null,
176
+ touchHighlightActive: false,
155
177
  },
156
178
  // Render loop state balances performance with responsiveness.
157
179
  render: {
@@ -166,6 +188,7 @@ export default class BabylonJSController {
166
188
  resize: {
167
189
  isScheduled: false,
168
190
  timeoutId: null,
191
+ frameId: null,
169
192
  lastAppliedAt: 0,
170
193
  },
171
194
  };
@@ -175,6 +198,7 @@ export default class BabylonJSController {
175
198
  * All instances use this object, including the quality preset updated by hardware detection.
176
199
  * render.defaultSettings supplies defaults for each instance's persisted render settings snapshot.
177
200
  * Per-instance render and interaction bookkeeping remains in #state.
201
+ * camera wheel/pinch percentages tune separate native inputs; both scale zoom by the current orbital radius.
178
202
  * @public
179
203
  * @static
180
204
  * @type {object}
@@ -182,6 +206,7 @@ export default class BabylonJSController {
182
206
  static config = {
183
207
  camera: {
184
208
  wheelDeltaPercentage: 0.015,
209
+ pinchDeltaPercentage: 0.001, // Native pinch uses squared finger separation, unlike wheel deltas
185
210
  fitMarginFactor: 0.15, // Total free viewport fraction: 0.15 leaves 85% for the model
186
211
  maxRadiusFactor: 4, // Pull back past the whole model
187
212
  minRadiusFactor: 0.25, // ViewCube minimum radius relative to transition distance
@@ -229,6 +254,7 @@ export default class BabylonJSController {
229
254
  movePickIntervalMs: 50, // Cap expensive scene.pick calls to ~20 Hz while moving the pointer
230
255
  movePickMinDistancePx: 2, // Skip picks for sub-pixel jitter
231
256
  clickMaxDistancePx: 6,
257
+ longPressMs: 500,
232
258
  },
233
259
  selection: {
234
260
  selectableColor: [1, 1, 0],
@@ -531,13 +557,14 @@ export default class BabylonJSController {
531
557
  * Constructs a new BabylonJSController instance.
532
558
  * Initializes the canvas, asset containers, and options for the Babylon.js scene.
533
559
  * @public
534
- * @param {HTMLCanvasElement|null} canvas - The canvas element to render the scene on.
535
- * @param {object} containers - An object containing container states for model, environment, materials, etc.
536
- * @param {object} options - Configuration options for the Babylon.js scene and assets.
560
+ * @param {HTMLCanvasElement|null} [canvas=null] - The canvas element to render the scene on.
561
+ * @param {object} [containers={}] - An object containing container states for model, environment, materials, etc.
562
+ * @param {object} [options={}] - Configuration options for the Babylon.js scene and assets.
537
563
  * @param {object} [viewCubeConfig={}] - Internal overlay integration options.
564
+ * @param {"svg"|"canvas"} [viewCubeConfig.renderer="canvas"] - ViewCube implementation; invalid values select Canvas.
538
565
  * @param {boolean} [viewCubeConfig.enabled=true] - Desired activation; creation waits for a live scene and camera.
539
566
  * @param {function(): string} [viewCubeConfig.getCulture] - Current UI culture for cube labels.
540
- * @param {function(number, number): void} [viewCubeConfig.onLayout] - Measured cube SVG height and scene viewport height in CSS pixels; (0, 0) on disposal.
567
+ * @param {function(number, number): void} [viewCubeConfig.onLayout] - Measured active ViewCube height and scene viewport height in CSS pixels; (0, 0) on disposal.
541
568
  * @returns {BabylonJSController}
542
569
  * @description
543
570
  * - Assigns the provided canvas to the controller.
@@ -904,6 +931,7 @@ export default class BabylonJSController {
904
931
  * Evaluates whether the renderer should stay in continuous mode.
905
932
  * Pending ViewCube transitions and active XR force continuous rendering; animation/camera motion extends the
906
933
  * continuous deadline window to avoid abrupt stop-start behavior.
934
+ * Entering/exiting XR also stays continuous: the native frame pump must survive asynchronous setup and teardown.
907
935
  * OPTIMIZATION: Added true idle mode - stops rendering completely after idleThresholdMs of inactivity.
908
936
  * @private
909
937
  * @param {number} now - Current high-resolution timestamp.
@@ -913,8 +941,8 @@ export default class BabylonJSController {
913
941
  if (this.#viewCube?.isAnimating) {
914
942
  return true;
915
943
  }
916
- const inXR = this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR;
917
- if (inXR) {
944
+ const xrState = this.#XRExperience?.baseExperience?.state;
945
+ if (xrState === WebXRState.ENTERING_XR || xrState === WebXRState.IN_XR || xrState === WebXRState.EXITING_XR) {
918
946
  this.#state.render.isIdle = false;
919
947
  this.#state.render.lastActivityAt = now;
920
948
  return true;
@@ -998,12 +1026,23 @@ export default class BabylonJSController {
998
1026
  }
999
1027
 
1000
1028
  /**
1001
- * Adds custom CSS styles to the Babylon.js AR button for consistent appearance and interaction.
1029
+ * Adds the supplied cube-and-scan-frame SVG and shared interaction styles to native XR and Quick Look buttons.
1030
+ * Preserves the supplied 24-unit geometry and renders its filled paths white against the toggle background.
1002
1031
  * @private
1003
1032
  * @returns {void}
1004
1033
  */
1005
1034
  #addStylesToARButton() {
1006
- const css = '.babylonVRicon { color: #868686; border-color: #868686; border-style: solid; margin-left: 10px; height: 50px; width: 80px; background-color: rgba(51,51,51,0.7); background-image: url(data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%222048%22%20height%3D%221152%22%20viewBox%3D%220%200%202048%201152%22%20version%3D%221.1%22%3E%3Cpath%20transform%3D%22rotate%28180%201024%2C576.0000000000001%29%22%20d%3D%22m1109%2C896q17%2C0%2030%2C-12t13%2C-30t-12.5%2C-30.5t-30.5%2C-12.5l-170%2C0q-18%2C0%20-30.5%2C12.5t-12.5%2C30.5t13%2C30t30%2C12l170%2C0zm-85%2C256q59%2C0%20132.5%2C-1.5t154.5%2C-5.5t164.5%2C-11.5t163%2C-20t150%2C-30t124.5%2C-41.5q23%2C-11%2042%2C-24t38%2C-30q27%2C-25%2041%2C-61.5t14%2C-72.5l0%2C-257q0%2C-123%20-47%2C-232t-128%2C-190t-190%2C-128t-232%2C-47l-81%2C0q-37%2C0%20-68.5%2C14t-60.5%2C34.5t-55.5%2C45t-53%2C45t-53%2C34.5t-55.5%2C14t-55.5%2C-14t-53%2C-34.5t-53%2C-45t-55.5%2C-45t-60.5%2C-34.5t-68.5%2C-14l-81%2C0q-123%2C0%20-232%2C47t-190%2C128t-128%2C190t-47%2C232l0%2C257q0%2C68%2038%2C115t97%2C73q54%2C24%20124.5%2C41.5t150%2C30t163%2C20t164.5%2C11.5t154.5%2C5.5t132.5%2C1.5zm939%2C-298q0%2C39%20-24.5%2C67t-58.5%2C42q-54%2C23%20-122%2C39.5t-143.5%2C28t-155.5%2C19t-157%2C11t-148.5%2C5t-129.5%2C1.5q-59%2C0%20-130%2C-1.5t-148%2C-5t-157%2C-11t-155.5%2C-19t-143.5%2C-28t-122%2C-39.5q-34%2C-14%20-58.5%2C-42t-24.5%2C-67l0%2C-257q0%2C-106%2040.5%2C-199t110%2C-162.5t162.5%2C-109.5t199%2C-40l81%2C0q27%2C0%2052%2C14t50%2C34.5t51%2C44.5t55.5%2C44.5t63.5%2C34.5t74%2C14t74%2C-14t63.5%2C-34.5t55.5%2C-44.5t51%2C-44.5t50%2C-34.5t52%2C-14l14%2C0q37%2C0%2070%2C0.5t64.5%2C4.5t63.5%2C12t68%2C23q71%2C30%20128.5%2C78.5t98.5%2C110t63.5%2C133.5t22.5%2C149l0%2C257z%22%20fill%3D%22white%22%20/%3E%3C/svg%3E%0A); background-size: 80%; background-repeat:no-repeat; background-position: center; border: none; outline: none; transition: transform 0.125s ease-out } .babylonVRicon:hover { transform: scale(1.05) } .babylonVRicon:active {background-color: rgba(51,51,51,1) } .babylonVRicon:focus {background-color: rgba(51,51,51,1) }.babylonVRicon.vrdisplaypresenting { background-image: none;} .vrdisplaypresenting::after { content: "EXIT"} .xr-error::after { content: "ERROR"}';
1035
+ const icon = `<svg fill="white" version="1.0" id="ar" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
1036
+ width="24px" height="24px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
1037
+ <path fill="none" d="M0,0h24v24H0V0z"/>
1038
+ <path d="M17,22v-2h3v-3h2v3.5c0,0.39-0.16,0.74-0.46,1.04c-0.3,0.3-0.65,0.46-1.04,0.46H17 M7,22H3.5c-0.39,0-0.74-0.16-1.04-0.46
1039
+ C2.16,21.24,2,20.89,2,20.5V17h2v3h3V22 M17,2h3.5c0.39,0,0.74,0.16,1.04,0.46C21.84,2.76,22,3.11,22,3.5V7h-2V4h-3V2 M7,2v2H4v3H2
1040
+ V3.5c0-0.39,0.16-0.74,0.46-1.04S3.11,2,3.5,2H7 M13,17.25l4-2.3v-4.59l-4,2.3V17.25 M12,10.92l4-2.29l-4-2.35L8,8.63L12,10.92
1041
+ M7,14.95l4,2.3v-4.59l-4-2.3V14.95 M18.23,7.59C18.73,7.91,19,8.34,19,8.91v6.32c0,0.57-0.27,1-0.77,1.32l-5.48,3.18
1042
+ c-0.5,0.32-1,0.32-1.5,0l-5.48-3.18C5.27,16.23,5,15.8,5,15.23V8.91c0-0.57,0.27-1,0.77-1.32l5.48-3.18
1043
+ c0.25-0.13,0.5-0.19,0.75-0.19s0.5,0.06,0.75,0.19L18.23,7.59z"/>
1044
+ </svg>`;
1045
+ const css = `.babylonVRicon { color: #868686; border-color: #868686; border-style: solid; margin-left: 10px; height: 50px; width: 80px; background-color: rgba(51,51,51,0.7); background-image: url(data:image/svg+xml;charset=UTF-8,${encodeURIComponent(icon)}); background-size: 80%; background-repeat:no-repeat; background-position: center; border: none; outline: none; transition: transform 0.125s ease-out } .babylonVRicon:hover { transform: scale(1.05) } .babylonVRicon:active {background-color: rgba(51,51,51,1) } .babylonVRicon:focus {background-color: rgba(51,51,51,1) }.babylonVRicon.vrdisplaypresenting { background-image: none;} .vrdisplaypresenting::after { content: "EXIT"} .xr-error::after { content: "ERROR"}`;
1007
1046
  let style = this.#canvas.parentElement.parentElement.querySelector("style");
1008
1047
  if (!style) {
1009
1048
  style = document.createElement("style");
@@ -1013,66 +1052,172 @@ export default class BabylonJSController {
1013
1052
  }
1014
1053
 
1015
1054
  /**
1016
- * Creates and initializes the Babylon.js WebXR experience for augmented reality (AR).
1055
+ * Selects an AR candidate from a completed WebXR support query and browser signals.
1056
+ * A failed or pending query is unknown, not permission to use the native fallback.
1057
+ * Native content preparation/opening is a separate step from this decision.
1058
+ * @param {boolean|null} webXRSupported Resolved immersive-ar support, or null if unknown.
1059
+ * @param {object} [browser=globalThis.navigator] Navigator-like browser signals; no GPU-tier inference.
1017
1060
  * @private
1018
- * @returns {Promise<boolean|void>} Resolves to true if XR experience is created, false if not supported, or void on error.
1061
+ * @returns {"webxr"|"quick-look"|null} Candidate route, not confirmation of native opening.
1062
+ */
1063
+ static #selectARRoute(webXRSupported, browser = globalThis.navigator) {
1064
+ if (webXRSupported === true) {
1065
+ return "webxr";
1066
+ }
1067
+ if (webXRSupported !== false || !browser) {
1068
+ return null;
1069
+ }
1070
+
1071
+ const userAgent = browser.userAgent ?? "";
1072
+ const isIOS = /iPhone|iPad|iPod/.test(userAgent);
1073
+ // iPadOS can request a desktop user agent. MacIntel alone also matches Macs.
1074
+ const isTouchIPad = browser.platform === "MacIntel" && browser.maxTouchPoints > 1;
1075
+ const isSafari = /AppleWebKit\//.test(userAgent) && /Version\//.test(userAgent) && /Safari\//.test(userAgent)
1076
+ && !/CriOS|FxiOS|EdgiOS|OPiOS|Chrome|Chromium|Android/.test(userAgent);
1077
+ return (isIOS || isTouchIPad) && isSafari ? "quick-look" : null;
1078
+ }
1079
+
1080
+ /**
1081
+ * Selects and initializes supported WebXR AR or an eligible Safari Quick Look action.
1082
+ * @private
1083
+ * @returns {Promise<boolean>} True when an AR route is configured, false when unsupported, obsolete or setup fails.
1019
1084
  * @description
1020
- * Checks for AR session support, creates a hidden ground mesh, and configures session and UI options.
1021
- * Enables teleportation feature and sets the initial XR camera pose when the session starts.
1085
+ * Checks AR support and requires native floor hit testing at real-world metre scale.
1086
+ * Requests optional plane detection so initial placement can use an observed floor contour when supplied.
1087
+ * Placement follows the tracked XR camera rather than inheriting the showroom camera's fit.
1022
1088
  * Adds custom styles to the AR button for consistent appearance.
1023
- * If AR is not supported or initialization fails, logs a warning and sets XRExperience to null.
1089
+ * Unsupported or failed XR setup leaves ordinary 3D usable. Obsolete setup owns its own cleanup.
1090
+ * Quick Look prepares current-model USDZ separately; configured routing does not prove native opening.
1091
+ * Native state changes wake the idle engine; transition states keep rendering alive before the first XR frame.
1024
1092
  */
1025
1093
  async #createXRExperience() {
1026
1094
  if (this.#XRExperience) {
1027
1095
  return true;
1028
1096
  }
1029
1097
 
1098
+ const scene = this.#scene;
1099
+ const generation = this.#xrGeneration;
1100
+ /** @returns {boolean} Whether this setup still owns the live scene generation. */
1101
+ const isCurrent = () => this.#scene === scene && this.#xrGeneration === generation && !scene.isDisposed;
1030
1102
  const sessionMode = "immersive-ar";
1031
- const sessionSupported = await WebXRSessionManager.IsSessionSupportedAsync(sessionMode);
1032
- if (!sessionSupported) {
1033
- console.info("PrefViewer: WebXR in mode AR is not supported");
1034
- return false;
1035
- }
1036
-
1103
+ let experience = null;
1104
+ let placement = null;
1037
1105
  try {
1038
- const ground = MeshBuilder.CreateGround("ground", { width: 1000, height: 1000 }, this.#scene);
1039
- ground.isVisible = false;
1106
+ const sessionSupported = await WebXRSessionManager.IsSessionSupportedAsync(sessionMode);
1107
+ if (!isCurrent()) {
1108
+ return false;
1109
+ }
1110
+ const browser = this.#canvas?.ownerDocument?.defaultView?.navigator ?? globalThis.navigator;
1111
+ const route = BabylonJSController.#selectARRoute(sessionSupported, browser);
1112
+ if (route === "quick-look") {
1113
+ this.#addStylesToARButton();
1114
+ this.#quickLookAction = new QuickLookAction(this.#canvas.parentElement, () => {
1115
+ const component = this.#canvas.closest("pref-viewer-3d");
1116
+ const root = component?.getRootNode();
1117
+ const viewer = root?.host?.matches("pref-viewer") ? root : component?.closest("pref-viewer") ?? component;
1118
+ return this.#quickLookLoads === 0 && !this.#containers.model?.state?.isPending
1119
+ && !this.#containers.materials?.state?.isPending
1120
+ && component?.getAttribute("visible") !== "false" && !viewer?.querySelector("pref-viewer-dialog[open]");
1121
+ }, () => this.#viewCubeConfig.getCulture?.() ?? I18n.getLocale());
1122
+ this.#refreshQuickLook();
1123
+ return true;
1124
+ }
1125
+ if (route !== "webxr") {
1126
+ console.info("PrefViewer: WebXR in mode AR is not supported");
1127
+ return false;
1128
+ }
1040
1129
 
1041
1130
  const options = {
1042
- floorMeshes: [ground],
1131
+ disableTeleportation: true,
1132
+ disablePointerSelection: true,
1133
+ disableNearInteraction: true,
1134
+ disableHandTracking: true,
1135
+ ignoreNativeCameraTransformation: true,
1043
1136
  uiOptions: {
1044
1137
  sessionMode: sessionMode,
1045
1138
  renderTarget: "xrLayer",
1046
1139
  referenceSpaceType: "local",
1140
+ requiredFeatures: ["hit-test"],
1141
+ optionalFeatures: ["plane-detection"],
1047
1142
  },
1048
- optionalFeatures: true,
1049
1143
  };
1050
1144
 
1051
- this.#XRExperience = await WebXRDefaultExperience.CreateAsync(this.#scene, options);
1052
-
1053
- const featuresManager = this.#XRExperience.baseExperience.featuresManager;
1054
- featuresManager.enableFeature(WebXRFeatureName.TELEPORTATION, "stable", {
1055
- xrInput: this.#XRExperience.input,
1056
- floorMeshes: [ground],
1057
- timeToTeleport: 1500,
1058
- });
1145
+ experience = await WebXRDefaultExperience.CreateAsync(scene, options);
1146
+ if (!isCurrent()) {
1147
+ return false;
1148
+ }
1059
1149
 
1060
- this.#XRExperience.baseExperience.sessionManager.onXRReady.add(() => {
1061
- // Set the initial position of xrCamera: use nonVRCamera, which contains a copy of the original this.#scene.activeCamera before entering XR
1062
- this.#XRExperience.baseExperience.camera.setTransformationFromNonVRCamera(this.#XRExperience.baseExperience._nonVRCamera);
1063
- this.#XRExperience.baseExperience.camera.setTarget(Vector3.Zero());
1064
- this.#XRExperience.baseExperience.onInitialXRPoseSetObservable.notifyObservers(this.#XRExperience.baseExperience.camera);
1150
+ placement = new BabylonJSARPlacement(scene, experience,
1151
+ () => this.#containers.model?.state?.isVisible && !this.#containers.model.state.isPending
1152
+ ? this.#containers.model.assetContainer : null,
1153
+ () => this.#containers.environment?.assetContainer);
1154
+
1155
+ experience.baseExperience.onStateChangedObservable.add(() => {
1156
+ // Babylon schedules XR frames only while engine render callbacks are registered.
1157
+ // Clicking its external AR control need not have woken the idle canvas first.
1158
+ if (isCurrent() && this.#XRExperience === experience) {
1159
+ this.#requestRender({
1160
+ frames: 1
1161
+ });
1162
+ }
1065
1163
  });
1066
1164
 
1067
1165
  this.#addStylesToARButton();
1166
+ this.#XRExperience = experience;
1167
+ this.#arPlacement = placement;
1168
+ return true;
1068
1169
  } catch (error) {
1069
1170
  console.warn("PrefViewer: failed to create WebXR experience", error);
1070
- this.#XRExperience = null;
1171
+ return false;
1172
+ } finally {
1173
+ if (experience !== this.#XRExperience || !experience) {
1174
+ try {
1175
+ experience?.dispose();
1176
+ } catch (error) {
1177
+ console.warn("PrefViewer: failed to dispose obsolete WebXR experience", error);
1178
+ } finally {
1179
+ placement?.dispose();
1180
+ }
1181
+ }
1182
+ }
1183
+ }
1184
+
1185
+ /**
1186
+ * Prepares Quick Look from completed, visible model/material state without changing download helpers.
1187
+ * Invalid or absent content invalidates the link. Only supported visible model meshes are serialized;
1188
+ * asynchronous export ownership, stale results and preparation errors are handled by QuickLookAction.
1189
+ * @private
1190
+ * @returns {void} Starts preparation without waiting for it or changing public task-queue ordering.
1191
+ */
1192
+ #refreshQuickLook() {
1193
+ if (!this.#quickLookAction || this.#quickLookLoads > 0) {
1194
+ return;
1195
+ }
1196
+ const model = this.#containers.model;
1197
+ const asset = model?.assetContainer;
1198
+ if (!this.#scene || !asset || !model.state.isVisible || !this.#quickLookValid.model || !this.#quickLookValid.materials) {
1199
+ this.#quickLookAction.invalidate();
1200
+ return;
1201
+ }
1202
+ /**
1203
+ * Selects current visible geometry supported by the installed USDZ material exporter.
1204
+ * @param {import("@babylonjs/core").AbstractMesh} mesh - Candidate model mesh.
1205
+ * @returns {boolean} Whether the mesh can be included in prepared native content.
1206
+ */
1207
+ const eligible = (mesh) => !!mesh.geometry && mesh.getTotalVertices() > 0
1208
+ && mesh.isVisible !== false && mesh.visibility > 0 && mesh.isEnabled()
1209
+ && ["StandardMaterial", "PBRMaterial", "PBRMetallicRoughnessMaterial"].includes(mesh.material?.getClassName());
1210
+ if (!asset.meshes.some(eligible)) {
1211
+ this.#quickLookAction.invalidate();
1212
+ return;
1071
1213
  }
1214
+ // AssetContainer contains only model meshes; applied materials and world transforms are retained.
1215
+ // No environment, XR ground, ViewCube or utility-scene overlays enter this export.
1216
+ void this.#quickLookAction.update(() => USDZExportAsync(asset, {}, eligible));
1072
1217
  }
1073
1218
 
1074
1219
  /**
1075
- * Creates the default ArcRotateCamera with unrestricted beta, upright orbit and proportional native wheel zoom.
1220
+ * Creates the default ArcRotateCamera with unrestricted beta, upright orbit and proportional wheel/pinch zoom.
1076
1221
  * Starts at the exact Inner orientation (+Z, alpha/beta pi/2), matching the ViewCube before loading.
1077
1222
  * Starts without a fixed lower radius; the independent model zoom guard supplies the dynamic floor.
1078
1223
  * Imported-camera configuration is applied separately and is not reset here.
@@ -1084,7 +1229,7 @@ export default class BabylonJSController {
1084
1229
  this.#camera.upperBetaLimit = null;
1085
1230
  this.#camera.lowerBetaLimit = null;
1086
1231
  this.#camera.allowUpsideDown = false;
1087
- this.#camera.wheelDeltaPercentage = BabylonJSController.config.camera.wheelDeltaPercentage;
1232
+ this.#configureOrbitalZoom(this.#camera);
1088
1233
  this.#camera.zoomToMouseLocation = true;
1089
1234
  this.#camera.lowerRadiusLimit = null;
1090
1235
  this.#camera.upperRadiusLimit = 20;
@@ -1107,6 +1252,28 @@ export default class BabylonJSController {
1107
1252
  BabylonJSCameraWheelMotion.focusOnSurface(camera, () => this.#containers.model?.state?.isVisible ? this.#containers.model.assetContainer?.meshes : null);
1108
1253
  }
1109
1254
 
1255
+ /**
1256
+ * Applies distance-proportional native wheel and pinch inputs to an unlocked orbital camera.
1257
+ * Pinch uses its own sensitivity because Babylon measures squared finger separation, not wheel pixels.
1258
+ * Disables natural pinch's direct-radius override so both inputs keep Babylon's inertia and zoom guard.
1259
+ * Shares surface-focused wheel zoom with midpoint-focused pinch when mouse-location zoom is enabled.
1260
+ * Installs accumulated-motion gesture discrimination so slow pinch cannot become premature touch panning.
1261
+ * Combined pan/zoom stays disabled; classification is owned by the pointers input until release or detachment.
1262
+ * @private
1263
+ * @param {ArcRotateCamera} camera - Orbital camera being initialized or selected for interactive navigation.
1264
+ * @returns {void}
1265
+ */
1266
+ #configureOrbitalZoom(camera) {
1267
+ camera.wheelDeltaPercentage = BabylonJSController.config.camera.wheelDeltaPercentage;
1268
+ camera.pinchDeltaPercentage = BabylonJSController.config.camera.pinchDeltaPercentage;
1269
+ camera.useNaturalPinchZoom = false;
1270
+ const pointers = BabylonJSOrbitalPointersInput.install(camera);
1271
+ if (pointers) {
1272
+ pointers.multiTouchPanAndZoom = false;
1273
+ pointers.multiTouchPanning = true;
1274
+ }
1275
+ }
1276
+
1110
1277
  /**
1111
1278
  * Creates and configures the main lights for the Babylon.js scene.
1112
1279
  * Initializes the environment texture if needed.
@@ -1780,16 +1947,16 @@ export default class BabylonJSController {
1780
1947
  }
1781
1948
 
1782
1949
  const showroom = BabylonJSController.config.look?.showroom || {};
1783
- const timeOfDay = RenderMath.clamp01(this.#renderSettings.lightingTimeOfDay ? this.#renderSettings.lightingTimeOfDay ?? 0.5 : 0.5);
1784
- const profile = RenderMath.getLightingCycleProfile(timeOfDay);
1950
+ const timeOfDay = BabylonJSRenderMath.clamp01(this.#renderSettings.lightingTimeOfDay ? this.#renderSettings.lightingTimeOfDay ?? 0.5 : 0.5);
1951
+ const profile = BabylonJSRenderMath.getLightingCycleProfile(timeOfDay);
1785
1952
  const daylight = profile.dayFactor;
1786
1953
  const peakLight = daylight > 0 ? Math.pow(daylight, 0.72) : 0;
1787
1954
  const twilight = 1 - peakLight;
1788
1955
 
1789
1956
  if (this.#scene.imageProcessingConfiguration) {
1790
1957
  const imageProcessing = this.#scene.imageProcessingConfiguration;
1791
- imageProcessing.exposure = RenderMath.lerp(0.78, showroom.exposure ?? 0.92, peakLight);
1792
- imageProcessing.contrast = RenderMath.lerp(0.98, showroom.contrast ?? 1.02, peakLight);
1958
+ imageProcessing.exposure = BabylonJSRenderMath.lerp(0.78, showroom.exposure ?? 0.92, peakLight);
1959
+ imageProcessing.contrast = BabylonJSRenderMath.lerp(0.98, showroom.contrast ?? 1.02, peakLight);
1793
1960
  imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
1794
1961
  imageProcessing.toneMappingType = showroom.toneMappingType ?? (ImageProcessingConfiguration?.TONEMAPPING_ACES ?? 0);
1795
1962
  imageProcessing.vignetteEnabled = false;
@@ -1800,57 +1967,57 @@ export default class BabylonJSController {
1800
1967
  // HDR off-white. Both are linear already, so no toLinearSpace() on either.
1801
1968
  const bgDay = showroom.backgroundDay ?? BabylonJSController.config.look.showroom.backgroundDay;
1802
1969
  this.#scene.clearColor = new Color4(
1803
- RenderMath.lerp(0.000627, bgDay[0], daylight),
1804
- RenderMath.lerp(0.001089, bgDay[1], daylight),
1805
- RenderMath.lerp(0.004413, bgDay[2], daylight),
1970
+ BabylonJSRenderMath.lerp(0.000627, bgDay[0], daylight),
1971
+ BabylonJSRenderMath.lerp(0.001089, bgDay[1], daylight),
1972
+ BabylonJSRenderMath.lerp(0.004413, bgDay[2], daylight),
1806
1973
  1,
1807
1974
  );
1808
1975
 
1809
1976
  if ("environmentIntensity" in this.#scene) {
1810
- this.#scene.environmentIntensity = RenderMath.lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
1977
+ this.#scene.environmentIntensity = BabylonJSRenderMath.lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
1811
1978
  }
1812
1979
 
1813
1980
  if (this.#hemiLight) {
1814
1981
  this.#hemiLight.direction = new Vector3(
1815
- RenderMath.lerp(-0.45, -0.8, peakLight),
1816
- RenderMath.lerp(0.45, 0.95, peakLight),
1817
- RenderMath.lerp(-0.75, -0.35, peakLight),
1982
+ BabylonJSRenderMath.lerp(-0.45, -0.8, peakLight),
1983
+ BabylonJSRenderMath.lerp(0.45, 0.95, peakLight),
1984
+ BabylonJSRenderMath.lerp(-0.75, -0.35, peakLight),
1818
1985
  );
1819
- this.#hemiLight.intensity = RenderMath.lerp(0.18, showroom.hemiLightIntensity ?? 0.52, peakLight);
1986
+ this.#hemiLight.intensity = BabylonJSRenderMath.lerp(0.18, showroom.hemiLightIntensity ?? 0.52, peakLight);
1820
1987
  this.#hemiLight.diffuse = new Color3(
1821
- RenderMath.lerp(0.42, 0.92, peakLight),
1822
- RenderMath.lerp(0.48, 0.96, peakLight),
1823
- RenderMath.lerp(0.62, 0.98, peakLight),
1988
+ BabylonJSRenderMath.lerp(0.42, 0.92, peakLight),
1989
+ BabylonJSRenderMath.lerp(0.48, 0.96, peakLight),
1990
+ BabylonJSRenderMath.lerp(0.62, 0.98, peakLight),
1824
1991
  );
1825
1992
  this.#hemiLight.groundColor = new Color3(
1826
- RenderMath.lerp(0.08, 0.3, twilight),
1827
- RenderMath.lerp(0.08, 0.28, twilight),
1828
- RenderMath.lerp(0.1, 0.22, twilight),
1993
+ BabylonJSRenderMath.lerp(0.08, 0.3, twilight),
1994
+ BabylonJSRenderMath.lerp(0.08, 0.28, twilight),
1995
+ BabylonJSRenderMath.lerp(0.1, 0.22, twilight),
1829
1996
  );
1830
1997
  }
1831
1998
 
1832
1999
  if (this.#dirLight) {
1833
2000
  this.#dirLight.direction = new Vector3(profile.sunDirection.x, profile.sunDirection.y, profile.sunDirection.z);
1834
- this.#dirLight.intensity = RenderMath.lerp(0.02, showroom.dirLightIntensity ?? 0.56, peakLight);
2001
+ this.#dirLight.intensity = BabylonJSRenderMath.lerp(0.02, showroom.dirLightIntensity ?? 0.56, peakLight);
1835
2002
  this.#dirLight.diffuse = new Color3(
1836
- RenderMath.lerp(0.28, 1.0, peakLight),
1837
- RenderMath.lerp(0.3, 0.97, peakLight),
1838
- RenderMath.lerp(0.34, 0.9, peakLight),
2003
+ BabylonJSRenderMath.lerp(0.28, 1.0, peakLight),
2004
+ BabylonJSRenderMath.lerp(0.3, 0.97, peakLight),
2005
+ BabylonJSRenderMath.lerp(0.34, 0.9, peakLight),
1839
2006
  );
1840
2007
  }
1841
2008
 
1842
2009
  if (this.#moonLight) {
1843
2010
  this.#moonLight.direction = new Vector3(profile.moonDirection.x, profile.moonDirection.y, profile.moonDirection.z);
1844
- this.#moonLight.intensity = RenderMath.lerp(0.44, 0.03, peakLight);
2011
+ this.#moonLight.intensity = BabylonJSRenderMath.lerp(0.44, 0.03, peakLight);
1845
2012
  this.#moonLight.diffuse = new Color3(0.58, 0.7, 1.0);
1846
2013
  }
1847
2014
 
1848
2015
  if (this.#cameraLight) {
1849
- this.#cameraLight.intensity = RenderMath.lerp(0.06, showroom.cameraLightIntensity ?? 0.16, peakLight);
2016
+ this.#cameraLight.intensity = BabylonJSRenderMath.lerp(0.06, showroom.cameraLightIntensity ?? 0.16, peakLight);
1850
2017
  this.#cameraLight.diffuse = new Color3(
1851
- RenderMath.lerp(0.35, 1.0, peakLight),
1852
- RenderMath.lerp(0.35, 0.97, peakLight),
1853
- RenderMath.lerp(0.36, 0.92, peakLight),
2018
+ BabylonJSRenderMath.lerp(0.35, 1.0, peakLight),
2019
+ BabylonJSRenderMath.lerp(0.35, 0.97, peakLight),
2020
+ BabylonJSRenderMath.lerp(0.36, 0.92, peakLight),
1854
2021
  );
1855
2022
  }
1856
2023
 
@@ -1872,7 +2039,7 @@ export default class BabylonJSController {
1872
2039
 
1873
2040
  const pixelRatio = typeof window !== "undefined" ? window.devicePixelRatio : 1;
1874
2041
  const baseScaling = BabylonJSController.config.quality.hardwareScaling || 1;
1875
- const scalingLevel = RenderMath.getAdaptiveHardwareScalingLevel(baseScaling, pixelRatio);
2042
+ const scalingLevel = BabylonJSRenderMath.getAdaptiveHardwareScalingLevel(baseScaling, pixelRatio);
1876
2043
  this.#engine.setHardwareScalingLevel(scalingLevel);
1877
2044
  }
1878
2045
 
@@ -1934,7 +2101,7 @@ export default class BabylonJSController {
1934
2101
  * The 3D showroom is meant to feel compositionally stable, so we prefer a horizontal-fixed
1935
2102
  * field of view on ArcRotate cameras to avoid the background appearing undersized on wide layouts.
1936
2103
  * @private
1937
- * @param {Camera|null|undefined} camera - Active camera to adjust.
2104
+ * @param {Camera|null|undefined} [camera] - Active camera to adjust; defaults to the current scene camera.
1938
2105
  * @returns {void}
1939
2106
  */
1940
2107
  #applyShowroomCameraFraming(camera = this.#scene?.activeCamera) {
@@ -1946,11 +2113,13 @@ export default class BabylonJSController {
1946
2113
  }
1947
2114
 
1948
2115
  /**
1949
- * Resets pointer-picking sampling state.
2116
+ * Resets pointer-picking sampling and active touch state, cancelling any pending animation-menu hold.
1950
2117
  * @private
1951
2118
  * @returns {void}
1952
2119
  */
1953
2120
  #resetPointerPickingState() {
2121
+ this.#cancelTouchMenuHold();
2122
+ this.#state.pointerPicking.touchPointers.clear();
1954
2123
  this.#state.pointerPicking.lastMovePickAt = 0;
1955
2124
  this.#state.pointerPicking.lastMovePickX = NaN;
1956
2125
  this.#state.pointerPicking.lastMovePickY = NaN;
@@ -1996,7 +2165,7 @@ export default class BabylonJSController {
1996
2165
  }
1997
2166
 
1998
2167
  /**
1999
- * Sets up interaction handlers for the Babylon.js canvas and scene.
2168
+ * Sets up canvas/scene interactions and cancels incomplete gestures when the owning window loses focus.
2000
2169
  * @private
2001
2170
  * @returns {void}
2002
2171
  */
@@ -2004,6 +2173,7 @@ export default class BabylonJSController {
2004
2173
  if (this.#canvas) {
2005
2174
  this.#canvas.addEventListener("keyup", this.#handlers.onKeyUp);
2006
2175
  this.#canvas.addEventListener("pointercancel", this.#handlers.onPointerCancel);
2176
+ this.#canvas.ownerDocument?.defaultView?.addEventListener("blur", this.#handlers.onPointerCancel);
2007
2177
  }
2008
2178
  if (this.#scene) {
2009
2179
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
@@ -2015,7 +2185,7 @@ export default class BabylonJSController {
2015
2185
  }
2016
2186
 
2017
2187
  /**
2018
- * Removes interaction event listeners from the Babylon.js canvas.
2188
+ * Removes canvas/scene/window interaction listeners and releases pending gesture/resize resources.
2019
2189
  * @private
2020
2190
  * @returns {void}
2021
2191
  */
@@ -2023,6 +2193,7 @@ export default class BabylonJSController {
2023
2193
  if (this.#canvas) {
2024
2194
  this.#canvas.removeEventListener("keyup", this.#handlers.onKeyUp);
2025
2195
  this.#canvas.removeEventListener("pointercancel", this.#handlers.onPointerCancel);
2196
+ this.#canvas.ownerDocument?.defaultView?.removeEventListener("blur", this.#handlers.onPointerCancel);
2026
2197
  }
2027
2198
  if (this.#scene !== null) {
2028
2199
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
@@ -2035,11 +2206,12 @@ export default class BabylonJSController {
2035
2206
  }
2036
2207
 
2037
2208
  /**
2038
- * Disposes the BabylonJSAnimationController instance if it exists.
2209
+ * Cancels pending touch-menu activation and disposes the animation controller if it exists.
2039
2210
  * @private
2040
2211
  * @returns {void}
2041
2212
  */
2042
2213
  #disposeAnimationController() {
2214
+ this.#cancelTouchMenuHold();
2043
2215
  if (this.#babylonJSAnimationController) {
2044
2216
  this.#babylonJSAnimationController.dispose();
2045
2217
  this.#babylonJSAnimationController = null;
@@ -2063,6 +2235,7 @@ export default class BabylonJSController {
2063
2235
  * If XR is currently active, waits for `exitXRAsync()` before disposing to avoid
2064
2236
  * tearing down the engine while the XR session is still shutting down.
2065
2237
  * Concurrent calls share the same in-flight promise so disposal runs only once.
2238
+ * Restores metric-placement transforms and releases owned native hit-test/input observers before disposal.
2066
2239
  * @private
2067
2240
  * @returns {Promise<void>}
2068
2241
  */
@@ -2072,7 +2245,10 @@ export default class BabylonJSController {
2072
2245
  }
2073
2246
 
2074
2247
  const xrExperience = this.#XRExperience;
2248
+ const placement = this.#arPlacement;
2075
2249
  if (!xrExperience) {
2250
+ placement?.dispose();
2251
+ this.#arPlacement = null;
2076
2252
  return;
2077
2253
  }
2078
2254
 
@@ -2084,6 +2260,7 @@ export default class BabylonJSController {
2084
2260
  } catch (error) {
2085
2261
  console.warn("PrefViewer: Error exiting XR experience:", error);
2086
2262
  } finally {
2263
+ placement?.dispose();
2087
2264
  try {
2088
2265
  xrExperience.dispose();
2089
2266
  } catch (error) {
@@ -2091,6 +2268,7 @@ export default class BabylonJSController {
2091
2268
  }
2092
2269
  if (this.#XRExperience === xrExperience) {
2093
2270
  this.#XRExperience = null;
2271
+ this.#arPlacement = null;
2094
2272
  }
2095
2273
  this.#disablingPromises.xr = null;
2096
2274
  }
@@ -2270,7 +2448,7 @@ export default class BabylonJSController {
2270
2448
  }
2271
2449
 
2272
2450
  /**
2273
- * Handles pointer move events on the Babylon.js scene.
2451
+ * Handles camera activity and mouse/pen hover; touch-generated moves never enable persistent animation hover.
2274
2452
  * OPTIMIZATION: Tracks activity for idle mode.
2275
2453
  * @private
2276
2454
  * @param {PointerEvent} event - The pointer move event.
@@ -2287,7 +2465,7 @@ export default class BabylonJSController {
2287
2465
  if (camera && !camera.metadata?.locked) {
2288
2466
  this.#requestRender({ frames: 1, continuousMs: BabylonJSController.config.render.interactionMs });
2289
2467
  }
2290
- if (this.#babylonJSAnimationController && pickInfo) {
2468
+ if (!this.#isTouchPointerEvent(event) && this.#babylonJSAnimationController && pickInfo) {
2291
2469
  const pickedMeshId = pickInfo?.pickedMesh?.id || null;
2292
2470
  if (this.#state.pointerPicking.lastPickedMeshId !== pickedMeshId) {
2293
2471
  const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
@@ -2299,14 +2477,108 @@ export default class BabylonJSController {
2299
2477
  }
2300
2478
 
2301
2479
  /**
2302
- * Invalidates an incomplete click gesture when the browser cancels its pointer.
2480
+ * Invalidates incomplete clicks/holds and tracked touches on pointer cancellation or window focus loss.
2303
2481
  * @private
2304
2482
  * @returns {void}
2305
2483
  */
2306
2484
  #onPointerCancel() {
2485
+ this.#cancelTouchMenuHold();
2486
+ this.#state.pointerPicking.touchPointers.clear();
2307
2487
  this.#state.pointerPicking.pointerDown = null;
2308
2488
  }
2309
2489
 
2490
+ /**
2491
+ * Clears the owned long-press timer and temporary touch highlight, preserving selection, camera controls and menu visibility.
2492
+ * @private
2493
+ * @returns {void}
2494
+ */
2495
+ #cancelTouchMenuHold() {
2496
+ clearTimeout(this.#state.pointerPicking.longPressTimeout);
2497
+ this.#state.pointerPicking.longPressTimeout = null;
2498
+ if (this.#state.pointerPicking.touchHighlightActive) {
2499
+ this.#setTouchAnimationHighlight(null);
2500
+ }
2501
+ }
2502
+
2503
+ /**
2504
+ * Identifies direct touch input and compatibility mouse events explicitly marked as touch-generated.
2505
+ * @private
2506
+ * @param {PointerEvent|MouseEvent|null|undefined} event - Current browser pointer event.
2507
+ * @returns {boolean} True when the event must not drive persistent mouse hover.
2508
+ */
2509
+ #isTouchPointerEvent(event) {
2510
+ return event?.pointerType === "touch" || event?.sourceCapabilities?.firesTouchEvents === true;
2511
+ }
2512
+
2513
+ /**
2514
+ * Applies opening-only feedback for a pending touch hold, or clears previous touch/mouse highlight.
2515
+ * Honors the configured highlight preference without changing it; invalidates the hover cache and wakes rendering on changes.
2516
+ * @private
2517
+ * @param {PickingInfo|null} pick - Opening candidate, or null to remove feedback.
2518
+ * @returns {void}
2519
+ */
2520
+ #setTouchAnimationHighlight(pick) {
2521
+ const result = this.#babylonJSAnimationController?.highlightMeshes(this.#renderSettings.highlightEnabled ? pick : null, true);
2522
+ this.#state.pointerPicking.touchHighlightActive = result?.highlighted ?? false;
2523
+ this.#state.pointerPicking.lastPickedMeshId = null;
2524
+ this.#state.pointerPicking.lastMovePickX = NaN;
2525
+ this.#state.pointerPicking.lastMovePickY = NaN;
2526
+ if (result?.changed) {
2527
+ this.#requestRender({
2528
+ frames: 1
2529
+ });
2530
+ }
2531
+ }
2532
+
2533
+ /**
2534
+ * Checks whether current content and UI can accept a touch opening-menu gesture or its temporary feedback.
2535
+ * @private
2536
+ * @returns {boolean} False for missing content, hidden/modal UI, disconnected canvas or an active XR transition/session.
2537
+ */
2538
+ #canUseTouchAnimationMenu() {
2539
+ const component = this.#canvas?.closest("pref-viewer-3d");
2540
+ const viewer = this.#canvas?.closest("pref-viewer");
2541
+ const xrState = this.#XRExperience?.baseExperience?.state;
2542
+ return !!(this.#scene && this.#canvas?.isConnected && this.#babylonJSAnimationController?.hasAnimations()
2543
+ && this.#canvas.ownerDocument.visibilityState !== "hidden" && component?.getAttribute("visible") !== "false"
2544
+ && viewer?.getAttribute("mode") !== "2d" && !viewer?.querySelector("pref-viewer-dialog[open]")
2545
+ && (xrState === undefined || xrState === WebXRState.NOT_IN_XR));
2546
+ }
2547
+
2548
+ /**
2549
+ * Schedules a single-touch context action using the existing click movement tolerance.
2550
+ * Highlights the opening candidate during the pending half-second only, if enabled; early release or cancellation clears it.
2551
+ * Rechecks current content, visible UI and XR eligibility before picking the original CSS touch position.
2552
+ * Recognized holds consume the click and its release; no camera input is prevented or captured here.
2553
+ * @private
2554
+ * @param {PointerEvent} event - Primary touch down on this viewer's canvas.
2555
+ * @returns {void} Owns one cancellable timer until activation, interruption or teardown.
2556
+ */
2557
+ #scheduleTouchMenuHold(event) {
2558
+ this.#cancelTouchMenuHold();
2559
+ const state = this.#state.pointerPicking;
2560
+ const start = state.pointerDown;
2561
+ if (event?.pointerType !== "touch" || !start || state.touchPointers.size !== 1 || !this.#canUseTouchAnimationMenu()) {
2562
+ return;
2563
+ }
2564
+ const rect = this.#canvas.getBoundingClientRect();
2565
+ this.#setTouchAnimationHighlight(this.#scene.pick(start.x - rect.left, start.y - rect.top));
2566
+ state.longPressTimeout = setTimeout(() => {
2567
+ state.longPressTimeout = null;
2568
+ this.#cancelTouchMenuHold();
2569
+ if (state.pointerDown !== start || start.dragged || state.touchPointers.size !== 1 || !this.#canUseTouchAnimationMenu()) {
2570
+ return;
2571
+ }
2572
+ start.longPressed = start.dragged = true;
2573
+ const rect = this.#canvas.getBoundingClientRect();
2574
+ const pick = this.#scene.pick(start.x - rect.left, start.y - rect.top);
2575
+ this.#babylonJSAnimationController.showMenu(pick);
2576
+ this.#requestRender({
2577
+ frames: 1
2578
+ });
2579
+ }, BabylonJSController.config.pointerPicking.longPressMs);
2580
+ }
2581
+
2310
2582
  /**
2311
2583
  * Emits the internal 3D click contract for every model mesh crossed by the pointer ray.
2312
2584
  * @private
@@ -2344,6 +2616,8 @@ export default class BabylonJSController {
2344
2616
  /**
2345
2617
  * Handles pointer events observed on the Babylon.js scene.
2346
2618
  * Uses pointer-move sampling to avoid running expensive `scene.pick()` on every event.
2619
+ * Recognizes stationary primary touch holds independently of short-click selection; release preserves their menu.
2620
+ * Touch motion skips hover raycasts; touch down clears stale hover before starting temporary hold feedback.
2347
2621
  * @private
2348
2622
  * @param {PointerInfo} info - The pointer event information from Babylon.js.
2349
2623
  * @returns {void}
@@ -2351,26 +2625,47 @@ export default class BabylonJSController {
2351
2625
  #onPointerObservable(info) {
2352
2626
  if (info.type === PointerEventTypes.POINTERMOVE) {
2353
2627
  const start = this.#state.pointerPicking.pointerDown;
2354
- if (start && (info.event?.pointerId !== start.pointerId || (info.event?.buttons & 1) === 0
2355
- || Math.hypot(info.event?.clientX - start.x, info.event?.clientY - start.y) > BabylonJSController.config.pointerPicking.clickMaxDistancePx)) {
2628
+ if (start && (info.event?.pointerId !== start.pointerId || (info.event?.buttons & 1) === 0 || Math.hypot(info.event?.clientX - start.x, info.event?.clientY - start.y) > BabylonJSController.config.pointerPicking.clickMaxDistancePx)) {
2356
2629
  start.dragged = true;
2630
+ this.#cancelTouchMenuHold();
2357
2631
  }
2358
2632
  let pickInfo = null;
2359
2633
  let lastPickedMeshId = null;
2360
- if (this.#shouldPickOnPointerMove()) {
2634
+ if (!this.#isTouchPointerEvent(info.event) && this.#shouldPickOnPointerMove()) {
2361
2635
  pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
2362
2636
  lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
2363
2637
  }
2364
2638
  this.#onPointerMove(info.event, pickInfo);
2365
2639
  this.#state.pointerPicking.lastPickedMeshId = lastPickedMeshId;
2366
2640
  } else if (info.type === PointerEventTypes.POINTERDOWN) {
2367
- this.#state.pointerPicking.pointerDown = info.event?.button === 0 && !this.#state.pointerPicking.pointerDown
2368
- ? { x: info.event.clientX, y: info.event.clientY, button: info.event.button, pointerId: info.event.pointerId }
2369
- : null;
2641
+ this.#cancelTouchMenuHold();
2642
+ if (this.#isTouchPointerEvent(info.event)) {
2643
+ this.#setTouchAnimationHighlight(null);
2644
+ }
2645
+ if (info.event?.pointerType === "touch") {
2646
+ this.#state.pointerPicking.touchPointers.add(info.event.pointerId);
2647
+ }
2648
+ this.#state.pointerPicking.pointerDown = info.event?.button === 0 && info.event.isPrimary !== false && this.#state.pointerPicking.touchPointers.size <= 1 && !this.#state.pointerPicking.pointerDown ? {
2649
+ x: info.event.clientX,
2650
+ y: info.event.clientY,
2651
+ button: info.event.button,
2652
+ pointerId: info.event.pointerId
2653
+ } : null;
2654
+ this.#scheduleTouchMenuHold(info.event);
2370
2655
  } else if (info.type === PointerEventTypes.POINTERUP) {
2656
+ this.#cancelTouchMenuHold();
2657
+ this.#state.pointerPicking.touchPointers.delete(info.event?.pointerId);
2371
2658
  const pointerDown = this.#state.pointerPicking.pointerDown;
2372
2659
  this.#state.pointerPicking.pointerDown = null;
2373
- const pointerUp = { x: info.event?.clientX, y: info.event?.clientY, button: info.event?.button, pointerId: info.event?.pointerId };
2660
+ if (pointerDown?.longPressed && pointerDown.pointerId === info.event?.pointerId) {
2661
+ return;
2662
+ }
2663
+ const pointerUp = {
2664
+ x: info.event?.clientX,
2665
+ y: info.event?.clientY,
2666
+ button: info.event?.button,
2667
+ pointerId: info.event?.pointerId
2668
+ };
2374
2669
  if (BabylonJSSelectionController.isPrimaryClickGesture(pointerDown, pointerUp, BabylonJSController.config.pointerPicking.clickMaxDistancePx)) {
2375
2670
  this.#emitModelMeshClick();
2376
2671
  }
@@ -2386,6 +2681,7 @@ export default class BabylonJSController {
2386
2681
 
2387
2682
  /**
2388
2683
  * Applies a pending engine resize, cancels cube navigation and wakes rendering.
2684
+ * Skips zero-sized hidden canvases to prevent intrinsic-buffer/DPR growth; a visible resize retries later.
2389
2685
  * @private
2390
2686
  * @param {number} elapsed - Time since the previous resize, retained for diagnostics.
2391
2687
  * @returns {void}
@@ -2396,6 +2692,12 @@ export default class BabylonJSController {
2396
2692
  if (!this.#engine) {
2397
2693
  return;
2398
2694
  }
2695
+ // Babylon falls back to the intrinsic canvas size for hidden elements, then applies
2696
+ // DPR again. Repeated hidden resizes can exponentially grow GPU attachments on WebKit.
2697
+ // ResizeObserver will notify us again when the canvas has a visible CSS size.
2698
+ if (!this.#canvas.clientWidth || !this.#canvas.clientHeight) {
2699
+ return;
2700
+ }
2399
2701
  this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2400
2702
  console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
2401
2703
  this.#applyShowroomCameraFraming();
@@ -2531,6 +2833,7 @@ export default class BabylonJSController {
2531
2833
  * Handles canvas resize notifications.
2532
2834
  * Resizes the Babylon engine and requests a short on-demand render burst so camera-dependent
2533
2835
  * buffers and post-process pipelines are redrawn at the new viewport size.
2836
+ * Coalesces notifications through a throttled timer and animation frame, outside ResizeObserver delivery.
2534
2837
  * @private
2535
2838
  * @returns {void}
2536
2839
  */
@@ -2542,28 +2845,33 @@ export default class BabylonJSController {
2542
2845
  const elapsed = now - this.#state.resize.lastAppliedAt;
2543
2846
 
2544
2847
 
2545
- if (elapsed >= BabylonJSController.config.resize.throttleMs) {
2546
- this.#applyResize(elapsed);
2547
- return;
2548
- }
2549
-
2550
2848
  if (this.#state.resize.isScheduled) {
2551
2849
  return;
2552
2850
  }
2553
2851
 
2554
2852
  this.#state.resize.isScheduled = true;
2853
+ // Mutating canvas buffers/layout inside ResizeObserver delivery causes WebKit's
2854
+ // "undelivered notifications" loop. Apply before the next paint instead.
2555
2855
  const waitMs = Math.max(0, BabylonJSController.config.resize.throttleMs - elapsed);
2556
2856
  this.#state.resize.timeoutId = setTimeout(() => {
2557
- this.#applyResize(elapsed);
2857
+ this.#state.resize.timeoutId = null;
2858
+ this.#state.resize.frameId = requestAnimationFrame(() => {
2859
+ this.#state.resize.frameId = null;
2860
+ this.#applyResize(elapsed);
2861
+ });
2558
2862
  }, waitMs);
2559
2863
  }
2560
2864
 
2561
2865
  /**
2562
- * Clears any queued throttled resize callback.
2866
+ * Clears both the queued throttle timer and animation-frame resize callback during teardown.
2563
2867
  * @private
2564
2868
  * @returns {void}
2565
2869
  */
2566
2870
  #cancelScheduledResize() {
2871
+ if (this.#state.resize.frameId != null) {
2872
+ cancelAnimationFrame(this.#state.resize.frameId);
2873
+ }
2874
+ this.#state.resize.frameId = null;
2567
2875
  if (this.#state.resize.timeoutId !== null) {
2568
2876
  clearTimeout(this.#state.resize.timeoutId);
2569
2877
  }
@@ -2639,7 +2947,7 @@ export default class BabylonJSController {
2639
2947
 
2640
2948
  /**
2641
2949
  * Cancels pending cube navigation and applies configured camera selection and locking.
2642
- * Configures unlocked orbital cameras for proportional native wheel input, rebinds the model zoom guard
2950
+ * Configures unlocked orbital cameras for proportional native wheel/pinch input, rebinds the model zoom guard
2643
2951
  * and synchronizes the cube after selecting the active camera.
2644
2952
  * A missing named camera falls back to the unlocked default camera, rather than retaining an imported view.
2645
2953
  * @private
@@ -2694,7 +3002,7 @@ export default class BabylonJSController {
2694
3002
  camera.detachControl();
2695
3003
  if (!cameraState.locked) {
2696
3004
  if (camera instanceof ArcRotateCamera) {
2697
- camera.wheelDeltaPercentage = BabylonJSController.config.camera.wheelDeltaPercentage;
3005
+ this.#configureOrbitalZoom(camera);
2698
3006
  }
2699
3007
  camera.attachControl(this.#canvas, true);
2700
3008
  }
@@ -3000,11 +3308,13 @@ export default class BabylonJSController {
3000
3308
  * Loads a single asset container (model, environment, materials, etc.) based on the container state flags.
3001
3309
  * Skips work when nothing is pending, otherwise resolves the GLTF source, refreshes cache metadata and streams it
3002
3310
  * into the Babylon.js scene via `LoadAssetContainerAsync`.
3311
+ * Distinguishes unchanged content from a failed replacement for native AR readiness and revokes source URLs
3312
+ * after attempted imports. Resolver failures may reject; Babylon import errors are logged and returned as null.
3003
3313
  * @private
3004
3314
  * @param {object} container - Container descriptor that carries the GLTF storage pointer and current cache info.
3005
3315
  * @param {boolean} [force=false] - When true, bypasses cached size/timestamp so the resolver re-downloads the asset.
3006
- * @returns {Promise<[object, AssetContainer|boolean]>} Resolves to `[container, assetContainer]` on success, or
3007
- * `[container, false]` when loading was skipped or failed.
3316
+ * @returns {Promise<[object, AssetContainer|false|null]>} Container paired with imported assets, false for skipped,
3317
+ * unchanged or unresolved content, or null after a caught import failure.
3008
3318
  * @description
3009
3319
  * 1. Validates that the container has pending data and initializes the shared `GLTFResolver` if needed.
3010
3320
  * 2. Requests the source blob (respecting the cached size/timestamp unless `force` is set) and stores the new cache
@@ -3028,7 +3338,18 @@ export default class BabylonJSController {
3028
3338
  const currentSize = force ? 0 : container.state.size;
3029
3339
  const currentTimeStamp = force ? null : container.state.timeStamp;
3030
3340
 
3031
- let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, currentSize, currentTimeStamp);
3341
+ const isQuickLookContent = container.state.name === "model" || container.state.name === "materials";
3342
+ if (isQuickLookContent) {
3343
+ this.#quickLookValid[container.state.name] = false;
3344
+ }
3345
+ let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, currentSize, currentTimeStamp, true);
3346
+
3347
+ if (sourceData?.unchanged) {
3348
+ if (isQuickLookContent) {
3349
+ this.#quickLookValid[container.state.name] = !!container.assetContainer;
3350
+ }
3351
+ return [container, false];
3352
+ }
3032
3353
 
3033
3354
  if (!sourceData) {
3034
3355
  return [container, false];
@@ -3070,6 +3391,7 @@ export default class BabylonJSController {
3070
3391
  /**
3071
3392
  * Cancels pending cube navigation, loads asset containers and adds replacements to the scene.
3072
3393
  * Current model selection is resolved by the zoom guard on subsequent input updates.
3394
+ * Invalidates native content during overlapping loads, then prepares only completed current-model content.
3073
3395
  * @private
3074
3396
  * @param {boolean} [force=false] - Whether to bypass existing asset cache metadata.
3075
3397
  * @returns {Promise<{success: boolean, error: any}>} Resolves to an object indicating if loading succeeded and any error encountered.
@@ -3079,6 +3401,29 @@ export default class BabylonJSController {
3079
3401
  * Returns an object with success status and error details.
3080
3402
  */
3081
3403
  async #loadContainers(force = false) {
3404
+ this.#quickLookAction?.invalidate();
3405
+ this.#quickLookLoads += 1;
3406
+ try {
3407
+ const result = await this.#loadContainersInternal(force);
3408
+ if (!result.success) {
3409
+ this.#quickLookValid.materials = false;
3410
+ }
3411
+ return result;
3412
+ } finally {
3413
+ this.#quickLookLoads -= 1;
3414
+ this.#refreshQuickLook();
3415
+ }
3416
+ }
3417
+
3418
+ /**
3419
+ * Loads pending containers in parallel, replaces scene assets and reapplies material/camera/light state.
3420
+ * Cancels cube/animation work, stops rendering during changes and restarts it after setup. Disposed scenes
3421
+ * discard late assets; successful model/material replacements update Quick Look content-validity state.
3422
+ * @private
3423
+ * @param {boolean} [force=false] - Whether to bypass cached source size/timestamp checks.
3424
+ * @returns {Promise<{success: boolean, error: *}>} Aggregate scene-load result and any captured setup error.
3425
+ */
3426
+ async #loadContainersInternal(force = false) {
3082
3427
  this.#viewCube?.cancel();
3083
3428
  this.#resetPointerPickingState();
3084
3429
  this.#detachAnimationChangedListener();
@@ -3125,6 +3470,9 @@ export default class BabylonJSController {
3125
3470
  }
3126
3471
  this.#replaceContainer(container, assetContainer);
3127
3472
  container.state.setSuccess(true);
3473
+ if (container.state.name === "model" || container.state.name === "materials") {
3474
+ this.#quickLookValid[container.state.name] = true;
3475
+ }
3128
3476
  } else {
3129
3477
  if (container.assetContainer && container.state.mustBeShown !== container.state.isVisible && container.state.name === "materials") {
3130
3478
  container.state.mustBeShown ? this.#addContainer(container) : this.#removeContainer(container);
@@ -3345,6 +3693,7 @@ export default class BabylonJSController {
3345
3693
 
3346
3694
  /**
3347
3695
  * Opens a modal dialog for downloading the 3D scene, model, or environment.
3696
+ * Uses shared dialog presentation and wrapping option groups; preserves export choices and routing.
3348
3697
  * @private
3349
3698
  * @returns {void}
3350
3699
  */
@@ -3358,15 +3707,15 @@ export default class BabylonJSController {
3358
3707
  const texts = I18n.translate("prefViewer.downloadDialog", locale ? { locale } : undefined) || {};
3359
3708
  const header = texts.title || "Download 3D Scene";
3360
3709
  const content = `
3361
- <form slot="content" id="download-dialog-form" style="display:flex;flex-direction:column;gap:16px;">
3710
+ <form slot="content" id="download-dialog-form" class="download-dialog-form">
3362
3711
  <h4>${texts.sections?.content || "Content"}</h4>
3363
- <div style="display:flex;flex-direction:row;gap:16px;margin:0 8px 0 8px;">
3712
+ <div class="download-dialog-options">
3364
3713
  <label><input type="radio" name="content" value="1"> ${texts.options?.model || "Model"}</label>
3365
3714
  <label><input type="radio" name="content" value="2"> ${texts.options?.scene || "Scene"}</label>
3366
3715
  <label><input type="radio" name="content" value="0" checked> ${texts.options?.both || "Both"}</label>
3367
3716
  </div>
3368
3717
  <h4>${texts.sections?.format || "Format"}</h4>
3369
- <div style="display:flex;flex-direction:row;gap:16px;margin:0 8px 0 8px;">
3718
+ <div class="download-dialog-options">
3370
3719
  <label><input type="radio" name="format" value="gltf" checked> glTF (ZIP)</label>
3371
3720
  <label><input type="radio" name="format" value="glb"> GLB</label>
3372
3721
  <label><input type="radio" name="format" value="usdz"> USDZ</label>
@@ -3416,7 +3765,7 @@ export default class BabylonJSController {
3416
3765
  */
3417
3766
 
3418
3767
  /**
3419
- * Creates the enabled cube once a live scene/camera exists and teardown is not in progress.
3768
+ * Creates the selected SVG/Canvas cube once a live scene/camera exists and teardown is not in progress.
3420
3769
  * Resolves camera/model state through callbacks; does not reload assets or camera options.
3421
3770
  * @private
3422
3771
  * @returns {void}
@@ -3435,7 +3784,7 @@ export default class BabylonJSController {
3435
3784
  onLayout: this.#viewCubeConfig.onLayout,
3436
3785
  // A cube drag is pointer interaction like a main-canvas drag: keep the loop continuous for the same window.
3437
3786
  requestRender: (options) => this.#requestRender({ frames: 2, continuousMs: options?.interaction ? BabylonJSController.config.render.interactionMs : 0 }),
3438
- });
3787
+ }, this.#viewCubeConfig.renderer);
3439
3788
  }
3440
3789
 
3441
3790
  /**
@@ -3444,10 +3793,15 @@ export default class BabylonJSController {
3444
3793
  * Observes canvas resize events, binds model-only orbital zoom protection and adaptive default-camera near clipping,
3445
3794
  * and lazily creates the requested cube. Imported camera clipping is preserved by the host policy.
3446
3795
  * Aborts remaining initialization if the scene was replaced/disposed during texture readiness.
3796
+ * Waits for preceding teardown and guards XR route creation with the current scene generation.
3447
3797
  * @public
3448
3798
  * @returns {Promise<void>}
3449
3799
  */
3450
3800
  async enable() {
3801
+ if (this.#disablingPromises.general) {
3802
+ await this.#disablingPromises.general;
3803
+ }
3804
+ const enabledGeneration = ++this.#xrGeneration;
3451
3805
  this.#configureDracoCompression();
3452
3806
  this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false }, true);
3453
3807
  this.#engine.disableUniformBuffers = true;
@@ -3490,7 +3844,7 @@ export default class BabylonJSController {
3490
3844
  this.#initializeViewCube();
3491
3845
  const enabledScene = this.#scene;
3492
3846
  await this.#ensureEnvironmentBRDFTextureReady();
3493
- if (!this.#engine || this.#scene !== enabledScene) {
3847
+ if (!this.#engine || this.#scene !== enabledScene || this.#xrGeneration !== enabledGeneration) {
3494
3848
  return;
3495
3849
  }
3496
3850
  this.#enableInteraction();
@@ -3503,10 +3857,16 @@ export default class BabylonJSController {
3503
3857
  * floor and suspending cube creation until the next enable. Cleans up light, XR and resolver resources.
3504
3858
  * The teardown is asynchronous: it waits for XR/session-dependent shutdown work
3505
3859
  * before disposing the engine, and coalesces concurrent calls into one in-flight promise.
3860
+ * Immediately invalidates pending XR setup and disposes the Quick Look action before awaiting teardown.
3506
3861
  * @public
3507
3862
  * @returns {Promise<void>}
3508
3863
  */
3509
3864
  async disable() {
3865
+ // Invalidate pending support/setup before the first teardown await, even while the scene is live.
3866
+ this.#xrGeneration += 1;
3867
+ this.#quickLookAction?.dispose();
3868
+ this.#quickLookAction = null;
3869
+ this.#quickLookValid = { model: false, materials: true };
3510
3870
  BabylonJSCameraWheelMotion.cancel(this.#scene?.activeCamera);
3511
3871
  BabylonJSCameraWheelMotion.cancel(this.#camera);
3512
3872
  BabylonJSCameraWheelMotion.releaseSurfaceFocus(this.#camera);
@@ -3564,18 +3924,30 @@ export default class BabylonJSController {
3564
3924
  }
3565
3925
 
3566
3926
  /**
3567
- * Synchronizes the existing cube after culture or camera-state changes without creating an inactive cube.
3927
+ * Selects the cube implementation while preserving the active scene, camera and asset state.
3928
+ * @param {"svg"|"canvas"} renderer - Per-viewer preference; invalid values use Canvas.
3929
+ * @returns {void} Disabled cubes retain preference without allocating a new overlay.
3930
+ */
3931
+ setViewCubeRenderer(renderer) {
3932
+ this.#viewCubeConfig.renderer = BabylonJSViewCubeController.normalizeRenderer(renderer);
3933
+ this.#viewCube?.setRenderer(this.#viewCubeConfig.renderer);
3934
+ }
3935
+
3936
+ /**
3937
+ * Resizes/synchronizes the existing cube after culture, camera or presentation changes without activating it.
3938
+ * Also refreshes the existing Quick Look action's localized label from the host culture.
3568
3939
  * @public
3569
3940
  * @returns {void}
3570
3941
  */
3571
3942
  refreshViewCube() {
3572
- this.#viewCube?.sync();
3943
+ this.#quickLookAction?.refreshCulture();
3944
+ this.#viewCube?.resize();
3573
3945
  }
3574
3946
 
3575
3947
  /**
3576
3948
  * Applies the host-owned semantic selection state to the current model meshes.
3577
3949
  * @public
3578
- * @param {object} state - Canonical PrefViewer selection snapshot.
3950
+ * @param {object} [state={}] - Canonical PrefViewer selection snapshot.
3579
3951
  * @returns {void}
3580
3952
  */
3581
3953
  applySelectionState(state = {}) {
@@ -3583,9 +3955,9 @@ export default class BabylonJSController {
3583
3955
  }
3584
3956
 
3585
3957
  /**
3586
- * Downloads the current scene, model, or environment as a GLB file.
3958
+ * Downloads the current scene, model, or environment as a GLB file, excluding transient AR feedback.
3587
3959
  * @public
3588
- * @param {number} content - 0 for full scene, 1 for model, 2 for environment.
3960
+ * @param {number} [content=0] - 0 for full scene, 1 for model, 2 for environment.
3589
3961
  * @returns {void}
3590
3962
  */
3591
3963
  downloadGLB(content = 0) {
@@ -3608,7 +3980,8 @@ export default class BabylonJSController {
3608
3980
  break;
3609
3981
  }
3610
3982
  fileName = this.#addDateToName(fileName);
3611
- GLTF2Export.GLBAsync(container, fileName, { exportWithoutWaitingForScene: true }).then((glb) => {
3983
+ GLTF2Export.GLBAsync(container, fileName, { exportWithoutWaitingForScene: true,
3984
+ shouldExportNode: node => !node.metadata?.prefViewerARFeedback }).then((glb) => {
3612
3985
  if (glb) {
3613
3986
  glb.downloadFiles();
3614
3987
  }
@@ -3616,9 +3989,9 @@ export default class BabylonJSController {
3616
3989
  }
3617
3990
 
3618
3991
  /**
3619
- * Downloads the current scene, model, or environment as a glTF ZIP file.
3992
+ * Downloads the current scene, model, or environment as a glTF ZIP file, excluding transient AR feedback.
3620
3993
  * @public
3621
- * @param {number} content - 0 for full scene, 1 for model, 2 for environment.
3994
+ * @param {number} [content=0] - 0 for full scene, 1 for model, 2 for environment.
3622
3995
  * @returns {void}
3623
3996
  */
3624
3997
  downloadGLTF(content = 0) {
@@ -3645,7 +4018,8 @@ export default class BabylonJSController {
3645
4018
  break;
3646
4019
  }
3647
4020
  comment += "\nPrefViewer by Preference, S.L.\npreference.com\n";
3648
- GLTF2Export.GLTFAsync(container, fileName, { exportWithoutWaitingForScene: true }).then((gltf) => {
4021
+ GLTF2Export.GLTFAsync(container, fileName, { exportWithoutWaitingForScene: true,
4022
+ shouldExportNode: node => !node.metadata?.prefViewerARFeedback }).then((gltf) => {
3649
4023
  if (gltf?.files) {
3650
4024
  this.#downloadZip(gltf.files, fileName, comment, true);
3651
4025
  }
@@ -3653,9 +4027,9 @@ export default class BabylonJSController {
3653
4027
  }
3654
4028
 
3655
4029
  /**
3656
- * Downloads the current scene, model, or environment as a USDZ file.
4030
+ * Downloads the current scene, model, or environment as a USDZ file, excluding transient AR feedback.
3657
4031
  * @public
3658
- * @param {number} content - 0 for full scene, 1 for model, 2 for environment.
4032
+ * @param {number} [content=0] - 0 for full scene, 1 for model, 2 for environment.
3659
4033
  * @returns {void}
3660
4034
  */
3661
4035
  downloadUSDZ(content = 0) {
@@ -3678,7 +4052,7 @@ export default class BabylonJSController {
3678
4052
  break;
3679
4053
  }
3680
4054
  fileName = this.#addDateToName(fileName);
3681
- USDZExportAsync(container).then((response) => {
4055
+ USDZExportAsync(container, undefined, mesh => !mesh.metadata?.prefViewerARFeedback).then((response) => {
3682
4056
  if (response) {
3683
4057
  Tools.Download(new Blob([response], { type: "model/vnd.usdz+zip" }), `${fileName}.usdz`);
3684
4058
  }
@@ -3698,7 +4072,8 @@ export default class BabylonJSController {
3698
4072
  * Returns a diagnostic snapshot of the current Babylon.js runtime state.
3699
4073
  * Intended for integration troubleshooting and should not mutate scene state.
3700
4074
  * @public
3701
- * @returns {object} Debug information for canvas, engine, scene, containers, render loop and settings.
4075
+ * @returns {object} Canvas, engine, scene, container, render-loop and settings diagnostics, plus nullable
4076
+ * `ar` placement state, metric distance/position and drag/model availability when WebXR is configured.
3702
4077
  */
3703
4078
  getDebugInfo() {
3704
4079
 
@@ -3737,6 +4112,7 @@ export default class BabylonJSController {
3737
4112
 
3738
4113
 
3739
4114
  return {
4115
+ ar: this.#arPlacement?.getDebugInfo() ?? null,
3740
4116
  canvas: {
3741
4117
  clientWidth: this.#canvas?.clientWidth ?? null,
3742
4118
  clientHeight: this.#canvas?.clientHeight ?? null,
@@ -3833,13 +4209,16 @@ export default class BabylonJSController {
3833
4209
  /**
3834
4210
  * Applies material options from the configuration to the relevant meshes.
3835
4211
  * Stops and restarts the render loop to apply changes.
4212
+ * Invalidates native content during the update and requests fresh Quick Look preparation after render readiness.
3836
4213
  * @public
3837
4214
  * @returns {boolean} True if material options were set successfully, false otherwise.
3838
4215
  */
3839
4216
  setMaterialOptions() {
4217
+ this.#quickLookAction?.invalidate();
3840
4218
  this.#stopRender();
3841
4219
  const materialsOptionsSetted = this.#setOptions_Materials();
3842
- this.#startRender();
4220
+ void this.#startRender().then(() => this.#refreshQuickLook())
4221
+ .catch(error => console.warn("PrefViewer: failed to prepare material rendering", error));
3843
4222
  return materialsOptionsSetted;
3844
4223
  }
3845
4224
 
@@ -3864,7 +4243,7 @@ export default class BabylonJSController {
3864
4243
  * When `showLightingTimeOfDay` is set to false and no explicit `lightingTimeOfDay` is supplied,
3865
4244
  * the active lighting time is reset to `BabylonJSController.config.render.defaultSettings.lightingTimeOfDay` (0.5).
3866
4245
  * @public
3867
- * @param {object} config - Immediate render/illumination configuration.
4246
+ * @param {object} [config={}] - Immediate render/illumination configuration.
3868
4247
  * @param {boolean|string} [config.showAnimationMenu] - Enables/disables the animation controls when animations are available.
3869
4248
  * @param {boolean|string|null} [config.highlightEnabled] - Enables/disables hover highlight; string "false" is normalized to false, null is ignored.
3870
4249
  * @param {string|null} [config.highlightColor] - Hex color used by hover highlight; null is ignored.
@@ -3896,7 +4275,7 @@ export default class BabylonJSController {
3896
4275
  }
3897
4276
 
3898
4277
  if (typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay)) {
3899
- const normalizedTime = RenderMath.clamp01(config.lightingTimeOfDay);
4278
+ const normalizedTime = BabylonJSRenderMath.clamp01(config.lightingTimeOfDay);
3900
4279
  if (this.#renderSettings.lightingTimeOfDay !== normalizedTime) {
3901
4280
  this.#renderSettings.lightingTimeOfDay = normalizedTime;
3902
4281
  shouldApplyLighting = true;
@@ -3947,6 +4326,8 @@ export default class BabylonJSController {
3947
4326
  * Sets the visibility of a container (model, environment, etc.) by name.
3948
4327
  * Adds or removes the container from the scene and updates wall/floor visibility.
3949
4328
  * Restarts the render loop to apply changes.
4329
+ * Refreshes Quick Look readiness after rendering so hidden model content cannot retain an active native link.
4330
+ * Render/preparation failures are logged asynchronously; this method does not wait for native preparation.
3950
4331
  * @public
3951
4332
  * @param {string} name - The name of the container to show or hide.
3952
4333
  * @param {boolean} show - True to show the container, false to hide it.
@@ -3964,7 +4345,8 @@ export default class BabylonJSController {
3964
4345
  this.#stopRender();
3965
4346
  show ? this.#addContainer(container) : this.#removeContainer(container);
3966
4347
  this.#setVisibilityOfWallAndFloorInModel();
3967
- this.#startRender();
4348
+ void this.#startRender().then(() => this.#refreshQuickLook())
4349
+ .catch(error => console.warn("PrefViewer: failed to prepare visibility rendering", error));
3968
4350
  }
3969
4351
 
3970
4352
  /**
@@ -3973,7 +4355,7 @@ export default class BabylonJSController {
3973
4355
  * Immediate settings such as highlight, animation menu, and time-of-day lighting are handled by
3974
4356
  * `setIlluminationConfig()` before this method receives the remaining reload settings.
3975
4357
  * @public
3976
- * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of reload render toggles to apply.
4358
+ * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} [settings={}] Partial set of reload render toggles to apply.
3977
4359
  * @returns {{changed:boolean, settings:object}} Current full render settings snapshot and whether a reload is required.
3978
4360
  * @description
3979
4361
  * Callers can inspect the `changed` flag to decide whether to trigger a reload with
@@ -4042,9 +4424,9 @@ export default class BabylonJSController {
4042
4424
  }
4043
4425
 
4044
4426
  /**
4045
- * Configures the highlight settings for animated nodes on hover.
4427
+ * Configures animation highlights for mouse hover and pending touch holds.
4046
4428
  * @private
4047
- * @param {{enabled?: boolean, color?: string}} config - Configuration object.
4429
+ * @param {{enabled?: boolean, color?: string}} [config={}] - Configuration object.
4048
4430
  * @returns {void}
4049
4431
  */
4050
4432
  #setHighlightConfig(config = {}) {