@preference-sl/pref-viewer 2.18.0 → 3.0.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.
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 +542 -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 +22 -4
  22. package/src/gltf-resolver.js +83 -13
  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 +291 -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,9 +1,14 @@
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";
3
+ import BabylonJSARGlass from "./babylonjs-ar-glass.js";
2
4
  import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression.js";
3
5
  import "@babylonjs/loaders";
4
6
  import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_draco_mesh_compression.js";
5
7
  import { USDZExportAsync, GLTF2Export } from "@babylonjs/serializers";
6
8
  import JSZip from "jszip";
9
+ // Browser build of fflate: the package's default export map resolves to its Node flavour under
10
+ // esbuild's `platform: node`, which imports `createRequire` from "module" and breaks in browsers.
11
+ import * as fflate from "fflate/browser";
7
12
 
8
13
  import GLTFResolver from "./gltf-resolver.js";
9
14
  import { MaterialData } from "./pref-viewer-3d-data.js";
@@ -11,13 +16,15 @@ import BabylonJSAnimationController from "./babylonjs-animation-controller.js";
11
16
  import BabylonJSViewCubeController from "./babylonjs-view-cube-controller.js";
12
17
  import BabylonJSCameraFraming from "./babylonjs-camera-framing.js";
13
18
  import BabylonJSCameraZoomController from "./babylonjs-camera-zoom-controller.js";
19
+ import BabylonJSOrbitalPointersInput from "./babylonjs-orbital-pointers-input.js";
14
20
  import BabylonJSCameraWheelMotion from "./babylonjs-camera-wheel-motion.js";
15
21
  import BabylonJSSelectionController from "./babylonjs-selection-controller.js";
16
22
  import OpeningAnimation from "./babylonjs-animation-opening.js";
17
23
  import I18n from "./localization/i18n.js";
18
24
  import { PrefViewerStyleVariables } from "./styles.js";
19
- import RenderMath from "./render-math.js";
25
+ import BabylonJSRenderMath from "./babylonjs-render-math.js";
20
26
  import MaterialUtils from "./material-utils.js";
27
+ import QuickLookAction from "./quick-look-action.js";
21
28
 
22
29
  /**
23
30
  * BabylonJSController coordinates the PrefViewer 3D runtime: it bootstraps Babylon.js, manages asset containers,
@@ -35,8 +42,12 @@ import MaterialUtils from "./material-utils.js";
35
42
  * - Manages keyboard/pointer/wheel handlers, animation menus, and WebXR so PrefViewer menus and DOM attributes stay
36
43
  * synchronized with Babylon state, using sampled pointer picking and last-picked tracking to reduce raycast/highlight
37
44
  * cost on dense scenes.
45
+ * - Configures distance-proportional orbital wheel/pinch zoom, with focal-enabled touch pinch following the two-finger midpoint.
38
46
  * - 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.
47
+ * - Generates GLB, glTF+ZIP, or USDZ exports with timestamped names and localized, density-aware download dialogs.
48
+ * - Shares cube-and-scan-frame AR artwork between native XR and Quick Look controls.
49
+ * - Owns metric WebXR floor placement or an eligible Safari Quick Look action. Prepared native content tracks
50
+ * completed model/material state; obsolete asynchronous work cannot publish into a replacement lifecycle.
40
51
  * - Translates metadata (inner floor offsets, cast/receive shadows, camera locks) into scene adjustments after reloads.
41
52
  *
42
53
  * Runtime Flow
@@ -55,7 +66,7 @@ import MaterialUtils from "./material-utils.js";
55
66
  *
56
67
  * Public API Highlights
57
68
  * - constructor(canvas, containers, options, viewCubeConfig?)
58
- * - setViewCubeEnabled(enabled) / refreshViewCube()
69
+ * - setViewCubeEnabled(enabled) / setViewCubeRenderer(renderer) / refreshViewCube()
59
70
  * - enable() / disable()
60
71
  * - load(force?) / reloadWithCurrentSettings()
61
72
  * - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
@@ -82,13 +93,20 @@ import MaterialUtils from "./material-utils.js";
82
93
  * in SSR/Node contexts (though functionality activates only in browsers).
83
94
  * - Pointer-pick lifecycle: hover/highlight raycasts are sampled on POINTERMOVE (time + distance thresholds), wheel
84
95
  * 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.
96
+ * Right-click POINTERUP and a stationary touch hold perform on-demand animation-menu picking.
97
+ * Touch holds consume release selection and are cancelled by navigation, interruption or teardown.
98
+ * Touch feedback lasts only while a hold is pending; touch-generated hover cannot retain animation highlights.
86
99
  * - Runtime bookkeeping is split into mutable `#state` and shared tuning settings under `BabylonJSController.config` to keep behavior changes
87
100
  * explicit and reduce cross-field drift.
88
101
  * - Resize lifecycle: canvas resize notifications are throttled (with trailing execution) before calling `engine.resize()`
89
102
  * and any queued resize callback is canceled during teardown.
90
103
  * - Teardown lifecycle: concurrent `disable()` calls are coalesced into a single in-flight promise to avoid races
91
104
  * during XR exit and engine disposal.
105
+ * - AR routing is selected internally from confirmed WebXR capability and eligible Safari browser signals.
106
+ * - XR lifecycle: entry wakes an idle engine and keeps its frame pump running through session transitions;
107
+ * otherwise Babylon cannot request native frames or deliver floor hit tests to placement.
108
+ * Transient AR gesture feedback is excluded from all scene/model download formats.
109
+ * Optional native plane detection exposes contours for initial footprint fitting when the runtime supports it.
92
110
  */
93
111
  export default class BabylonJSController {
94
112
  // Canvas HTML element
@@ -108,6 +126,12 @@ export default class BabylonJSController {
108
126
  #cameraLight = null;
109
127
  #shadowGen = [];
110
128
  #XRExperience = null;
129
+ #arPlacement = null;
130
+ #xrGeneration = 0;
131
+ #quickLookAction = null;
132
+ #quickLookGlass = new BabylonJSARGlass();
133
+ #quickLookLoads = 0;
134
+ #quickLookValid = { model: false, materials: true };
111
135
  #canvasResizeObserver = null;
112
136
 
113
137
  #hdrTexture = null; // Reusable in-memory HDR source cloned into scene.environmentTexture across reloads
@@ -152,6 +176,9 @@ export default class BabylonJSController {
152
176
  lastMovePickY: NaN,
153
177
  lastPickedMeshId: null,
154
178
  pointerDown: null,
179
+ touchPointers: new Set(),
180
+ longPressTimeout: null,
181
+ touchHighlightActive: false,
155
182
  },
156
183
  // Render loop state balances performance with responsiveness.
157
184
  render: {
@@ -166,6 +193,7 @@ export default class BabylonJSController {
166
193
  resize: {
167
194
  isScheduled: false,
168
195
  timeoutId: null,
196
+ frameId: null,
169
197
  lastAppliedAt: 0,
170
198
  },
171
199
  };
@@ -175,6 +203,7 @@ export default class BabylonJSController {
175
203
  * All instances use this object, including the quality preset updated by hardware detection.
176
204
  * render.defaultSettings supplies defaults for each instance's persisted render settings snapshot.
177
205
  * Per-instance render and interaction bookkeeping remains in #state.
206
+ * camera wheel/pinch percentages tune separate native inputs; both scale zoom by the current orbital radius.
178
207
  * @public
179
208
  * @static
180
209
  * @type {object}
@@ -182,6 +211,7 @@ export default class BabylonJSController {
182
211
  static config = {
183
212
  camera: {
184
213
  wheelDeltaPercentage: 0.015,
214
+ pinchDeltaPercentage: 0.001, // Native pinch uses squared finger separation, unlike wheel deltas
185
215
  fitMarginFactor: 0.15, // Total free viewport fraction: 0.15 leaves 85% for the model
186
216
  maxRadiusFactor: 4, // Pull back past the whole model
187
217
  minRadiusFactor: 0.25, // ViewCube minimum radius relative to transition distance
@@ -229,6 +259,7 @@ export default class BabylonJSController {
229
259
  movePickIntervalMs: 50, // Cap expensive scene.pick calls to ~20 Hz while moving the pointer
230
260
  movePickMinDistancePx: 2, // Skip picks for sub-pixel jitter
231
261
  clickMaxDistancePx: 6,
262
+ longPressMs: 500,
232
263
  },
233
264
  selection: {
234
265
  selectableColor: [1, 1, 0],
@@ -531,13 +562,14 @@ export default class BabylonJSController {
531
562
  * Constructs a new BabylonJSController instance.
532
563
  * Initializes the canvas, asset containers, and options for the Babylon.js scene.
533
564
  * @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.
565
+ * @param {HTMLCanvasElement|null} [canvas=null] - The canvas element to render the scene on.
566
+ * @param {object} [containers={}] - An object containing container states for model, environment, materials, etc.
567
+ * @param {object} [options={}] - Configuration options for the Babylon.js scene and assets.
537
568
  * @param {object} [viewCubeConfig={}] - Internal overlay integration options.
569
+ * @param {"svg"|"canvas"} [viewCubeConfig.renderer="canvas"] - ViewCube implementation; invalid values select Canvas.
538
570
  * @param {boolean} [viewCubeConfig.enabled=true] - Desired activation; creation waits for a live scene and camera.
539
571
  * @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.
572
+ * @param {function(number, number): void} [viewCubeConfig.onLayout] - Measured active ViewCube height and scene viewport height in CSS pixels; (0, 0) on disposal.
541
573
  * @returns {BabylonJSController}
542
574
  * @description
543
575
  * - Assigns the provided canvas to the controller.
@@ -904,6 +936,7 @@ export default class BabylonJSController {
904
936
  * Evaluates whether the renderer should stay in continuous mode.
905
937
  * Pending ViewCube transitions and active XR force continuous rendering; animation/camera motion extends the
906
938
  * continuous deadline window to avoid abrupt stop-start behavior.
939
+ * Entering/exiting XR also stays continuous: the native frame pump must survive asynchronous setup and teardown.
907
940
  * OPTIMIZATION: Added true idle mode - stops rendering completely after idleThresholdMs of inactivity.
908
941
  * @private
909
942
  * @param {number} now - Current high-resolution timestamp.
@@ -913,8 +946,8 @@ export default class BabylonJSController {
913
946
  if (this.#viewCube?.isAnimating) {
914
947
  return true;
915
948
  }
916
- const inXR = this.#XRExperience?.baseExperience?.state === WebXRState.IN_XR;
917
- if (inXR) {
949
+ const xrState = this.#XRExperience?.baseExperience?.state;
950
+ if (xrState === WebXRState.ENTERING_XR || xrState === WebXRState.IN_XR || xrState === WebXRState.EXITING_XR) {
918
951
  this.#state.render.isIdle = false;
919
952
  this.#state.render.lastActivityAt = now;
920
953
  return true;
@@ -998,12 +1031,23 @@ export default class BabylonJSController {
998
1031
  }
999
1032
 
1000
1033
  /**
1001
- * Adds custom CSS styles to the Babylon.js AR button for consistent appearance and interaction.
1034
+ * Adds the supplied cube-and-scan-frame SVG and shared interaction styles to native XR and Quick Look buttons.
1035
+ * Preserves the supplied 24-unit geometry and renders its filled paths white against the toggle background.
1002
1036
  * @private
1003
1037
  * @returns {void}
1004
1038
  */
1005
1039
  #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"}';
1040
+ 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"
1041
+ width="24px" height="24px" viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
1042
+ <path fill="none" d="M0,0h24v24H0V0z"/>
1043
+ <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
1044
+ 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
1045
+ 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
1046
+ 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
1047
+ 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
1048
+ c0.25-0.13,0.5-0.19,0.75-0.19s0.5,0.06,0.75,0.19L18.23,7.59z"/>
1049
+ </svg>`;
1050
+ 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
1051
  let style = this.#canvas.parentElement.parentElement.querySelector("style");
1008
1052
  if (!style) {
1009
1053
  style = document.createElement("style");
@@ -1013,66 +1057,202 @@ export default class BabylonJSController {
1013
1057
  }
1014
1058
 
1015
1059
  /**
1016
- * Creates and initializes the Babylon.js WebXR experience for augmented reality (AR).
1060
+ * Selects an AR candidate from a completed WebXR support query and browser signals.
1061
+ * A failed or pending query is unknown, not permission to use the native fallback.
1062
+ * Native content preparation/opening is a separate step from this decision.
1063
+ * @param {boolean|null} webXRSupported Resolved immersive-ar support, or null if unknown.
1064
+ * @param {object} [browser=globalThis.navigator] Navigator-like browser signals; no GPU-tier inference.
1017
1065
  * @private
1018
- * @returns {Promise<boolean|void>} Resolves to true if XR experience is created, false if not supported, or void on error.
1066
+ * @returns {"webxr"|"quick-look"|null} Candidate route, not confirmation of native opening.
1067
+ */
1068
+ static #selectARRoute(webXRSupported, browser = globalThis.navigator) {
1069
+ if (webXRSupported === true) {
1070
+ return "webxr";
1071
+ }
1072
+ if (webXRSupported !== false || !browser) {
1073
+ return null;
1074
+ }
1075
+
1076
+ const userAgent = browser.userAgent ?? "";
1077
+ const isIOS = /iPhone|iPad|iPod/.test(userAgent);
1078
+ // iPadOS can request a desktop user agent. MacIntel alone also matches Macs.
1079
+ const isTouchIPad = browser.platform === "MacIntel" && browser.maxTouchPoints > 1;
1080
+ const isSafari = /AppleWebKit\//.test(userAgent) && /Version\//.test(userAgent) && /Safari\//.test(userAgent)
1081
+ && !/CriOS|FxiOS|EdgiOS|OPiOS|Chrome|Chromium|Android/.test(userAgent);
1082
+ return (isIOS || isTouchIPad) && isSafari ? "quick-look" : null;
1083
+ }
1084
+
1085
+ /**
1086
+ * Selects and initializes supported WebXR AR or an eligible Safari Quick Look action.
1087
+ * @private
1088
+ * @returns {Promise<boolean>} True when an AR route is configured, false when unsupported, obsolete or setup fails.
1019
1089
  * @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.
1090
+ * Checks AR support and requires native floor hit testing at real-world metre scale.
1091
+ * Requests optional plane detection so initial placement can use an observed floor contour when supplied.
1092
+ * Placement follows the tracked XR camera rather than inheriting the showroom camera's fit.
1022
1093
  * 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.
1094
+ * Unsupported or failed XR setup leaves ordinary 3D usable. Obsolete setup owns its own cleanup.
1095
+ * Quick Look prepares current-model USDZ separately; configured routing does not prove native opening.
1096
+ * Native state changes wake the idle engine; transition states keep rendering alive before the first XR frame.
1024
1097
  */
1025
1098
  async #createXRExperience() {
1026
1099
  if (this.#XRExperience) {
1027
1100
  return true;
1028
1101
  }
1029
1102
 
1103
+ const scene = this.#scene;
1104
+ const generation = this.#xrGeneration;
1105
+ /** @returns {boolean} Whether this setup still owns the live scene generation. */
1106
+ const isCurrent = () => this.#scene === scene && this.#xrGeneration === generation && !scene.isDisposed;
1030
1107
  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
-
1108
+ let experience = null;
1109
+ let placement = null;
1037
1110
  try {
1038
- const ground = MeshBuilder.CreateGround("ground", { width: 1000, height: 1000 }, this.#scene);
1039
- ground.isVisible = false;
1111
+ const sessionSupported = await WebXRSessionManager.IsSessionSupportedAsync(sessionMode);
1112
+ if (!isCurrent()) {
1113
+ return false;
1114
+ }
1115
+ const browser = this.#canvas?.ownerDocument?.defaultView?.navigator ?? globalThis.navigator;
1116
+ const route = BabylonJSController.#selectARRoute(sessionSupported, browser);
1117
+ if (route === "quick-look") {
1118
+ this.#addStylesToARButton();
1119
+ this.#quickLookAction = new QuickLookAction(this.#canvas.parentElement, () => {
1120
+ const component = this.#canvas.closest("pref-viewer-3d");
1121
+ const root = component?.getRootNode();
1122
+ const viewer = root?.host?.matches("pref-viewer") ? root : component?.closest("pref-viewer") ?? component;
1123
+ return this.#quickLookLoads === 0 && !this.#containers.model?.state?.isPending
1124
+ && !this.#containers.materials?.state?.isPending
1125
+ && component?.getAttribute("visible") !== "false" && !viewer?.querySelector("pref-viewer-dialog[open]");
1126
+ }, () => this.#viewCubeConfig.getCulture?.() ?? I18n.getLocale());
1127
+ this.#refreshQuickLook();
1128
+ return true;
1129
+ }
1130
+ if (route !== "webxr") {
1131
+ console.info("PrefViewer: WebXR in mode AR is not supported");
1132
+ return false;
1133
+ }
1040
1134
 
1041
1135
  const options = {
1042
- floorMeshes: [ground],
1136
+ disableTeleportation: true,
1137
+ disablePointerSelection: true,
1138
+ disableNearInteraction: true,
1139
+ disableHandTracking: true,
1140
+ ignoreNativeCameraTransformation: true,
1043
1141
  uiOptions: {
1044
1142
  sessionMode: sessionMode,
1045
1143
  renderTarget: "xrLayer",
1046
1144
  referenceSpaceType: "local",
1145
+ requiredFeatures: ["hit-test"],
1146
+ optionalFeatures: ["plane-detection"],
1047
1147
  },
1048
- optionalFeatures: true,
1049
1148
  };
1050
1149
 
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
- });
1150
+ experience = await WebXRDefaultExperience.CreateAsync(scene, options);
1151
+ if (!isCurrent()) {
1152
+ return false;
1153
+ }
1059
1154
 
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);
1155
+ placement = new BabylonJSARPlacement(scene, experience,
1156
+ () => this.#containers.model?.state?.isVisible && !this.#containers.model.state.isPending
1157
+ ? this.#containers.model.assetContainer : null,
1158
+ () => this.#containers.environment?.assetContainer);
1159
+
1160
+ experience.baseExperience.onStateChangedObservable.add(() => {
1161
+ // Babylon schedules XR frames only while engine render callbacks are registered.
1162
+ // Clicking its external AR control need not have woken the idle canvas first.
1163
+ if (isCurrent() && this.#XRExperience === experience) {
1164
+ this.#requestRender({
1165
+ frames: 1
1166
+ });
1167
+ }
1065
1168
  });
1066
1169
 
1067
1170
  this.#addStylesToARButton();
1171
+ this.#XRExperience = experience;
1172
+ this.#arPlacement = placement;
1173
+ return true;
1068
1174
  } catch (error) {
1069
1175
  console.warn("PrefViewer: failed to create WebXR experience", error);
1070
- this.#XRExperience = null;
1176
+ return false;
1177
+ } finally {
1178
+ if (experience !== this.#XRExperience || !experience) {
1179
+ try {
1180
+ experience?.dispose();
1181
+ } catch (error) {
1182
+ console.warn("PrefViewer: failed to dispose obsolete WebXR experience", error);
1183
+ } finally {
1184
+ placement?.dispose();
1185
+ }
1186
+ }
1071
1187
  }
1072
1188
  }
1073
1189
 
1074
1190
  /**
1075
- * Creates the default ArcRotateCamera with unrestricted beta, upright orbit and proportional native wheel zoom.
1191
+ * Prepares Quick Look from completed, visible model/material state without changing download helpers.
1192
+ * Invalid or absent content invalidates the link. Only supported visible model meshes are serialized;
1193
+ * asynchronous export ownership, stale results and preparation errors are handled by QuickLookAction.
1194
+ * @private
1195
+ * @returns {void} Starts preparation without waiting for it or changing public task-queue ordering.
1196
+ */
1197
+ #refreshQuickLook() {
1198
+ if (!this.#quickLookAction || this.#quickLookLoads > 0) {
1199
+ return;
1200
+ }
1201
+ const model = this.#containers.model;
1202
+ const asset = model?.assetContainer;
1203
+ if (!this.#scene || !asset || !model.state.isVisible || !this.#quickLookValid.model || !this.#quickLookValid.materials) {
1204
+ this.#quickLookAction.invalidate();
1205
+ return;
1206
+ }
1207
+ /**
1208
+ * Selects current visible geometry supported by the installed USDZ material exporter.
1209
+ * @param {import("@babylonjs/core").AbstractMesh} mesh - Candidate model mesh.
1210
+ * @returns {boolean} Whether the mesh can be included in prepared native content.
1211
+ */
1212
+ const eligible = (mesh) => !!mesh.geometry && mesh.getTotalVertices() > 0
1213
+ && mesh.isVisible !== false && mesh.visibility > 0 && mesh.isEnabled()
1214
+ && ["StandardMaterial", "PBRMaterial", "PBRMetallicRoughnessMaterial"].includes(mesh.material?.getClassName());
1215
+ if (!asset.meshes.some(eligible)) {
1216
+ this.#quickLookAction.invalidate();
1217
+ return;
1218
+ }
1219
+ // AssetContainer contains only model meshes; applied materials and world transforms are retained.
1220
+ // No environment, XR ground, ViewCube or utility-scene overlays enter this export.
1221
+ void this.#quickLookAction.update(() => this.#exportQuickLookUSDZ(asset, eligible));
1222
+ }
1223
+
1224
+ /**
1225
+ * Exposes the bundled fflate module as the global the Babylon USDZ exporter checks for, so it neither
1226
+ * fetches the library from a CDN nor awaits before its synchronous material pass.
1227
+ * @private
1228
+ * @returns {void} Leaves an existing global untouched.
1229
+ */
1230
+ #ensureUSDZDependencies() {
1231
+ globalThis.fflate ??= fflate;
1232
+ }
1233
+
1234
+ /**
1235
+ * Serializes the model to USDZ with physical (transmission) glass converted to alpha opacity, because the
1236
+ * exporter ignores transmission and would write the on-screen opaque alpha. The exporter reads every
1237
+ * material synchronously before its first await, so the adapter is restored right after the call returns
1238
+ * and the live render loop never draws the adapted glass.
1239
+ * @private
1240
+ * @param {import("@babylonjs/core").AssetContainer} asset - Model container to serialize.
1241
+ * @param {function(import("@babylonjs/core").AbstractMesh): boolean} eligible - Mesh filter for the export.
1242
+ * @returns {Promise<Uint8Array>} The serialized USDZ bytes.
1243
+ */
1244
+ #exportQuickLookUSDZ(asset, eligible) {
1245
+ this.#ensureUSDZDependencies();
1246
+ this.#quickLookGlass.sync(asset);
1247
+ try {
1248
+ return USDZExportAsync(asset, {}, eligible);
1249
+ } finally {
1250
+ this.#quickLookGlass.restore();
1251
+ }
1252
+ }
1253
+
1254
+ /**
1255
+ * Creates the default ArcRotateCamera with unrestricted beta, upright orbit and proportional wheel/pinch zoom.
1076
1256
  * Starts at the exact Inner orientation (+Z, alpha/beta pi/2), matching the ViewCube before loading.
1077
1257
  * Starts without a fixed lower radius; the independent model zoom guard supplies the dynamic floor.
1078
1258
  * Imported-camera configuration is applied separately and is not reset here.
@@ -1084,7 +1264,7 @@ export default class BabylonJSController {
1084
1264
  this.#camera.upperBetaLimit = null;
1085
1265
  this.#camera.lowerBetaLimit = null;
1086
1266
  this.#camera.allowUpsideDown = false;
1087
- this.#camera.wheelDeltaPercentage = BabylonJSController.config.camera.wheelDeltaPercentage;
1267
+ this.#configureOrbitalZoom(this.#camera);
1088
1268
  this.#camera.zoomToMouseLocation = true;
1089
1269
  this.#camera.lowerRadiusLimit = null;
1090
1270
  this.#camera.upperRadiusLimit = 20;
@@ -1107,6 +1287,28 @@ export default class BabylonJSController {
1107
1287
  BabylonJSCameraWheelMotion.focusOnSurface(camera, () => this.#containers.model?.state?.isVisible ? this.#containers.model.assetContainer?.meshes : null);
1108
1288
  }
1109
1289
 
1290
+ /**
1291
+ * Applies distance-proportional native wheel and pinch inputs to an unlocked orbital camera.
1292
+ * Pinch uses its own sensitivity because Babylon measures squared finger separation, not wheel pixels.
1293
+ * Disables natural pinch's direct-radius override so both inputs keep Babylon's inertia and zoom guard.
1294
+ * Shares surface-focused wheel zoom with midpoint-focused pinch when mouse-location zoom is enabled.
1295
+ * Installs accumulated-motion gesture discrimination so slow pinch cannot become premature touch panning.
1296
+ * Combined pan/zoom stays disabled; classification is owned by the pointers input until release or detachment.
1297
+ * @private
1298
+ * @param {ArcRotateCamera} camera - Orbital camera being initialized or selected for interactive navigation.
1299
+ * @returns {void}
1300
+ */
1301
+ #configureOrbitalZoom(camera) {
1302
+ camera.wheelDeltaPercentage = BabylonJSController.config.camera.wheelDeltaPercentage;
1303
+ camera.pinchDeltaPercentage = BabylonJSController.config.camera.pinchDeltaPercentage;
1304
+ camera.useNaturalPinchZoom = false;
1305
+ const pointers = BabylonJSOrbitalPointersInput.install(camera);
1306
+ if (pointers) {
1307
+ pointers.multiTouchPanAndZoom = false;
1308
+ pointers.multiTouchPanning = true;
1309
+ }
1310
+ }
1311
+
1110
1312
  /**
1111
1313
  * Creates and configures the main lights for the Babylon.js scene.
1112
1314
  * Initializes the environment texture if needed.
@@ -1780,16 +1982,16 @@ export default class BabylonJSController {
1780
1982
  }
1781
1983
 
1782
1984
  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);
1985
+ const timeOfDay = BabylonJSRenderMath.clamp01(this.#renderSettings.lightingTimeOfDay ? this.#renderSettings.lightingTimeOfDay ?? 0.5 : 0.5);
1986
+ const profile = BabylonJSRenderMath.getLightingCycleProfile(timeOfDay);
1785
1987
  const daylight = profile.dayFactor;
1786
1988
  const peakLight = daylight > 0 ? Math.pow(daylight, 0.72) : 0;
1787
1989
  const twilight = 1 - peakLight;
1788
1990
 
1789
1991
  if (this.#scene.imageProcessingConfiguration) {
1790
1992
  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);
1993
+ imageProcessing.exposure = BabylonJSRenderMath.lerp(0.78, showroom.exposure ?? 0.92, peakLight);
1994
+ imageProcessing.contrast = BabylonJSRenderMath.lerp(0.98, showroom.contrast ?? 1.02, peakLight);
1793
1995
  imageProcessing.toneMappingEnabled = showroom.toneMappingEnabled ?? true;
1794
1996
  imageProcessing.toneMappingType = showroom.toneMappingType ?? (ImageProcessingConfiguration?.TONEMAPPING_ACES ?? 0);
1795
1997
  imageProcessing.vignetteEnabled = false;
@@ -1800,57 +2002,57 @@ export default class BabylonJSController {
1800
2002
  // HDR off-white. Both are linear already, so no toLinearSpace() on either.
1801
2003
  const bgDay = showroom.backgroundDay ?? BabylonJSController.config.look.showroom.backgroundDay;
1802
2004
  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),
2005
+ BabylonJSRenderMath.lerp(0.000627, bgDay[0], daylight),
2006
+ BabylonJSRenderMath.lerp(0.001089, bgDay[1], daylight),
2007
+ BabylonJSRenderMath.lerp(0.004413, bgDay[2], daylight),
1806
2008
  1,
1807
2009
  );
1808
2010
 
1809
2011
  if ("environmentIntensity" in this.#scene) {
1810
- this.#scene.environmentIntensity = RenderMath.lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
2012
+ this.#scene.environmentIntensity = BabylonJSRenderMath.lerp(0.42, showroom.environmentIntensity ?? 1.08, peakLight);
1811
2013
  }
1812
2014
 
1813
2015
  if (this.#hemiLight) {
1814
2016
  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),
2017
+ BabylonJSRenderMath.lerp(-0.45, -0.8, peakLight),
2018
+ BabylonJSRenderMath.lerp(0.45, 0.95, peakLight),
2019
+ BabylonJSRenderMath.lerp(-0.75, -0.35, peakLight),
1818
2020
  );
1819
- this.#hemiLight.intensity = RenderMath.lerp(0.18, showroom.hemiLightIntensity ?? 0.52, peakLight);
2021
+ this.#hemiLight.intensity = BabylonJSRenderMath.lerp(0.18, showroom.hemiLightIntensity ?? 0.52, peakLight);
1820
2022
  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),
2023
+ BabylonJSRenderMath.lerp(0.42, 0.92, peakLight),
2024
+ BabylonJSRenderMath.lerp(0.48, 0.96, peakLight),
2025
+ BabylonJSRenderMath.lerp(0.62, 0.98, peakLight),
1824
2026
  );
1825
2027
  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),
2028
+ BabylonJSRenderMath.lerp(0.08, 0.3, twilight),
2029
+ BabylonJSRenderMath.lerp(0.08, 0.28, twilight),
2030
+ BabylonJSRenderMath.lerp(0.1, 0.22, twilight),
1829
2031
  );
1830
2032
  }
1831
2033
 
1832
2034
  if (this.#dirLight) {
1833
2035
  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);
2036
+ this.#dirLight.intensity = BabylonJSRenderMath.lerp(0.02, showroom.dirLightIntensity ?? 0.56, peakLight);
1835
2037
  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),
2038
+ BabylonJSRenderMath.lerp(0.28, 1.0, peakLight),
2039
+ BabylonJSRenderMath.lerp(0.3, 0.97, peakLight),
2040
+ BabylonJSRenderMath.lerp(0.34, 0.9, peakLight),
1839
2041
  );
1840
2042
  }
1841
2043
 
1842
2044
  if (this.#moonLight) {
1843
2045
  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);
2046
+ this.#moonLight.intensity = BabylonJSRenderMath.lerp(0.44, 0.03, peakLight);
1845
2047
  this.#moonLight.diffuse = new Color3(0.58, 0.7, 1.0);
1846
2048
  }
1847
2049
 
1848
2050
  if (this.#cameraLight) {
1849
- this.#cameraLight.intensity = RenderMath.lerp(0.06, showroom.cameraLightIntensity ?? 0.16, peakLight);
2051
+ this.#cameraLight.intensity = BabylonJSRenderMath.lerp(0.06, showroom.cameraLightIntensity ?? 0.16, peakLight);
1850
2052
  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),
2053
+ BabylonJSRenderMath.lerp(0.35, 1.0, peakLight),
2054
+ BabylonJSRenderMath.lerp(0.35, 0.97, peakLight),
2055
+ BabylonJSRenderMath.lerp(0.36, 0.92, peakLight),
1854
2056
  );
1855
2057
  }
1856
2058
 
@@ -1872,7 +2074,7 @@ export default class BabylonJSController {
1872
2074
 
1873
2075
  const pixelRatio = typeof window !== "undefined" ? window.devicePixelRatio : 1;
1874
2076
  const baseScaling = BabylonJSController.config.quality.hardwareScaling || 1;
1875
- const scalingLevel = RenderMath.getAdaptiveHardwareScalingLevel(baseScaling, pixelRatio);
2077
+ const scalingLevel = BabylonJSRenderMath.getAdaptiveHardwareScalingLevel(baseScaling, pixelRatio);
1876
2078
  this.#engine.setHardwareScalingLevel(scalingLevel);
1877
2079
  }
1878
2080
 
@@ -1934,7 +2136,7 @@ export default class BabylonJSController {
1934
2136
  * The 3D showroom is meant to feel compositionally stable, so we prefer a horizontal-fixed
1935
2137
  * field of view on ArcRotate cameras to avoid the background appearing undersized on wide layouts.
1936
2138
  * @private
1937
- * @param {Camera|null|undefined} camera - Active camera to adjust.
2139
+ * @param {Camera|null|undefined} [camera] - Active camera to adjust; defaults to the current scene camera.
1938
2140
  * @returns {void}
1939
2141
  */
1940
2142
  #applyShowroomCameraFraming(camera = this.#scene?.activeCamera) {
@@ -1946,11 +2148,13 @@ export default class BabylonJSController {
1946
2148
  }
1947
2149
 
1948
2150
  /**
1949
- * Resets pointer-picking sampling state.
2151
+ * Resets pointer-picking sampling and active touch state, cancelling any pending animation-menu hold.
1950
2152
  * @private
1951
2153
  * @returns {void}
1952
2154
  */
1953
2155
  #resetPointerPickingState() {
2156
+ this.#cancelTouchMenuHold();
2157
+ this.#state.pointerPicking.touchPointers.clear();
1954
2158
  this.#state.pointerPicking.lastMovePickAt = 0;
1955
2159
  this.#state.pointerPicking.lastMovePickX = NaN;
1956
2160
  this.#state.pointerPicking.lastMovePickY = NaN;
@@ -1996,7 +2200,7 @@ export default class BabylonJSController {
1996
2200
  }
1997
2201
 
1998
2202
  /**
1999
- * Sets up interaction handlers for the Babylon.js canvas and scene.
2203
+ * Sets up canvas/scene interactions and cancels incomplete gestures when the owning window loses focus.
2000
2204
  * @private
2001
2205
  * @returns {void}
2002
2206
  */
@@ -2004,6 +2208,7 @@ export default class BabylonJSController {
2004
2208
  if (this.#canvas) {
2005
2209
  this.#canvas.addEventListener("keyup", this.#handlers.onKeyUp);
2006
2210
  this.#canvas.addEventListener("pointercancel", this.#handlers.onPointerCancel);
2211
+ this.#canvas.ownerDocument?.defaultView?.addEventListener("blur", this.#handlers.onPointerCancel);
2007
2212
  }
2008
2213
  if (this.#scene) {
2009
2214
  this.#scene.onPointerObservable.add(this.#handlers.onPointerObservable);
@@ -2015,7 +2220,7 @@ export default class BabylonJSController {
2015
2220
  }
2016
2221
 
2017
2222
  /**
2018
- * Removes interaction event listeners from the Babylon.js canvas.
2223
+ * Removes canvas/scene/window interaction listeners and releases pending gesture/resize resources.
2019
2224
  * @private
2020
2225
  * @returns {void}
2021
2226
  */
@@ -2023,6 +2228,7 @@ export default class BabylonJSController {
2023
2228
  if (this.#canvas) {
2024
2229
  this.#canvas.removeEventListener("keyup", this.#handlers.onKeyUp);
2025
2230
  this.#canvas.removeEventListener("pointercancel", this.#handlers.onPointerCancel);
2231
+ this.#canvas.ownerDocument?.defaultView?.removeEventListener("blur", this.#handlers.onPointerCancel);
2026
2232
  }
2027
2233
  if (this.#scene !== null) {
2028
2234
  this.#scene.onPointerObservable.removeCallback(this.#handlers.onPointerObservable);
@@ -2035,11 +2241,12 @@ export default class BabylonJSController {
2035
2241
  }
2036
2242
 
2037
2243
  /**
2038
- * Disposes the BabylonJSAnimationController instance if it exists.
2244
+ * Cancels pending touch-menu activation and disposes the animation controller if it exists.
2039
2245
  * @private
2040
2246
  * @returns {void}
2041
2247
  */
2042
2248
  #disposeAnimationController() {
2249
+ this.#cancelTouchMenuHold();
2043
2250
  if (this.#babylonJSAnimationController) {
2044
2251
  this.#babylonJSAnimationController.dispose();
2045
2252
  this.#babylonJSAnimationController = null;
@@ -2063,6 +2270,7 @@ export default class BabylonJSController {
2063
2270
  * If XR is currently active, waits for `exitXRAsync()` before disposing to avoid
2064
2271
  * tearing down the engine while the XR session is still shutting down.
2065
2272
  * Concurrent calls share the same in-flight promise so disposal runs only once.
2273
+ * Restores metric-placement transforms and releases owned native hit-test/input observers before disposal.
2066
2274
  * @private
2067
2275
  * @returns {Promise<void>}
2068
2276
  */
@@ -2072,7 +2280,10 @@ export default class BabylonJSController {
2072
2280
  }
2073
2281
 
2074
2282
  const xrExperience = this.#XRExperience;
2283
+ const placement = this.#arPlacement;
2075
2284
  if (!xrExperience) {
2285
+ placement?.dispose();
2286
+ this.#arPlacement = null;
2076
2287
  return;
2077
2288
  }
2078
2289
 
@@ -2084,6 +2295,7 @@ export default class BabylonJSController {
2084
2295
  } catch (error) {
2085
2296
  console.warn("PrefViewer: Error exiting XR experience:", error);
2086
2297
  } finally {
2298
+ placement?.dispose();
2087
2299
  try {
2088
2300
  xrExperience.dispose();
2089
2301
  } catch (error) {
@@ -2091,6 +2303,7 @@ export default class BabylonJSController {
2091
2303
  }
2092
2304
  if (this.#XRExperience === xrExperience) {
2093
2305
  this.#XRExperience = null;
2306
+ this.#arPlacement = null;
2094
2307
  }
2095
2308
  this.#disablingPromises.xr = null;
2096
2309
  }
@@ -2270,7 +2483,7 @@ export default class BabylonJSController {
2270
2483
  }
2271
2484
 
2272
2485
  /**
2273
- * Handles pointer move events on the Babylon.js scene.
2486
+ * Handles camera activity and mouse/pen hover; touch-generated moves never enable persistent animation hover.
2274
2487
  * OPTIMIZATION: Tracks activity for idle mode.
2275
2488
  * @private
2276
2489
  * @param {PointerEvent} event - The pointer move event.
@@ -2287,7 +2500,7 @@ export default class BabylonJSController {
2287
2500
  if (camera && !camera.metadata?.locked) {
2288
2501
  this.#requestRender({ frames: 1, continuousMs: BabylonJSController.config.render.interactionMs });
2289
2502
  }
2290
- if (this.#babylonJSAnimationController && pickInfo) {
2503
+ if (!this.#isTouchPointerEvent(event) && this.#babylonJSAnimationController && pickInfo) {
2291
2504
  const pickedMeshId = pickInfo?.pickedMesh?.id || null;
2292
2505
  if (this.#state.pointerPicking.lastPickedMeshId !== pickedMeshId) {
2293
2506
  const highlightResult = this.#babylonJSAnimationController.highlightMeshes(pickInfo);
@@ -2299,14 +2512,108 @@ export default class BabylonJSController {
2299
2512
  }
2300
2513
 
2301
2514
  /**
2302
- * Invalidates an incomplete click gesture when the browser cancels its pointer.
2515
+ * Invalidates incomplete clicks/holds and tracked touches on pointer cancellation or window focus loss.
2303
2516
  * @private
2304
2517
  * @returns {void}
2305
2518
  */
2306
2519
  #onPointerCancel() {
2520
+ this.#cancelTouchMenuHold();
2521
+ this.#state.pointerPicking.touchPointers.clear();
2307
2522
  this.#state.pointerPicking.pointerDown = null;
2308
2523
  }
2309
2524
 
2525
+ /**
2526
+ * Clears the owned long-press timer and temporary touch highlight, preserving selection, camera controls and menu visibility.
2527
+ * @private
2528
+ * @returns {void}
2529
+ */
2530
+ #cancelTouchMenuHold() {
2531
+ clearTimeout(this.#state.pointerPicking.longPressTimeout);
2532
+ this.#state.pointerPicking.longPressTimeout = null;
2533
+ if (this.#state.pointerPicking.touchHighlightActive) {
2534
+ this.#setTouchAnimationHighlight(null);
2535
+ }
2536
+ }
2537
+
2538
+ /**
2539
+ * Identifies direct touch input and compatibility mouse events explicitly marked as touch-generated.
2540
+ * @private
2541
+ * @param {PointerEvent|MouseEvent|null|undefined} event - Current browser pointer event.
2542
+ * @returns {boolean} True when the event must not drive persistent mouse hover.
2543
+ */
2544
+ #isTouchPointerEvent(event) {
2545
+ return event?.pointerType === "touch" || event?.sourceCapabilities?.firesTouchEvents === true;
2546
+ }
2547
+
2548
+ /**
2549
+ * Applies opening-only feedback for a pending touch hold, or clears previous touch/mouse highlight.
2550
+ * Honors the configured highlight preference without changing it; invalidates the hover cache and wakes rendering on changes.
2551
+ * @private
2552
+ * @param {PickingInfo|null} pick - Opening candidate, or null to remove feedback.
2553
+ * @returns {void}
2554
+ */
2555
+ #setTouchAnimationHighlight(pick) {
2556
+ const result = this.#babylonJSAnimationController?.highlightMeshes(this.#renderSettings.highlightEnabled ? pick : null, true);
2557
+ this.#state.pointerPicking.touchHighlightActive = result?.highlighted ?? false;
2558
+ this.#state.pointerPicking.lastPickedMeshId = null;
2559
+ this.#state.pointerPicking.lastMovePickX = NaN;
2560
+ this.#state.pointerPicking.lastMovePickY = NaN;
2561
+ if (result?.changed) {
2562
+ this.#requestRender({
2563
+ frames: 1
2564
+ });
2565
+ }
2566
+ }
2567
+
2568
+ /**
2569
+ * Checks whether current content and UI can accept a touch opening-menu gesture or its temporary feedback.
2570
+ * @private
2571
+ * @returns {boolean} False for missing content, hidden/modal UI, disconnected canvas or an active XR transition/session.
2572
+ */
2573
+ #canUseTouchAnimationMenu() {
2574
+ const component = this.#canvas?.closest("pref-viewer-3d");
2575
+ const viewer = this.#canvas?.closest("pref-viewer");
2576
+ const xrState = this.#XRExperience?.baseExperience?.state;
2577
+ return !!(this.#scene && this.#canvas?.isConnected && this.#babylonJSAnimationController?.hasAnimations()
2578
+ && this.#canvas.ownerDocument.visibilityState !== "hidden" && component?.getAttribute("visible") !== "false"
2579
+ && viewer?.getAttribute("mode") !== "2d" && !viewer?.querySelector("pref-viewer-dialog[open]")
2580
+ && (xrState === undefined || xrState === WebXRState.NOT_IN_XR));
2581
+ }
2582
+
2583
+ /**
2584
+ * Schedules a single-touch context action using the existing click movement tolerance.
2585
+ * Highlights the opening candidate during the pending half-second only, if enabled; early release or cancellation clears it.
2586
+ * Rechecks current content, visible UI and XR eligibility before picking the original CSS touch position.
2587
+ * Recognized holds consume the click and its release; no camera input is prevented or captured here.
2588
+ * @private
2589
+ * @param {PointerEvent} event - Primary touch down on this viewer's canvas.
2590
+ * @returns {void} Owns one cancellable timer until activation, interruption or teardown.
2591
+ */
2592
+ #scheduleTouchMenuHold(event) {
2593
+ this.#cancelTouchMenuHold();
2594
+ const state = this.#state.pointerPicking;
2595
+ const start = state.pointerDown;
2596
+ if (event?.pointerType !== "touch" || !start || state.touchPointers.size !== 1 || !this.#canUseTouchAnimationMenu()) {
2597
+ return;
2598
+ }
2599
+ const rect = this.#canvas.getBoundingClientRect();
2600
+ this.#setTouchAnimationHighlight(this.#scene.pick(start.x - rect.left, start.y - rect.top));
2601
+ state.longPressTimeout = setTimeout(() => {
2602
+ state.longPressTimeout = null;
2603
+ this.#cancelTouchMenuHold();
2604
+ if (state.pointerDown !== start || start.dragged || state.touchPointers.size !== 1 || !this.#canUseTouchAnimationMenu()) {
2605
+ return;
2606
+ }
2607
+ start.longPressed = start.dragged = true;
2608
+ const rect = this.#canvas.getBoundingClientRect();
2609
+ const pick = this.#scene.pick(start.x - rect.left, start.y - rect.top);
2610
+ this.#babylonJSAnimationController.showMenu(pick);
2611
+ this.#requestRender({
2612
+ frames: 1
2613
+ });
2614
+ }, BabylonJSController.config.pointerPicking.longPressMs);
2615
+ }
2616
+
2310
2617
  /**
2311
2618
  * Emits the internal 3D click contract for every model mesh crossed by the pointer ray.
2312
2619
  * @private
@@ -2344,6 +2651,8 @@ export default class BabylonJSController {
2344
2651
  /**
2345
2652
  * Handles pointer events observed on the Babylon.js scene.
2346
2653
  * Uses pointer-move sampling to avoid running expensive `scene.pick()` on every event.
2654
+ * Recognizes stationary primary touch holds independently of short-click selection; release preserves their menu.
2655
+ * Touch motion skips hover raycasts; touch down clears stale hover before starting temporary hold feedback.
2347
2656
  * @private
2348
2657
  * @param {PointerInfo} info - The pointer event information from Babylon.js.
2349
2658
  * @returns {void}
@@ -2351,26 +2660,47 @@ export default class BabylonJSController {
2351
2660
  #onPointerObservable(info) {
2352
2661
  if (info.type === PointerEventTypes.POINTERMOVE) {
2353
2662
  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)) {
2663
+ 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
2664
  start.dragged = true;
2665
+ this.#cancelTouchMenuHold();
2357
2666
  }
2358
2667
  let pickInfo = null;
2359
2668
  let lastPickedMeshId = null;
2360
- if (this.#shouldPickOnPointerMove()) {
2669
+ if (!this.#isTouchPointerEvent(info.event) && this.#shouldPickOnPointerMove()) {
2361
2670
  pickInfo = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
2362
2671
  lastPickedMeshId = pickInfo?.pickedMesh?.id || null;
2363
2672
  }
2364
2673
  this.#onPointerMove(info.event, pickInfo);
2365
2674
  this.#state.pointerPicking.lastPickedMeshId = lastPickedMeshId;
2366
2675
  } 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;
2676
+ this.#cancelTouchMenuHold();
2677
+ if (this.#isTouchPointerEvent(info.event)) {
2678
+ this.#setTouchAnimationHighlight(null);
2679
+ }
2680
+ if (info.event?.pointerType === "touch") {
2681
+ this.#state.pointerPicking.touchPointers.add(info.event.pointerId);
2682
+ }
2683
+ this.#state.pointerPicking.pointerDown = info.event?.button === 0 && info.event.isPrimary !== false && this.#state.pointerPicking.touchPointers.size <= 1 && !this.#state.pointerPicking.pointerDown ? {
2684
+ x: info.event.clientX,
2685
+ y: info.event.clientY,
2686
+ button: info.event.button,
2687
+ pointerId: info.event.pointerId
2688
+ } : null;
2689
+ this.#scheduleTouchMenuHold(info.event);
2370
2690
  } else if (info.type === PointerEventTypes.POINTERUP) {
2691
+ this.#cancelTouchMenuHold();
2692
+ this.#state.pointerPicking.touchPointers.delete(info.event?.pointerId);
2371
2693
  const pointerDown = this.#state.pointerPicking.pointerDown;
2372
2694
  this.#state.pointerPicking.pointerDown = null;
2373
- const pointerUp = { x: info.event?.clientX, y: info.event?.clientY, button: info.event?.button, pointerId: info.event?.pointerId };
2695
+ if (pointerDown?.longPressed && pointerDown.pointerId === info.event?.pointerId) {
2696
+ return;
2697
+ }
2698
+ const pointerUp = {
2699
+ x: info.event?.clientX,
2700
+ y: info.event?.clientY,
2701
+ button: info.event?.button,
2702
+ pointerId: info.event?.pointerId
2703
+ };
2374
2704
  if (BabylonJSSelectionController.isPrimaryClickGesture(pointerDown, pointerUp, BabylonJSController.config.pointerPicking.clickMaxDistancePx)) {
2375
2705
  this.#emitModelMeshClick();
2376
2706
  }
@@ -2386,6 +2716,7 @@ export default class BabylonJSController {
2386
2716
 
2387
2717
  /**
2388
2718
  * Applies a pending engine resize, cancels cube navigation and wakes rendering.
2719
+ * Skips zero-sized hidden canvases to prevent intrinsic-buffer/DPR growth; a visible resize retries later.
2389
2720
  * @private
2390
2721
  * @param {number} elapsed - Time since the previous resize, retained for diagnostics.
2391
2722
  * @returns {void}
@@ -2396,6 +2727,12 @@ export default class BabylonJSController {
2396
2727
  if (!this.#engine) {
2397
2728
  return;
2398
2729
  }
2730
+ // Babylon falls back to the intrinsic canvas size for hidden elements, then applies
2731
+ // DPR again. Repeated hidden resizes can exponentially grow GPU attachments on WebKit.
2732
+ // ResizeObserver will notify us again when the canvas has a visible CSS size.
2733
+ if (!this.#canvas.clientWidth || !this.#canvas.clientHeight) {
2734
+ return;
2735
+ }
2399
2736
  this.#state.resize.lastAppliedAt = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
2400
2737
  console.log(`PrefViewer: Applying resize after ${Math.round(elapsed)}ms`);
2401
2738
  this.#applyShowroomCameraFraming();
@@ -2531,6 +2868,7 @@ export default class BabylonJSController {
2531
2868
  * Handles canvas resize notifications.
2532
2869
  * Resizes the Babylon engine and requests a short on-demand render burst so camera-dependent
2533
2870
  * buffers and post-process pipelines are redrawn at the new viewport size.
2871
+ * Coalesces notifications through a throttled timer and animation frame, outside ResizeObserver delivery.
2534
2872
  * @private
2535
2873
  * @returns {void}
2536
2874
  */
@@ -2542,28 +2880,33 @@ export default class BabylonJSController {
2542
2880
  const elapsed = now - this.#state.resize.lastAppliedAt;
2543
2881
 
2544
2882
 
2545
- if (elapsed >= BabylonJSController.config.resize.throttleMs) {
2546
- this.#applyResize(elapsed);
2547
- return;
2548
- }
2549
-
2550
2883
  if (this.#state.resize.isScheduled) {
2551
2884
  return;
2552
2885
  }
2553
2886
 
2554
2887
  this.#state.resize.isScheduled = true;
2888
+ // Mutating canvas buffers/layout inside ResizeObserver delivery causes WebKit's
2889
+ // "undelivered notifications" loop. Apply before the next paint instead.
2555
2890
  const waitMs = Math.max(0, BabylonJSController.config.resize.throttleMs - elapsed);
2556
2891
  this.#state.resize.timeoutId = setTimeout(() => {
2557
- this.#applyResize(elapsed);
2892
+ this.#state.resize.timeoutId = null;
2893
+ this.#state.resize.frameId = requestAnimationFrame(() => {
2894
+ this.#state.resize.frameId = null;
2895
+ this.#applyResize(elapsed);
2896
+ });
2558
2897
  }, waitMs);
2559
2898
  }
2560
2899
 
2561
2900
  /**
2562
- * Clears any queued throttled resize callback.
2901
+ * Clears both the queued throttle timer and animation-frame resize callback during teardown.
2563
2902
  * @private
2564
2903
  * @returns {void}
2565
2904
  */
2566
2905
  #cancelScheduledResize() {
2906
+ if (this.#state.resize.frameId != null) {
2907
+ cancelAnimationFrame(this.#state.resize.frameId);
2908
+ }
2909
+ this.#state.resize.frameId = null;
2567
2910
  if (this.#state.resize.timeoutId !== null) {
2568
2911
  clearTimeout(this.#state.resize.timeoutId);
2569
2912
  }
@@ -2639,7 +2982,7 @@ export default class BabylonJSController {
2639
2982
 
2640
2983
  /**
2641
2984
  * 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
2985
+ * Configures unlocked orbital cameras for proportional native wheel/pinch input, rebinds the model zoom guard
2643
2986
  * and synchronizes the cube after selecting the active camera.
2644
2987
  * A missing named camera falls back to the unlocked default camera, rather than retaining an imported view.
2645
2988
  * @private
@@ -2694,7 +3037,7 @@ export default class BabylonJSController {
2694
3037
  camera.detachControl();
2695
3038
  if (!cameraState.locked) {
2696
3039
  if (camera instanceof ArcRotateCamera) {
2697
- camera.wheelDeltaPercentage = BabylonJSController.config.camera.wheelDeltaPercentage;
3040
+ this.#configureOrbitalZoom(camera);
2698
3041
  }
2699
3042
  camera.attachControl(this.#canvas, true);
2700
3043
  }
@@ -3000,11 +3343,13 @@ export default class BabylonJSController {
3000
3343
  * Loads a single asset container (model, environment, materials, etc.) based on the container state flags.
3001
3344
  * Skips work when nothing is pending, otherwise resolves the GLTF source, refreshes cache metadata and streams it
3002
3345
  * into the Babylon.js scene via `LoadAssetContainerAsync`.
3346
+ * Distinguishes unchanged content from a failed replacement for native AR readiness and revokes source URLs
3347
+ * after attempted imports. Resolver failures may reject; Babylon import errors are logged and returned as null.
3003
3348
  * @private
3004
3349
  * @param {object} container - Container descriptor that carries the GLTF storage pointer and current cache info.
3005
3350
  * @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.
3351
+ * @returns {Promise<[object, AssetContainer|false|null]>} Container paired with imported assets, false for skipped,
3352
+ * unchanged or unresolved content, or null after a caught import failure.
3008
3353
  * @description
3009
3354
  * 1. Validates that the container has pending data and initializes the shared `GLTFResolver` if needed.
3010
3355
  * 2. Requests the source blob (respecting the cached size/timestamp unless `force` is set) and stores the new cache
@@ -3028,7 +3373,18 @@ export default class BabylonJSController {
3028
3373
  const currentSize = force ? 0 : container.state.size;
3029
3374
  const currentTimeStamp = force ? null : container.state.timeStamp;
3030
3375
 
3031
- let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, currentSize, currentTimeStamp);
3376
+ const isQuickLookContent = container.state.name === "model" || container.state.name === "materials";
3377
+ if (isQuickLookContent) {
3378
+ this.#quickLookValid[container.state.name] = false;
3379
+ }
3380
+ let sourceData = await this.#gltfResolver.getSource(container.state.update.storage, currentSize, currentTimeStamp, true);
3381
+
3382
+ if (sourceData?.unchanged) {
3383
+ if (isQuickLookContent) {
3384
+ this.#quickLookValid[container.state.name] = !!container.assetContainer;
3385
+ }
3386
+ return [container, false];
3387
+ }
3032
3388
 
3033
3389
  if (!sourceData) {
3034
3390
  return [container, false];
@@ -3070,6 +3426,7 @@ export default class BabylonJSController {
3070
3426
  /**
3071
3427
  * Cancels pending cube navigation, loads asset containers and adds replacements to the scene.
3072
3428
  * Current model selection is resolved by the zoom guard on subsequent input updates.
3429
+ * Invalidates native content during overlapping loads, then prepares only completed current-model content.
3073
3430
  * @private
3074
3431
  * @param {boolean} [force=false] - Whether to bypass existing asset cache metadata.
3075
3432
  * @returns {Promise<{success: boolean, error: any}>} Resolves to an object indicating if loading succeeded and any error encountered.
@@ -3079,6 +3436,29 @@ export default class BabylonJSController {
3079
3436
  * Returns an object with success status and error details.
3080
3437
  */
3081
3438
  async #loadContainers(force = false) {
3439
+ this.#quickLookAction?.invalidate();
3440
+ this.#quickLookLoads += 1;
3441
+ try {
3442
+ const result = await this.#loadContainersInternal(force);
3443
+ if (!result.success) {
3444
+ this.#quickLookValid.materials = false;
3445
+ }
3446
+ return result;
3447
+ } finally {
3448
+ this.#quickLookLoads -= 1;
3449
+ this.#refreshQuickLook();
3450
+ }
3451
+ }
3452
+
3453
+ /**
3454
+ * Loads pending containers in parallel, replaces scene assets and reapplies material/camera/light state.
3455
+ * Cancels cube/animation work, stops rendering during changes and restarts it after setup. Disposed scenes
3456
+ * discard late assets; successful model/material replacements update Quick Look content-validity state.
3457
+ * @private
3458
+ * @param {boolean} [force=false] - Whether to bypass cached source size/timestamp checks.
3459
+ * @returns {Promise<{success: boolean, error: *}>} Aggregate scene-load result and any captured setup error.
3460
+ */
3461
+ async #loadContainersInternal(force = false) {
3082
3462
  this.#viewCube?.cancel();
3083
3463
  this.#resetPointerPickingState();
3084
3464
  this.#detachAnimationChangedListener();
@@ -3125,6 +3505,9 @@ export default class BabylonJSController {
3125
3505
  }
3126
3506
  this.#replaceContainer(container, assetContainer);
3127
3507
  container.state.setSuccess(true);
3508
+ if (container.state.name === "model" || container.state.name === "materials") {
3509
+ this.#quickLookValid[container.state.name] = true;
3510
+ }
3128
3511
  } else {
3129
3512
  if (container.assetContainer && container.state.mustBeShown !== container.state.isVisible && container.state.name === "materials") {
3130
3513
  container.state.mustBeShown ? this.#addContainer(container) : this.#removeContainer(container);
@@ -3345,6 +3728,7 @@ export default class BabylonJSController {
3345
3728
 
3346
3729
  /**
3347
3730
  * Opens a modal dialog for downloading the 3D scene, model, or environment.
3731
+ * Uses shared dialog presentation and wrapping option groups; preserves export choices and routing.
3348
3732
  * @private
3349
3733
  * @returns {void}
3350
3734
  */
@@ -3358,15 +3742,15 @@ export default class BabylonJSController {
3358
3742
  const texts = I18n.translate("prefViewer.downloadDialog", locale ? { locale } : undefined) || {};
3359
3743
  const header = texts.title || "Download 3D Scene";
3360
3744
  const content = `
3361
- <form slot="content" id="download-dialog-form" style="display:flex;flex-direction:column;gap:16px;">
3745
+ <form slot="content" id="download-dialog-form" class="download-dialog-form">
3362
3746
  <h4>${texts.sections?.content || "Content"}</h4>
3363
- <div style="display:flex;flex-direction:row;gap:16px;margin:0 8px 0 8px;">
3747
+ <div class="download-dialog-options">
3364
3748
  <label><input type="radio" name="content" value="1"> ${texts.options?.model || "Model"}</label>
3365
3749
  <label><input type="radio" name="content" value="2"> ${texts.options?.scene || "Scene"}</label>
3366
3750
  <label><input type="radio" name="content" value="0" checked> ${texts.options?.both || "Both"}</label>
3367
3751
  </div>
3368
3752
  <h4>${texts.sections?.format || "Format"}</h4>
3369
- <div style="display:flex;flex-direction:row;gap:16px;margin:0 8px 0 8px;">
3753
+ <div class="download-dialog-options">
3370
3754
  <label><input type="radio" name="format" value="gltf" checked> glTF (ZIP)</label>
3371
3755
  <label><input type="radio" name="format" value="glb"> GLB</label>
3372
3756
  <label><input type="radio" name="format" value="usdz"> USDZ</label>
@@ -3416,7 +3800,7 @@ export default class BabylonJSController {
3416
3800
  */
3417
3801
 
3418
3802
  /**
3419
- * Creates the enabled cube once a live scene/camera exists and teardown is not in progress.
3803
+ * Creates the selected SVG/Canvas cube once a live scene/camera exists and teardown is not in progress.
3420
3804
  * Resolves camera/model state through callbacks; does not reload assets or camera options.
3421
3805
  * @private
3422
3806
  * @returns {void}
@@ -3435,7 +3819,7 @@ export default class BabylonJSController {
3435
3819
  onLayout: this.#viewCubeConfig.onLayout,
3436
3820
  // A cube drag is pointer interaction like a main-canvas drag: keep the loop continuous for the same window.
3437
3821
  requestRender: (options) => this.#requestRender({ frames: 2, continuousMs: options?.interaction ? BabylonJSController.config.render.interactionMs : 0 }),
3438
- });
3822
+ }, this.#viewCubeConfig.renderer);
3439
3823
  }
3440
3824
 
3441
3825
  /**
@@ -3444,10 +3828,15 @@ export default class BabylonJSController {
3444
3828
  * Observes canvas resize events, binds model-only orbital zoom protection and adaptive default-camera near clipping,
3445
3829
  * and lazily creates the requested cube. Imported camera clipping is preserved by the host policy.
3446
3830
  * Aborts remaining initialization if the scene was replaced/disposed during texture readiness.
3831
+ * Waits for preceding teardown and guards XR route creation with the current scene generation.
3447
3832
  * @public
3448
3833
  * @returns {Promise<void>}
3449
3834
  */
3450
3835
  async enable() {
3836
+ if (this.#disablingPromises.general) {
3837
+ await this.#disablingPromises.general;
3838
+ }
3839
+ const enabledGeneration = ++this.#xrGeneration;
3451
3840
  this.#configureDracoCompression();
3452
3841
  this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false }, true);
3453
3842
  this.#engine.disableUniformBuffers = true;
@@ -3490,7 +3879,7 @@ export default class BabylonJSController {
3490
3879
  this.#initializeViewCube();
3491
3880
  const enabledScene = this.#scene;
3492
3881
  await this.#ensureEnvironmentBRDFTextureReady();
3493
- if (!this.#engine || this.#scene !== enabledScene) {
3882
+ if (!this.#engine || this.#scene !== enabledScene || this.#xrGeneration !== enabledGeneration) {
3494
3883
  return;
3495
3884
  }
3496
3885
  this.#enableInteraction();
@@ -3503,10 +3892,16 @@ export default class BabylonJSController {
3503
3892
  * floor and suspending cube creation until the next enable. Cleans up light, XR and resolver resources.
3504
3893
  * The teardown is asynchronous: it waits for XR/session-dependent shutdown work
3505
3894
  * before disposing the engine, and coalesces concurrent calls into one in-flight promise.
3895
+ * Immediately invalidates pending XR setup and disposes the Quick Look action before awaiting teardown.
3506
3896
  * @public
3507
3897
  * @returns {Promise<void>}
3508
3898
  */
3509
3899
  async disable() {
3900
+ // Invalidate pending support/setup before the first teardown await, even while the scene is live.
3901
+ this.#xrGeneration += 1;
3902
+ this.#quickLookAction?.dispose();
3903
+ this.#quickLookAction = null;
3904
+ this.#quickLookValid = { model: false, materials: true };
3510
3905
  BabylonJSCameraWheelMotion.cancel(this.#scene?.activeCamera);
3511
3906
  BabylonJSCameraWheelMotion.cancel(this.#camera);
3512
3907
  BabylonJSCameraWheelMotion.releaseSurfaceFocus(this.#camera);
@@ -3564,18 +3959,30 @@ export default class BabylonJSController {
3564
3959
  }
3565
3960
 
3566
3961
  /**
3567
- * Synchronizes the existing cube after culture or camera-state changes without creating an inactive cube.
3962
+ * Selects the cube implementation while preserving the active scene, camera and asset state.
3963
+ * @param {"svg"|"canvas"} renderer - Per-viewer preference; invalid values use Canvas.
3964
+ * @returns {void} Disabled cubes retain preference without allocating a new overlay.
3965
+ */
3966
+ setViewCubeRenderer(renderer) {
3967
+ this.#viewCubeConfig.renderer = BabylonJSViewCubeController.normalizeRenderer(renderer);
3968
+ this.#viewCube?.setRenderer(this.#viewCubeConfig.renderer);
3969
+ }
3970
+
3971
+ /**
3972
+ * Resizes/synchronizes the existing cube after culture, camera or presentation changes without activating it.
3973
+ * Also refreshes the existing Quick Look action's localized label from the host culture.
3568
3974
  * @public
3569
3975
  * @returns {void}
3570
3976
  */
3571
3977
  refreshViewCube() {
3572
- this.#viewCube?.sync();
3978
+ this.#quickLookAction?.refreshCulture();
3979
+ this.#viewCube?.resize();
3573
3980
  }
3574
3981
 
3575
3982
  /**
3576
3983
  * Applies the host-owned semantic selection state to the current model meshes.
3577
3984
  * @public
3578
- * @param {object} state - Canonical PrefViewer selection snapshot.
3985
+ * @param {object} [state={}] - Canonical PrefViewer selection snapshot.
3579
3986
  * @returns {void}
3580
3987
  */
3581
3988
  applySelectionState(state = {}) {
@@ -3583,9 +3990,9 @@ export default class BabylonJSController {
3583
3990
  }
3584
3991
 
3585
3992
  /**
3586
- * Downloads the current scene, model, or environment as a GLB file.
3993
+ * Downloads the current scene, model, or environment as a GLB file, excluding transient AR feedback.
3587
3994
  * @public
3588
- * @param {number} content - 0 for full scene, 1 for model, 2 for environment.
3995
+ * @param {number} [content=0] - 0 for full scene, 1 for model, 2 for environment.
3589
3996
  * @returns {void}
3590
3997
  */
3591
3998
  downloadGLB(content = 0) {
@@ -3608,7 +4015,8 @@ export default class BabylonJSController {
3608
4015
  break;
3609
4016
  }
3610
4017
  fileName = this.#addDateToName(fileName);
3611
- GLTF2Export.GLBAsync(container, fileName, { exportWithoutWaitingForScene: true }).then((glb) => {
4018
+ GLTF2Export.GLBAsync(container, fileName, { exportWithoutWaitingForScene: true,
4019
+ shouldExportNode: node => !node.metadata?.prefViewerARFeedback }).then((glb) => {
3612
4020
  if (glb) {
3613
4021
  glb.downloadFiles();
3614
4022
  }
@@ -3616,9 +4024,9 @@ export default class BabylonJSController {
3616
4024
  }
3617
4025
 
3618
4026
  /**
3619
- * Downloads the current scene, model, or environment as a glTF ZIP file.
4027
+ * Downloads the current scene, model, or environment as a glTF ZIP file, excluding transient AR feedback.
3620
4028
  * @public
3621
- * @param {number} content - 0 for full scene, 1 for model, 2 for environment.
4029
+ * @param {number} [content=0] - 0 for full scene, 1 for model, 2 for environment.
3622
4030
  * @returns {void}
3623
4031
  */
3624
4032
  downloadGLTF(content = 0) {
@@ -3645,7 +4053,8 @@ export default class BabylonJSController {
3645
4053
  break;
3646
4054
  }
3647
4055
  comment += "\nPrefViewer by Preference, S.L.\npreference.com\n";
3648
- GLTF2Export.GLTFAsync(container, fileName, { exportWithoutWaitingForScene: true }).then((gltf) => {
4056
+ GLTF2Export.GLTFAsync(container, fileName, { exportWithoutWaitingForScene: true,
4057
+ shouldExportNode: node => !node.metadata?.prefViewerARFeedback }).then((gltf) => {
3649
4058
  if (gltf?.files) {
3650
4059
  this.#downloadZip(gltf.files, fileName, comment, true);
3651
4060
  }
@@ -3653,9 +4062,9 @@ export default class BabylonJSController {
3653
4062
  }
3654
4063
 
3655
4064
  /**
3656
- * Downloads the current scene, model, or environment as a USDZ file.
4065
+ * Downloads the current scene, model, or environment as a USDZ file, excluding transient AR feedback.
3657
4066
  * @public
3658
- * @param {number} content - 0 for full scene, 1 for model, 2 for environment.
4067
+ * @param {number} [content=0] - 0 for full scene, 1 for model, 2 for environment.
3659
4068
  * @returns {void}
3660
4069
  */
3661
4070
  downloadUSDZ(content = 0) {
@@ -3678,7 +4087,7 @@ export default class BabylonJSController {
3678
4087
  break;
3679
4088
  }
3680
4089
  fileName = this.#addDateToName(fileName);
3681
- USDZExportAsync(container).then((response) => {
4090
+ USDZExportAsync(container, undefined, mesh => !mesh.metadata?.prefViewerARFeedback).then((response) => {
3682
4091
  if (response) {
3683
4092
  Tools.Download(new Blob([response], { type: "model/vnd.usdz+zip" }), `${fileName}.usdz`);
3684
4093
  }
@@ -3698,7 +4107,8 @@ export default class BabylonJSController {
3698
4107
  * Returns a diagnostic snapshot of the current Babylon.js runtime state.
3699
4108
  * Intended for integration troubleshooting and should not mutate scene state.
3700
4109
  * @public
3701
- * @returns {object} Debug information for canvas, engine, scene, containers, render loop and settings.
4110
+ * @returns {object} Canvas, engine, scene, container, render-loop and settings diagnostics, plus nullable
4111
+ * `ar` placement state, metric distance/position and drag/model availability when WebXR is configured.
3702
4112
  */
3703
4113
  getDebugInfo() {
3704
4114
 
@@ -3737,6 +4147,7 @@ export default class BabylonJSController {
3737
4147
 
3738
4148
 
3739
4149
  return {
4150
+ ar: this.#arPlacement?.getDebugInfo() ?? null,
3740
4151
  canvas: {
3741
4152
  clientWidth: this.#canvas?.clientWidth ?? null,
3742
4153
  clientHeight: this.#canvas?.clientHeight ?? null,
@@ -3833,13 +4244,16 @@ export default class BabylonJSController {
3833
4244
  /**
3834
4245
  * Applies material options from the configuration to the relevant meshes.
3835
4246
  * Stops and restarts the render loop to apply changes.
4247
+ * Invalidates native content during the update and requests fresh Quick Look preparation after render readiness.
3836
4248
  * @public
3837
4249
  * @returns {boolean} True if material options were set successfully, false otherwise.
3838
4250
  */
3839
4251
  setMaterialOptions() {
4252
+ this.#quickLookAction?.invalidate();
3840
4253
  this.#stopRender();
3841
4254
  const materialsOptionsSetted = this.#setOptions_Materials();
3842
- this.#startRender();
4255
+ void this.#startRender().then(() => this.#refreshQuickLook())
4256
+ .catch(error => console.warn("PrefViewer: failed to prepare material rendering", error));
3843
4257
  return materialsOptionsSetted;
3844
4258
  }
3845
4259
 
@@ -3864,7 +4278,7 @@ export default class BabylonJSController {
3864
4278
  * When `showLightingTimeOfDay` is set to false and no explicit `lightingTimeOfDay` is supplied,
3865
4279
  * the active lighting time is reset to `BabylonJSController.config.render.defaultSettings.lightingTimeOfDay` (0.5).
3866
4280
  * @public
3867
- * @param {object} config - Immediate render/illumination configuration.
4281
+ * @param {object} [config={}] - Immediate render/illumination configuration.
3868
4282
  * @param {boolean|string} [config.showAnimationMenu] - Enables/disables the animation controls when animations are available.
3869
4283
  * @param {boolean|string|null} [config.highlightEnabled] - Enables/disables hover highlight; string "false" is normalized to false, null is ignored.
3870
4284
  * @param {string|null} [config.highlightColor] - Hex color used by hover highlight; null is ignored.
@@ -3896,7 +4310,7 @@ export default class BabylonJSController {
3896
4310
  }
3897
4311
 
3898
4312
  if (typeof config.lightingTimeOfDay === "number" && Number.isFinite(config.lightingTimeOfDay)) {
3899
- const normalizedTime = RenderMath.clamp01(config.lightingTimeOfDay);
4313
+ const normalizedTime = BabylonJSRenderMath.clamp01(config.lightingTimeOfDay);
3900
4314
  if (this.#renderSettings.lightingTimeOfDay !== normalizedTime) {
3901
4315
  this.#renderSettings.lightingTimeOfDay = normalizedTime;
3902
4316
  shouldApplyLighting = true;
@@ -3947,6 +4361,8 @@ export default class BabylonJSController {
3947
4361
  * Sets the visibility of a container (model, environment, etc.) by name.
3948
4362
  * Adds or removes the container from the scene and updates wall/floor visibility.
3949
4363
  * Restarts the render loop to apply changes.
4364
+ * Refreshes Quick Look readiness after rendering so hidden model content cannot retain an active native link.
4365
+ * Render/preparation failures are logged asynchronously; this method does not wait for native preparation.
3950
4366
  * @public
3951
4367
  * @param {string} name - The name of the container to show or hide.
3952
4368
  * @param {boolean} show - True to show the container, false to hide it.
@@ -3964,7 +4380,8 @@ export default class BabylonJSController {
3964
4380
  this.#stopRender();
3965
4381
  show ? this.#addContainer(container) : this.#removeContainer(container);
3966
4382
  this.#setVisibilityOfWallAndFloorInModel();
3967
- this.#startRender();
4383
+ void this.#startRender().then(() => this.#refreshQuickLook())
4384
+ .catch(error => console.warn("PrefViewer: failed to prepare visibility rendering", error));
3968
4385
  }
3969
4386
 
3970
4387
  /**
@@ -3973,7 +4390,7 @@ export default class BabylonJSController {
3973
4390
  * Immediate settings such as highlight, animation menu, and time-of-day lighting are handled by
3974
4391
  * `setIlluminationConfig()` before this method receives the remaining reload settings.
3975
4392
  * @public
3976
- * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} settings Partial set of reload render toggles to apply.
4393
+ * @param {{antiAliasingEnabled?:boolean, ambientOcclusionEnabled?:boolean, iblEnabled?:boolean, shadowsEnabled?:boolean}} [settings={}] Partial set of reload render toggles to apply.
3977
4394
  * @returns {{changed:boolean, settings:object}} Current full render settings snapshot and whether a reload is required.
3978
4395
  * @description
3979
4396
  * Callers can inspect the `changed` flag to decide whether to trigger a reload with
@@ -4042,9 +4459,9 @@ export default class BabylonJSController {
4042
4459
  }
4043
4460
 
4044
4461
  /**
4045
- * Configures the highlight settings for animated nodes on hover.
4462
+ * Configures animation highlights for mouse hover and pending touch holds.
4046
4463
  * @private
4047
- * @param {{enabled?: boolean, color?: string}} config - Configuration object.
4464
+ * @param {{enabled?: boolean, color?: string}} [config={}] - Configuration object.
4048
4465
  * @returns {void}
4049
4466
  */
4050
4467
  #setHighlightConfig(config = {}) {