@preference-sl/pref-viewer 3.1.0-beta.3 → 3.1.0-beta.4

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
@@ -591,7 +591,7 @@ The model's transmissive glass (`KHR_materials_transmission`, as authored by the
591
591
  | `.env` | Prefiltered Babylon environment (`CubeTexture.CreateFromPrefilteredData`) | No prefiltering in the browser; a 4K environment is typically under 1 MB. |
592
592
  | anything else | Radiance HDR (`HDRCubeTexture`) | Prefiltered on load, which costs time and memory for large files. |
593
593
 
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.
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; a first download that does not answer within 5 s also falls back to the server URL instead of leaving the scene without an environment. File names are reduced to a URL-safe alphabet, so spaces, `%` and accents in the original name are harmless.
595
595
 
596
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:
597
597
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "3.1.0-beta.3",
3
+ "version": "3.1.0-beta.4",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -69,7 +69,7 @@ export default class BabylonJSARGlass {
69
69
  sub.isRefractionEnabled = false;
70
70
  sub.isTranslucencyEnabled = false;
71
71
  const authored = linked ? null : MaterialUtils.authoredGlassAlpha(material);
72
- material.alpha = authored !== null && authored < 1
72
+ material.alpha = authored !== null && authored > 0 && authored < 1
73
73
  ? authored
74
74
  : (linked ? 1 : alpha) * Math.max(BabylonJSARGlass.#MIN_GLASS_OPACITY,
75
75
  1 - transmission * BabylonJSARGlass.#TRANSMISSION_REDUCTION);
@@ -1651,7 +1651,7 @@ export default class BabylonJSController {
1651
1651
  * Lifecycle implemented here:
1652
1652
  * 1. If `options.ibl.cachedUrl` exists, create `HDRCubeTexture` from it (a prefiltered `CubeTexture` when `options.ibl.url` is a `.env`).
1653
1653
  * 2. Wait for readiness, clone it into `#hdrTexture` for reuse.
1654
- * 3. Call `options.ibl.consumeCachedUrl(true)` to revoke temporary object URLs and clear `cachedUrl`.
1654
+ * 3. Call `options.ibl.consumeCachedUrl(true)` to release the temporary `file:` source and clear `cachedUrl`.
1655
1655
  * 4. On following reloads, if `options.ibl.valid === true` and no `cachedUrl` is present, use `#hdrTexture.clone()`.
1656
1656
  */
1657
1657
  async #initializeEnvironmentTexture() {
@@ -1664,7 +1664,7 @@ export default class BabylonJSController {
1664
1664
  if (this.#options.ibl?.cachedUrl) {
1665
1665
  const hdrTextureURI = this.#options.ibl.cachedUrl;
1666
1666
  if (/\.env$/i.test(String(this.#options.ibl.url ?? "").split(/[?#]/)[0])) {
1667
- // The cached file is a blob: URL with no extension, so the loader is told it is a .env.
1667
+ // The cached file is a file: URL whose name may not end in .env, so the loader is told it is a .env.
1668
1668
  hdrTexture = CubeTexture.CreateFromPrefilteredData(hdrTextureURI, this.#scene, ".env");
1669
1669
  } else {
1670
1670
  const envTextureSize = BabylonJSController.config.quality.environmentTextureSize || 1024;
@@ -9,6 +9,11 @@ import FileStorage from "./file-storage.js";
9
9
  * 3D load never finished. Like `GLTFResolver` does for `.glb` files, the cached bytes are read here into memory and
10
10
  * handed to Babylon as a `File` through its `file:` scheme (`FilesInputStore`), so no XHR touches a blob. A record
11
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
+ * The same goes for a cache read-through that does not answer in time (a slow first download): Babylon then loads
13
+ * the server URL itself instead of the scene being left without an environment.
14
+ *
15
+ * File names are reduced to a URL-safe alphabet, because Babylon runs `decodeURIComponent` on the `file:` URL before
16
+ * looking the name up and a space, `%` or accent in the original file name would otherwise miss the store.
12
17
  *
13
18
  * Every resolution registers its own file name; the owner releases it with `release` once Babylon has consumed it.
14
19
  */
@@ -26,6 +31,8 @@ export default class IBLSourceResolver {
26
31
  static CACHE_READ_TIMEOUT_MS = 5000;
27
32
 
28
33
  /**
34
+ * Creates a resolver bound to one cache.
35
+ * @public
29
36
  * @param {FileStorage} [fileStorage] - Cache to read from; defaults to the viewer's shared store.
30
37
  */
31
38
  constructor(fileStorage = new FileStorage("PrefViewer", "Files")) {
@@ -37,15 +44,16 @@ export default class IBLSourceResolver {
37
44
  * @public
38
45
  * @param {string} url - Server URL of the `.env` or `.hdr` file.
39
46
  * @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.
47
+ * (or the server URL when the cache cannot serve it in time) with the record's time stamp, or false when the
48
+ * file is unavailable both in the cache and on the server. Every `file:` source returned must reach `release`.
42
49
  */
43
50
  async resolve(url) {
44
51
  let record;
45
52
  try {
46
53
  record = await IBLSourceResolver.#withTimeout(this.#fileStorage.get(url), IBLSourceResolver.CACHE_READ_TIMEOUT_MS);
47
- } catch {
48
- return false;
54
+ } catch (error) {
55
+ // A slow read-through is not a missing file: let Babylon download the server URL itself.
56
+ return error?.timedOut === true ? { cachedUrl: url, timeStamp: null } : false;
49
57
  }
50
58
  if (!record || !record.blob) {
51
59
  return false;
@@ -64,8 +72,7 @@ export default class IBLSourceResolver {
64
72
  void Promise.resolve(this.#fileStorage.remove(url)).catch(() => {});
65
73
  return { cachedUrl: url, timeStamp };
66
74
  }
67
- const baseName = url.split(/[?#]/)[0].split("/").pop() || "environment";
68
- const name = `${IBLSourceResolver.#FILE_PREFIX}${++IBLSourceResolver.#sequence}-${baseName}`;
75
+ const name = `${IBLSourceResolver.#FILE_PREFIX}${++IBLSourceResolver.#sequence}-${IBLSourceResolver.#safeBaseName(url)}`;
69
76
  FilesInputStore.FilesToLoad[name.toLowerCase()] = new File([bytes], name, { type: record.blob.type || "" });
70
77
  return { cachedUrl: `file:${name}`, timeStamp };
71
78
  }
@@ -83,24 +90,51 @@ export default class IBLSourceResolver {
83
90
  return;
84
91
  }
85
92
  if (cachedUrl.startsWith("file:")) {
86
- delete FilesInputStore.FilesToLoad[decodeURIComponent(cachedUrl.slice("file:".length)).toLowerCase()];
93
+ const name = cachedUrl.slice("file:".length);
94
+ let decoded = name;
95
+ try {
96
+ decoded = decodeURIComponent(name);
97
+ } catch {
98
+ // Not percent-encoded: the raw name is the key.
99
+ }
100
+ delete FilesInputStore.FilesToLoad[decoded.toLowerCase()];
87
101
  } else if (cachedUrl.startsWith("blob:")) {
88
102
  URL.revokeObjectURL(cachedUrl);
89
103
  }
90
104
  }
91
105
 
106
+ /**
107
+ * Reduces the last path segment of a URL to letters, digits, dot, underscore and dash, so the name survives
108
+ * Babylon's `decodeURIComponent` of `file:` URLs unchanged.
109
+ * @private
110
+ * @static
111
+ * @param {string} url - Server URL of the environment file.
112
+ * @returns {string} Safe, non-empty base name.
113
+ */
114
+ static #safeBaseName(url) {
115
+ const segment = url.split(/[?#]/)[0].split("/").pop() || "";
116
+ let decoded = segment;
117
+ try {
118
+ decoded = decodeURIComponent(segment);
119
+ } catch {
120
+ // Keep the raw segment; the replacement below removes the stray percent sign.
121
+ }
122
+ const safe = decoded.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^A-Za-z0-9._-]+/g, "_");
123
+ return safe.replace(/^_+|_+$/g, "") || "environment";
124
+ }
125
+
92
126
  /**
93
127
  * Bounds a cache read so an IndexedDB transaction that never settles cannot block the load.
94
128
  * @private
95
129
  * @static
96
130
  * @param {Promise<any>} promise - Read to bound.
97
131
  * @param {number} ms - Time allowed before rejecting.
98
- * @returns {Promise<any>} The read's result, or a rejection after `ms`.
132
+ * @returns {Promise<any>} The read's result, or a rejection after `ms` whose error has `timedOut === true`.
99
133
  */
100
134
  static #withTimeout(promise, ms) {
101
135
  let timer = null;
102
136
  const timeout = new Promise((resolve, reject) => {
103
- timer = setTimeout(() => reject(new Error(`Cache read timed out after ${ms}ms`)), ms);
137
+ timer = setTimeout(() => reject(Object.assign(new Error(`Cache read timed out after ${ms}ms`), { timedOut: true })), ms);
104
138
  });
105
139
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
106
140
  }
@@ -58,6 +58,9 @@ export default class MaterialUtils {
58
58
  * 1, raising the dielectric F0 and re-enabling the over-alpha reflections lets the environment
59
59
  * show up on the glass while transmission keeps it see-through.
60
60
  *
61
+ * Before raising the alpha, the alpha the SDK authored is stored once in the material metadata so the
62
+ * AR adapter can restore the authored see-through look in Quick Look / WebXR (see `authoredGlassAlpha`).
63
+ *
61
64
  * Only physical glass (see `isPrefViewerPhysicalGlassMaterial`) is touched; `transparencyMode`
62
65
  * and `subSurface` are left as authored so the glass stays in the transparent pass.
63
66
  * @public
@@ -386,7 +386,7 @@ export class CameraData {
386
386
  * - Instantiate with defaults: `const ibl = new IBLData();`.
387
387
  * - Call `setValues(...)` with only the fields you want to update (`undefined` preserves current values).
388
388
  * - Lifecycle: set a fresh `cachedUrl` via `setValues(...)`, create/clone the Babylon HDR texture, then call
389
- * `consumeCachedUrl(true)` to revoke temporary blob URLs, set `cachedUrl` to null, and keep `valid=true`.
389
+ * `consumeCachedUrl(true)` to release the temporary `file:` source, set `cachedUrl` to null, and keep `valid=true`.
390
390
  * - Call `reset()` to clear the current IBL environment and restore default intensity/shadows.
391
391
  */
392
392
  export class IBLData {
@@ -418,7 +418,7 @@ export class IBLData {
418
418
  * @param {string|null|undefined} url Cached source to release when applicable.
419
419
  * @returns {void}
420
420
  */
421
- #revokeIfBlobURL(url) {
421
+ #releaseSource(url) {
422
422
  IBLSourceResolver.release(url);
423
423
  }
424
424
 
@@ -428,7 +428,7 @@ export class IBLData {
428
428
  * @returns {void}
429
429
  */
430
430
  reset() {
431
- this.#revokeIfBlobURL(this.cachedUrl);
431
+ this.#releaseSource(this.cachedUrl);
432
432
  this.url = null
433
433
  this.cachedUrl = null;
434
434
  this.intensity = this.defaultIntensity;
@@ -439,14 +439,14 @@ export class IBLData {
439
439
 
440
440
  /**
441
441
  * Consumes the temporary cached URL after Babylon texture creation.
442
- * Revokes the URL when it is a `blob:` object URL, clears `cachedUrl`,
442
+ * Releases the source (a `file:` entry or a legacy `blob:` object URL), clears `cachedUrl`,
443
443
  * and marks whether a reusable in-memory texture is available.
444
444
  * @public
445
445
  * @param {boolean} [valid=true] Whether the corresponding HDR texture has been validated and cached in memory.
446
446
  * @returns {void}
447
447
  */
448
448
  consumeCachedUrl(valid = true) {
449
- this.#revokeIfBlobURL(this.cachedUrl);
449
+ this.#releaseSource(this.cachedUrl);
450
450
  this.cachedUrl = null;
451
451
  this.valid = !!valid;
452
452
  }
@@ -457,7 +457,7 @@ export class IBLData {
457
457
  * until the URL is consumed into a Babylon texture.
458
458
  * @public
459
459
  * @param {string|null|undefined} url Source URL identifier.
460
- * @param {string|null|undefined} cachedUrl Resolved URL (including possible `blob:` URL).
460
+ * @param {string|null|undefined} cachedUrl Loadable source from `IBLSourceResolver` (a `file:` source or the server URL; a legacy `blob:` URL is also released).
461
461
  * @param {number|undefined} intensity IBL intensity.
462
462
  * @param {boolean|undefined} shadows Shadow toggle for IBL path.
463
463
  * @param {string|null|undefined} timeStamp Last-modified marker.
@@ -465,7 +465,7 @@ export class IBLData {
465
465
  */
466
466
  setValues(url, cachedUrl, intensity, shadows, timeStamp) {
467
467
  if (cachedUrl !== undefined && cachedUrl !== this.cachedUrl) {
468
- this.#revokeIfBlobURL(this.cachedUrl);
468
+ this.#releaseSource(this.cachedUrl);
469
469
  }
470
470
  this.url = url !== undefined ? url : this.url;
471
471
  this.cachedUrl = cachedUrl !== undefined ? cachedUrl : this.cachedUrl;
@@ -476,4 +476,35 @@ export class IBLData {
476
476
  this.valid = typeof cachedUrl === "string" && cachedUrl.length > 0 ? false : this.valid;
477
477
  }
478
478
  }
479
+
480
+ /**
481
+ * Applies freshly resolved IBL options and tells whether the environment must be reloaded.
482
+ * A `null` `url` or `cachedUrl` clears the environment. When nothing differs from the current state, the
483
+ * `cachedUrl` the caller just resolved is released here, so an in-memory copy registered for nothing never
484
+ * outlives the call. Otherwise ownership of `cachedUrl` passes to this state until it is consumed or replaced.
485
+ * @public
486
+ * @param {object} [resolved] Resolved IBL options; `undefined` fields keep the current value.
487
+ * @param {string|null} [resolved.url] Source URL identifier, or `null` to clear the environment.
488
+ * @param {string|null} [resolved.cachedUrl] Loadable source produced by `IBLSourceResolver`, or `null`.
489
+ * @param {string|null} [resolved.timeStamp] Source modification marker.
490
+ * @param {boolean} [resolved.shadows] IBL shadow toggle.
491
+ * @param {number} [resolved.intensity] IBL intensity.
492
+ * @returns {boolean} True when the state changed and the environment must be reloaded.
493
+ */
494
+ applyResolved({ url, cachedUrl, timeStamp, shadows, intensity } = {}) {
495
+ const needUpdate = url !== undefined && url !== this.url ||
496
+ timeStamp !== undefined && timeStamp !== this.timeStamp ||
497
+ shadows !== undefined && shadows !== this.shadows ||
498
+ shadows === undefined && this.shadows !== this.defaultShadows ||
499
+ intensity !== undefined && intensity !== this.intensity ||
500
+ intensity === undefined && this.intensity !== this.defaultIntensity;
501
+ if (!needUpdate) {
502
+ this.#releaseSource(cachedUrl);
503
+ } else if (url === null || cachedUrl === null) {
504
+ this.reset();
505
+ } else {
506
+ this.setValues(url, cachedUrl, intensity, shadows, timeStamp);
507
+ }
508
+ return needUpdate;
509
+ }
479
510
  }
@@ -429,6 +429,8 @@ export default class PrefViewer3D extends HTMLElement {
429
429
  * This allows updating other IBL properties (such as intensity or shadows) without requiring a new URL.
430
430
  * If `options.ibl.url` is explicitly provided as `null` (or resolves to an unavailable URL), it is treated as a request to
431
431
  * clear the current IBL environment, and the IBL state is reset so Babylon can remove the environment map.
432
+ * The cached environment is resolved by `IBLSourceResolver` into a `file:` source; the comparison and the release of a
433
+ * source that turned out not to be needed are delegated to `IBLData.applyResolved`, so repeated identical options leave nothing behind.
432
434
  * @private
433
435
  * @param {object} options - Options payload that may contain an `ibl` block with url, intensity, or shadow flags.
434
436
  * @returns {Promise<boolean>} Resolves to true when any IBL property differs from the cached state, otherwise false.
@@ -467,21 +469,7 @@ export default class PrefViewer3D extends HTMLElement {
467
469
  intensity = options.ibl.intensity;
468
470
  }
469
471
 
470
- const needUpdate = url !== undefined && url !== iblState.url ||
471
- timeStamp !== undefined && timeStamp !== iblState.timeStamp ||
472
- shadows !== undefined && shadows !== iblState.shadows ||
473
- shadows === undefined && iblState.shadows !== iblState.defaultShadows ||
474
- intensity !== undefined && intensity !== iblState.intensity ||
475
- intensity === undefined && iblState.intensity !== iblState.defaultIntensity;
476
- if (needUpdate) {
477
- if (url === null || cachedUrl === null) {
478
- iblState.reset();
479
- } else {
480
- iblState.setValues(url, cachedUrl, intensity, shadows, timeStamp);
481
- }
482
- }
483
-
484
- return needUpdate;
472
+ return iblState.applyResolved({ url, cachedUrl, timeStamp, shadows, intensity });
485
473
  }
486
474
 
487
475
  /**