@preference-sl/pref-viewer 3.0.0-beta.0 → 3.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,7 +46,7 @@ While AR waits for floor placement (including `preview`), `getDebugInfo().ar.dia
46
46
 
47
47
  On eligible Safari, the AR control uses the existing XR appearance and position. The viewer prepares USDZ after model/material loading; the control becomes usable when that preparation finishes. Updates invalidate the previous file. A failed preparation offers a retry through the same control. No new public method or configuration is required, and the existing USDZ download methods remain downloads.
48
48
 
49
- The link uses `rel="ar"`, `download="model.usdz"` and a `model/vnd.usdz+zip` Blob URL, so its native navigation runs directly on the user tap. Only model meshes with supported materials and current visibility are exported. Environment and viewer overlays are excluded. Babylon's exporter currently loads its `fflate` helper from `https://unpkg.com/fflate@0.8.2` when that global is absent; the host's network/CSP must permit that existing exporter dependency.
49
+ 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
50
 
51
51
  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
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "3.0.0-beta.0",
3
+ "version": "3.0.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": {
@@ -45,6 +45,7 @@
45
45
  "@panzoom/panzoom": "^4.6.2",
46
46
  "babylonjs-gltf2interface": "^9.11.0",
47
47
  "buffer": "^6.0.3",
48
+ "fflate": "0.8.3",
48
49
  "idb": "^8.0.3",
49
50
  "is-svg": "^6.1.0",
50
51
  "jszip": "^3.10.1",
@@ -59,7 +60,6 @@
59
60
  "clean-webpack-plugin": "^4.0.0",
60
61
  "draco3dgltf": "1.5.7",
61
62
  "esbuild": "^0.25.10",
62
- "fflate": "0.8.3",
63
63
  "http-server": "^14.1.1",
64
64
  "jsdom": "^26.1.0",
65
65
  "standard-version": "^9.5.0",
@@ -1,10 +1,14 @@
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 BabylonJSARGlass from "./babylonjs-ar-glass.js";
3
4
  import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression.js";
4
5
  import "@babylonjs/loaders";
5
6
  import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_draco_mesh_compression.js";
6
7
  import { USDZExportAsync, GLTF2Export } from "@babylonjs/serializers";
7
8
  import JSZip from "jszip";
9
+ // Browser build of fflate: the package's default export map resolves to its Node flavour under
10
+ // esbuild's `platform: node`, which imports `createRequire` from "module" and breaks in browsers.
11
+ import * as fflate from "fflate/browser";
8
12
 
9
13
  import GLTFResolver from "./gltf-resolver.js";
10
14
  import { MaterialData } from "./pref-viewer-3d-data.js";
@@ -125,6 +129,7 @@ export default class BabylonJSController {
125
129
  #arPlacement = null;
126
130
  #xrGeneration = 0;
127
131
  #quickLookAction = null;
132
+ #quickLookGlass = new BabylonJSARGlass();
128
133
  #quickLookLoads = 0;
129
134
  #quickLookValid = { model: false, materials: true };
130
135
  #canvasResizeObserver = null;
@@ -1213,7 +1218,37 @@ export default class BabylonJSController {
1213
1218
  }
1214
1219
  // AssetContainer contains only model meshes; applied materials and world transforms are retained.
1215
1220
  // No environment, XR ground, ViewCube or utility-scene overlays enter this export.
1216
- void this.#quickLookAction.update(() => USDZExportAsync(asset, {}, eligible));
1221
+ void this.#quickLookAction.update(() => this.#exportQuickLookUSDZ(asset, eligible));
1222
+ }
1223
+
1224
+ /**
1225
+ * Exposes the bundled fflate module as the global the Babylon USDZ exporter checks for, so it neither
1226
+ * fetches the library from a CDN nor awaits before its synchronous material pass.
1227
+ * @private
1228
+ * @returns {void} Leaves an existing global untouched.
1229
+ */
1230
+ #ensureUSDZDependencies() {
1231
+ globalThis.fflate ??= fflate;
1232
+ }
1233
+
1234
+ /**
1235
+ * Serializes the model to USDZ with physical (transmission) glass converted to alpha opacity, because the
1236
+ * exporter ignores transmission and would write the on-screen opaque alpha. The exporter reads every
1237
+ * material synchronously before its first await, so the adapter is restored right after the call returns
1238
+ * and the live render loop never draws the adapted glass.
1239
+ * @private
1240
+ * @param {import("@babylonjs/core").AssetContainer} asset - Model container to serialize.
1241
+ * @param {function(import("@babylonjs/core").AbstractMesh): boolean} eligible - Mesh filter for the export.
1242
+ * @returns {Promise<Uint8Array>} The serialized USDZ bytes.
1243
+ */
1244
+ #exportQuickLookUSDZ(asset, eligible) {
1245
+ this.#ensureUSDZDependencies();
1246
+ this.#quickLookGlass.sync(asset);
1247
+ try {
1248
+ return USDZExportAsync(asset, {}, eligible);
1249
+ } finally {
1250
+ this.#quickLookGlass.restore();
1251
+ }
1217
1252
  }
1218
1253
 
1219
1254
  /**
@@ -35,6 +35,7 @@ import { openDB } from "idb";
35
35
  * - getTimeStamp(uri): Retrieves cached or server Last-Modified timestamp.
36
36
  * - getSize(uri): Gets cached or server file size in bytes.
37
37
  * - getBlob(uri): Retrieves file blob from cache or server.
38
+ * - remove(uri): Deletes a cached record (e.g. an unreadable blob) so it is downloaded again next time.
38
39
  * - get(uri): Gets file from cache with automatic server sync and cache versioning.
39
40
  * - put(uri): Stores file from server in IndexedDB cache.
40
41
  * - dispose(): Closes the active IndexedDB handle held by the instance.
@@ -698,6 +699,20 @@ export default class FileStorage {
698
699
  return storedFile ? URL.createObjectURL(storedFile.blob) : (await this.#getServerFileTimeStamp(uri)) ? uri : false;
699
700
  }
700
701
 
702
+ /**
703
+ * Removes a cached record so the next access downloads the file again.
704
+ * Used when a stored blob turns out to be unreadable (WebKit can abort IndexedDB writes and leave a broken record).
705
+ * @public
706
+ * @param {String} uri - File URI (record key).
707
+ * @returns {Promise<number>} Number of requested deletions (0 when IndexedDB is unavailable).
708
+ */
709
+ async remove(uri) {
710
+ if (!this.#supported || !uri) {
711
+ return 0;
712
+ }
713
+ return this.#deleteKeys([uri]);
714
+ }
715
+
701
716
  /**
702
717
  * Get the cached or server Last-Modified timestamp for a file.
703
718
  * @public
@@ -10,7 +10,8 @@ import { closeDb, initDb, loadModel } from "./gltf-storage.js";
10
10
  * - Normalizes and replaces asset URIs with resolved URLs from FileStorage.
11
11
  * - Handles cache validation using asset size and timestamp to avoid unnecessary reloads.
12
12
  * Callers may opt into an explicit unchanged marker to distinguish cache hits from unavailable content.
13
- * - Tracks generated object URLs so callers can revoke them after Babylon finishes loading.
13
+ * - Tracks generated object URLs (used for .gltf dependencies) so callers can revoke them after Babylon finishes loading.
14
+ * - Hands cached remote .glb files to Babylon as File objects (no blob: URL, which fails on iOS Safari), falling back to the server URL when the cached blob is missing or empty.
14
15
  * - Provides methods for initializing storage, decoding assets, and preparing sources for viewer components.
15
16
  *
16
17
  * Usage:
@@ -218,6 +219,72 @@ export default class GLTFResolver {
218
219
  await Promise.all(promisesArray);
219
220
  }
220
221
 
222
+ /**
223
+ * Resolves a remote .glb URL into the source handed to Babylon.
224
+ * @private
225
+ * @param {string} url - Absolute or relative URL of the .glb asset.
226
+ * @returns {Promise<File|string>} A File holding the cached bytes, or the original URL when no readable cached blob exists.
227
+ * @description
228
+ * - The cached blob is read into memory here, so Babylon receives a File backed by plain bytes. This avoids both
229
+ * the XHR over a blob: URL (status 0 on iOS Safari) and FileReader on an IndexedDB blob that iOS cannot read.
230
+ * - A missing or empty cached blob (WebKit can abort IndexedDB writes leaving empty records) falls back to the server URL.
231
+ * - A cached blob whose bytes cannot be read, or whose read does not settle within `CACHE_READ_TIMEOUT_MS`
232
+ * (iOS Safari can hang on a blob left by an aborted IndexedDB write), is purged from the cache without
233
+ * waiting for the purge, and this load falls back to the server URL so the load always completes.
234
+ * - No object URL is created, so there is nothing to revoke.
235
+ */
236
+ async #getGlbSource(url) {
237
+ let bytes = null;
238
+ let type = "";
239
+ try {
240
+ bytes = await GLTFResolver.#withTimeout((async () => {
241
+ const blob = await this.#fileStorage.getBlob(url);
242
+ if (!blob || !(blob.size > 0)) {
243
+ return undefined;
244
+ }
245
+ type = blob.type || "";
246
+ return blob.arrayBuffer();
247
+ })(), GLTFResolver.CACHE_READ_TIMEOUT_MS);
248
+ } catch (error) {
249
+ bytes = null;
250
+ }
251
+ if (bytes === undefined) {
252
+ return url;
253
+ }
254
+ if (!bytes || bytes.byteLength === 0) {
255
+ // Fire-and-forget: a hung IndexedDB transaction must not block this load either.
256
+ void Promise.resolve(this.#fileStorage.remove(url)).catch(() => {});
257
+ return url;
258
+ }
259
+ const name = url.split(/[?#]/)[0].split("/").pop() || "file.glb";
260
+ return new File([bytes], name, { type: type || "model/gltf-binary" });
261
+ }
262
+
263
+ /**
264
+ * Upper bound for reading a cached .glb out of IndexedDB before giving up on the cache.
265
+ * @public
266
+ * @static
267
+ * @type {number}
268
+ */
269
+ static CACHE_READ_TIMEOUT_MS = 5000;
270
+
271
+ /**
272
+ * Races a promise against a timeout.
273
+ * @private
274
+ * @static
275
+ * @param {Promise<T>} promise - Work to bound.
276
+ * @param {number} ms - Milliseconds before rejecting with a timeout error.
277
+ * @returns {Promise<T>} The promise result, or a rejection when the timeout elapses first.
278
+ * @template T
279
+ */
280
+ static #withTimeout(promise, ms) {
281
+ let timer = null;
282
+ const timeout = new Promise((resolve, reject) => {
283
+ timer = setTimeout(() => reject(new Error(`Cache read timed out after ${ms}ms`)), ms);
284
+ });
285
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
286
+ }
287
+
221
288
  /**
222
289
  * Revoke browser object URLs and clear the provided list.
223
290
  * @public
@@ -263,6 +330,7 @@ export default class GLTFResolver {
263
330
  * - If storage specifies a direct URL or base64 data, decodes and validates the asset.
264
331
  * - For .gltf files, replaces internal URIs with resolved URLs from FileStorage.
265
332
  * - Performs cache validation using size and timestamp to avoid unnecessary reloads.
333
+ * - For remote .glb files, source is a File built from the cached blob, or the server URL string when the cached blob is missing or empty.
266
334
  * - Returns the prepared source, size, timestamp, extension, metadata, and objectURLs for further processing.
267
335
  * - The caller owns revoking returned objectURLs after the loader finishes consuming them.
268
336
  */
@@ -332,10 +400,7 @@ export default class GLTFResolver {
332
400
  await this.#replaceSceneURIAsync(assetContainerJSON, source, objectURLs);
333
401
  source = `data:${JSON.stringify(assetContainerJSON)}`;
334
402
  } else {
335
- source = await this.#fileStorage.getURL(source);
336
- if (this.#isBlobURL(source)) {
337
- objectURLs.push(source);
338
- }
403
+ source = await this.#getGlbSource(source);
339
404
  }
340
405
  }
341
406
  }
@@ -8,6 +8,15 @@ import I18n from "./localization/i18n.js";
8
8
  * Call dispose() when the owning viewer is torn down.
9
9
  */
10
10
  export default class QuickLookAction {
11
+ /**
12
+ * URL fragment appended to the prepared USDZ link; AR Quick Look parses it as viewer options.
13
+ * `allowsContentScaling=0` disables pinch scaling so the model keeps its authored real-world size.
14
+ * @public
15
+ * @static
16
+ * @type {string}
17
+ */
18
+ static QUICK_LOOK_FRAGMENT = "allowsContentScaling=0";
19
+
11
20
  #anchor;
12
21
  #style;
13
22
  #window;
@@ -234,7 +243,9 @@ export default class QuickLookAction {
234
243
  type: "model/vnd.usdz+zip"
235
244
  });
236
245
  this.#url = this.#urlAPI.createObjectURL(blob);
237
- this.#anchor.href = this.#url;
246
+ // Quick Look reads the fragment: content scaling off keeps the authored 1:1 metre scale,
247
+ // matching the Android WebXR path. The bare URL is kept for revocation.
248
+ this.#anchor.href = `${this.#url}#${QuickLookAction.QUICK_LOOK_FRAGMENT}`;
238
249
  this.#anchor.dataset.state = "ready";
239
250
  this.#anchor.setAttribute("aria-disabled", "false");
240
251
  } catch (error) {