@preference-sl/pref-viewer 3.0.1-beta.0 → 3.1.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.
package/README.md CHANGED
@@ -20,15 +20,21 @@
20
20
  - Export helpers for model, scene, or combined model + scene downloads.
21
21
  - Type declarations for TypeScript and JSX usage.
22
22
 
23
- ## AR support
23
+ ## AR and VR support
24
24
 
25
25
  | Browser/platform | Viewer route |
26
26
  | --- | --- |
27
27
  | Android Chrome with confirmed `immersive-ar` and native plane hit-test support | WebXR AR at authored metre scale |
28
28
  | Eligible Safari on iOS/iPadOS without WebXR AR support | AR Quick Look using a client-generated USDZ of the current visible model |
29
- | Other browsers without a supported AR route | No AR action |
30
- | iPhone VR | Not implemented |
31
- | visionOS WebXR VR | Platform capability; this viewer does not implement `immersive-vr` |
29
+ | Headset browsers with `immersive-vr` (Meta Quest Browser, Pico, SteamVR-backed desktop browsers, visionOS Safari with WebXR enabled) | WebXR VR at authored metre scale, standing on the virtual floor in front of the model |
30
+ | Other browsers without a supported AR or VR route | No AR/VR action |
31
+ | iPhone VR (Cardboard, device-orientation stereo) | Not implemented |
32
+
33
+ The viewer renders one control per supported session mode (`.babylonVRicon.pref-viewer-xr-ar`, `.babylonVRicon.pref-viewer-xr-vr`) inside its `.xr-button-overlay`. Session support is queried through `navigator.xr.isSessionSupported` at viewer start; a device that supports both modes shows both controls, and the other control is hidden while a session is active.
34
+
35
+ ### VR
36
+
37
+ VR uses the `local-floor` reference space without teleportation, controller selection, hand tracking or model interaction: the user looks around and walks within the tracked area. On entry the XR camera is placed at floor height (the model's lowest point), centred on the model's horizontal bounds and 2.5 m along the viewer's existing Inner (+Z) direction, facing the model. The world scale is forced to 1 so the glTF's authored metre dimensions apply, and restored on exit. The virtual environment stays visible (unlike AR, which hides it), model materials are not adapted, and the model keeps its current transforms. `getDebugInfo().vr` reports `phase`, `standingDistance`, `position` and `floorHeight`. Headset-specific rendering, comfort and visionOS WebXR availability still require physical-device validation.
32
38
 
33
39
  Android WebXR preserves the glTF's authored metre dimensions, including existing unit-conversion transforms. It never fits the model to the screen or permits pinch scaling. Assets must encode their dimensions in metres and orient their interior toward the viewer's existing Inner (+Z) direction; the viewer cannot infer an interior face or correct arbitrary source units. The upstream exporter owns those asset conventions.
34
40
 
@@ -48,6 +54,22 @@ On eligible Safari, the AR control uses the existing XR appearance and position.
48
54
 
49
55
  The link uses `rel="ar"`, `download="model.usdz"` and a `model/vnd.usdz+zip` Blob URL with the `#allowsContentScaling=0` fragment, so its native navigation runs directly on the user tap and Quick Look keeps the authored real-world size (no pinch scaling, as on Android WebXR). Only model meshes with supported materials and current visibility are exported. Environment and viewer overlays are excluded. Physical transmission glass is exported with an equivalent alpha opacity, because USDZ has no transmission. The viewer bundles the `fflate` helper the exporter needs, so no request to `unpkg.com` is made.
50
56
 
57
+ #### Host-supplied Quick Look asset
58
+
59
+ A host that produces the AR asset elsewhere (for example a server-generated USDZ or USD) can hand it to the Quick Look action with `setQuickLookAssetProvider(provider)`. The provider is a function returning `Promise<{ data: Uint8Array | ArrayBuffer | Blob, mimeType?: string, fileName?: string }>`; `mimeType` defaults to `model/vnd.usdz+zip` and `fileName` to `model.usdz`. Pass `null` to clear it. The viewer never learns how the asset is made.
60
+
61
+ ```js
62
+ viewer.setQuickLookAssetProvider(async () => {
63
+ const response = await fetch("/my-server/ar-model");
64
+ return { data: await response.arrayBuffer(), mimeType: "model/vnd.usdz+zip", fileName: "model.usdz" };
65
+ });
66
+ ```
67
+
68
+ - While a provider is set, the Quick Look link uses its asset instead of the Babylon USDZ export. It is called each time the link is prepared (after model/material loading and changes), only on the Quick Look route (eligible iOS/iPadOS Safari without WebXR AR).
69
+ - Setting or clearing the provider re-prepares the link when Quick Look is active; a provider set earlier (even before the element is connected) is retained.
70
+ - The ZIP signature check applies only when `mimeType` is `model/vnd.usdz+zip`. Any other MIME type (for example `model/vnd.usd`, or `text/plain` for USDA) is passed through unchanged as the Blob type, with `fileName` as the link's `download` attribute. AR Quick Look officially opens only `.usdz`/`.reality`, so what iOS does with other types is the host's experiment.
71
+ - If the provider throws, rejects or returns empty data, the viewer logs `PrefViewer: Quick Look asset provider failed` with `console.warn` and falls back to its own USDZ export.
72
+
51
73
  Manual iOS Quick Look validation has been reported: its AR button remains usable even when the Babylon 3D view is empty. The empty scene/model failure has also been reproduced on an iPhone and remains unresolved; Quick Look validation does not establish successful in-page 3D rendering. Device/browser versions and itemized scale, glass, texture, close/reopen, iPad and production-iframe results were not supplied, so those details remain unconfirmed. Safari remote inspection is unavailable in the current test setup because no macOS system is available. An iframe must permit the browser's native AR/navigation behavior; no automatic top-level redirect or parent-message protocol is added. Used Blob URLs are retained through model replacement/detach until an observed browser return; if Safari emits no return signal, their lifetime ends with the document.
52
74
 
53
75
  ## Installation
@@ -432,6 +454,7 @@ Dialogs opened with `openDialog()`, including the **Ctrl+Alt+D** download dialog
432
454
  | `downloadModelGLB()` / `downloadModelGLTF()` / `downloadModelUSDZ()` | Exports the current model. |
433
455
  | `downloadSceneGLB()` / `downloadSceneGLTF()` / `downloadSceneUSDZ()` | Exports the current scene/environment. |
434
456
  | `downloadModelAndSceneGLB()` / `downloadModelAndSceneGLTF()` / `downloadModelAndSceneUSDZ()` | Exports the combined model and scene. |
457
+ | `setQuickLookAssetProvider(provider \| null)` | Lets the host supply the asset opened by the iOS AR Quick Look action instead of the viewer's client-generated USDZ (for example a file produced by a server). See [AR and VR support](#ar-and-vr-support). |
435
458
 
436
459
  All viewer dialogs share this priority, including the model-download dialog opened with **Ctrl+Alt+D** while the 3D canvas receives keyboard input. Existing backdrop dismissal remains available: a click over a covered control may close the dialog, but does not also activate that control. Closing the dialog restores access to the underlying controls.
437
460
 
package/index.d.ts CHANGED
@@ -139,6 +139,18 @@ export interface PrefViewerElementClickDetail {
139
139
  ids: string[];
140
140
  }
141
141
 
142
+ /** Asset a host hands to the iOS AR Quick Look action. */
143
+ export interface PrefViewerQuickLookAsset {
144
+ data: Uint8Array | ArrayBuffer | Blob;
145
+ /** Defaults to `model/vnd.usdz+zip`; the ZIP signature is checked only for that type. */
146
+ mimeType?: string;
147
+ /** Defaults to `model.usdz`; used as the link's `download` attribute. */
148
+ fileName?: string;
149
+ }
150
+
151
+ /** Async factory of the Quick Look asset, called each time the Quick Look link is prepared. */
152
+ export type PrefViewerQuickLookAssetProvider = () => Promise<PrefViewerQuickLookAsset>;
153
+
142
154
  /** Optional speed/direction for `startTurnable` / `toggleTurnable`. */
143
155
  export interface PrefViewerTurnableOptions {
144
156
  speed?: number;
@@ -356,6 +368,13 @@ export declare class PrefViewer extends HTMLElement {
356
368
  setAnimationProgress(name: string, progress: number): boolean;
357
369
  setAnimationLoop(name: string, loop: boolean): boolean;
358
370
 
371
+ // ── AR Quick Look (iOS) ──
372
+ /**
373
+ * Supplies (or clears with `null`) the asset opened by the iOS AR Quick Look action instead of the
374
+ * viewer's client-generated USDZ. A failing provider falls back to the built-in export with a console warning.
375
+ */
376
+ setQuickLookAssetProvider(provider: PrefViewerQuickLookAssetProvider | null): void;
377
+
359
378
  // ── Downloads ──
360
379
  downloadModelGLB(): void;
361
380
  downloadModelGLTF(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "3.0.1-beta.0",
3
+ "version": "3.1.0-beta.1",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -30,6 +30,7 @@ export default class BabylonJSARPlacement {
30
30
  #manager;
31
31
  #getModel;
32
32
  #getEnvironment;
33
+ #isActive;
33
34
  #observers = [];
34
35
  #session = null;
35
36
  #generation = 0;
@@ -81,23 +82,36 @@ export default class BabylonJSARPlacement {
81
82
  * @param {import("@babylonjs/core").WebXRDefaultExperience} experience - Native XR lifecycle owner.
82
83
  * @param {function(): ?import("@babylonjs/core").AssetContainer} getModel - Current usable model, or null while unavailable.
83
84
  * @param {function(): ?import("@babylonjs/core").AssetContainer} [getEnvironment] - Current environment; defaults to returning null.
85
+ * @param {function(): boolean} [isActive] - Whether the session being entered is an AR session; defaults to always true.
86
+ * While it returns false and no placement is active, entry and session-init events are ignored (frames already
87
+ * require an active placement) so other session modes such as VR leave the scene untouched.
88
+ * An already active placement still ends normally.
84
89
  */
85
- constructor(scene, experience, getModel, getEnvironment = () => null) {
90
+ constructor(scene, experience, getModel, getEnvironment = () => null, isActive = () => true) {
86
91
  this.#scene = scene;
87
92
  this.#feedback = new BabylonJSARPlacementFeedback(scene);
88
93
  this.#manager = experience.baseExperience.sessionManager;
89
94
  this.#getModel = getModel;
90
95
  this.#getEnvironment = getEnvironment;
96
+ this.#isActive = isActive;
91
97
  this.#observe(experience.baseExperience.onStateChangedObservable, state => {
92
- if (state === WebXRState.ENTERING_XR) {
98
+ if (state === WebXRState.ENTERING_XR && this.#isActive()) {
93
99
  this.#begin();
94
100
  }
95
- if (state === WebXRState.NOT_IN_XR) {
101
+ if (state === WebXRState.NOT_IN_XR && this.#active) {
102
+ this.#end();
103
+ }
104
+ });
105
+ this.#observe(this.#manager.onXRSessionInit, session => {
106
+ if (this.#active || this.#isActive()) {
107
+ void this.#start(session);
108
+ }
109
+ });
110
+ this.#observe(this.#manager.onXRSessionEnded, () => {
111
+ if (this.#active) {
96
112
  this.#end();
97
113
  }
98
114
  });
99
- this.#observe(this.#manager.onXRSessionInit, session => { void this.#start(session); });
100
- this.#observe(this.#manager.onXRSessionEnded, () => this.#end());
101
115
  this.#observe(this.#manager.onXRFrameObservable, frame => this.#frame(frame));
102
116
  }
103
117
 
@@ -1,5 +1,7 @@
1
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
2
  import BabylonJSARPlacement from "./babylonjs-ar-placement.js";
3
+ import BabylonJSVRStage from "./babylonjs-vr-stage.js";
4
+ import XRModeButtons from "./xr-mode-buttons.js";
3
5
  import BabylonJSARGlass from "./babylonjs-ar-glass.js";
4
6
  import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression.js";
5
7
  import "@babylonjs/loaders";
@@ -70,6 +72,7 @@ import QuickLookAction from "./quick-look-action.js";
70
72
  * - enable() / disable()
71
73
  * - load(force?) / reloadWithCurrentSettings()
72
74
  * - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
75
+ * - setQuickLookAssetProvider(provider)
73
76
  * - getRenderSettings() / scheduleRenderSettingsReload(settings)
74
77
  * - getDebugInfo()
75
78
  * - setContainerVisibility(name, show)
@@ -127,8 +130,11 @@ export default class BabylonJSController {
127
130
  #shadowGen = [];
128
131
  #XRExperience = null;
129
132
  #arPlacement = null;
133
+ #vrStage = null;
134
+ #xrButtons = null;
130
135
  #xrGeneration = 0;
131
136
  #quickLookAction = null;
137
+ #quickLookAssetProvider = null;
132
138
  #quickLookGlass = new BabylonJSARGlass();
133
139
  #quickLookLoads = 0;
134
140
  #quickLookValid = { model: false, materials: true };
@@ -1031,7 +1037,8 @@ export default class BabylonJSController {
1031
1037
  }
1032
1038
 
1033
1039
  /**
1034
- * Adds the supplied cube-and-scan-frame SVG and shared interaction styles to native XR and Quick Look buttons.
1040
+ * Adds the supplied cube-and-scan-frame SVG, a headset icon for the VR button and shared interaction styles
1041
+ * to the XR mode buttons and the Quick Look action.
1035
1042
  * Preserves the supplied 24-unit geometry and renders its filled paths white against the toggle background.
1036
1043
  * @private
1037
1044
  * @returns {void}
@@ -1047,7 +1054,8 @@ export default class BabylonJSController {
1047
1054
  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
1055
  c0.25-0.13,0.5-0.19,0.75-0.19s0.5,0.06,0.75,0.19L18.23,7.59z"/>
1049
1056
  </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"}`;
1057
+ const vrIcon = `<svg fill="white" xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24"><path fill="none" d="M0,0h24v24H0V0z"/><path d="M20.74 6H3.21C2.55 6 2 6.57 2 7.28v10.44c0 .7.55 1.28 1.23 1.28h4.79c.52 0 .96-.33 1.14-.79l1.4-3.48c.23-.59.79-1.01 1.44-1.01s1.21.42 1.45 1.01l1.39 3.48c.19.46.63.79 1.11.79h4.79c.71 0 1.26-.57 1.26-1.28V7.28c0-.7-.55-1.28-1.26-1.28zM7.5 14.62c-1.17 0-2.13-.95-2.13-2.12 0-1.17.96-2.13 2.13-2.13 1.18 0 2.12.96 2.12 2.13s-.95 2.12-2.12 2.12zm9 0c-1.17 0-2.13-.95-2.13-2.12 0-1.17.96-2.13 2.13-2.13s2.12.96 2.12 2.13-.95 2.12-2.12 2.12z"/></svg>`;
1058
+ 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.pref-viewer-xr-vr { background-image: url(data:image/svg+xml;charset=UTF-8,${encodeURIComponent(vrIcon)}) } .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"}`;
1051
1059
  let style = this.#canvas.parentElement.parentElement.querySelector("style");
1052
1060
  if (!style) {
1053
1061
  style = document.createElement("style");
@@ -1083,11 +1091,14 @@ export default class BabylonJSController {
1083
1091
  }
1084
1092
 
1085
1093
  /**
1086
- * Selects and initializes supported WebXR AR or an eligible Safari Quick Look action.
1094
+ * Selects and initializes supported WebXR AR and/or VR, or an eligible Safari Quick Look action.
1087
1095
  * @private
1088
- * @returns {Promise<boolean>} True when an AR route is configured, false when unsupported, obsolete or setup fails.
1096
+ * @returns {Promise<boolean>} True when an XR route is configured, false when unsupported, obsolete or setup fails.
1089
1097
  * @description
1090
- * Checks AR support and requires native floor hit testing at real-world metre scale.
1098
+ * Queries `immersive-ar` and `immersive-vr` support; a failed VR query only disables VR.
1099
+ * Babylon's default enter/exit UI is disabled; an XRModeButtons overlay renders one button per supported mode,
1100
+ * each requesting its own reference space and features. VR is standing-scale (`local-floor`) and positioned by BabylonJSVRStage.
1101
+ * AR requires native floor hit testing at real-world metre scale.
1091
1102
  * Requests optional plane detection so initial placement can use an observed floor contour when supplied.
1092
1103
  * Placement follows the tracked XR camera rather than inheriting the showroom camera's fit.
1093
1104
  * Adds custom styles to the AR button for consistent appearance.
@@ -1104,11 +1115,16 @@ export default class BabylonJSController {
1104
1115
  const generation = this.#xrGeneration;
1105
1116
  /** @returns {boolean} Whether this setup still owns the live scene generation. */
1106
1117
  const isCurrent = () => this.#scene === scene && this.#xrGeneration === generation && !scene.isDisposed;
1107
- const sessionMode = "immersive-ar";
1108
1118
  let experience = null;
1109
1119
  let placement = null;
1120
+ let stage = null;
1121
+ let buttons = null;
1110
1122
  try {
1111
- const sessionSupported = await WebXRSessionManager.IsSessionSupportedAsync(sessionMode);
1123
+ const [sessionSupported, vrSupported] = await Promise.all([
1124
+ WebXRSessionManager.IsSessionSupportedAsync("immersive-ar"),
1125
+ // An unavailable VR query must not take AR down with it.
1126
+ Promise.resolve(WebXRSessionManager.IsSessionSupportedAsync("immersive-vr")).catch(() => null),
1127
+ ]);
1112
1128
  if (!isCurrent()) {
1113
1129
  return false;
1114
1130
  }
@@ -1127,10 +1143,19 @@ export default class BabylonJSController {
1127
1143
  this.#refreshQuickLook();
1128
1144
  return true;
1129
1145
  }
1130
- if (route !== "webxr") {
1131
- console.info("PrefViewer: WebXR in mode AR is not supported");
1146
+ const modes = [];
1147
+ if (route === "webxr") {
1148
+ modes.push({ mode: "immersive-ar", referenceSpaceType: "local", requiredFeatures: ["hit-test"], optionalFeatures: ["plane-detection"] });
1149
+ }
1150
+ if (vrSupported === true) {
1151
+ modes.push({ mode: "immersive-vr", referenceSpaceType: "local-floor", requiredFeatures: [], optionalFeatures: [] });
1152
+ }
1153
+ if (modes.length === 0) {
1154
+ console.info("PrefViewer: WebXR in mode AR/VR is not supported");
1132
1155
  return false;
1133
1156
  }
1157
+ const hasAR = modes.some((item) => item.mode === "immersive-ar");
1158
+ const hasVR = modes.some((item) => item.mode === "immersive-vr");
1134
1159
 
1135
1160
  const options = {
1136
1161
  disableTeleportation: true,
@@ -1138,13 +1163,7 @@ export default class BabylonJSController {
1138
1163
  disableNearInteraction: true,
1139
1164
  disableHandTracking: true,
1140
1165
  ignoreNativeCameraTransformation: true,
1141
- uiOptions: {
1142
- sessionMode: sessionMode,
1143
- renderTarget: "xrLayer",
1144
- referenceSpaceType: "local",
1145
- requiredFeatures: ["hit-test"],
1146
- optionalFeatures: ["plane-detection"],
1147
- },
1166
+ disableDefaultUI: true,
1148
1167
  };
1149
1168
 
1150
1169
  experience = await WebXRDefaultExperience.CreateAsync(scene, options);
@@ -1152,10 +1171,19 @@ export default class BabylonJSController {
1152
1171
  return false;
1153
1172
  }
1154
1173
 
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);
1174
+ /** @returns {?AssetContainer} Visible, settled model container, or null while unavailable. */
1175
+ const getModel = () => this.#containers.model?.state?.isVisible && !this.#containers.model.state.isPending
1176
+ ? this.#containers.model.assetContainer : null;
1177
+ buttons = new XRModeButtons(this.#canvas.parentElement, experience, modes,
1178
+ () => this.#viewCubeConfig.getCulture?.() ?? I18n.getLocale());
1179
+ if (hasAR) {
1180
+ placement = new BabylonJSARPlacement(scene, experience, getModel,
1181
+ () => this.#containers.environment?.assetContainer,
1182
+ () => buttons?.requestedMode === "immersive-ar");
1183
+ }
1184
+ if (hasVR) {
1185
+ stage = new BabylonJSVRStage(scene, experience, getModel, () => buttons?.requestedMode === "immersive-vr");
1186
+ }
1159
1187
 
1160
1188
  experience.baseExperience.onStateChangedObservable.add(() => {
1161
1189
  // Babylon schedules XR frames only while engine render callbacks are registered.
@@ -1170,6 +1198,8 @@ export default class BabylonJSController {
1170
1198
  this.#addStylesToARButton();
1171
1199
  this.#XRExperience = experience;
1172
1200
  this.#arPlacement = placement;
1201
+ this.#vrStage = stage;
1202
+ this.#xrButtons = buttons;
1173
1203
  return true;
1174
1204
  } catch (error) {
1175
1205
  console.warn("PrefViewer: failed to create WebXR experience", error);
@@ -1182,6 +1212,8 @@ export default class BabylonJSController {
1182
1212
  console.warn("PrefViewer: failed to dispose obsolete WebXR experience", error);
1183
1213
  } finally {
1184
1214
  placement?.dispose();
1215
+ stage?.dispose();
1216
+ buttons?.dispose();
1185
1217
  }
1186
1218
  }
1187
1219
  }
@@ -1191,6 +1223,7 @@ export default class BabylonJSController {
1191
1223
  * Prepares Quick Look from completed, visible model/material state without changing download helpers.
1192
1224
  * Invalid or absent content invalidates the link. Only supported visible model meshes are serialized;
1193
1225
  * asynchronous export ownership, stale results and preparation errors are handled by QuickLookAction.
1226
+ * The prepared asset comes from the host provider when one is set (see setQuickLookAssetProvider), otherwise from the export.
1194
1227
  * @private
1195
1228
  * @returns {void} Starts preparation without waiting for it or changing public task-queue ordering.
1196
1229
  */
@@ -1218,7 +1251,66 @@ export default class BabylonJSController {
1218
1251
  }
1219
1252
  // AssetContainer contains only model meshes; applied materials and world transforms are retained.
1220
1253
  // No environment, XR ground, ViewCube or utility-scene overlays enter this export.
1221
- void this.#quickLookAction.update(() => this.#exportQuickLookUSDZ(asset, eligible));
1254
+ void this.#quickLookAction.update(() => this.#prepareQuickLookAsset(asset, eligible));
1255
+ }
1256
+
1257
+ /**
1258
+ * Installs or clears the host-supplied provider of the asset opened by the iOS Quick Look action.
1259
+ * The provider is retained even before the Quick Look route exists. While Quick Look is active, changing it
1260
+ * re-prepares the link at once, so clearing returns to the in-browser USDZ export.
1261
+ * @public
1262
+ * @param {?(function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>)} provider
1263
+ * Async asset factory (defaults: MIME `model/vnd.usdz+zip`, file name `model.usdz`), or null to clear.
1264
+ * @returns {void} Ignores any value that is neither a function nor null by clearing the provider.
1265
+ */
1266
+ setQuickLookAssetProvider(provider) {
1267
+ this.#quickLookAssetProvider = typeof provider === "function" ? provider : null;
1268
+ this.#refreshQuickLook();
1269
+ }
1270
+
1271
+ /**
1272
+ * Prepares the Quick Look asset: the host provider's asset when one is set, otherwise (or when the provider
1273
+ * fails or returns no data) the in-browser USDZ export of the current model.
1274
+ * @private
1275
+ * @param {import("@babylonjs/core").AssetContainer} asset - Model container used by the fallback export.
1276
+ * @param {function(import("@babylonjs/core").AbstractMesh): boolean} eligible - Mesh filter for the fallback export.
1277
+ * @returns {Promise<Uint8Array|{data: Uint8Array, mimeType?: string, fileName?: string}>} Asset for QuickLookAction.
1278
+ */
1279
+ async #prepareQuickLookAsset(asset, eligible) {
1280
+ const provider = this.#quickLookAssetProvider;
1281
+ if (provider) {
1282
+ try {
1283
+ return await BabylonJSController.#readQuickLookAsset(provider);
1284
+ } catch (error) {
1285
+ console.warn("PrefViewer: Quick Look asset provider failed", error);
1286
+ }
1287
+ }
1288
+ return this.#exportQuickLookUSDZ(asset, eligible);
1289
+ }
1290
+
1291
+ /**
1292
+ * Invokes a host asset provider and converts its data to bytes.
1293
+ * @private
1294
+ * @static
1295
+ * @param {function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>} provider - Host asset factory.
1296
+ * @returns {Promise<{data: Uint8Array, mimeType?: string, fileName?: string}>} Nonempty asset with byte data.
1297
+ * @throws {Error} When the provider rejects, or returns no data or empty/unsupported data.
1298
+ */
1299
+ static async #readQuickLookAsset(provider) {
1300
+ const result = await provider();
1301
+ const raw = result?.data;
1302
+ let data = null;
1303
+ if (raw instanceof Uint8Array) {
1304
+ data = raw;
1305
+ } else if (raw instanceof ArrayBuffer) {
1306
+ data = new Uint8Array(raw);
1307
+ } else if (typeof Blob !== "undefined" && raw instanceof Blob) {
1308
+ data = new Uint8Array(await raw.arrayBuffer());
1309
+ }
1310
+ if (!data || data.length === 0) {
1311
+ throw new Error("PrefViewer: Quick Look asset provider returned no data");
1312
+ }
1313
+ return { data, mimeType: result.mimeType, fileName: result.fileName };
1222
1314
  }
1223
1315
 
1224
1316
  /**
@@ -2281,9 +2373,15 @@ export default class BabylonJSController {
2281
2373
 
2282
2374
  const xrExperience = this.#XRExperience;
2283
2375
  const placement = this.#arPlacement;
2376
+ const stage = this.#vrStage;
2377
+ const buttons = this.#xrButtons;
2284
2378
  if (!xrExperience) {
2285
2379
  placement?.dispose();
2380
+ stage?.dispose();
2381
+ buttons?.dispose();
2286
2382
  this.#arPlacement = null;
2383
+ this.#vrStage = null;
2384
+ this.#xrButtons = null;
2287
2385
  return;
2288
2386
  }
2289
2387
 
@@ -2296,6 +2394,8 @@ export default class BabylonJSController {
2296
2394
  console.warn("PrefViewer: Error exiting XR experience:", error);
2297
2395
  } finally {
2298
2396
  placement?.dispose();
2397
+ stage?.dispose();
2398
+ buttons?.dispose();
2299
2399
  try {
2300
2400
  xrExperience.dispose();
2301
2401
  } catch (error) {
@@ -2304,6 +2404,8 @@ export default class BabylonJSController {
2304
2404
  if (this.#XRExperience === xrExperience) {
2305
2405
  this.#XRExperience = null;
2306
2406
  this.#arPlacement = null;
2407
+ this.#vrStage = null;
2408
+ this.#xrButtons = null;
2307
2409
  }
2308
2410
  this.#disablingPromises.xr = null;
2309
2411
  }
@@ -3976,6 +4078,7 @@ export default class BabylonJSController {
3976
4078
  */
3977
4079
  refreshViewCube() {
3978
4080
  this.#quickLookAction?.refreshCulture();
4081
+ this.#xrButtons?.refreshCulture();
3979
4082
  this.#viewCube?.resize();
3980
4083
  }
3981
4084
 
@@ -4148,6 +4251,7 @@ export default class BabylonJSController {
4148
4251
 
4149
4252
  return {
4150
4253
  ar: this.#arPlacement?.getDebugInfo() ?? null,
4254
+ vr: this.#vrStage?.getDebugInfo() ?? null,
4151
4255
  canvas: {
4152
4256
  clientWidth: this.#canvas?.clientWidth ?? null,
4153
4257
  clientHeight: this.#canvas?.clientHeight ?? null,
@@ -0,0 +1,191 @@
1
+ import { Vector3 } from "@babylonjs/core/Maths/math.vector.js";
2
+ import { FreeCamera } from "@babylonjs/core/Cameras/freeCamera.js";
3
+ import { WebXRState } from "@babylonjs/core/XR/webXRTypes.js";
4
+
5
+ /**
6
+ * Owns the standing pose of a native `immersive-vr` session.
7
+ * When Babylon announces the initial pose (after it copied the orbit camera into the XR camera) and the
8
+ * session was requested as VR, forces metre scale and moves the viewer to floor height, 2.5 m in front of
9
+ * the model's bottom-centre along +Z (the Inner face), looking at it. The model and environment are not moved or hidden.
10
+ * Exit or cancellation restores the previous world scale. Call dispose() before releasing the owning scene.
11
+ */
12
+ export default class BabylonJSVRStage {
13
+ /**
14
+ * Distance, in metres, between the viewer and the model's bottom-centre along +Z.
15
+ * @public
16
+ * @static
17
+ * @type {number}
18
+ */
19
+ static STANDING_DISTANCE_METRES = 2.5;
20
+
21
+ #scene;
22
+ #experience;
23
+ #manager;
24
+ #getModel;
25
+ #isActive;
26
+ #observers = [];
27
+ #previousWorldScale = null;
28
+ #diagnostics = BabylonJSVRStage.#createDiagnostics();
29
+
30
+ /**
31
+ * Creates fresh diagnostics for an inactive stage.
32
+ * @private
33
+ * @static
34
+ * @returns {{phase: string, position: ?{x: number, y: number, z: number}, floorHeight: ?number}} Mutable diagnostic state.
35
+ */
36
+ static #createDiagnostics() {
37
+ return { phase: "inactive", position: null, floorHeight: null };
38
+ }
39
+
40
+ /**
41
+ * Subscribes to the XR pose and lifecycle events without touching the scene or session yet.
42
+ * @param {import("@babylonjs/core").Scene} scene - Scene containing the model.
43
+ * @param {import("@babylonjs/core").WebXRDefaultExperience} experience - Native XR lifecycle owner.
44
+ * @param {function(): ?import("@babylonjs/core").AssetContainer} getModel - Current usable model, or null while unavailable.
45
+ * @param {function(): boolean} isActive - Whether the session being entered is a VR session.
46
+ */
47
+ constructor(scene, experience, getModel, isActive) {
48
+ this.#scene = scene;
49
+ this.#experience = experience;
50
+ this.#manager = experience.baseExperience.sessionManager;
51
+ this.#getModel = getModel;
52
+ this.#isActive = isActive;
53
+ this.#observe(experience.baseExperience.onInitialXRPoseSetObservable, () => this.#place());
54
+ this.#observe(experience.baseExperience.onStateChangedObservable, (state) => {
55
+ if (state === WebXRState.NOT_IN_XR) {
56
+ this.#end();
57
+ }
58
+ });
59
+ this.#observe(this.#manager.onXRSessionEnded, () => this.#end());
60
+ }
61
+
62
+ /**
63
+ * Registers a Babylon observer and retains its handle for disposal.
64
+ * @private
65
+ * @param {import("@babylonjs/core").Observable<*>} observable - Event stream.
66
+ * @param {function(*): void} callback - Handler invoked by that stream.
67
+ * @returns {void}
68
+ */
69
+ #observe(observable, callback) {
70
+ this.#observers.push([observable, observable.add(callback)]);
71
+ }
72
+
73
+ /**
74
+ * Computes the world bounds of the model's root meshes (and their descendants).
75
+ * @private
76
+ * @param {import("@babylonjs/core").AssetContainer} asset - Model container.
77
+ * @returns {?{min: Vector3, max: Vector3}} World-space bounds, or null when the model has no geometry.
78
+ */
79
+ #getBounds(asset) {
80
+ let min = null;
81
+ let max = null;
82
+ for (const mesh of asset.meshes ?? []) {
83
+ if (mesh.parent) {
84
+ continue;
85
+ }
86
+ mesh.computeWorldMatrix(true);
87
+ const bounds = mesh.getHierarchyBoundingVectors(true);
88
+ if (![bounds.min.x, bounds.min.y, bounds.min.z, bounds.max.x, bounds.max.y, bounds.max.z].every(Number.isFinite)) {
89
+ continue;
90
+ }
91
+ min = min ? Vector3.Minimize(min, bounds.min) : bounds.min.clone();
92
+ max = max ? Vector3.Maximize(max, bounds.max) : bounds.max.clone();
93
+ }
94
+ return min && max ? { min, max } : null;
95
+ }
96
+
97
+ /**
98
+ * Sets metre scale and places the XR camera at floor height in front of the model, facing it.
99
+ * Re-applies the floor height after the copy because Babylon resets it to zero (webXRCamera.js, "set the ground level").
100
+ * Keeps Babylon's pose when the model is unavailable. Ignores sessions that were not requested as VR.
101
+ * @private
102
+ * @returns {void}
103
+ */
104
+ #place() {
105
+ if (!this.#isActive()) {
106
+ return;
107
+ }
108
+ if (this.#previousWorldScale === null) {
109
+ this.#previousWorldScale = this.#manager.worldScalingFactor;
110
+ }
111
+ if (this.#manager.worldScalingFactor !== 1) {
112
+ this.#manager.worldScalingFactor = 1;
113
+ }
114
+ const asset = this.#getModel();
115
+ const bounds = asset ? this.#getBounds(asset) : null;
116
+ if (!bounds) {
117
+ this.#diagnostics = { phase: "no-model", position: null, floorHeight: null };
118
+ return;
119
+ }
120
+ const floorY = bounds.min.y;
121
+ const centreX = (bounds.min.x + bounds.max.x) / 2;
122
+ const centreZ = (bounds.min.z + bounds.max.z) / 2;
123
+ const standing = new FreeCamera("PrefViewerVRStanding",
124
+ new Vector3(centreX, floorY, centreZ + BabylonJSVRStage.STANDING_DISTANCE_METRES), this.#scene);
125
+ const camera = this.#experience.baseExperience.camera;
126
+ try {
127
+ standing.setTarget(new Vector3(centreX, floorY, centreZ));
128
+ camera.setTransformationFromNonVRCamera(standing, true);
129
+ // Babylon's WebXRCamera forces the copied ground level to y = 0; the model floor is the real
130
+ // ground, and the native head height is added on the first tracked frame.
131
+ camera.position.y = floorY;
132
+ } finally {
133
+ standing.dispose();
134
+ }
135
+ this.#diagnostics = {
136
+ phase: "placed",
137
+ position: { x: centreX, y: floorY, z: centreZ + BabylonJSVRStage.STANDING_DISTANCE_METRES },
138
+ floorHeight: floorY,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Restores the world scale saved at entry and marks the stage inactive. Safe to call repeatedly.
144
+ * Native update failures are logged so Babylon can finish restoring its camera.
145
+ * @private
146
+ * @returns {void}
147
+ */
148
+ #end() {
149
+ const previous = this.#previousWorldScale;
150
+ this.#previousWorldScale = null;
151
+ this.#diagnostics.phase = "inactive";
152
+ if (previous === null || this.#manager.worldScalingFactor === previous) {
153
+ return;
154
+ }
155
+ try {
156
+ this.#manager.worldScalingFactor = previous;
157
+ } catch (error) {
158
+ if (error?.name !== "InvalidStateError" || this.#manager.inXRSession) {
159
+ console.warn("PrefViewer: VR world-scale cleanup failed", error);
160
+ }
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Returns a detached snapshot of the last standing placement; it survives session exit as inactive.
166
+ * @public
167
+ * @returns {{phase: string, standingDistance: number, position: ?{x: number, y: number, z: number}, floorHeight: ?number}}
168
+ * Phase is "inactive", "placed" or "no-model"; position is where the XR camera was placed in scene metres.
169
+ */
170
+ getDebugInfo() {
171
+ return {
172
+ phase: this.#diagnostics.phase,
173
+ standingDistance: BabylonJSVRStage.STANDING_DISTANCE_METRES,
174
+ position: this.#diagnostics.position ? { ...this.#diagnostics.position } : null,
175
+ floorHeight: this.#diagnostics.floorHeight,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Restores the world scale and removes every registered observer. Idempotent.
181
+ * @public
182
+ * @returns {void}
183
+ */
184
+ dispose() {
185
+ this.#end();
186
+ for (const [observable, observer] of this.#observers) {
187
+ observable.remove(observer);
188
+ }
189
+ this.#observers = [];
190
+ }
191
+ }
@@ -6,6 +6,9 @@ export const translations = {
6
6
  preparing: "Preparing AR…",
7
7
  retry: "Retry preparing AR",
8
8
  },
9
+ vr: {
10
+ open: "View in VR",
11
+ },
9
12
  viewCube: {
10
13
  title: "View orientation",
11
14
  front: "Inner",
@@ -82,6 +85,9 @@ export const translations = {
82
85
  preparing: "Preparando RA…",
83
86
  retry: "Reintentar preparar RA",
84
87
  },
88
+ vr: {
89
+ open: "Ver en VR",
90
+ },
85
91
  viewCube: {
86
92
  title: "Orientación de vista",
87
93
  front: "Interior",
@@ -51,6 +51,7 @@ import FileStorage from "./file-storage.js";
51
51
  * - downloadModelGLB(): Downloads the current 3D model as a GLB file.
52
52
  * - downloadModelGLTF(): Downloads the current 3D model as a glTF ZIP file.
53
53
  * - downloadModelUSDZ(): Downloads the current 3D model as a USDZ file.
54
+ * - setQuickLookAssetProvider(provider): Supplies (or clears) the asset opened by the iOS AR Quick Look action.
54
55
  * - downloadModelAndSceneGLB(): Downloads both the model and scene as a GLB file.
55
56
  * - downloadModelAndSceneGLTF(): Downloads both the model and scene as a glTF ZIP file.
56
57
  * - downloadModelAndSceneUSDZ(): Downloads both the model and scene as a USDZ file.
@@ -102,6 +103,7 @@ export default class PrefViewer3D extends HTMLElement {
102
103
  #data = null; // Component data
103
104
 
104
105
  #babylonJSController = null; // BabylonJSController instance
106
+ #quickLookAssetProvider = null; // Host asset provider, reapplied to every controller this element creates
105
107
 
106
108
  /** @returns {"svg"|"canvas"} Per-instance normalized renderer; missing/invalid attributes use Canvas. */
107
109
  get viewCubeRenderer() {
@@ -283,6 +285,7 @@ export default class PrefViewer3D extends HTMLElement {
283
285
  * Initializes the Babylon controller with asset/option state and the selected visibility-aware cube contract.
284
286
  * Supplies current host culture and dispatches measured view-cube-layout events for render-menu positioning.
285
287
  * Starts asynchronous engine initialization without waiting for asset loading.
288
+ * Reapplies any retained Quick Look asset provider to the new controller before it starts.
286
289
  * @private
287
290
  * @returns {void}
288
291
  */
@@ -303,6 +306,7 @@ export default class PrefViewer3D extends HTMLElement {
303
306
  */
304
307
  onLayout: (offset, height) => this.dispatchEvent(new CustomEvent("view-cube-layout", { detail: { offset, height } })),
305
308
  });
309
+ this.#babylonJSController.setQuickLookAssetProvider(this.#quickLookAssetProvider);
306
310
  this.#babylonJSController.enable();
307
311
 
308
312
  }
@@ -875,6 +879,20 @@ export default class PrefViewer3D extends HTMLElement {
875
879
  this.#babylonJSController.downloadGLTF(1);
876
880
  }
877
881
 
882
+ /**
883
+ * Sets or clears the host-supplied asset opened by the iOS AR Quick Look action instead of the in-browser USDZ export.
884
+ * The provider is retained across connection cycles, even when set before the element is connected.
885
+ * @public
886
+ * @param {?(function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>)} provider
887
+ * Async asset factory (defaults: MIME `model/vnd.usdz+zip`, file name `model.usdz`), or null to restore the built-in export.
888
+ * A failing provider falls back to the built-in export with a console warning.
889
+ * @returns {void}
890
+ */
891
+ setQuickLookAssetProvider(provider) {
892
+ this.#quickLookAssetProvider = typeof provider === "function" ? provider : null;
893
+ this.#babylonJSController?.setQuickLookAssetProvider(this.#quickLookAssetProvider);
894
+ }
895
+
878
896
  /**
879
897
  * Downloads the current 3D model as a USDZ file.
880
898
  * @public
@@ -43,6 +43,7 @@ import I18n from "./localization/i18n.js";
43
43
  * - showModel / hideModel / showScene / hideScene: Toggle visibility using the internal task queue for consistent sequencing.
44
44
  * - zoomCenter / zoomExtentsAll / zoomIn / zoomOut: Forward zoom commands to the 2D controller when active.
45
45
  * - downloadModelGLB / downloadModelGLTF / downloadModelUSDZ: Export the loaded model in the requested format.
46
+ * - setQuickLookAssetProvider(provider): Supplies (or clears) the asset opened by the iOS AR Quick Look action.
46
47
  * - downloadSceneGLB / downloadSceneGLTF / downloadSceneUSDZ: Export the loaded environment in the requested format.
47
48
  * - downloadModelAndSceneGLB / downloadModelAndSceneGLTF / downloadModelAndSceneUSDZ: Export the combined scene in the requested format.
48
49
  * - openDialog / closeDialog: Manage PrefViewerDialog instances rendered inside the shadow tree.
@@ -115,6 +116,7 @@ export default class PrefViewer extends HTMLElement {
115
116
  #wrapper = null;
116
117
  #component2D = null;
117
118
  #component3D = null;
119
+ #quickLookAssetProvider = null; // Host asset provider, reapplied to every 3D component this element creates
118
120
  #dialog = null;
119
121
  #menu3D = null;
120
122
 
@@ -394,6 +396,7 @@ export default class PrefViewer extends HTMLElement {
394
396
  * Creates and appends the 3D viewer component to the shadow DOM, initially hidden.
395
397
  * Forwards renderer selection and show-view-cube, stores its layout handler in #handlers and subscribes before activation
396
398
  * so the first measured layout positions the render menu correctly.
399
+ * Hands any retained Quick Look asset provider to the new component.
397
400
  * @private
398
401
  * @returns {void}
399
402
  */
@@ -401,6 +404,7 @@ export default class PrefViewer extends HTMLElement {
401
404
  this.#handlers.on3DElementClick = this.#on3DElementClick.bind(this);
402
405
  this.#component3D = document.createElement("pref-viewer-3d");
403
406
  this.#component3D.setAttribute("visible", "false");
407
+ this.#component3D.setQuickLookAssetProvider?.(this.#quickLookAssetProvider);
404
408
  this.#component3D.setAttribute("view-cube-renderer", this.viewCubeRenderer);
405
409
  this.#component3D.setAttribute("show-view-cube", this.getAttribute("show-view-cube") === "false" ? "false" : "true");
406
410
  this.#handlers.onViewCubeLayout = (event) => {
@@ -1289,6 +1293,21 @@ export default class PrefViewer extends HTMLElement {
1289
1293
  this.#component3D.downloadModelGLTF();
1290
1294
  }
1291
1295
 
1296
+ /**
1297
+ * Sets or clears the host-supplied asset opened by the iOS AR Quick Look action instead of the in-browser USDZ export.
1298
+ * The provider is retained across connection cycles, even when set before the element is connected.
1299
+ * The viewer knows nothing about how the host produces the asset.
1300
+ * @public
1301
+ * @param {?(function(): Promise<{data: (Uint8Array|ArrayBuffer|Blob), mimeType?: string, fileName?: string}>)} provider
1302
+ * Async asset factory (defaults: MIME `model/vnd.usdz+zip`, file name `model.usdz`), or null to restore the built-in export.
1303
+ * A failing provider falls back to the built-in export with a console warning.
1304
+ * @returns {void}
1305
+ */
1306
+ setQuickLookAssetProvider(provider) {
1307
+ this.#quickLookAssetProvider = typeof provider === "function" ? provider : null;
1308
+ this.#component3D?.setQuickLookAssetProvider?.(this.#quickLookAssetProvider);
1309
+ }
1310
+
1292
1311
  /**
1293
1312
  * Initiates download of the current 3D model in USDZ format.
1294
1313
  * @public
@@ -17,6 +17,22 @@ export default class QuickLookAction {
17
17
  */
18
18
  static QUICK_LOOK_FRAGMENT = "allowsContentScaling=0";
19
19
 
20
+ /**
21
+ * MIME type of a USDZ archive; the only type whose bytes are validated as a ZIP before being exposed.
22
+ * @public
23
+ * @static
24
+ * @type {string}
25
+ */
26
+ static USDZ_MIME_TYPE = "model/vnd.usdz+zip";
27
+
28
+ /**
29
+ * File name given to the anchor's `download` attribute when the prepared asset does not name itself.
30
+ * @public
31
+ * @static
32
+ * @type {string}
33
+ */
34
+ static DEFAULT_FILE_NAME = "model.usdz";
35
+
20
36
  #anchor;
21
37
  #style;
22
38
  #window;
@@ -55,7 +71,7 @@ export default class QuickLookAction {
55
71
  this.#anchor = document.createElement("a");
56
72
  this.#anchor.className = "babylonVRicon pref-viewer-quick-look";
57
73
  this.#anchor.rel = "ar";
58
- this.#anchor.download = "model.usdz";
74
+ this.#anchor.download = QuickLookAction.DEFAULT_FILE_NAME;
59
75
  this.#anchor.setAttribute("role", "button");
60
76
  this.#anchor.tabIndex = 0;
61
77
  this.#anchor.hidden = true;
@@ -204,11 +220,38 @@ export default class QuickLookAction {
204
220
  }
205
221
 
206
222
  /**
207
- * Queues current-model export and exposes only a nonempty ZIP-signature USDZ as a native link.
223
+ * Normalizes an exporter result into the bytes, MIME type and file name of the asset to expose.
224
+ * A bare `Uint8Array` is a USDZ with default metadata; an object carries its own optional metadata.
225
+ * USDZ content must be a nonempty ZIP-signature archive; other MIME types are passed through as-is.
226
+ * @private
227
+ * @static
228
+ * @param {Uint8Array|{data: Uint8Array, mimeType?: string, fileName?: string}} result - Exporter result.
229
+ * @returns {{data: Uint8Array, mimeType: string, fileName: string}} Validated asset description.
230
+ * @throws {Error} When the result is not byte content or a USDZ lacks the ZIP signature.
231
+ */
232
+ static #resolveAsset(result) {
233
+ const isDescribed = !!result && typeof result === "object" && !(result instanceof Uint8Array);
234
+ const data = isDescribed ? result.data : result;
235
+ const mimeType = (isDescribed && result.mimeType) || QuickLookAction.USDZ_MIME_TYPE;
236
+ const fileName = (isDescribed && result.fileName) || QuickLookAction.DEFAULT_FILE_NAME;
237
+ if (!(data instanceof Uint8Array) || data.length === 0) {
238
+ throw new Error("PrefViewer: invalid Quick Look export");
239
+ }
240
+ // USDZ is a ZIP archive; never expose empty, JSON or glTF results as native AR.
241
+ if (mimeType === QuickLookAction.USDZ_MIME_TYPE
242
+ && (data.length < 4 || data[0] !== 0x50 || data[1] !== 0x4b || data[2] !== 3 || data[3] !== 4)) {
243
+ throw new Error("PrefViewer: invalid USDZ export");
244
+ }
245
+ return { data, mimeType, fileName };
246
+ }
247
+
248
+ /**
249
+ * Queues current-model export and exposes only a valid asset as a native link: a nonempty ZIP-signature USDZ,
250
+ * or, when the exporter describes another MIME type, that asset unchanged.
208
251
  * Obsolete revisions are ignored. Export failures are logged and shown as a retry action;
209
252
  * they do not reject the returned work promise or expose an older model's content.
210
253
  * @public
211
- * @param {?(function(): Promise<Uint8Array>)} exporter - Current-model serializer, or null to invalidate without preparation.
254
+ * @param {?(function(): Promise<Uint8Array|{data: Uint8Array, mimeType?: string, fileName?: string}>)} exporter - Current-model serializer returning USDZ bytes or a described asset (defaults: USDZ MIME type, `model.usdz`), or null to invalidate without preparation.
212
255
  * @returns {Promise<void>} Resolves after this queued preparation settles or becomes obsolete.
213
256
  */
214
257
  update(exporter) {
@@ -231,17 +274,15 @@ export default class QuickLookAction {
231
274
  return;
232
275
  }
233
276
  try {
234
- const bytes = await exporter();
277
+ const result = await exporter();
235
278
  if (this.#disposed || revision !== this.#revision) {
236
279
  return;
237
280
  }
238
- // USDZ is a ZIP archive; never expose empty, JSON or glTF results as native AR.
239
- if (!(bytes instanceof Uint8Array) || bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4b || bytes[2] !== 3 || bytes[3] !== 4) {
240
- throw new Error("PrefViewer: invalid USDZ export");
241
- }
242
- const blob = new this.#window.Blob([bytes], {
243
- type: "model/vnd.usdz+zip"
281
+ const asset = QuickLookAction.#resolveAsset(result);
282
+ const blob = new this.#window.Blob([asset.data], {
283
+ type: asset.mimeType
244
284
  });
285
+ this.#anchor.download = asset.fileName;
245
286
  this.#url = this.#urlAPI.createObjectURL(blob);
246
287
  // Quick Look reads the fragment: content scaling off keeps the authored 1:1 metre scale,
247
288
  // matching the Android WebXR path. The bare URL is kept for revocation.
@@ -0,0 +1,146 @@
1
+ import { WebXRState } from "@babylonjs/core/XR/webXRTypes.js";
2
+ import I18n from "./localization/i18n.js";
3
+
4
+ /**
5
+ * Owns the viewer's WebXR enter/exit buttons, one per supported session mode (AR and/or VR).
6
+ * Replaces Babylon's default enter/exit UI so every mode requests its own reference space and features.
7
+ * Records the requested mode before calling `enterXRAsync`, because Babylon reports ENTERING_XR before the
8
+ * session manager knows the session mode; collaborators read it through `requestedMode`.
9
+ * Call dispose() when the owning viewer is torn down.
10
+ */
11
+ export default class XRModeButtons {
12
+ static #CLASS_BY_MODE = { "immersive-ar": "pref-viewer-xr-ar", "immersive-vr": "pref-viewer-xr-vr" };
13
+ static #LABEL_BY_MODE = { "immersive-ar": "prefViewer.ar.open", "immersive-vr": "prefViewer.vr.open" };
14
+
15
+ #experience;
16
+ #getCulture;
17
+ #overlay;
18
+ #entries = [];
19
+ #requestedMode = null;
20
+ #stateObserver;
21
+ #unsubscribe;
22
+ #disposed = false;
23
+
24
+ /**
25
+ * Creates the overlay and buttons, then subscribes to XR state and locale changes.
26
+ * @param {HTMLElement} container - Canvas wrapper receiving the overlay.
27
+ * @param {import("@babylonjs/core").WebXRDefaultExperience} experience - Experience created with its default UI disabled.
28
+ * @param {Array<{mode: string, referenceSpaceType: string, requiredFeatures: string[], optionalFeatures: string[]}>} modes - Session modes to expose, one button each.
29
+ * @param {function(): string} [getCulture] - Host locale provider; defaults to the active I18n locale.
30
+ */
31
+ constructor(container, experience, modes, getCulture = () => I18n.getLocale()) {
32
+ this.#experience = experience;
33
+ this.#getCulture = getCulture;
34
+ const document = container.ownerDocument;
35
+ this.#overlay = document.createElement("div");
36
+ this.#overlay.className = "xr-button-overlay";
37
+ this.#overlay.style.cssText = "z-index:11;position:absolute;right:20px;bottom:50px;display:flex;";
38
+ for (const config of modes) {
39
+ const button = document.createElement("button");
40
+ button.type = "button";
41
+ button.className = `babylonVRicon ${XRModeButtons.#CLASS_BY_MODE[config.mode] ?? ""}`.trim();
42
+ button.addEventListener("click", () => { void this.#onClick(config); });
43
+ this.#overlay.append(button);
44
+ this.#entries.push({ config, button });
45
+ }
46
+ // Subscribe before attaching so a failing subscription leaves nothing in the DOM.
47
+ this.#stateObserver = experience.baseExperience.onStateChangedObservable.add((state) => this.#onState(state));
48
+ this.#unsubscribe = I18n.onLocaleChange(() => this.refreshCulture());
49
+ container.append(this.#overlay);
50
+ this.refreshCulture();
51
+ }
52
+
53
+ /**
54
+ * Mode of the session being entered or active, set before Babylon announces ENTERING_XR.
55
+ * @public
56
+ * @returns {?string} "immersive-ar", "immersive-vr", or null when no session was requested.
57
+ */
58
+ get requestedMode() {
59
+ return this.#requestedMode;
60
+ }
61
+
62
+ /**
63
+ * Enters the clicked mode when idle or exits the current session when active; ignores transitional states.
64
+ * Entry failures are logged, flagged on the button with the `xr-error` class and clear the requested mode.
65
+ * @private
66
+ * @param {{mode: string, referenceSpaceType: string, requiredFeatures: string[], optionalFeatures: string[]}} config - Mode of the clicked button.
67
+ * @returns {Promise<void>} Resolves after the enter/exit request settles; never rejects.
68
+ */
69
+ async #onClick(config) {
70
+ const base = this.#experience.baseExperience;
71
+ const entry = this.#entries.find((item) => item.config === config);
72
+ try {
73
+ if (base.state === WebXRState.IN_XR) {
74
+ await base.exitXRAsync();
75
+ } else if (base.state === WebXRState.NOT_IN_XR) {
76
+ entry?.button.classList.remove("xr-error");
77
+ this.#requestedMode = config.mode;
78
+ await base.enterXRAsync(config.mode, config.referenceSpaceType, this.#experience.renderTarget, {
79
+ requiredFeatures: config.requiredFeatures,
80
+ optionalFeatures: config.optionalFeatures,
81
+ });
82
+ }
83
+ } catch (error) {
84
+ if (!this.#disposed && base.state !== WebXRState.IN_XR) {
85
+ this.#requestedMode = null;
86
+ entry?.button.classList.add("xr-error");
87
+ }
88
+ console.warn("PrefViewer: WebXR session request failed", error);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Shows only the active mode's button as "exit" while in XR and restores all buttons on exit.
94
+ * @private
95
+ * @param {number} state - New WebXRState value.
96
+ * @returns {void}
97
+ */
98
+ #onState(state) {
99
+ if (state === WebXRState.IN_XR) {
100
+ for (const { config, button } of this.#entries) {
101
+ const active = config.mode === this.#requestedMode;
102
+ button.classList.toggle("vrdisplaypresenting", active);
103
+ button.style.display = active ? "" : "none";
104
+ }
105
+ } else if (state === WebXRState.NOT_IN_XR) {
106
+ this.#requestedMode = null;
107
+ for (const { button } of this.#entries) {
108
+ button.classList.remove("vrdisplaypresenting");
109
+ button.style.display = "";
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Re-applies the localized accessible label and tooltip of every button from the host culture.
116
+ * @public
117
+ * @returns {void}
118
+ */
119
+ refreshCulture() {
120
+ if (this.#disposed) {
121
+ return;
122
+ }
123
+ for (const { config, button } of this.#entries) {
124
+ const text = I18n.translate(XRModeButtons.#LABEL_BY_MODE[config.mode], { locale: this.#getCulture() });
125
+ button.setAttribute("aria-label", text);
126
+ button.title = text;
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Idempotently removes the overlay, the XR state observer and the locale subscription.
132
+ * @public
133
+ * @returns {void}
134
+ */
135
+ dispose() {
136
+ if (this.#disposed) {
137
+ return;
138
+ }
139
+ this.#disposed = true;
140
+ this.#experience.baseExperience.onStateChangedObservable.remove(this.#stateObserver);
141
+ this.#unsubscribe();
142
+ this.#overlay.remove();
143
+ this.#entries = [];
144
+ this.#requestedMode = null;
145
+ }
146
+ }