@preference-sl/pref-viewer 2.13.0-beta.6 → 2.13.0-beta.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.13.0-beta.6",
3
+ "version": "2.13.0-beta.7",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -56,6 +56,9 @@ import { translate } from "./localization/i18n.js";
56
56
  * - `show-model`/`show-scene` DOM attributes reflect container visibility; there are no direct `showModel()/hideModel()` APIs.
57
57
  * - IBL shadows require `iblEnabled` plus `options.ibl.shadows` and a loaded HDR texture; otherwise fallback directional
58
58
  * lights and environment-contributed lights supply classic shadow generators.
59
+ * - IBL lifecycle: when `options.ibl.cachedUrl` is present, a new `HDRCubeTexture` is created and cloned into `#hdrTexture`;
60
+ * then `options.ibl.consumeCachedUrl(true)` clears/revokes the temporary URL. Subsequent reloads reuse `#hdrTexture.clone()`
61
+ * while `options.ibl.valid` remains true.
59
62
  * - Browser-only features guard `window`, localStorage, and XR APIs before use so the controller is safe to construct
60
63
  * in SSR/Node contexts (though functionality activates only in browsers).
61
64
  */
@@ -90,6 +93,7 @@ export default class BabylonJSController {
90
93
  #shadowGen = [];
91
94
  #XRExperience = null;
92
95
  #canvasResizeObserver = null;
96
+ #hdrTexture = null; // reusable in-memory HDR source cloned into scene.environmentTexture across reloads
93
97
 
94
98
  #containers = {};
95
99
  #options = {};
@@ -376,6 +380,10 @@ export default class BabylonJSController {
376
380
  * Sets light intensities and shadow properties for realistic rendering.
377
381
  * @private
378
382
  * @returns {Promise<boolean>} Returns true if lights were changed, false otherwise.
383
+ * @description
384
+ * IBL path is considered available when either:
385
+ * - `options.ibl.cachedUrl` is present (new pending URL), or
386
+ * - `options.ibl.valid === true` (reusable in-memory `#hdrTexture` exists).
379
387
  */
380
388
  async #createLights() {
381
389
  const hemiLightName = "PrefViewerHemiLight";
@@ -388,7 +396,7 @@ export default class BabylonJSController {
388
396
 
389
397
  let lightsChanged = false;
390
398
 
391
- const iblEnabled = this.#settings.iblEnabled && this.#options.ibl?.cachedUrl !== null;
399
+ const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
392
400
 
393
401
  if (iblEnabled) {
394
402
  if (hemiLight) {
@@ -409,6 +417,10 @@ export default class BabylonJSController {
409
417
  this.#scene.environmentTexture = null;
410
418
  lightsChanged = true;
411
419
  }
420
+ if (this.#hdrTexture) {
421
+ this.#hdrTexture.dispose();
422
+ this.#hdrTexture = null;
423
+ }
412
424
 
413
425
  // Add a hemispheric light for basic ambient illumination
414
426
  if (!this.#hemiLight) {
@@ -647,19 +659,43 @@ export default class BabylonJSController {
647
659
 
648
660
  /**
649
661
  * Initializes the environment texture for the Babylon.js scene.
650
- * Loads an HDR texture from a predefined URI and assigns it to the scene's environmentTexture property.
651
- * Configures gamma space, mipmaps, and intensity level for realistic lighting.
662
+ * Resolves the active HDR environment texture using either a fresh `cachedUrl`
663
+ * or the reusable in-memory clone (`#hdrTexture`), then assigns it to `scene.environmentTexture`.
652
664
  * @private
653
665
  * @returns {Promise<boolean>} Returns true if the environment texture was changed, false if it was already up to date or failed to load.
666
+ * @description
667
+ * Lifecycle implemented here:
668
+ * 1. If `options.ibl.cachedUrl` exists, create `HDRCubeTexture` from it.
669
+ * 2. Wait for readiness, clone it into `#hdrTexture` for reuse.
670
+ * 3. Call `options.ibl.consumeCachedUrl(true)` to revoke temporary object URLs and clear `cachedUrl`.
671
+ * 4. On following reloads, if `options.ibl.valid === true` and no `cachedUrl` is present, use `#hdrTexture.clone()`.
654
672
  */
655
673
  async #initializeEnvironmentTexture() {
656
674
  if (this.#scene.environmentTexture) {
657
675
  this.#scene.environmentTexture.dispose();
658
676
  this.#scene.environmentTexture = null;
659
677
  }
660
- const hdrTextureURI = this.#options.ibl.cachedUrl;
661
- const hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, 1024, false, false, false, true, undefined, undefined, false, true, true);
678
+
679
+ let hdrTexture = null;
680
+ if (this.#options.ibl?.cachedUrl) {
681
+ const hdrTextureURI = this.#options.ibl.cachedUrl;
682
+ hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, 1024, false, false, false, true, undefined, undefined, false, true, true);
683
+ } else if (this.#hdrTexture && this.#options.ibl?.valid === true) {
684
+ hdrTexture = this.#hdrTexture.clone();
685
+ } else {
686
+ return false;
687
+ }
688
+
662
689
  await WhenTextureReadyAsync(hdrTexture);
690
+
691
+ if (this.#options.ibl?.cachedUrl) {
692
+ if (this.#hdrTexture) {
693
+ this.#hdrTexture.dispose();
694
+ }
695
+ this.#hdrTexture = hdrTexture.clone();
696
+ this.#options.ibl?.consumeCachedUrl?.(true);
697
+ }
698
+
663
699
  hdrTexture.level = this.#options.ibl.intensity;
664
700
  this.#scene.environmentTexture = hdrTexture;
665
701
  this.#scene.markAllMaterialsAsDirty(Material.TextureDirtyFlag);
@@ -715,20 +751,13 @@ export default class BabylonJSController {
715
751
  * @private
716
752
  * @returns {Promise<void|boolean>} Returns false if no environment texture is set; otherwise void.
717
753
  */
718
- async #initializeIBLShadows() {
719
-
720
- await this.#scene.whenReadyAsync();
721
-
754
+ async #initializeIBLShadows() {
722
755
  const pipelineManager = this.#scene?.postProcessRenderPipelineManager;
723
-
724
- if (!this.#scene || !pipelineManager || this.#scene?.activeCamera === null) {
725
- return false;
726
- }
727
756
 
728
- if (!this.#scene.environmentTexture) {
757
+ if (!this.#scene || !this.#scene?.activeCamera || !this.#scene?.environmentTexture || !pipelineManager) {
729
758
  return false;
730
759
  }
731
-
760
+
732
761
  if (!this.#scene.environmentTexture.isReady()) {
733
762
  const self = this;
734
763
  this.#scene.environmentTexture.onLoadObservable.addOnce(() => {
@@ -736,13 +765,41 @@ export default class BabylonJSController {
736
765
  });
737
766
  return false;
738
767
  }
739
-
768
+
769
+ const meshesForCastingShadows = this.#scene.meshes.filter((mesh) => {
770
+ const isRootMesh = mesh.id.startsWith("__root__");
771
+ if (isRootMesh) {
772
+ return false;
773
+ }
774
+
775
+ const isHDRIMesh = mesh.name?.toLowerCase() === "hdri";
776
+ const extrasCastShadows = mesh.metadata?.gltf?.extras?.castShadows;
777
+ const meshGenerateShadows = typeof extrasCastShadows === "boolean" ? extrasCastShadows : isHDRIMesh ? false : true;
778
+
779
+ if (meshGenerateShadows) {
780
+ return true;
781
+ }
782
+ return false;
783
+ });
784
+ const materialsForReceivingShadows = this.#scene.materials.filter((material) => {
785
+ if (material instanceof PBRMaterial) {
786
+ material.enableSpecularAntiAliasing = false;
787
+ }
788
+ return true;
789
+ });
790
+
791
+ if (meshesForCastingShadows.length === 0 || materialsForReceivingShadows.length === 0) {
792
+ return false;
793
+ }
794
+
740
795
  const supportedPipelines = pipelineManager.supportedPipelines;
741
796
 
742
797
  if (!supportedPipelines) {
743
798
  return false;
744
799
  }
745
800
 
801
+ await this.#scene.whenReadyAsync();
802
+
746
803
  const pipelineName = "PrefViewerIblShadowsRenderPipeline";
747
804
 
748
805
  const pipelineOptions = {
@@ -775,30 +832,11 @@ export default class BabylonJSController {
775
832
  };
776
833
 
777
834
  Object.assign(iblShadowsPipeline, pipelineProps);
778
-
779
- this.#scene.meshes.forEach((mesh) => {
780
- const isRootMesh = mesh.id.startsWith("__root__");
781
- if (isRootMesh) {
782
- return false;
783
- }
784
-
785
- const isHDRIMesh = mesh.name?.toLowerCase() === "hdri";
786
- const extrasCastShadows = mesh.metadata?.gltf?.extras?.castShadows;
787
- const meshGenerateShadows = typeof extrasCastShadows === "boolean" ? extrasCastShadows : isHDRIMesh ? false : true;
788
-
789
- if (meshGenerateShadows) {
790
- iblShadowsPipeline.addShadowCastingMesh(mesh);
791
- iblShadowsPipeline.updateSceneBounds();
792
- }
793
- });
794
-
795
- this.#scene.materials.forEach((material) => {
796
- if (material instanceof PBRMaterial) {
797
- material.enableSpecularAntiAliasing = false;
798
- }
799
- iblShadowsPipeline.addShadowReceivingMaterial(material);
800
- });
801
-
835
+
836
+ meshesForCastingShadows.forEach((mesh) => iblShadowsPipeline.addShadowCastingMesh(mesh));
837
+ materialsForReceivingShadows.forEach((material) => iblShadowsPipeline.addShadowReceivingMaterial(material));
838
+
839
+ iblShadowsPipeline.updateSceneBounds();
802
840
  iblShadowsPipeline.toggleShadow(true);
803
841
  iblShadowsPipeline.updateVoxelization();
804
842
  this.#renderPipelines.iblShadows = iblShadowsPipeline;
@@ -935,7 +973,7 @@ export default class BabylonJSController {
935
973
 
936
974
  this.#ensureMeshesReceiveShadows();
937
975
 
938
- const iblEnabled = this.#settings.iblEnabled && this.#options.ibl?.cachedUrl !== null;
976
+ const iblEnabled = this.#settings.iblEnabled && (this.#options.ibl?.valid === true || !!this.#options.ibl?.cachedUrl);
939
977
  const iblShadowsEnabled = iblEnabled && this.#options.ibl.shadows;
940
978
 
941
979
  if (iblShadowsEnabled) {
@@ -1058,6 +1096,10 @@ export default class BabylonJSController {
1058
1096
  if (!this.#engine) {
1059
1097
  return;
1060
1098
  }
1099
+ if (this.#hdrTexture) {
1100
+ this.#hdrTexture.dispose();
1101
+ this.#hdrTexture = null;
1102
+ }
1061
1103
  this.#engine.dispose();
1062
1104
  this.#engine = this.#scene = this.#camera = null;
1063
1105
  this.#hemiLight = this.#dirLight = this.#cameraLight = null;
@@ -1275,6 +1317,9 @@ export default class BabylonJSController {
1275
1317
  * Marks the IBL state as successful, recreates lights so the new environment takes effect, and reports whether anything changed.
1276
1318
  * @private
1277
1319
  * @returns {boolean} True when lights were refreshed due to pending IBL changes, otherwise false.
1320
+ * @description
1321
+ * Delegates to `#createLights()`, which executes the full IBL URL-to-texture lifecycle:
1322
+ * `cachedUrl -> HDRCubeTexture -> #hdrTexture clone -> consumeCachedUrl(true)`.
1278
1323
  */
1279
1324
  async #setOptions_IBL() {
1280
1325
  return await this.#createLights();
@@ -1546,6 +1591,8 @@ export default class BabylonJSController {
1546
1591
  return [container, assetContainer];
1547
1592
  } catch (error) {
1548
1593
  return [container, assetContainer];
1594
+ } finally {
1595
+ this.#gltfResolver.revokeObjectURLs(sourceData.objectURLs);
1549
1596
  }
1550
1597
  }
1551
1598
 
@@ -42,6 +42,9 @@ import { openDB } from "idb";
42
42
  * - Fallback Support: Uses direct server access when IndexedDB is unavailable.
43
43
  * - Object URL Generation: Creates efficient object URLs for cached blobs.
44
44
  * - HEAD Request Optimization: Uses HEAD requests to check metadata without downloading full files.
45
+ * - TTL per entry: Expired records are removed automatically.
46
+ * - Maximum entry limit: Old records are evicted with an LRU strategy when limit is exceeded.
47
+ * - Quota-aware cleanup: Proactively frees space and retries writes on quota errors.
45
48
  * - Promise-Based API: All operations return promises for async/await usage.
46
49
  *
47
50
  * Usage Example:
@@ -101,7 +104,7 @@ import { openDB } from "idb";
101
104
  * - Uses XMLHttpRequest for file downloads and HEAD requests
102
105
  * - Supports both HTTPS and HTTP (HTTP not recommended for production)
103
106
  * - Object URLs should be revoked after use to free memory
104
- * - IndexedDB quota management should be implemented for long-term storage
107
+ * - IndexedDB cache is auto-maintained (TTL, max entries, quota cleanup)
105
108
  *
106
109
  * Error Handling:
107
110
  * - Network failures: Returns undefined (get) or false (other methods)
@@ -114,16 +117,329 @@ import { openDB } from "idb";
114
117
  * - idb Library: https://github.com/jakearchibald/idb
115
118
  * - npm idb: https://www.npmjs.com/package/idb
116
119
  */
117
- export class FileStorage {
120
+ export default class FileStorage {
118
121
  #supported = "indexedDB" in window && openDB; // true if IndexedDB is available in the browser and the idb helper is loaded
119
122
  #dbVersion = 1; // single DB version; cache is managed per-file
120
123
  #db = undefined; // IndexedDB database handle
124
+ #maxEntries = 300; // hard cap for cache records; oldest entries are evicted first
125
+ #entryTTLms = 7 * 24 * 60 * 60 * 1000; // 7 days
126
+ #cleanupIntervalMs = 5 * 60 * 1000; // run background cleanup at most every 5 minutes
127
+ #minimumFreeSpaceRatio = 0.05; // try to keep at least 5% of quota free
128
+ #maxQuotaEvictionRetries = 5; // max retries after quota error
129
+ #lastCleanupAt = 0; // timestamp of last automatic cleanup run
121
130
 
122
131
  constructor(dbName = "FilesDB", osName = "FilesObjectStore") {
123
132
  this.dbName = dbName; // database name
124
133
  this.osName = osName; // object store name used for file cache
125
134
  }
126
135
 
136
+ /**
137
+ * Returns the current timestamp in milliseconds.
138
+ * @private
139
+ * @returns {number} Epoch time in milliseconds.
140
+ */
141
+ #now() {
142
+ return Date.now();
143
+ }
144
+
145
+ /**
146
+ * Determines whether a cached record is expired.
147
+ * @private
148
+ * @param {Object|null|undefined} record Cached record with `expiresAt`.
149
+ * @param {number} [now=this.#now()] Current timestamp used for comparison.
150
+ * @returns {boolean} True when the record has expired; false otherwise.
151
+ */
152
+ #isExpired(record, now = this.#now()) {
153
+ return !!record && typeof record.expiresAt === "number" && record.expiresAt <= now;
154
+ }
155
+
156
+ /**
157
+ * Normalizes a raw cache record into the internal shape expected by the storage layer.
158
+ * Fills missing metadata (`size`, `createdAt`, `lastAccess`, `expiresAt`) using defaults.
159
+ * @private
160
+ * @param {Object|null|undefined} record Raw record from IndexedDB or network conversion.
161
+ * @returns {{blob: Blob, timeStamp: string|null, size: number, createdAt: number, lastAccess: number, expiresAt: number}|null}
162
+ * Normalized record, or null if the input is invalid.
163
+ */
164
+ #normalizeRecord(record) {
165
+ if (!record || !record.blob) {
166
+ return null;
167
+ }
168
+ const now = this.#now();
169
+ const createdAt = typeof record.createdAt === "number" ? record.createdAt : now;
170
+ const expiresAt = typeof record.expiresAt === "number" ? record.expiresAt : createdAt + this.#entryTTLms;
171
+ return {
172
+ blob: record.blob,
173
+ timeStamp: record.timeStamp ?? null,
174
+ size: typeof record.size === "number" ? record.size : record.blob.size,
175
+ createdAt: createdAt,
176
+ lastAccess: typeof record.lastAccess === "number" ? record.lastAccess : now,
177
+ expiresAt: expiresAt,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Converts an incoming file payload into a normalized cache record.
183
+ * @private
184
+ * @param {Object|Blob} file Incoming payload ({ blob, timeStamp } or raw Blob).
185
+ * @returns {{blob: Blob, timeStamp: string|null, size: number, createdAt: number, lastAccess: number, expiresAt: number}|null}
186
+ * Normalized record ready to persist, or null when payload is invalid.
187
+ */
188
+ #createRecordFromFile(file) {
189
+ if (!file) {
190
+ return null;
191
+ }
192
+ const now = this.#now();
193
+ const record = file instanceof Blob ? { blob: file, timeStamp: null } : file;
194
+ if (!record?.blob) {
195
+ return null;
196
+ }
197
+ return this.#normalizeRecord({
198
+ ...record,
199
+ createdAt: now,
200
+ lastAccess: now,
201
+ expiresAt: now + this.#entryTTLms,
202
+ size: record.blob.size,
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Checks whether an IndexedDB error corresponds to quota exhaustion.
208
+ * @private
209
+ * @param {any} error Error thrown by IndexedDB operations.
210
+ * @returns {boolean} True when the error indicates storage quota was exceeded.
211
+ */
212
+ #isQuotaExceededError(error) {
213
+ if (!error) {
214
+ return false;
215
+ }
216
+ return error.name === "QuotaExceededError" || error.name === "NS_ERROR_DOM_QUOTA_REACHED" || error.code === 22 || error.code === 1014;
217
+ }
218
+
219
+ /**
220
+ * Loads all records from the object store with their keys.
221
+ * Invalid rows are ignored.
222
+ * @private
223
+ * @returns {Promise<Array<{key: IDBValidKey, record: {blob: Blob, timeStamp: string|null, size: number, createdAt: number, lastAccess: number, expiresAt: number}}>>}
224
+ * Array of key-record pairs.
225
+ */
226
+ async #getAllRecords() {
227
+ if (this.#db === undefined) {
228
+ this.#db = await this.#openDB();
229
+ }
230
+ if (this.#db === null) {
231
+ return [];
232
+ }
233
+ const transaction = this.#db.transaction(this.osName, "readonly");
234
+ const store = transaction.objectStore(this.osName);
235
+ const [keys, values] = await Promise.all([store.getAllKeys(), store.getAll()]);
236
+ return keys.map((key, index) => ({ key: key, record: this.#normalizeRecord(values[index]) })).filter((entry) => entry.record !== null);
237
+ }
238
+
239
+ /**
240
+ * Deletes a list of keys from the object store.
241
+ * @private
242
+ * @param {IDBValidKey[]} [keys=[]] Keys to delete.
243
+ * @returns {Promise<number>} Number of requested deletions.
244
+ */
245
+ async #deleteKeys(keys = []) {
246
+ if (!keys.length) {
247
+ return 0;
248
+ }
249
+ if (this.#db === undefined) {
250
+ this.#db = await this.#openDB();
251
+ }
252
+ if (this.#db === null) {
253
+ return 0;
254
+ }
255
+ const transaction = this.#db.transaction(this.osName, "readwrite");
256
+ const store = transaction.objectStore(this.osName);
257
+ keys.forEach((key) => {
258
+ store.delete(key);
259
+ });
260
+ await transaction.done;
261
+ return keys.length;
262
+ }
263
+
264
+ /**
265
+ * Removes expired entries based on their `expiresAt` field.
266
+ * @private
267
+ * @returns {Promise<number>} Number of deleted expired entries.
268
+ */
269
+ async #deleteExpiredEntries() {
270
+ const now = this.#now();
271
+ const records = await this.#getAllRecords();
272
+ const expiredKeys = records.filter((entry) => this.#isExpired(entry.record, now)).map((entry) => entry.key);
273
+ return this.#deleteKeys(expiredKeys);
274
+ }
275
+
276
+ /**
277
+ * Evicts least-recently-used records, excluding protected keys.
278
+ * @private
279
+ * @param {number} [maxToDelete=1] Maximum number of entries to delete.
280
+ * @param {IDBValidKey[]} [protectedKeys=[]] Keys that must not be evicted.
281
+ * @returns {Promise<number>} Number of deleted entries.
282
+ */
283
+ async #evictLeastRecentlyUsed(maxToDelete = 1, protectedKeys = []) {
284
+ if (maxToDelete <= 0) {
285
+ return 0;
286
+ }
287
+ const protectedSet = new Set(protectedKeys);
288
+ const records = await this.#getAllRecords();
289
+ const candidates = records
290
+ .filter((entry) => !protectedSet.has(entry.key))
291
+ .sort((a, b) => {
292
+ const aAccess = typeof a.record.lastAccess === "number" ? a.record.lastAccess : 0;
293
+ const bAccess = typeof b.record.lastAccess === "number" ? b.record.lastAccess : 0;
294
+ if (aAccess !== bAccess) {
295
+ return aAccess - bAccess;
296
+ }
297
+ return a.record.createdAt - b.record.createdAt;
298
+ })
299
+ .slice(0, maxToDelete);
300
+ const keys = candidates.map((entry) => entry.key);
301
+ return this.#deleteKeys(keys);
302
+ }
303
+
304
+ /**
305
+ * Enforces the maximum number of cache entries by evicting LRU records.
306
+ * @private
307
+ * @param {IDBValidKey[]} [protectedKeys=[]] Keys excluded from eviction.
308
+ * @returns {Promise<number>} Number of removed entries.
309
+ */
310
+ async #enforceMaxEntries(protectedKeys = []) {
311
+ const protectedSet = new Set(protectedKeys);
312
+ const records = await this.#getAllRecords();
313
+ const count = records.length;
314
+ if (count <= this.#maxEntries) {
315
+ return 0;
316
+ }
317
+ const overflow = count - this.#maxEntries;
318
+ const candidates = records
319
+ .filter((entry) => !protectedSet.has(entry.key))
320
+ .sort((a, b) => {
321
+ const aAccess = typeof a.record.lastAccess === "number" ? a.record.lastAccess : 0;
322
+ const bAccess = typeof b.record.lastAccess === "number" ? b.record.lastAccess : 0;
323
+ if (aAccess !== bAccess) {
324
+ return aAccess - bAccess;
325
+ }
326
+ return a.record.createdAt - b.record.createdAt;
327
+ })
328
+ .slice(0, overflow)
329
+ .map((entry) => entry.key);
330
+ return this.#deleteKeys(candidates);
331
+ }
332
+
333
+ /**
334
+ * Frees cache space when estimated free quota is below threshold.
335
+ * Uses LRU eviction and can reserve bytes for an upcoming write.
336
+ * @private
337
+ * @param {number} [requiredBytes=0] Extra free bytes desired for the next write.
338
+ * @param {IDBValidKey[]} [protectedKeys=[]] Keys excluded from eviction.
339
+ * @returns {Promise<number>} Number of removed entries.
340
+ */
341
+ async #freeSpaceForQuota(requiredBytes = 0, protectedKeys = []) {
342
+ if (typeof navigator === "undefined" || !navigator.storage?.estimate) {
343
+ return 0;
344
+ }
345
+ let estimate = null;
346
+ try {
347
+ estimate = await navigator.storage.estimate();
348
+ } catch (error) {
349
+ return 0;
350
+ }
351
+ const quota = Number(estimate?.quota || 0);
352
+ const usage = Number(estimate?.usage || 0);
353
+ if (quota <= 0) {
354
+ return 0;
355
+ }
356
+ const minimumFreeBytes = Math.max(requiredBytes, Math.floor(quota * this.#minimumFreeSpaceRatio));
357
+ const freeBytes = Math.max(0, quota - usage);
358
+ if (freeBytes >= minimumFreeBytes) {
359
+ return 0;
360
+ }
361
+ const bytesToFree = minimumFreeBytes - freeBytes;
362
+
363
+ const protectedSet = new Set(protectedKeys);
364
+ const records = await this.#getAllRecords();
365
+ const candidates = records
366
+ .filter((entry) => !protectedSet.has(entry.key))
367
+ .sort((a, b) => {
368
+ const aAccess = typeof a.record.lastAccess === "number" ? a.record.lastAccess : 0;
369
+ const bAccess = typeof b.record.lastAccess === "number" ? b.record.lastAccess : 0;
370
+ if (aAccess !== bAccess) {
371
+ return aAccess - bAccess;
372
+ }
373
+ return a.record.createdAt - b.record.createdAt;
374
+ });
375
+
376
+ let accumulated = 0;
377
+ const keysToDelete = [];
378
+ for (const candidate of candidates) {
379
+ keysToDelete.push(candidate.key);
380
+ accumulated += Number(candidate.record.size || 0);
381
+ if (accumulated >= bytesToFree) {
382
+ break;
383
+ }
384
+ }
385
+ return this.#deleteKeys(keysToDelete);
386
+ }
387
+
388
+ /**
389
+ * Runs full maintenance: delete expired entries, enforce max entries, and free quota.
390
+ * @private
391
+ * @param {Object} [options={}] Cleanup options.
392
+ * @param {number} [options.requiredBytes=0] Extra bytes to reserve for a pending write.
393
+ * @param {IDBValidKey[]} [options.protectedKeys=[]] Keys excluded from eviction.
394
+ * @returns {Promise<void>}
395
+ */
396
+ async #runCleanup({ requiredBytes = 0, protectedKeys = [] } = {}) {
397
+ await this.#deleteExpiredEntries();
398
+ await this.#enforceMaxEntries(protectedKeys);
399
+ await this.#freeSpaceForQuota(requiredBytes, protectedKeys);
400
+ }
401
+
402
+ /**
403
+ * Runs maintenance only when the cleanup interval has elapsed.
404
+ * @private
405
+ * @returns {Promise<void>}
406
+ */
407
+ async #runCleanupIfDue() {
408
+ const now = this.#now();
409
+ if (now - this.#lastCleanupAt < this.#cleanupIntervalMs) {
410
+ return;
411
+ }
412
+ this.#lastCleanupAt = now;
413
+ await this.#runCleanup();
414
+ }
415
+
416
+ /**
417
+ * Updates `lastAccess` for a record to keep LRU ordering current.
418
+ * @private
419
+ * @param {String} uri Record key.
420
+ * @param {Object} record Existing normalized record.
421
+ * @returns {Promise<void>}
422
+ */
423
+ async #touchFile(uri, record) {
424
+ const normalized = this.#normalizeRecord(record);
425
+ if (!normalized) {
426
+ return;
427
+ }
428
+ normalized.lastAccess = this.#now();
429
+ if (this.#db === undefined) {
430
+ this.#db = await this.#openDB();
431
+ }
432
+ if (this.#db === null) {
433
+ return;
434
+ }
435
+ try {
436
+ const transaction = this.#db.transaction(this.osName, "readwrite");
437
+ const store = transaction.objectStore(this.osName);
438
+ store.put(normalized, uri);
439
+ await transaction.done;
440
+ } catch (error) {}
441
+ }
442
+
127
443
  /**
128
444
  * Create the object store if it does not exist.
129
445
  * @private
@@ -260,10 +576,11 @@ export class FileStorage {
260
576
  * @private
261
577
  * @param {Object|Blob} file The value to store (for example an object {blob, timeStamp} or a Blob).
262
578
  * @param {String} uri Key to use for storage (the original URI).
263
- * @returns {Promise<any|undefined>} Resolves with the value returned by the underlying idb store.put (usually the record key — string or number)
264
- * - resolves to the put result on success
265
- * - resolves to undefined if the database could not be opened
266
- * - rejects if the underlying put operation throws
579
+ * @returns {Promise<any|undefined>} Resolves with idb store.put result on success,
580
+ * or undefined when the database is unavailable, input is invalid, or write fails after quota retries.
581
+ * @description
582
+ * Runs maintenance before write (TTL, max entries, and quota-aware cleanup), then retries quota errors
583
+ * by evicting least-recently-used entries.
267
584
  */
268
585
  async #putFile(file, uri) {
269
586
  if (this.#db === undefined) {
@@ -272,30 +589,71 @@ export class FileStorage {
272
589
  if (this.#db === null) {
273
590
  return undefined;
274
591
  }
275
- const transation = this.#db.transaction(this.osName, "readwrite");
276
- const store = transation.objectStore(this.osName);
277
- return store.put(file, uri);
592
+ const record = this.#createRecordFromFile(file);
593
+ if (!record) {
594
+ return undefined;
595
+ }
596
+
597
+ const currentRecord = await this.#getFile(uri, { touch: false, allowExpired: true });
598
+ const currentSize = currentRecord ? Number(currentRecord.size || currentRecord.blob?.size || 0) : 0;
599
+ const requiredBytes = Math.max(0, record.size - currentSize);
600
+
601
+ await this.#runCleanup({ requiredBytes: requiredBytes, protectedKeys: [uri] });
602
+
603
+ for (let attempt = 0; attempt <= this.#maxQuotaEvictionRetries; attempt++) {
604
+ try {
605
+ const transaction = this.#db.transaction(this.osName, "readwrite");
606
+ const store = transaction.objectStore(this.osName);
607
+ const result = await store.put(record, uri);
608
+ await transaction.done;
609
+ return result;
610
+ } catch (error) {
611
+ if (!this.#isQuotaExceededError(error) || attempt === this.#maxQuotaEvictionRetries) {
612
+ return undefined;
613
+ }
614
+ const deleted = await this.#evictLeastRecentlyUsed(1, [uri]);
615
+ if (!deleted) {
616
+ return undefined;
617
+ }
618
+ }
619
+ }
620
+ return undefined;
278
621
  }
279
622
 
280
623
  /**
281
624
  * Retrieve a file record from IndexedDB.
282
625
  * @private
283
626
  * @param {String} uri File URI
284
- * @returns {Promise<{blob: Blob, timeStamp: string}|undefined|false>} Resolves with:
285
- * - {blob, timeStamp}: object with the Blob and ISO timestamp if the entry exists in IndexedDB
627
+ * @param {{touch?: boolean, allowExpired?: boolean}} [options] Read options.
628
+ * @param {boolean} [options.touch=true] Update lastAccess when the record is found.
629
+ * @param {boolean} [options.allowExpired=false] Return expired records instead of deleting them.
630
+ * @returns {Promise<{blob: Blob, timeStamp: string|null, size: number, createdAt: number, lastAccess: number, expiresAt: number}|undefined|false>} Resolves with:
631
+ * - normalized cache record when found
286
632
  * - undefined: if there is no stored entry for that URI
287
633
  * - false: if the database could not be opened (IndexedDB unsupported or open failure)
288
634
  */
289
- async #getFile(uri) {
635
+ async #getFile(uri, { touch = true, allowExpired = false } = {}) {
290
636
  if (this.#db === undefined) {
291
637
  this.#db = await this.#openDB();
292
638
  }
293
639
  if (this.#db === null) {
294
640
  return false;
295
641
  }
296
- const transation = this.#db.transaction(this.osName, "readonly");
297
- const store = transation.objectStore(this.osName);
298
- return store.get(uri);
642
+ const transaction = this.#db.transaction(this.osName, "readonly");
643
+ const store = transaction.objectStore(this.osName);
644
+ const record = this.#normalizeRecord(await store.get(uri));
645
+ if (!record) {
646
+ return undefined;
647
+ }
648
+ if (!allowExpired && this.#isExpired(record)) {
649
+ await this.#deleteKeys([uri]);
650
+ return undefined;
651
+ }
652
+ if (touch) {
653
+ await this.#touchFile(uri, record);
654
+ record.lastAccess = this.#now();
655
+ }
656
+ return record;
299
657
  }
300
658
 
301
659
  /**
@@ -309,7 +667,7 @@ export class FileStorage {
309
667
  * @public
310
668
  * @param {String} uri - File URI
311
669
  * @returns {Promise<string|boolean>} Resolves with:
312
- * - object URL string for the cached Blob
670
+ * - object URL string for the cached Blob (caller must revoke it when done)
313
671
  * - original URI string if IndexedDB unsupported but server file exists
314
672
  * - false if the file is not available on the server
315
673
  */
@@ -317,6 +675,7 @@ export class FileStorage {
317
675
  if (!this.#supported) {
318
676
  return (await this.#getServerFileTimeStamp(uri)) ? uri : false;
319
677
  }
678
+ await this.#runCleanupIfDue();
320
679
  const storedFile = await this.get(uri);
321
680
  return storedFile ? URL.createObjectURL(storedFile.blob) : (await this.#getServerFileTimeStamp(uri)) ? uri : false;
322
681
  }
@@ -326,7 +685,7 @@ export class FileStorage {
326
685
  * @public
327
686
  * @param {String} uri - File URI
328
687
  * @returns {Promise<string|null>} Resolves with:
329
- * - ISO 8601 string: Last-Modified timestamp from the cached record (when using IndexedDB) or from the server HEAD response
688
+ * - ISO 8601 string from the cached record (when using IndexedDB) or from the server HEAD response
330
689
  * - null: when no timestamp is available or on error
331
690
  */
332
691
  async getTimeStamp(uri) {
@@ -343,7 +702,7 @@ export class FileStorage {
343
702
  * @public
344
703
  * @param {String} uri - File URI
345
704
  * @returns {Promise<number|null>} Resolves with:
346
- * - number: size in bytes when available (from cache or server)
705
+ * - number: size in bytes when available (from cache metadata/blob or server HEAD)
347
706
  * - null: when size is not available or on error
348
707
  */
349
708
  async getSize(uri) {
@@ -376,8 +735,8 @@ export class FileStorage {
376
735
  * Get the file Blob from IndexedDB if stored; otherwise download and store it, then return the Blob.
377
736
  * @public
378
737
  * @param {String} uri - File URI
379
- * @returns {Promise<Blob|undefined|false>} Resolves with:
380
- * - Blob of the stored file
738
+ * @returns {Promise<{blob: Blob, timeStamp: string|null, size?: number, createdAt?: number, lastAccess?: number, expiresAt?: number}|undefined|false>} Resolves with:
739
+ * - cached/server record containing at least `{ blob, timeStamp }`
381
740
  * - undefined if the download fails
382
741
  * - false if uri is falsy
383
742
  * @description
@@ -392,6 +751,7 @@ export class FileStorage {
392
751
  if (!this.#supported) {
393
752
  return await this.#getServerFile(uri);
394
753
  }
754
+ await this.#runCleanupIfDue();
395
755
  let storedFile = await this.#getFile(uri);
396
756
  const serverFileTimeStamp = storedFile ? await this.#getServerFileTimeStamp(uri) : 0;
397
757
  const storedFileTimeStamp = storedFile ? storedFile.timeStamp : 0;
@@ -412,12 +772,13 @@ export class FileStorage {
412
772
  * @param {String} uri - File URI
413
773
  * @returns {Promise<boolean>} Resolves with:
414
774
  * - true if the file was stored successfully
415
- * - false if IndexedDB is not supported or storing failed
775
+ * - false if IndexedDB is not supported, download failed, or storing failed after cleanup/retries
416
776
  */
417
777
  async put(uri) {
418
778
  if (!this.#supported) {
419
779
  return false;
420
780
  }
781
+ await this.#runCleanupIfDue();
421
782
  const fileToStore = await this.#getServerFile(uri);
422
783
  return fileToStore ? !!(await this.#putFile(fileToStore, uri)) : false;
423
784
  }
@@ -1,4 +1,4 @@
1
- import { FileStorage } from "./file-storage.js";
1
+ import FileStorage from "./file-storage.js";
2
2
  import { initDb, loadModel } from "./gltf-storage.js";
3
3
 
4
4
  /**
@@ -9,6 +9,7 @@ import { initDb, loadModel } from "./gltf-storage.js";
9
9
  * - Decodes base64 data and determines file type (.gltf or .glb).
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
+ * - Tracks generated object URLs so callers can revoke them after Babylon finishes loading.
12
13
  * - Provides methods for initializing storage, decoding assets, and preparing sources for viewer components.
13
14
  *
14
15
  * Usage:
@@ -17,11 +18,13 @@ import { initDb, loadModel } from "./gltf-storage.js";
17
18
  *
18
19
  * Public Methods:
19
20
  * - getSource(storage, currentSize, currentTimeStamp): Resolves and prepares a glTF/GLB source for loading.
21
+ * - revokeObjectURLs(objectURLs): Releases temporary blob URLs generated during source resolution.
20
22
  *
21
23
  * Private Methods:
22
24
  * - #initializeStorage(db, table): Ensures IndexedDB store is initialized.
23
25
  * - #decodeBase64(base64): Decodes base64 data and determines file type.
24
26
  * - #isURLAbsolute(url): Checks if a URL is syntactically absolute.
27
+ * - #isBlobURL(url): Checks if a URL is a blob object URL.
25
28
  * - #saveAssetData(asset, index, parent, assetArray): Collects asset entries with external URIs.
26
29
  * - #replaceSceneURIAsync(assetContainerJSON, assetContainerURL): Replaces internal URIs in glTF JSON with resolved URLs.
27
30
  *
@@ -124,6 +127,16 @@ export default class GLTFResolver {
124
127
  }
125
128
  }
126
129
 
130
+ /**
131
+ * Check whether a URL is a browser object URL.
132
+ * @private
133
+ * @param {string} url - Value to test.
134
+ * @returns {boolean} True when `url` is a blob object URL.
135
+ */
136
+ #isBlobURL(url) {
137
+ return typeof url === "string" && url.startsWith("blob:");
138
+ }
139
+
127
140
  /**
128
141
  * Collects asset entries that have an external URI (non-data URI) and stores a normalized absolute or relative-resolved URI for later replacement.
129
142
  * @private
@@ -154,6 +167,7 @@ export default class GLTFResolver {
154
167
  * @private
155
168
  * @param {JSON} assetContainerJSON - AssetContainer in glTF (JSON) (modified in-place).
156
169
  * @param {URL} [assetContainerURL] - Optional URL of the AssetContainer. Used as the base path to resolve relative URIs.
170
+ * @param {string[]} [objectURLs=[]] - Collector array where generated blob URLs are appended for later cleanup.
157
171
  * @returns {Promise<void>} Resolves when all applicable URIs have been resolved/replaced.
158
172
  * @description
159
173
  * - When provided, assetContainerURL is used as the base path for other scene files (binary buffers and all images).
@@ -162,11 +176,12 @@ export default class GLTFResolver {
162
176
  * - Data URIs (embedded base64) are ignored and left unchanged.
163
177
  * - Matching asset URIs are normalized (backslashes converted to forward slashes) and passed to the FileStorage layer
164
178
  * to obtain a usable URL (object URL or cached URL).
179
+ * - Any generated blob URL is appended to `objectURLs` so the caller can revoke it later.
165
180
  * - The function performs replacements in parallel and waits for all lookups to complete.
166
181
  * - The JSON is updated in-place with the resolved URLs.
167
182
  * @see {@link https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#uris|glTF™ 2.0 Specification - URIs}
168
183
  */
169
- async #replaceSceneURIAsync(assetContainerJSON, assetContainerURL) {
184
+ async #replaceSceneURIAsync(assetContainerJSON, assetContainerURL, objectURLs = []) {
170
185
  if (!assetContainerJSON) {
171
186
  return;
172
187
  }
@@ -193,11 +208,29 @@ export default class GLTFResolver {
193
208
  const uri = await this.#fileStorage.getURL(asset.uri);
194
209
  if (uri) {
195
210
  asset.parent[asset.index].uri = uri;
211
+ if (this.#isBlobURL(uri)) {
212
+ objectURLs.push(uri);
213
+ }
196
214
  }
197
215
  });
198
216
  await Promise.all(promisesArray);
199
217
  }
200
218
 
219
+ /**
220
+ * Revoke browser object URLs and clear the provided list.
221
+ * @public
222
+ * @param {string[]} objectURLs - URLs previously created via URL.createObjectURL.
223
+ * @returns {void}
224
+ */
225
+ revokeObjectURLs(objectURLs = []) {
226
+ objectURLs.forEach((url) => {
227
+ if (this.#isBlobURL(url)) {
228
+ URL.revokeObjectURL(url);
229
+ }
230
+ });
231
+ objectURLs.length = 0;
232
+ }
233
+
201
234
  /**
202
235
  * Resolves and prepares a glTF/GLB source from various storage backends.
203
236
  * Supports IndexedDB, direct URLs, and base64-encoded data.
@@ -206,21 +239,22 @@ export default class GLTFResolver {
206
239
  * @param {object} storage - Storage descriptor containing url, db, table, and id properties.
207
240
  * @param {number|null} currentSize - The current cached size of the asset, or null if not cached.
208
241
  * @param {string|null} currentTimeStamp - The current cached timestamp of the asset, or null if not cached.
209
- * @returns {Promise<false|{source: File|string, size: number, timeStamp: string|null, extension: string}>}
242
+ * @returns {Promise<false|{source: File|string, size: number, timeStamp: string|null, extension: string, metadata: object, objectURLs: string[]}>}
210
243
  * - Resolves to false if no update is needed or on error.
211
- * - Resolves to an object containing the resolved source (File or data URI), size, timestamp, and extension if an update is required.
244
+ * - Resolves to an object containing source, cache metadata, and tracked object URLs if an update is required.
212
245
  * @description
213
246
  * - If storage specifies IndexedDB (db, table, id), loads the asset from IndexedDB and checks for updates.
214
247
  * - If storage specifies a direct URL or base64 data, decodes and validates the asset.
215
248
  * - For .gltf files, replaces internal URIs with resolved URLs from FileStorage.
216
249
  * - Performs cache validation using size and timestamp to avoid unnecessary reloads.
217
- * - Returns the prepared source, size, timestamp, and extension for further processing.
250
+ * - Returns the prepared source, size, timestamp, extension, metadata, and objectURLs for further processing.
218
251
  */
219
252
  async getSource(storage, currentSize, currentTimeStamp) {
220
253
  let source = storage.url || null;
221
254
  let newSize, newTimeStamp;
222
255
  let pending = false;
223
256
  let metadata = {};
257
+ const objectURLs = [];
224
258
 
225
259
  if (storage.db && storage.table && storage.id) {
226
260
  await this.#initializeStorage(storage.db, storage.table);
@@ -249,7 +283,7 @@ export default class GLTFResolver {
249
283
  if (blob && extension) {
250
284
  if (extension === ".gltf") {
251
285
  const assetContainerJSON = JSON.parse(await blob.text());
252
- await this.#replaceSceneURIAsync(assetContainerJSON, source);
286
+ await this.#replaceSceneURIAsync(assetContainerJSON, source, objectURLs);
253
287
  source = `data:${JSON.stringify(assetContainerJSON)}`;
254
288
  } else {
255
289
  file = new File([blob], `file${extension}`, {
@@ -278,13 +312,23 @@ export default class GLTFResolver {
278
312
  if (extension === ".gltf") {
279
313
  const assetContainerBlob = await this.#fileStorage.getBlob(source);
280
314
  const assetContainerJSON = JSON.parse(await assetContainerBlob.text());
281
- await this.#replaceSceneURIAsync(assetContainerJSON, source);
315
+ await this.#replaceSceneURIAsync(assetContainerJSON, source, objectURLs);
282
316
  source = `data:${JSON.stringify(assetContainerJSON)}`;
283
317
  } else {
284
318
  source = await this.#fileStorage.getURL(source);
319
+ if (this.#isBlobURL(source)) {
320
+ objectURLs.push(source);
321
+ }
285
322
  }
286
323
  }
287
324
  }
288
- return { source: file || source, size: newSize, timeStamp: newTimeStamp, extension: extension, metadata: metadata, };
325
+ return {
326
+ source: file || source,
327
+ size: newSize,
328
+ timeStamp: newTimeStamp,
329
+ extension: extension,
330
+ metadata: metadata,
331
+ objectURLs: objectURLs,
332
+ };
289
333
  }
290
334
  }
@@ -219,36 +219,94 @@ export class CameraData {
219
219
  *
220
220
  * Responsibilities:
221
221
  * - Stores source and cache references (`url`, `cachedUrl`) for the HDR environment map.
222
+ * - Tracks whether the HDR texture is already valid and reusable without needing `cachedUrl` (`valid`).
222
223
  * - Stores runtime tuning values (`intensity`, `shadows`) and cache identity (`timeStamp`).
223
224
  * - Provides helpers to fully reset IBL state (`reset`) or partially update known fields (`setValues`).
225
+ * - Provides a helper to consume and clear temporary cached URLs after texture creation (`consumeCachedUrl`).
224
226
  *
225
227
  * Usage:
226
228
  * - Instantiate with defaults: `const ibl = new IBLData();`.
227
- * - Call `setValues(...)` with only the fields you want to update (undefined preserves current values).
229
+ * - Call `setValues(...)` with only the fields you want to update (`undefined` preserves current values).
230
+ * - Lifecycle: set a fresh `cachedUrl` via `setValues(...)`, create/clone the Babylon HDR texture, then call
231
+ * `consumeCachedUrl(true)` to revoke temporary blob URLs, set `cachedUrl` to null, and keep `valid=true`.
228
232
  * - Call `reset()` to clear the current IBL environment and restore default intensity/shadows.
229
233
  */
230
234
  export class IBLData {
231
235
  defaultIntensity = 1.0;
232
236
  defaultShadows = false;
233
- constructor(url = null, cachedUrl = null, intensity = this.defaultIntensity, shadows = this.defaultShadows, timeStamp = null) {
237
+ constructor(url = null, cachedUrl = null, intensity = this.defaultIntensity, shadows = this.defaultShadows, timeStamp = null, valid = false) {
234
238
  this.url = url;
235
239
  this.cachedUrl = cachedUrl;
236
240
  this.intensity = intensity;
237
241
  this.shadows = shadows;
238
242
  this.timeStamp = timeStamp;
243
+ this.valid = valid;
239
244
  }
245
+
246
+ /**
247
+ * Revokes object URLs (`blob:`) to release browser memory.
248
+ * @private
249
+ * @param {string|null|undefined} url URL to revoke when applicable.
250
+ * @returns {void}
251
+ */
252
+ #revokeIfBlobURL(url) {
253
+ if (typeof url === "string" && url.startsWith("blob:")) {
254
+ URL.revokeObjectURL(url);
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Resets IBL state and revokes any temporary cached object URL.
260
+ * @public
261
+ * @returns {void}
262
+ */
240
263
  reset() {
264
+ this.#revokeIfBlobURL(this.cachedUrl);
241
265
  this.url = null
242
266
  this.cachedUrl = null;
243
267
  this.intensity = this.defaultIntensity;
244
268
  this.shadows = this.defaultShadows;
245
269
  this.timeStamp = null;
270
+ this.valid = false;
271
+ }
272
+
273
+ /**
274
+ * Consumes the temporary cached URL after Babylon texture creation.
275
+ * Revokes the URL when it is a `blob:` object URL, clears `cachedUrl`,
276
+ * and marks whether a reusable in-memory texture is available.
277
+ * @public
278
+ * @param {boolean} [valid=true] Whether the corresponding HDR texture has been validated and cached in memory.
279
+ * @returns {void}
280
+ */
281
+ consumeCachedUrl(valid = true) {
282
+ this.#revokeIfBlobURL(this.cachedUrl);
283
+ this.cachedUrl = null;
284
+ this.valid = !!valid;
246
285
  }
286
+
287
+ /**
288
+ * Updates selected IBL fields while preserving unspecified values.
289
+ * When a new non-empty `cachedUrl` is supplied, `valid` is reset to `false`
290
+ * until the URL is consumed into a Babylon texture.
291
+ * @public
292
+ * @param {string|null|undefined} url Source URL identifier.
293
+ * @param {string|null|undefined} cachedUrl Resolved URL (including possible `blob:` URL).
294
+ * @param {number|undefined} intensity IBL intensity.
295
+ * @param {boolean|undefined} shadows Shadow toggle for IBL path.
296
+ * @param {string|null|undefined} timeStamp Last-modified marker.
297
+ * @returns {void}
298
+ */
247
299
  setValues(url, cachedUrl, intensity, shadows, timeStamp) {
300
+ if (cachedUrl !== undefined && cachedUrl !== this.cachedUrl) {
301
+ this.#revokeIfBlobURL(this.cachedUrl);
302
+ }
248
303
  this.url = url !== undefined ? url : this.url;
249
304
  this.cachedUrl = cachedUrl !== undefined ? cachedUrl : this.cachedUrl;
250
305
  this.intensity = intensity !== undefined ? intensity : this.intensity;
251
306
  this.shadows = shadows !== undefined ? shadows : this.shadows;
252
307
  this.timeStamp = timeStamp !== undefined ? timeStamp : this.timeStamp;
308
+ if (cachedUrl !== undefined) {
309
+ this.valid = typeof cachedUrl === "string" && cachedUrl.length > 0 ? false : this.valid;
310
+ }
253
311
  }
254
312
  }
@@ -1,7 +1,7 @@
1
1
  import { CameraData, ContainerData, MaterialData, IBLData } from "./pref-viewer-3d-data.js";
2
2
  import BabylonJSController from "./babylonjs-controller.js";
3
3
  import { PrefViewer3DStyles } from "./styles.js";
4
- import { FileStorage } from "./file-storage.js";
4
+ import FileStorage from "./file-storage.js";
5
5
 
6
6
  /**
7
7
  * PrefViewer3D - Custom Web Component for interactive 3D visualization and configuration.
@@ -771,12 +771,19 @@ export default class PrefViewer3D extends HTMLElement {
771
771
  }
772
772
 
773
773
  /**
774
- * Reports whether an IBL environment map is currently available (cached URL present).
774
+ * Reports whether an IBL environment map is currently available.
775
775
  * @public
776
- * @returns {boolean} True when `options.ibl.cachedUrl` exists and is non-empty.
776
+ * @returns {boolean} True when a validated IBL texture exists or a pending cached URL is available.
777
777
  */
778
778
  isIBLAvailable() {
779
- const cachedUrl = this.#data?.options?.ibl?.cachedUrl;
779
+ const ibl = this.#data?.options?.ibl;
780
+ if (!ibl) {
781
+ return false;
782
+ }
783
+ if (ibl.valid === true) {
784
+ return true;
785
+ }
786
+ const cachedUrl = ibl.cachedUrl;
780
787
  return typeof cachedUrl === "string" ? cachedUrl.length > 0 : Boolean(cachedUrl);
781
788
  }
782
789