@preference-sl/pref-viewer 3.1.0-beta.0 → 3.1.0-beta.2

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
@@ -38,7 +38,7 @@ VR uses the `local-floor` reference space without teleportation, controller sele
38
38
 
39
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.
40
40
 
41
- During Android WebXR AR, model materials using physical transmission/refraction or translucency temporarily use alpha blending so the camera background can show through the glass. This is a scalar approximation: full transmission uses 0.35 opacity, combined with existing geometric opacity; linked-refraction alpha is interpreted as transmission rather than geometric opacity. Colors, textures and reflection parameters remain unchanged, but physical camera refraction, blur and transmission texture masks are not reproduced. Opaque and existing ordinary alpha-transparent materials are left alone. Original glass values are restored on exit/cancellation, model or material replacement and teardown; normal 3D and Quick Look preparation outside WebXR retain their original materials. The fallback also applies to the no-floor preview. Shared material objects are adapted once and restored together. Real Android glass appearance still requires a device check.
41
+ During Android WebXR AR, model materials using physical transmission/refraction or translucency temporarily use alpha blending so the camera background can show through the glass. This is a scalar approximation: glass the viewer tuned for the web look keeps its authored base color alpha as the AR opacity (the SDK's window glass, `0.15`), so it reads like the 3D view; other physical glass uses 0.35 opacity for full transmission, combined with existing geometric opacity; linked-refraction alpha is interpreted as transmission rather than geometric opacity. The same conversion is applied to the USDZ prepared for iOS Quick Look. Colors, textures and reflection parameters remain unchanged, but physical camera refraction, blur and transmission texture masks are not reproduced. Opaque and existing ordinary alpha-transparent materials are left alone. Original glass values are restored on exit/cancellation, model or material replacement and teardown; normal 3D and Quick Look preparation outside WebXR retain their original materials. The fallback also applies to the no-floor preview. Shared material objects are adapted once and restored together. Real Android glass appearance still requires a device check.
42
42
 
43
43
  On AR entry, the virtual environment is hidden. If no qualifying floor is available, the first finite native camera pose centres the preview two metres along its view ray, retaining the authored dimensions, with Inner facing the phone horizontally. The preview retains its initial world position and yaw instead of following the phone, and cannot be dragged. It is not a verified physical anchor or calibrated 1:1 measurement; erroneous native camera motion can still change its apparent size and position. Pending, failed or empty hit tests do not prevent this preview. A missing native camera pose still prevents initial placement. When a native horizontal-plane hit below the phone becomes available and the phone has a usable horizontal heading, the preview transitions to floor placement: its bottom centre is placed on that height, with its phone-facing orientation corrected by the minimum yaw to align model axis lines with the detected floor axes (nearest multiple of 90 degrees, at most 45 degrees correction). The preferred initial distance is three horizontal metres ahead. Optional native plane detection supplies a floor contour; the viewer checks the full horizontal bounding footprint at its authored scale and initial yaw. If it cannot fit at three metres, it uses the farthest fitting centre on the same forward segment up to three metres. If contours are unavailable/unusable or no full fit exists, it provisionally places the model at three metres without a fit guarantee. Later contour changes never reposition an already placed model. This transition can move the model visibly. It stays in world space as the user moves. The detected horizontal plane pose supplies the grid axes independently of contour-fit availability: a contour containing the accepted hit takes priority, otherwise the horizontal pose below the camera nearest in height to that hit is used (horizontal proximity breaks ties). Without a usable native pose, the X/Z axes of the WebXR tracking reference space provide the reported fallback. Pose-only orientation never changes the retained floor height or guarantees that the footprint fits. Diagnostics report `floorAlignmentSource`, `floorAlignmentStatus`, `floorAlignmentYaw` and `floorAlignmentHeightDifference`. The grid is frozen for model initial placement and gesture feedback; later camera/contour changes do not re-snap the model. Touch the model and drag with one finger to translate it on the retained floor plane, keeping the grab offset. A short tap outside the model places its bottom centre at the tapped point on that plane. Placement commits on release; moving the finger by more than one degree of ray direction cancels an off-model tap, even if it returns to its starting point. Adding a second tracked finger cancels translation or a pending floor tap and starts two-finger rotation around the vertical bottom-centre axis. Rotate the two fingers to change yaw (the rotation sense is inverted from the preceding implementation); pinching never changes scale. The bottom anchor stays fixed. Lifting either finger, a third finger or lost input cancels rotation; lift all fingers before starting another translation. This requires the Android runtime to expose both screen input sources. Fresh surface hits are not required after floor detection: other-height surfaces do not change the retained height. Missing/invalid input tracking, rays parallel to the floor or intersections behind the camera retain the last placement. This uses the mathematical plane at the observed height and does not verify its physical extent or rule out obstacles. WebXR does not universally distinguish a floor from a table. A contour fit checks the reported approximate surface, not obstacles or physical room boundaries; provisional placement provides no contour-fit guarantee. Leaving AR restores the authored model transforms and environment. `getDebugInfo().ar` reports placement state, metre units, initial distance, position and drag state. The diagnostic state `preview` distinguishes the camera-based fallback from `placed` on a detected floor; the fallback does not claim a detected floor or solve native calibration/tracking failures. Large models may extend beyond the viewport because their authored scale is preserved. The no-floor preview remains centred two metres along the initial camera ray.
44
44
 
@@ -54,6 +54,22 @@ On eligible Safari, the AR control uses the existing XR appearance and position.
54
54
 
55
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.
56
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
+
57
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.
58
74
 
59
75
  ## Installation
@@ -438,6 +454,7 @@ Dialogs opened with `openDialog()`, including the **Ctrl+Alt+D** download dialog
438
454
  | `downloadModelGLB()` / `downloadModelGLTF()` / `downloadModelUSDZ()` | Exports the current model. |
439
455
  | `downloadSceneGLB()` / `downloadSceneGLTF()` / `downloadSceneUSDZ()` | Exports the current scene/environment. |
440
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). |
441
458
 
442
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.
443
460
 
@@ -563,7 +580,7 @@ Render settings are persisted in `localStorage` under the `pref-viewer/render-se
563
580
 
564
581
  The showroom daylight background uses a shared linear HDR color calibrated with exposure and ACES tone mapping for the current light studio gray target, `#d9d9d9`, in a scene-less view. Both the static look and time-of-day cycle use this same daylight value; the cycle retains its dark night endpoint. Scene geometry can cover the background. The internal `BabylonJSController.config.look.showroom.backgroundDay` value is not a public background-color option and must be recalibrated if tone mapping or exposure changes.
565
582
 
566
- The model's transmissive glass (`KHR_materials_transmission`, as authored by the SDK) also carries a base color alpha of `0.15`, which would multiply its reflection away and leave it flat grey. The viewer sets that alpha to `1` and `metallicF0Factor` to `3` (`BabylonJSController.config.look.glass`, internal like the showroom look) so the environment reflection reads while the glass stays see-through through transmission. Glass in the environment glTF is unaffected: it is simplified to alpha blending instead.
583
+ The model's transmissive glass (`KHR_materials_transmission`, as authored by the SDK) also carries a base color alpha of `0.15`, which would multiply its reflection away and leave it flat grey. The viewer sets that alpha to `1` and `metallicF0Factor` to `3` (`BabylonJSController.config.look.glass`, internal like the showroom look) so the environment reflection reads while the glass stays see-through through transmission. The authored alpha is remembered and becomes the glass opacity in native AR (Android WebXR and the iOS Quick Look USDZ), where there is no physical transmission. Glass in the environment glTF is unaffected: it is simplified to alpha blending instead.
567
584
 
568
585
  ## Environment map and scene lights
569
586
 
@@ -574,7 +591,7 @@ The model's transmissive glass (`KHR_materials_transmission`, as authored by the
574
591
  | `.env` | Prefiltered Babylon environment (`CubeTexture.CreateFromPrefilteredData`) | No prefiltering in the browser; a 4K environment is typically under 1 MB. |
575
592
  | anything else | Radiance HDR (`HDRCubeTexture`) | Prefiltered on load, which costs time and memory for large files. |
576
593
 
577
- Either file is downloaded once, cached in IndexedDB, and reused across reloads.
594
+ Either file is downloaded once, cached in IndexedDB, and reused across reloads. The cached bytes reach Babylon as an in-memory file, never as a `blob:` URL: iOS WebKit answers requests to such URLs with status 0, and the environment then never became ready, so the 3D load never finished. A cached record that is empty or cannot be read within 5 s is purged and the server URL is used for that load.
578
595
 
579
596
  Directional and spot lights that come with the scene glTF (`KHR_lights_punctual`) cast shadows when `render.shadowsEnabled` is `true` and `ibl.shadows` is off, alongside the built-in light's. Every scene and model mesh casts unless its node sets `extras.castShadows: false`, and receives unless it sets `extras.receiveShadows: false`. A light's shadow darkness comes from its node's `extras.shadowDarkness`, from `0` (black) to `1` (no shadow), and is `0.5` when absent:
580
597
 
@@ -754,7 +771,7 @@ Both ViewCubes use the palette defined once by `PrefViewerViewCubePalette` in `s
754
771
 
755
772
  Public customization uses the unified [Runtime styles](#runtime-styles) schema. The CSS tokens in `PrefViewerViewCubePalette` and `PrefViewerViewCubeTypography` are internal implementation details projected inside the shadow boundary. Palette defaults are centralized; Canvas resolves the computed internal values and falls back for invalid colors. The optional internal dark palette is not automatically selected or a package-root export.
756
773
 
757
- Keep these rules when changing the renderer: the cube scene stays virtual and its engine stays out of `EngineStore.Instances` while it is alive (otherwise the viewer can create resources on the cube's WebGL context); Babylon input stays detached from the cube canvas (otherwise the cursor and keyboard focus are disturbed); and the engine keeps `loseContextOnDispose` so its WebGL context is released.
774
+ Keep these rules when changing the renderer: the cube scene stays virtual and its engine stays out of `EngineStore.Instances` while it is alive (otherwise the viewer can create resources on the cube's WebGL context); Babylon input stays detached from the cube canvas (otherwise the cursor and keyboard focus are disturbed); and the engine keeps `loseContextOnDispose` so its WebGL context is released. Both the viewer engine and the cube engine request their WebGL context with an explicit `powerPreference: "default"`: Babylon fills in `"high-performance"` when the option is absent, and iOS 18.7 / Safari 26.6.1 on iPhone returns a context that is already lost for that preference, which left the 3D view blank.
758
775
 
759
776
  ViewCube listener registration, drag handling and hover updates are private instance methods. Region construction, colour parsing and extent interpolation are private static helpers; interpolation progress is passed explicitly. The render menu also encapsulates hue-channel calculation and lighting playback as private methods. Playback uses one callback bound to the menu instance and cancels its scheduled frame on stop or disconnection.
760
777
 
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.1.0-beta.0",
3
+ "version": "3.1.0-beta.2",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -42,7 +42,11 @@ export default class BabylonJSARGlass {
42
42
 
43
43
  /**
44
44
  * Snapshots physical glass and switches it to alpha blending without depth occlusion.
45
- * Linked refraction uses alpha as inverse transmission, so it is not reused as geometric opacity.
45
+ * Glass the viewer tuned for the web look (`MaterialUtils.tunePhysicalGlassMaterial`) takes its authored
46
+ * base alpha as the AR opacity when that alpha is below 1, so it reads like the web view instead of the
47
+ * 0.35 floor of the transmission approximation; glass authored opaque keeps the approximation.
48
+ * Linked refraction uses alpha as inverse transmission, so neither the
49
+ * live nor the authored alpha is reused as geometric opacity there.
46
50
  * Retains a disposal observer only while the snapshot is owned; disposed materials are forgotten untouched.
47
51
  * @param {import("@babylonjs/core").PBRMaterial} material - Current physical glass not already adapted.
48
52
  * @returns {void} Does not allocate/dispose materials or textures or modify color/reflection properties.
@@ -64,8 +68,11 @@ export default class BabylonJSARGlass {
64
68
  this.#materials.set(material, saved);
65
69
  sub.isRefractionEnabled = false;
66
70
  sub.isTranslucencyEnabled = false;
67
- material.alpha = (linked ? 1 : alpha) * Math.max(BabylonJSARGlass.#MIN_GLASS_OPACITY,
68
- 1 - transmission * BabylonJSARGlass.#TRANSMISSION_REDUCTION);
71
+ const authored = linked ? null : MaterialUtils.authoredGlassAlpha(material);
72
+ material.alpha = authored !== null && authored < 1
73
+ ? authored
74
+ : (linked ? 1 : alpha) * Math.max(BabylonJSARGlass.#MIN_GLASS_OPACITY,
75
+ 1 - transmission * BabylonJSARGlass.#TRANSMISSION_REDUCTION);
69
76
  material.transparencyMode = Material.MATERIAL_ALPHABLEND;
70
77
  material.disableDepthWrite = true;
71
78
  material.forceDepthWrite = false;
@@ -72,6 +72,7 @@ import QuickLookAction from "./quick-look-action.js";
72
72
  * - enable() / disable()
73
73
  * - load(force?) / reloadWithCurrentSettings()
74
74
  * - downloadGLB(content) / downloadGLTF(content) / downloadUSDZ(content)
75
+ * - setQuickLookAssetProvider(provider)
75
76
  * - getRenderSettings() / scheduleRenderSettingsReload(settings)
76
77
  * - getDebugInfo()
77
78
  * - setContainerVisibility(name, show)
@@ -133,6 +134,7 @@ export default class BabylonJSController {
133
134
  #xrButtons = null;
134
135
  #xrGeneration = 0;
135
136
  #quickLookAction = null;
137
+ #quickLookAssetProvider = null;
136
138
  #quickLookGlass = new BabylonJSARGlass();
137
139
  #quickLookLoads = 0;
138
140
  #quickLookValid = { model: false, materials: true };
@@ -1221,6 +1223,7 @@ export default class BabylonJSController {
1221
1223
  * Prepares Quick Look from completed, visible model/material state without changing download helpers.
1222
1224
  * Invalid or absent content invalidates the link. Only supported visible model meshes are serialized;
1223
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.
1224
1227
  * @private
1225
1228
  * @returns {void} Starts preparation without waiting for it or changing public task-queue ordering.
1226
1229
  */
@@ -1248,7 +1251,66 @@ export default class BabylonJSController {
1248
1251
  }
1249
1252
  // AssetContainer contains only model meshes; applied materials and world transforms are retained.
1250
1253
  // No environment, XR ground, ViewCube or utility-scene overlays enter this export.
1251
- 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 };
1252
1314
  }
1253
1315
 
1254
1316
  /**
@@ -3878,7 +3940,9 @@ export default class BabylonJSController {
3878
3940
  }
3879
3941
  const enabledGeneration = ++this.#xrGeneration;
3880
3942
  this.#configureDracoCompression();
3881
- this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false }, true);
3943
+ // powerPreference is explicit: Babylon fills in "high-performance" when it is absent, and iOS 18.7 /
3944
+ // Safari 26.6.1 (iPhone) returns a WebGL2 context that is already lost for that preference.
3945
+ this.#engine = new Engine(this.#canvas, true, { alpha: true, stencil: true, preserveDrawingBuffer: false, powerPreference: "default" }, true);
3882
3946
  this.#engine.disableUniformBuffers = true;
3883
3947
 
3884
3948
  // OPTIMIZATION: Detect hardware tier and apply quality settings
@@ -87,11 +87,13 @@ export default class BabylonJSViewCubeCanvasRenderer {
87
87
 
88
88
 
89
89
  /**
90
- * Options of the default cube Engine (as in PrefSketch, plus loseContextOnDispose). Babylon only calls
91
- * WEBGL_lose_context.loseContext() on dispose when loseContextOnDispose is set; without it the cube's WebGL context
92
- * would linger until garbage collection, and browsers cap the number of live contexts per page.
90
+ * Options of the default cube Engine (as in PrefSketch, plus loseContextOnDispose and powerPreference). Babylon only
91
+ * calls WEBGL_lose_context.loseContext() on dispose when loseContextOnDispose is set; without it the cube's WebGL
92
+ * context would linger until garbage collection, and browsers cap the number of live contexts per page.
93
+ * powerPreference is explicit because Babylon fills in "high-performance" otherwise, and iOS 18.7 / Safari 26.6.1
94
+ * (iPhone) returns a WebGL2 context that is already lost for that preference.
93
95
  */
94
- static #VIEW_CUBE_ENGINE_OPTIONS = Object.freeze({ alpha: true, antialias: true, premultipliedAlpha: false, stencil: false, loseContextOnDispose: true });
96
+ static #VIEW_CUBE_ENGINE_OPTIONS = Object.freeze({ alpha: true, antialias: true, premultipliedAlpha: false, stencil: false, loseContextOnDispose: true, powerPreference: "default" });
95
97
 
96
98
  static #DEFAULT_LABELS = Object.freeze({ right: "Right", left: "Left", bottom: "Bottom", top: "Top", back: "Back", front: "Front" });
97
99
 
@@ -0,0 +1,107 @@
1
+ import { FilesInputStore } from "@babylonjs/core";
2
+ import FileStorage from "./file-storage.js";
3
+
4
+ /**
5
+ * Resolves the environment map (`options.ibl.url`) into a source Babylon can load on every browser.
6
+ *
7
+ * The cached environment used to reach Babylon as a `blob:` object URL of the IndexedDB record. iOS WebKit answers
8
+ * an XHR to such a URL with status 0, Babylon retries and gives up, and the texture never becomes ready, so the
9
+ * 3D load never finished. Like `GLTFResolver` does for `.glb` files, the cached bytes are read here into memory and
10
+ * handed to Babylon as a `File` through its `file:` scheme (`FilesInputStore`), so no XHR touches a blob. A record
11
+ * that is empty or cannot be read in time is purged and the server URL is used instead, so the load always completes.
12
+ *
13
+ * Every resolution registers its own file name; the owner releases it with `release` once Babylon has consumed it.
14
+ */
15
+ export default class IBLSourceResolver {
16
+ static #FILE_PREFIX = "pref-viewer-ibl-";
17
+ static #sequence = 0;
18
+ #fileStorage;
19
+
20
+ /**
21
+ * Upper bound for reading a cached environment out of IndexedDB before falling back to the server URL.
22
+ * @public
23
+ * @static
24
+ * @type {number}
25
+ */
26
+ static CACHE_READ_TIMEOUT_MS = 5000;
27
+
28
+ /**
29
+ * @param {FileStorage} [fileStorage] - Cache to read from; defaults to the viewer's shared store.
30
+ */
31
+ constructor(fileStorage = new FileStorage("PrefViewer", "Files")) {
32
+ this.#fileStorage = fileStorage;
33
+ }
34
+
35
+ /**
36
+ * Resolves one environment URL into a loadable source.
37
+ * @public
38
+ * @param {string} url - Server URL of the `.env` or `.hdr` file.
39
+ * @returns {Promise<{cachedUrl: string, timeStamp: string|null}|false>} The `file:` source of the in-memory copy
40
+ * (or the server URL when the cache cannot serve it) with the record's time stamp, or false when the file is
41
+ * unavailable both in the cache and on the server.
42
+ */
43
+ async resolve(url) {
44
+ let record;
45
+ try {
46
+ record = await IBLSourceResolver.#withTimeout(this.#fileStorage.get(url), IBLSourceResolver.CACHE_READ_TIMEOUT_MS);
47
+ } catch {
48
+ return false;
49
+ }
50
+ if (!record || !record.blob) {
51
+ return false;
52
+ }
53
+ const timeStamp = record.timeStamp ?? null;
54
+ let bytes = null;
55
+ if (record.blob.size > 0) {
56
+ try {
57
+ bytes = await IBLSourceResolver.#withTimeout(record.blob.arrayBuffer(), IBLSourceResolver.CACHE_READ_TIMEOUT_MS);
58
+ } catch {
59
+ bytes = null;
60
+ }
61
+ }
62
+ if (!bytes || bytes.byteLength === 0) {
63
+ // Fire-and-forget: a hung IndexedDB transaction must not block this load either.
64
+ void Promise.resolve(this.#fileStorage.remove(url)).catch(() => {});
65
+ return { cachedUrl: url, timeStamp };
66
+ }
67
+ const baseName = url.split(/[?#]/)[0].split("/").pop() || "environment";
68
+ const name = `${IBLSourceResolver.#FILE_PREFIX}${++IBLSourceResolver.#sequence}-${baseName}`;
69
+ FilesInputStore.FilesToLoad[name.toLowerCase()] = new File([bytes], name, { type: record.blob.type || "" });
70
+ return { cachedUrl: `file:${name}`, timeStamp };
71
+ }
72
+
73
+ /**
74
+ * Releases a source produced by `resolve` (or an older `blob:` URL): drops the in-memory file from Babylon's
75
+ * store or revokes the object URL. Server URLs and empty values are ignored.
76
+ * @public
77
+ * @static
78
+ * @param {string|null|undefined} cachedUrl - Source to release.
79
+ * @returns {void}
80
+ */
81
+ static release(cachedUrl) {
82
+ if (typeof cachedUrl !== "string") {
83
+ return;
84
+ }
85
+ if (cachedUrl.startsWith("file:")) {
86
+ delete FilesInputStore.FilesToLoad[decodeURIComponent(cachedUrl.slice("file:".length)).toLowerCase()];
87
+ } else if (cachedUrl.startsWith("blob:")) {
88
+ URL.revokeObjectURL(cachedUrl);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Bounds a cache read so an IndexedDB transaction that never settles cannot block the load.
94
+ * @private
95
+ * @static
96
+ * @param {Promise<any>} promise - Read to bound.
97
+ * @param {number} ms - Time allowed before rejecting.
98
+ * @returns {Promise<any>} The read's result, or a rejection after `ms`.
99
+ */
100
+ static #withTimeout(promise, ms) {
101
+ let timer = null;
102
+ const timeout = new Promise((resolve, reject) => {
103
+ timer = setTimeout(() => reject(new Error(`Cache read timed out after ${ms}ms`)), ms);
104
+ });
105
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
106
+ }
107
+ }
@@ -6,6 +6,7 @@ import { PBRMaterial } from "@babylonjs/core";
6
6
  */
7
7
  export default class MaterialUtils {
8
8
  static #SIMPLIFIED_TRANSMISSION_KEY = "prefViewerSimplifiedTransmission";
9
+ static #AUTHORED_GLASS_ALPHA_KEY = "prefViewerAuthoredGlassAlpha";
9
10
 
10
11
  /**
11
12
  * Tags a PBR material as having been converted from physical KHR_materials_transmission to simple
@@ -70,6 +71,12 @@ export default class MaterialUtils {
70
71
  return false;
71
72
  }
72
73
 
74
+ if (MaterialUtils.authoredGlassAlpha(mat) === null && Number.isFinite(mat.alpha)) {
75
+ mat.metadata = {
76
+ ...(mat.metadata ?? {}),
77
+ [MaterialUtils.#AUTHORED_GLASS_ALPHA_KEY]: Math.max(0, Math.min(1, mat.alpha)),
78
+ };
79
+ }
73
80
  mat.alpha = look?.alpha ?? 1;
74
81
  mat.metallicF0Factor = look?.metallicF0Factor ?? 3;
75
82
  mat.useSpecularOverAlpha = true;
@@ -77,6 +84,20 @@ export default class MaterialUtils {
77
84
  return true;
78
85
  }
79
86
 
87
+ /**
88
+ * Returns the base color alpha the glass was authored with (for example the SDK's `0.15`) before
89
+ * `tunePhysicalGlassMaterial` raised it for the web look. Native AR has no physical transmission,
90
+ * so that authored alpha is the opacity that keeps the glass looking like the web view.
91
+ * @public
92
+ * @static
93
+ * @param {import("@babylonjs/core").Material|null|undefined} mat - Material to read.
94
+ * @returns {number|null} The authored alpha in [0, 1], or null when the material was never tuned.
95
+ */
96
+ static authoredGlassAlpha(mat) {
97
+ const value = mat?.metadata?.[MaterialUtils.#AUTHORED_GLASS_ALPHA_KEY];
98
+ return Number.isFinite(value) ? value : null;
99
+ }
100
+
80
101
  /**
81
102
  * Returns true when a material should be placed in the transparent rendering group (renderingGroupId=1).
82
103
  *
@@ -1,3 +1,5 @@
1
+ import IBLSourceResolver from "./ibl-source-resolver.js";
2
+
1
3
  /**
2
4
  * ContainerData - Represents state, cache metadata, and update lifecycle for a 3D asset container
3
5
  * (for example: model, environment, or materials).
@@ -410,15 +412,14 @@ export class IBLData {
410
412
  }
411
413
 
412
414
  /**
413
- * Revokes object URLs (`blob:`) to release browser memory.
415
+ * Releases a temporary cached source: a `blob:` object URL is revoked and a `file:` source registered by
416
+ * `IBLSourceResolver` is dropped from Babylon's file store. Server URLs are left alone.
414
417
  * @private
415
- * @param {string|null|undefined} url URL to revoke when applicable.
418
+ * @param {string|null|undefined} url Cached source to release when applicable.
416
419
  * @returns {void}
417
420
  */
418
421
  #revokeIfBlobURL(url) {
419
- if (typeof url === "string" && url.startsWith("blob:")) {
420
- URL.revokeObjectURL(url);
421
- }
422
+ IBLSourceResolver.release(url);
422
423
  }
423
424
 
424
425
  /**
@@ -2,7 +2,7 @@ import { CameraData, ContainerData, MaterialData, IBLData } from "./pref-viewer-
2
2
  import BabylonJSController from "./babylonjs-controller.js";
3
3
  import { PrefViewer3DStyles } from "./styles.js";
4
4
  import PrefViewerControlsPresentation from "./pref-viewer-controls-presentation.js";
5
- import FileStorage from "./file-storage.js";
5
+ import IBLSourceResolver from "./ibl-source-resolver.js";
6
6
 
7
7
  /**
8
8
  * PrefViewer3D - Custom Web Component for interactive 3D visualization and configuration.
@@ -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
  }
@@ -442,12 +446,12 @@ export default class PrefViewer3D extends HTMLElement {
442
446
  let intensity = undefined;
443
447
 
444
448
  if (typeof options.ibl.url === "string" && options.ibl.url.length > 0) {
445
- const fileStorage = new FileStorage("PrefViewer", "Files");
446
- const newURL = await fileStorage.getURL(options.ibl.url);
447
- if (newURL) {
449
+ // The cached bytes reach Babylon as an in-memory file, never as a blob: URL (iOS WebKit cannot XHR those).
450
+ const source = await new IBLSourceResolver().resolve(options.ibl.url);
451
+ if (source) {
448
452
  url = options.ibl.url;
449
- cachedUrl = newURL;
450
- timeStamp = await fileStorage.getTimeStamp(options.ibl.url);
453
+ cachedUrl = source.cachedUrl;
454
+ timeStamp = source.timeStamp;
451
455
  } else {
452
456
  url = null;
453
457
  cachedUrl = null;
@@ -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.