@preference-sl/pref-viewer 2.13.0-beta.2 → 2.13.0-beta.21

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.
@@ -1,5 +1,5 @@
1
- import { FileStorage } from "./file-storage.js";
2
- import { initDb, loadModel } from "./gltf-storage.js";
1
+ import FileStorage from "./file-storage.js";
2
+ import { closeDb, initDb, loadModel } from "./gltf-storage.js";
3
3
 
4
4
  /**
5
5
  * GLTFResolver - Utility class for resolving, decoding, and preparing glTF/GLB assets from various sources.
@@ -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,14 @@ 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.
22
+ * - dispose(): Releases resolver resources and closes the internal FileStorage DB handle.
20
23
  *
21
24
  * Private Methods:
22
25
  * - #initializeStorage(db, table): Ensures IndexedDB store is initialized.
23
26
  * - #decodeBase64(base64): Decodes base64 data and determines file type.
24
27
  * - #isURLAbsolute(url): Checks if a URL is syntactically absolute.
28
+ * - #isBlobURL(url): Checks if a URL is a blob object URL.
25
29
  * - #saveAssetData(asset, index, parent, assetArray): Collects asset entries with external URIs.
26
30
  * - #replaceSceneURIAsync(assetContainerJSON, assetContainerURL): Replaces internal URIs in glTF JSON with resolved URLs.
27
31
  *
@@ -124,6 +128,16 @@ export default class GLTFResolver {
124
128
  }
125
129
  }
126
130
 
131
+ /**
132
+ * Check whether a URL is a browser object URL.
133
+ * @private
134
+ * @param {string} url - Value to test.
135
+ * @returns {boolean} True when `url` is a blob object URL.
136
+ */
137
+ #isBlobURL(url) {
138
+ return typeof url === "string" && url.startsWith("blob:");
139
+ }
140
+
127
141
  /**
128
142
  * Collects asset entries that have an external URI (non-data URI) and stores a normalized absolute or relative-resolved URI for later replacement.
129
143
  * @private
@@ -154,6 +168,7 @@ export default class GLTFResolver {
154
168
  * @private
155
169
  * @param {JSON} assetContainerJSON - AssetContainer in glTF (JSON) (modified in-place).
156
170
  * @param {URL} [assetContainerURL] - Optional URL of the AssetContainer. Used as the base path to resolve relative URIs.
171
+ * @param {string[]} [objectURLs=[]] - Collector array where generated blob URLs are appended for later cleanup.
157
172
  * @returns {Promise<void>} Resolves when all applicable URIs have been resolved/replaced.
158
173
  * @description
159
174
  * - When provided, assetContainerURL is used as the base path for other scene files (binary buffers and all images).
@@ -162,11 +177,12 @@ export default class GLTFResolver {
162
177
  * - Data URIs (embedded base64) are ignored and left unchanged.
163
178
  * - Matching asset URIs are normalized (backslashes converted to forward slashes) and passed to the FileStorage layer
164
179
  * to obtain a usable URL (object URL or cached URL).
180
+ * - Any generated blob URL is appended to `objectURLs` so the caller can revoke it later.
165
181
  * - The function performs replacements in parallel and waits for all lookups to complete.
166
182
  * - The JSON is updated in-place with the resolved URLs.
167
183
  * @see {@link https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#uris|glTF™ 2.0 Specification - URIs}
168
184
  */
169
- async #replaceSceneURIAsync(assetContainerJSON, assetContainerURL) {
185
+ async #replaceSceneURIAsync(assetContainerJSON, assetContainerURL, objectURLs = []) {
170
186
  if (!assetContainerJSON) {
171
187
  return;
172
188
  }
@@ -193,11 +209,40 @@ export default class GLTFResolver {
193
209
  const uri = await this.#fileStorage.getURL(asset.uri);
194
210
  if (uri) {
195
211
  asset.parent[asset.index].uri = uri;
212
+ if (this.#isBlobURL(uri)) {
213
+ objectURLs.push(uri);
214
+ }
196
215
  }
197
216
  });
198
217
  await Promise.all(promisesArray);
199
218
  }
200
219
 
220
+ /**
221
+ * Revoke browser object URLs and clear the provided list.
222
+ * @public
223
+ * @param {string[]} objectURLs - URLs previously created via URL.createObjectURL.
224
+ * @returns {void}
225
+ */
226
+ revokeObjectURLs(objectURLs = []) {
227
+ objectURLs.forEach((url) => {
228
+ if (this.#isBlobURL(url)) {
229
+ URL.revokeObjectURL(url);
230
+ }
231
+ });
232
+ objectURLs.length = 0;
233
+ }
234
+
235
+ /**
236
+ * Disposes resolver-owned resources.
237
+ * @public
238
+ * @returns {void}
239
+ */
240
+ dispose() {
241
+ this.#fileStorage?.dispose?.();
242
+ this.#fileStorage = null;
243
+ closeDb();
244
+ }
245
+
201
246
  /**
202
247
  * Resolves and prepares a glTF/GLB source from various storage backends.
203
248
  * Supports IndexedDB, direct URLs, and base64-encoded data.
@@ -206,21 +251,22 @@ export default class GLTFResolver {
206
251
  * @param {object} storage - Storage descriptor containing url, db, table, and id properties.
207
252
  * @param {number|null} currentSize - The current cached size of the asset, or null if not cached.
208
253
  * @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}>}
254
+ * @returns {Promise<false|{source: File|string, size: number, timeStamp: string|null, extension: string, metadata: object, objectURLs: string[]}>}
210
255
  * - 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.
256
+ * - Resolves to an object containing source, cache metadata, and tracked object URLs if an update is required.
212
257
  * @description
213
258
  * - If storage specifies IndexedDB (db, table, id), loads the asset from IndexedDB and checks for updates.
214
259
  * - If storage specifies a direct URL or base64 data, decodes and validates the asset.
215
260
  * - For .gltf files, replaces internal URIs with resolved URLs from FileStorage.
216
261
  * - Performs cache validation using size and timestamp to avoid unnecessary reloads.
217
- * - Returns the prepared source, size, timestamp, and extension for further processing.
262
+ * - Returns the prepared source, size, timestamp, extension, metadata, and objectURLs for further processing.
218
263
  */
219
264
  async getSource(storage, currentSize, currentTimeStamp) {
220
265
  let source = storage.url || null;
221
266
  let newSize, newTimeStamp;
222
267
  let pending = false;
223
268
  let metadata = {};
269
+ const objectURLs = [];
224
270
 
225
271
  if (storage.db && storage.table && storage.id) {
226
272
  await this.#initializeStorage(storage.db, storage.table);
@@ -249,7 +295,7 @@ export default class GLTFResolver {
249
295
  if (blob && extension) {
250
296
  if (extension === ".gltf") {
251
297
  const assetContainerJSON = JSON.parse(await blob.text());
252
- await this.#replaceSceneURIAsync(assetContainerJSON, source);
298
+ await this.#replaceSceneURIAsync(assetContainerJSON, source, objectURLs);
253
299
  source = `data:${JSON.stringify(assetContainerJSON)}`;
254
300
  } else {
255
301
  file = new File([blob], `file${extension}`, {
@@ -278,13 +324,23 @@ export default class GLTFResolver {
278
324
  if (extension === ".gltf") {
279
325
  const assetContainerBlob = await this.#fileStorage.getBlob(source);
280
326
  const assetContainerJSON = JSON.parse(await assetContainerBlob.text());
281
- await this.#replaceSceneURIAsync(assetContainerJSON, source);
327
+ await this.#replaceSceneURIAsync(assetContainerJSON, source, objectURLs);
282
328
  source = `data:${JSON.stringify(assetContainerJSON)}`;
283
329
  } else {
284
330
  source = await this.#fileStorage.getURL(source);
331
+ if (this.#isBlobURL(source)) {
332
+ objectURLs.push(source);
333
+ }
285
334
  }
286
335
  }
287
336
  }
288
- return { source: file || source, size: newSize, timeStamp: newTimeStamp, extension: extension, metadata: metadata, };
337
+ return {
338
+ source: file || source,
339
+ size: newSize,
340
+ timeStamp: newTimeStamp,
341
+ extension: extension,
342
+ metadata: metadata,
343
+ objectURLs: objectURLs,
344
+ };
289
345
  }
290
346
  }
@@ -4,6 +4,8 @@
4
4
  const PC = (globalThis.PrefConfigurator ??= {});
5
5
  PC.version = PC.version ?? "1.0.1"; // bump if your schema changes!!
6
6
  PC.db = PC.db ?? null;
7
+ PC.dbName = PC.dbName ?? null;
8
+ PC.storeName = PC.storeName ?? null;
7
9
 
8
10
  function _getDbOrThrow() {
9
11
  const db = PC.db;
@@ -23,22 +25,50 @@ function _openEnsuringStore(dbName, storeName) {
23
25
  }
24
26
  open.onupgradeneeded = (ev) => {
25
27
  const upgradeDb = ev.target.result;
28
+ const tx = ev.target.transaction;
26
29
 
27
- // Eliminar la object store si ya existe
28
- if (upgradeDb.objectStoreNames.contains(storeName)) {
29
- upgradeDb.deleteObjectStore(storeName);
30
+ let store;
31
+ if (!upgradeDb.objectStoreNames.contains(storeName)) {
32
+ store = upgradeDb.createObjectStore(storeName, { keyPath: 'id' });
33
+ } else if (tx) {
34
+ store = tx.objectStore(storeName);
30
35
  }
31
36
 
32
- const store = upgradeDb.createObjectStore(storeName, { keyPath: 'id' });
33
- store.createIndex('expirationTimeStamp', 'expirationTimeStamp', { unique: false });
37
+ if (store && !store.indexNames.contains('expirationTimeStamp')) {
38
+ store.createIndex('expirationTimeStamp', 'expirationTimeStamp', { unique: false });
39
+ }
34
40
  };
35
41
  });
36
42
  }
37
43
 
44
+ function _isRecoverableDbError(err) {
45
+ const msg = String(err?.message ?? err ?? "");
46
+ if (!msg) return false;
47
+ return msg.includes("Database not initialized") ||
48
+ msg.includes("The database connection is closing") ||
49
+ msg.includes("InvalidStateError") ||
50
+ msg.includes("NotFoundError");
51
+ }
52
+
53
+ async function _withDbRetry(work) {
54
+ try {
55
+ return await work(_getDbOrThrow());
56
+ } catch (e) {
57
+ if (!_isRecoverableDbError(e) || !PC.dbName || !PC.storeName) {
58
+ throw e;
59
+ }
60
+ await initDb(PC.dbName, PC.storeName);
61
+ return await work(_getDbOrThrow());
62
+ }
63
+ }
64
+
38
65
  // --- public API -------------------------------------------------------------
39
66
 
40
67
  // Inicializar IndexedDB y dejar el handle en PrefConfigurator.db (público)
41
68
  export async function initDb(dbName, storeName) {
69
+ PC.dbName = dbName;
70
+ PC.storeName = storeName;
71
+
42
72
  const db = await _openEnsuringStore(dbName, storeName);
43
73
  // Close any previous handle to avoid versionchange blocking
44
74
  try { PC.db?.close?.(); } catch { }
@@ -54,10 +84,7 @@ export async function initDb(dbName, storeName) {
54
84
 
55
85
  // Guardar modelo
56
86
  export async function saveModel(modelDataStr, storeName) {
57
- return new Promise((resolve, reject) => {
58
- let db;
59
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
60
-
87
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
61
88
  let modelData;
62
89
  try { modelData = JSON.parse(modelDataStr); }
63
90
  catch (e) { reject(new Error("saveModel: modelDataStr is not valid JSON")); return; }
@@ -74,22 +101,19 @@ export async function saveModel(modelDataStr, storeName) {
74
101
 
75
102
  req.onerror = () => reject(req.error);
76
103
  req.onsuccess = () => resolve();
77
- });
104
+ }));
78
105
  }
79
106
 
80
107
  // Cargar modelo
81
108
  export function loadModel(modelId, storeName) {
82
- return new Promise((resolve, reject) => {
83
- let db;
84
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
85
-
109
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
86
110
  const tx = db.transaction([storeName], "readonly");
87
111
  const store = tx.objectStore(storeName);
88
112
  const req = store.get(modelId);
89
113
 
90
114
  req.onerror = () => reject(req.error);
91
115
  req.onsuccess = () => resolve(req.result ?? null);
92
- });
116
+ }));
93
117
  }
94
118
 
95
119
  // Descargar archivo desde base64
@@ -104,10 +128,7 @@ export function downloadFileFromBytes(fileName, bytesBase64, mimeType) {
104
128
 
105
129
  // Obtener todos los modelos (solo metadata)
106
130
  export async function getAllModels(storeName) {
107
- return new Promise((resolve, reject) => {
108
- let db;
109
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
110
-
131
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
111
132
  const tx = db.transaction([storeName], "readonly");
112
133
  const store = tx.objectStore(storeName);
113
134
  const req = store.getAll();
@@ -124,45 +145,36 @@ export async function getAllModels(storeName) {
124
145
  // keep old behavior: return JSON string
125
146
  resolve(JSON.stringify(results));
126
147
  };
127
- });
148
+ }));
128
149
  }
129
150
 
130
151
  // Eliminar modelo por id
131
152
  export async function deleteModel(modelId, storeName) {
132
- return new Promise((resolve, reject) => {
133
- let db;
134
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
135
-
153
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
136
154
  const tx = db.transaction([storeName], "readwrite");
137
155
  const store = tx.objectStore(storeName);
138
156
  const req = store.delete(modelId);
139
157
 
140
158
  req.onerror = () => reject(req.error);
141
159
  req.onsuccess = () => resolve();
142
- });
160
+ }));
143
161
  }
144
162
 
145
163
  // Limpiar toda la store
146
164
  export async function clearAll(storeName) {
147
- return new Promise((resolve, reject) => {
148
- let db;
149
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
150
-
165
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
151
166
  const tx = db.transaction([storeName], "readwrite");
152
167
  const store = tx.objectStore(storeName);
153
168
  const req = store.clear();
154
169
 
155
170
  req.onerror = () => reject(req.error);
156
171
  req.onsuccess = () => resolve();
157
- });
172
+ }));
158
173
  }
159
174
 
160
175
  // Borrar modelos expirados usando el índice "expirationTimeStamp"
161
176
  export async function cleanExpiredModels(storeName) {
162
- return new Promise((resolve, reject) => {
163
- let db;
164
- try { db = _getDbOrThrow(); } catch (e) { reject(e); return; }
165
-
177
+ return _withDbRetry((db) => new Promise((resolve, reject) => {
166
178
  const tx = db.transaction([storeName], "readwrite");
167
179
  const store = tx.objectStore(storeName);
168
180
 
@@ -182,7 +194,7 @@ export async function cleanExpiredModels(storeName) {
182
194
 
183
195
  tx.oncomplete = () => resolve();
184
196
  tx.onerror = () => reject(tx.error);
185
- });
197
+ }));
186
198
  }
187
199
 
188
200
  // Utilidades opcionales y visibles (por si las quieres usar en consola)
@@ -40,7 +40,7 @@ export const setLocale = (localeId) => {
40
40
  try {
41
41
  listener(currentLocale);
42
42
  } catch (error) {
43
- console.warn("PrefViewer i18n listener failed", error);
43
+ console.warn("PrefViewer: i18n listener failed", error);
44
44
  }
45
45
  });
46
46
  return currentLocale;
@@ -22,7 +22,7 @@ export const translations = {
22
22
  helper: "Reduction of ambient light in corners and areas close to surfaces.",
23
23
  },
24
24
  iblEnabled: {
25
- label: "IBL",
25
+ label: "Image Based Lighting (IBL)",
26
26
  helper: "Uses the HDR environment to light the scene.",
27
27
  },
28
28
  shadowsEnabled: {
@@ -68,11 +68,11 @@ export const translations = {
68
68
  helper: "Suaviza aristas y líneas.",
69
69
  },
70
70
  ambientOcclusionEnabled: {
71
- label: "Ambient Occlusion",
71
+ label: "Oclusión ambiental",
72
72
  helper: "Atenuación de la luz ambiental en rincones y zonas cercanas entre superficies.",
73
73
  },
74
74
  iblEnabled: {
75
- label: "IBL",
75
+ label: "Iluminación basada en imagen (IBL)",
76
76
  helper: "Usa la imagen HDR de entorno para iluminar la escena.",
77
77
  },
78
78
  shadowsEnabled: {
@@ -1,15 +1,18 @@
1
1
  /**
2
- * ContainerData - Represents the state and metadata of a asset container in the 3D viewer (e.g., model, environment, materials).
2
+ * ContainerData - Represents state, cache metadata, and update lifecycle for a 3D asset container
3
+ * (for example: model, environment, or materials).
3
4
  *
4
5
  * Responsibilities:
5
- * - Tracks container name, show (if must be visible), visibility (if currently visible), size, and timestamp.
6
- * - Manages update state for asynchronous operations (pending, success, etc.).
7
- * - Provides methods to reset, set pending, and set success states.
6
+ * - Tracks persistent container fields (`storage`, `show`, `size`, `timeStamp`, `metadata`, `visible`).
7
+ * - Tracks staged update data in `update` (`pending`, `success`, staged storage/show/cache info).
8
+ * - Provides helpers to stage updates (`setPending`, `setPendingCacheData`, `setPendingWithCurrentStorage`)
9
+ * and commit them (`setSuccess(true)`).
8
10
  *
9
11
  * Usage:
10
- * - Instantiate with a name: new ContainerData("model")
11
- * - Use setPending(), setSuccess(), and reset() to manage update state.
12
- * - Access status via isPending, isSuccess, isVisible getters.
12
+ * - Instantiate with a name: `new ContainerData("model")`.
13
+ * - Stage a reload with `setPending(...)`, optionally seed cache metadata, then finalize with `setSuccess(true)`.
14
+ * - Use `setPendingWithCurrentStorage()` to request a reload from the currently stored source.
15
+ * - Inspect flags via `isPending`, `isSuccess`, `isVisible`, and `mustBeShown`.
13
16
  */
14
17
  export class ContainerData {
15
18
  constructor(name = "") {
@@ -70,9 +73,7 @@ export class ContainerData {
70
73
  }
71
74
  const targetShow = this.update.show !== null ? this.update.show : this.show;
72
75
  this.setPending(storedSource, targetShow);
73
- this.update.size = this.size;
74
- this.update.timeStamp = this.timeStamp;
75
- this.update.metadata = { ...(this.metadata ?? {}) };
76
+ this.setPendingCacheData(this.size, this.timeStamp, this.metadata);
76
77
  return true;
77
78
  }
78
79
  get isPending() {
@@ -90,17 +91,18 @@ export class ContainerData {
90
91
  }
91
92
 
92
93
  /**
93
- * MaterialData - Represents the state and metadata of a material in the 3D viewer.
94
+ * MaterialData - Represents a material option plus its update lifecycle in the 3D viewer.
94
95
  *
95
96
  * Responsibilities:
96
- * - Tracks material name, value, node names, and node prefixes.
97
- * - Manages update state for asynchronous operations (pending, success, etc.).
98
- * - Provides methods to reset, set pending, and set success states.
97
+ * - Stores material identity and targeting info (`name`, `nodeNames`, `nodePrefixes`).
98
+ * - Stores current applied material value (`value`).
99
+ * - Tracks staged material changes in `update` (`pending`, `success`, `value`).
99
100
  *
100
101
  * Usage:
101
- * - Instantiate with a name and optional value: new MaterialData("innerWall", value, nodeNames, nodePrefixes)
102
- * - Use setPending(), setSuccess(), and reset() to manage update state.
103
- * - Access status via isPending, isSuccess getters.
102
+ * - Instantiate with initial values: `new MaterialData("innerWall", value, nodeNames, nodePrefixes)`.
103
+ * - Stage a change with `setPending(...)`, then commit with `setSuccess(true)`.
104
+ * - Use `setPendingWithCurrent()` to reapply the currently stored material value.
105
+ * - Inspect flags via `isPending` and `isSuccess`.
104
106
  */
105
107
  export class MaterialData {
106
108
  constructor(name = "", value = null, nodeNames = [], nodePrefixes = []) {
@@ -148,17 +150,18 @@ export class MaterialData {
148
150
  }
149
151
 
150
152
  /**
151
- * CameraData - Represents the state and metadata of a camera in the 3D viewer.
153
+ * CameraData - Represents camera selection state and camera-lock behavior for the 3D viewer.
152
154
  *
153
155
  * Responsibilities:
154
- * - Tracks camera name, value, and locked status.
155
- * - Manages update state for asynchronous operations (pending, success, etc.).
156
- * - Provides methods to reset, set pending, and set success states.
156
+ * - Stores current camera selection (`value`) and lock state (`locked`).
157
+ * - Tracks staged camera updates in `update` (`pending`, `success`, `value`, `locked`).
158
+ * - Provides helpers to stage (`setPending`, `setPendingWithCurrent`) and commit (`setSuccess(true)`) updates.
157
159
  *
158
160
  * Usage:
159
- * - Instantiate with a name and optional value: new CameraData("camera", value, locked)
160
- * - Use setPending(), setSuccess(), and reset() to manage update state.
161
- * - Access status via isPending, isSuccess getters.
161
+ * - Instantiate with a name and optional value: `new CameraData("camera", value, locked)`.
162
+ * - Stage a camera update with `setPending(value, locked)`.
163
+ * - Pass `null` as value to request an unlocked/default camera behavior.
164
+ * - Inspect flags via `isPending` and `isSuccess`.
162
165
  */
163
166
  export class CameraData {
164
167
  defaultLocked = true;
@@ -212,51 +215,98 @@ export class CameraData {
212
215
  }
213
216
 
214
217
  /**
215
- * IBLData - Tracks configurable settings for image-based lighting assets (IBL/HDR environments).
218
+ * IBLData - Stores image-based lighting configuration for HDR environment rendering.
216
219
  *
217
220
  * Responsibilities:
218
- * - Stores the HDR url, intensity scalar, whether environment-provided shadows should render, and cache timestamp.
219
- * - Exposes a lightweight pending/success state machine so UI flows know when a new IBL selection is being fetched.
220
- * - Provides helpers to update values atomically via `setValues`, toggle pending state, and mark completion.
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`).
223
+ * - Stores runtime tuning values (`intensity`, `shadows`) and cache identity (`timeStamp`).
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`).
221
226
  *
222
227
  * Usage:
223
- * - Instantiate with defaults: `const ibl = new IBLData();`
224
- * - Call `setPending()` before kicking off an async download, `setValues()` as metadata streams in, and `setSuccess(true)` once loading finishes.
225
- * - Inspect `isPending`/`isSuccess` to drive UI or re-render logic.
228
+ * - Instantiate with defaults: `const ibl = new IBLData();`.
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`.
232
+ * - Call `reset()` to clear the current IBL environment and restore default intensity/shadows.
226
233
  */
227
234
  export class IBLData {
228
- constructor(url = null, intensity = 1.0, shadows = false, timeStamp = null) {
235
+ defaultIntensity = 1.0;
236
+ defaultShadows = false;
237
+ constructor(url = null, cachedUrl = null, intensity = this.defaultIntensity, shadows = this.defaultShadows, timeStamp = null, valid = false) {
229
238
  this.url = url;
239
+ this.cachedUrl = cachedUrl;
230
240
  this.intensity = intensity;
231
241
  this.shadows = shadows;
232
242
  this.timeStamp = timeStamp;
233
- this.reset();
243
+ this.valid = valid;
234
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
+ */
235
263
  reset() {
236
- this.pending = false;
237
- this.success = false;
264
+ this.#revokeIfBlobURL(this.cachedUrl);
265
+ this.url = null
266
+ this.cachedUrl = null;
267
+ this.intensity = this.defaultIntensity;
268
+ this.shadows = this.defaultShadows;
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;
238
285
  }
239
- setValues(url, intensity, shadows, timeStamp) {
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
+ */
299
+ setValues(url, cachedUrl, intensity, shadows, timeStamp) {
300
+ if (cachedUrl !== undefined && cachedUrl !== this.cachedUrl) {
301
+ this.#revokeIfBlobURL(this.cachedUrl);
302
+ }
240
303
  this.url = url !== undefined ? url : this.url;
304
+ this.cachedUrl = cachedUrl !== undefined ? cachedUrl : this.cachedUrl;
241
305
  this.intensity = intensity !== undefined ? intensity : this.intensity;
242
306
  this.shadows = shadows !== undefined ? shadows : this.shadows;
243
307
  this.timeStamp = timeStamp !== undefined ? timeStamp : this.timeStamp;
244
- }
245
- setSuccess(success = false) {
246
- if (success) {
247
- this.success = true;
248
- } else {
249
- this.success = false;
308
+ if (cachedUrl !== undefined) {
309
+ this.valid = typeof cachedUrl === "string" && cachedUrl.length > 0 ? false : this.valid;
250
310
  }
251
311
  }
252
- setPending() {
253
- this.pending = true;
254
- this.success = false;
255
- }
256
- get isPending() {
257
- return this.pending === true;
258
- }
259
- get isSuccess() {
260
- return this.success === true;
261
- }
262
312
  }