@preference-sl/pref-viewer 2.11.0-beta.0 → 2.11.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/babylonjs-controller.js +932 -0
- package/src/file-storage.js +166 -39
- package/src/gltf-resolver.js +288 -0
- package/src/index.js +598 -1074
- package/src/panzoom-controller.js +494 -0
- package/src/pref-viewer-2d.js +459 -0
- package/src/pref-viewer-3d-data.js +178 -0
- package/src/pref-viewer-3d.js +635 -0
- package/src/pref-viewer-task.js +54 -0
- package/src/svg-resolver.js +281 -0
package/src/file-storage.js
CHANGED
|
@@ -1,12 +1,119 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* FileStore class to manage file storage in IndexedDB using a promise-based wrapper around the IndexedDB API.
|
|
3
|
-
* @see {@link https://github.com/jakearchibald/idb|GitHub - jakearchibald/idb: IndexedDB, but with promises}
|
|
4
|
-
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API|IndexedDB API - MDN}
|
|
5
|
-
* @see {@link https://www.npmjs.com/package/idb|idb - npm}
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
1
|
import { openDB } from "idb";
|
|
9
2
|
|
|
3
|
+
/**
|
|
4
|
+
* FileStorage - Class for managing file storage in IndexedDB with server synchronization.
|
|
5
|
+
*
|
|
6
|
+
* Overview:
|
|
7
|
+
* - Provides a promise-based wrapper around the IndexedDB API for file caching.
|
|
8
|
+
* - Automatically manages cache versioning by comparing server and cached file timestamps.
|
|
9
|
+
* - Falls back to direct server access when IndexedDB is unavailable.
|
|
10
|
+
* - Supports file blob retrieval, timestamp checking, and size queries.
|
|
11
|
+
* - Uses the idb library for simplified IndexedDB operations.
|
|
12
|
+
*
|
|
13
|
+
* Dependencies:
|
|
14
|
+
* - idb (IndexedDB wrapper library)
|
|
15
|
+
* - Modern browser with IndexedDB support
|
|
16
|
+
*
|
|
17
|
+
* Constructor:
|
|
18
|
+
* - FileStorage(dbName, osName): Creates a new FileStorage instance.
|
|
19
|
+
* Parameters:
|
|
20
|
+
* - dbName: Name of the IndexedDB database (default: "FilesDB")
|
|
21
|
+
* - osName: Name of the object store (default: "FilesObjectStore")
|
|
22
|
+
*
|
|
23
|
+
* Private Methods:
|
|
24
|
+
* - #createObjectStore(db, osName): Creates the object store if it doesn't exist.
|
|
25
|
+
* - #openDB(): Opens or creates the IndexedDB database.
|
|
26
|
+
* - #getServerFile(uri): Downloads file blob and timestamp from server.
|
|
27
|
+
* - #getServerFileTimeStamp(uri): Retrieves Last-Modified timestamp via HEAD request.
|
|
28
|
+
* - #getServerFileSize(uri): Retrieves Content-Length via HEAD request.
|
|
29
|
+
* - #putFile(file, uri): Stores file record in IndexedDB.
|
|
30
|
+
* - #getFile(uri): Retrieves file record from IndexedDB.
|
|
31
|
+
*
|
|
32
|
+
* Public Methods:
|
|
33
|
+
* - getURL(uri): Gets an object URL for cached blob or original URI.
|
|
34
|
+
* - getTimeStamp(uri): Retrieves cached or server Last-Modified timestamp.
|
|
35
|
+
* - getSize(uri): Gets cached or server file size in bytes.
|
|
36
|
+
* - getBlob(uri): Retrieves file blob from cache or server.
|
|
37
|
+
* - get(uri): Gets file from cache with automatic server sync and cache versioning.
|
|
38
|
+
* - put(uri): Stores file from server in IndexedDB cache.
|
|
39
|
+
*
|
|
40
|
+
* Features:
|
|
41
|
+
* - Automatic Cache Versioning: Compares server and cached timestamps to update cache.
|
|
42
|
+
* - Fallback Support: Uses direct server access when IndexedDB is unavailable.
|
|
43
|
+
* - Object URL Generation: Creates efficient object URLs for cached blobs.
|
|
44
|
+
* - HEAD Request Optimization: Uses HEAD requests to check metadata without downloading full files.
|
|
45
|
+
* - Promise-Based API: All operations return promises for async/await usage.
|
|
46
|
+
*
|
|
47
|
+
* Usage Example:
|
|
48
|
+
* const storage = new FileStorage("MyFilesDB", "CachedFiles");
|
|
49
|
+
*
|
|
50
|
+
* // Get file blob with automatic caching
|
|
51
|
+
* const file = await storage.get("https://example.com/model.glb");
|
|
52
|
+
*
|
|
53
|
+
* // Get object URL for cached file
|
|
54
|
+
* const url = await storage.getURL("https://example.com/model.glb");
|
|
55
|
+
*
|
|
56
|
+
* // Check file timestamp
|
|
57
|
+
* const timestamp = await storage.getTimeStamp("https://example.com/model.glb");
|
|
58
|
+
*
|
|
59
|
+
* // Get file size
|
|
60
|
+
* const size = await storage.getSize("https://example.com/model.glb");
|
|
61
|
+
*
|
|
62
|
+
* // Manually cache a file
|
|
63
|
+
* await storage.put("https://example.com/model.glb");
|
|
64
|
+
*
|
|
65
|
+
* Cache Behavior:
|
|
66
|
+
* - First call: Downloads from server and caches in IndexedDB
|
|
67
|
+
* - Subsequent calls: Returns cached version if timestamp matches
|
|
68
|
+
* - Updated file: Detects timestamp change and re-downloads automatically
|
|
69
|
+
* - No server access: Returns cached version if available
|
|
70
|
+
* - IndexedDB unavailable: Falls back to direct server access
|
|
71
|
+
*
|
|
72
|
+
* Return Values:
|
|
73
|
+
* - get(uri): Blob | undefined | false
|
|
74
|
+
* - Blob: File successfully retrieved
|
|
75
|
+
* - undefined: Download failed but no cached copy exists
|
|
76
|
+
* - false: URI is falsy
|
|
77
|
+
*
|
|
78
|
+
* - getURL(uri): string | boolean
|
|
79
|
+
* - string: Object URL or original URI
|
|
80
|
+
* - false: File not available
|
|
81
|
+
*
|
|
82
|
+
* - getTimeStamp(uri): string | null
|
|
83
|
+
* - string: ISO 8601 timestamp
|
|
84
|
+
* - null: Timestamp unavailable
|
|
85
|
+
*
|
|
86
|
+
* - getSize(uri): number | null
|
|
87
|
+
* - number: File size in bytes
|
|
88
|
+
* - null: Size unavailable
|
|
89
|
+
*
|
|
90
|
+
* - put(uri): boolean
|
|
91
|
+
* - true: Successfully cached
|
|
92
|
+
* - false: Failed to cache
|
|
93
|
+
*
|
|
94
|
+
* Storage Limits:
|
|
95
|
+
* - Chrome/Edge: ~50GB per origin
|
|
96
|
+
* - Firefox: ~1GB per origin
|
|
97
|
+
* - Safari: ~1GB per origin
|
|
98
|
+
*
|
|
99
|
+
* Notes:
|
|
100
|
+
* - Requires CORS headers for cross-origin file access
|
|
101
|
+
* - Uses XMLHttpRequest for file downloads and HEAD requests
|
|
102
|
+
* - Supports both HTTPS and HTTP (HTTP not recommended for production)
|
|
103
|
+
* - Object URLs should be revoked after use to free memory
|
|
104
|
+
* - IndexedDB quota management should be implemented for long-term storage
|
|
105
|
+
*
|
|
106
|
+
* Error Handling:
|
|
107
|
+
* - Network failures: Returns undefined (get) or false (other methods)
|
|
108
|
+
* - IndexedDB errors: Falls back to server access or returns false
|
|
109
|
+
* - CORS errors: Treated as download failures
|
|
110
|
+
* - Invalid URIs: Returns false or undefined
|
|
111
|
+
*
|
|
112
|
+
* References:
|
|
113
|
+
* - MDN IndexedDB API: https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
|
|
114
|
+
* - idb Library: https://github.com/jakearchibald/idb
|
|
115
|
+
* - npm idb: https://www.npmjs.com/package/idb
|
|
116
|
+
*/
|
|
10
117
|
export class FileStorage {
|
|
11
118
|
#supported = "indexedDB" in window && openDB; // true if IndexedDB is available in the browser and the idb helper is loaded
|
|
12
119
|
#dbVersion = 1; // single DB version; cache is managed per-file
|
|
@@ -19,19 +126,22 @@ export class FileStorage {
|
|
|
19
126
|
|
|
20
127
|
/**
|
|
21
128
|
* Create the object store if it does not exist.
|
|
129
|
+
* @private
|
|
22
130
|
* @param {IDBDatabase} db IndexedDB database instance
|
|
131
|
+
* @param {String} osName Object store name
|
|
23
132
|
*/
|
|
24
|
-
#createObjectStore
|
|
133
|
+
#createObjectStore(db, osName) {
|
|
25
134
|
if (!db.objectStoreNames.contains(osName)) {
|
|
26
135
|
db.createObjectStore(osName);
|
|
27
136
|
}
|
|
28
|
-
}
|
|
137
|
+
}
|
|
29
138
|
|
|
30
139
|
/**
|
|
31
140
|
* Open the IndexedDB database (creating it if needed) and ensure the object store for files exists.
|
|
141
|
+
* @private
|
|
32
142
|
* @returns {Promise<IDBDatabase|null>} Resolves with the opened database or null if IndexedDB is not supported or opening fails.
|
|
33
143
|
*/
|
|
34
|
-
#openDB
|
|
144
|
+
async #openDB() {
|
|
35
145
|
if (!this.#supported) {
|
|
36
146
|
return null;
|
|
37
147
|
}
|
|
@@ -45,16 +155,17 @@ export class FileStorage {
|
|
|
45
155
|
} catch (error) {
|
|
46
156
|
return null;
|
|
47
157
|
}
|
|
48
|
-
}
|
|
158
|
+
}
|
|
49
159
|
|
|
50
160
|
/**
|
|
51
161
|
* Download the file from the server (forced download).
|
|
162
|
+
* @private
|
|
52
163
|
* @param {String} uri File URI
|
|
53
164
|
* @returns {Promise<{blob: Blob, timeStamp: string}|undefined>} Resolves with:
|
|
54
165
|
* - { blob, timeStamp }: object with the downloaded Blob and the Last-Modified timestamp (ISO string) on success
|
|
55
166
|
* - undefined: if the download fails (non-200 status or network error)
|
|
56
167
|
*/
|
|
57
|
-
#getServerFile
|
|
168
|
+
async #getServerFile(uri) {
|
|
58
169
|
let file = undefined;
|
|
59
170
|
return new Promise((resolve) => {
|
|
60
171
|
const xhr = new XMLHttpRequest();
|
|
@@ -75,16 +186,17 @@ export class FileStorage {
|
|
|
75
186
|
};
|
|
76
187
|
xhr.send();
|
|
77
188
|
});
|
|
78
|
-
}
|
|
189
|
+
}
|
|
79
190
|
|
|
80
191
|
/**
|
|
81
192
|
* Get the Last-Modified timestamp of the file on the server without downloading its content.
|
|
193
|
+
* @private
|
|
82
194
|
* @param {String} uri File URI
|
|
83
195
|
* @returns {Promise<string|null>} Resolves with:
|
|
84
196
|
* - ISO 8601 string from the Last-Modified header if available
|
|
85
197
|
* - null if the header is not present or on error
|
|
86
198
|
*/
|
|
87
|
-
#getServerFileTimeStamp
|
|
199
|
+
async #getServerFileTimeStamp(uri) {
|
|
88
200
|
let timeStamp = null;
|
|
89
201
|
return new Promise((resolve) => {
|
|
90
202
|
const xhr = new XMLHttpRequest();
|
|
@@ -103,16 +215,17 @@ export class FileStorage {
|
|
|
103
215
|
};
|
|
104
216
|
xhr.send();
|
|
105
217
|
});
|
|
106
|
-
}
|
|
218
|
+
}
|
|
107
219
|
|
|
108
220
|
/**
|
|
109
221
|
* Get the size of the file on the server without downloading its content.
|
|
222
|
+
* @private
|
|
110
223
|
* @param {String} uri File URI
|
|
111
224
|
* @returns {Promise<number>} Promise that resolves with:
|
|
112
225
|
* - number: Content-Length in bytes when the HEAD request succeeds and the header is present
|
|
113
226
|
* - 0: when the header is missing or the request fails
|
|
114
227
|
*/
|
|
115
|
-
#getServerFileSize
|
|
228
|
+
async #getServerFileSize(uri) {
|
|
116
229
|
let size = 0;
|
|
117
230
|
return new Promise((resolve) => {
|
|
118
231
|
const xhr = new XMLHttpRequest();
|
|
@@ -131,10 +244,11 @@ export class FileStorage {
|
|
|
131
244
|
};
|
|
132
245
|
xhr.send();
|
|
133
246
|
});
|
|
134
|
-
}
|
|
247
|
+
}
|
|
135
248
|
|
|
136
249
|
/**
|
|
137
250
|
* Stores a file record in IndexedDB under the provided key (uri).
|
|
251
|
+
* @private
|
|
138
252
|
* @param {Object|Blob} file The value to store (for example an object {blob, timeStamp} or a Blob).
|
|
139
253
|
* @param {String} uri Key to use for storage (the original URI).
|
|
140
254
|
* @returns {Promise<any|undefined>} Resolves with the value returned by the underlying idb store.put (usually the record key — string or number)
|
|
@@ -142,7 +256,7 @@ export class FileStorage {
|
|
|
142
256
|
* - resolves to undefined if the database could not be opened
|
|
143
257
|
* - rejects if the underlying put operation throws
|
|
144
258
|
*/
|
|
145
|
-
#putFile
|
|
259
|
+
async #putFile(file, uri) {
|
|
146
260
|
if (this.#db === undefined) {
|
|
147
261
|
this.#db = await this.#openDB();
|
|
148
262
|
}
|
|
@@ -152,17 +266,18 @@ export class FileStorage {
|
|
|
152
266
|
const transation = this.#db.transaction(this.osName, "readwrite");
|
|
153
267
|
const store = transation.objectStore(this.osName);
|
|
154
268
|
return store.put(file, uri);
|
|
155
|
-
}
|
|
269
|
+
}
|
|
156
270
|
|
|
157
271
|
/**
|
|
158
272
|
* Retrieve a file record from IndexedDB.
|
|
273
|
+
* @private
|
|
159
274
|
* @param {String} uri File URI
|
|
160
275
|
* @returns {Promise<{blob: Blob, timeStamp: string}|undefined|false>} Resolves with:
|
|
161
276
|
* - {blob, timeStamp}: object with the Blob and ISO timestamp if the entry exists in IndexedDB
|
|
162
277
|
* - undefined: if there is no stored entry for that URI
|
|
163
278
|
* - false: if the database could not be opened (IndexedDB unsupported or open failure)
|
|
164
279
|
*/
|
|
165
|
-
#getFile
|
|
280
|
+
async #getFile(uri) {
|
|
166
281
|
if (this.#db === undefined) {
|
|
167
282
|
this.#db = await this.#openDB();
|
|
168
283
|
}
|
|
@@ -172,75 +287,86 @@ export class FileStorage {
|
|
|
172
287
|
const transation = this.#db.transaction(this.osName, "readonly");
|
|
173
288
|
const store = transation.objectStore(this.osName);
|
|
174
289
|
return store.get(uri);
|
|
175
|
-
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* ---------------------------
|
|
294
|
+
* Public methods
|
|
295
|
+
* ---------------------------
|
|
296
|
+
*/
|
|
176
297
|
|
|
177
298
|
/**
|
|
178
299
|
* Get a URL to the file: either an object URL for the cached Blob, or the original URI if the server copy is available and IndexedDB isn't supported.
|
|
179
|
-
* @
|
|
300
|
+
* @public
|
|
301
|
+
* @param {String} uri - File URI
|
|
180
302
|
* @returns {Promise<string|boolean>} Resolves with:
|
|
181
303
|
* - object URL string for the cached Blob
|
|
182
304
|
* - original URI string if IndexedDB unsupported but server file exists
|
|
183
305
|
* - false if the file is not available on the server
|
|
184
306
|
*/
|
|
185
|
-
|
|
307
|
+
async getURL(uri) {
|
|
186
308
|
if (!this.#supported) {
|
|
187
309
|
return (await this.#getServerFileTimeStamp(uri)) ? uri : false;
|
|
188
310
|
}
|
|
189
311
|
const storedFile = await this.get(uri);
|
|
190
312
|
return storedFile ? URL.createObjectURL(storedFile.blob) : (await this.#getServerFileTimeStamp(uri)) ? uri : false;
|
|
191
|
-
}
|
|
313
|
+
}
|
|
192
314
|
|
|
193
315
|
/**
|
|
194
316
|
* Get the cached or server Last-Modified timestamp for a file.
|
|
195
|
-
* @
|
|
317
|
+
* @public
|
|
318
|
+
* @param {String} uri - File URI
|
|
196
319
|
* @returns {Promise<string|null>} Resolves with:
|
|
197
320
|
* - ISO 8601 string: Last-Modified timestamp from the cached record (when using IndexedDB) or from the server HEAD response
|
|
198
321
|
* - null: when no timestamp is available or on error
|
|
199
322
|
*/
|
|
200
|
-
|
|
323
|
+
async getTimeStamp(uri) {
|
|
201
324
|
if (!this.#supported) {
|
|
202
325
|
const serverFileTimeStamp = await this.#getServerFileTimeStamp(uri);
|
|
203
326
|
return serverFileTimeStamp ? serverFileTimeStamp : null;
|
|
204
327
|
}
|
|
205
328
|
const storedFile = await this.get(uri);
|
|
206
329
|
return storedFile ? storedFile.timeStamp : null;
|
|
207
|
-
}
|
|
330
|
+
}
|
|
208
331
|
|
|
209
332
|
/**
|
|
210
333
|
* Get the cached or server size of a file in bytes.
|
|
211
|
-
* @
|
|
334
|
+
* @public
|
|
335
|
+
* @param {String} uri - File URI
|
|
212
336
|
* @returns {Promise<number|null>} Resolves with:
|
|
213
337
|
* - number: size in bytes when available (from cache or server)
|
|
214
338
|
* - null: when size is not available or on error
|
|
215
339
|
*/
|
|
216
|
-
|
|
340
|
+
async getSize(uri) {
|
|
217
341
|
if (!this.#supported) {
|
|
218
342
|
const serverFileSize = await this.#getServerFileSize(uri);
|
|
219
343
|
return serverFileSize ? serverFileSize : null;
|
|
220
344
|
}
|
|
221
345
|
const storedFile = await this.get(uri);
|
|
222
346
|
return storedFile ? storedFile.blob.size : null;
|
|
223
|
-
}
|
|
347
|
+
}
|
|
224
348
|
|
|
225
349
|
/**
|
|
226
350
|
* Retrieve the Blob for a file from cache or server.
|
|
227
|
-
* @
|
|
351
|
+
* @public
|
|
352
|
+
* @param {String} uri - File URI
|
|
228
353
|
* @returns {Promise<Blob|false>} Resolves with:
|
|
229
354
|
* - Blob: the file blob when available (from cache or server)
|
|
230
355
|
* - false: when the file cannot be retrieved
|
|
231
356
|
*/
|
|
232
|
-
|
|
357
|
+
async getBlob(uri) {
|
|
233
358
|
if (!this.#supported) {
|
|
234
359
|
const serverFile = await this.#getServerFile(uri);
|
|
235
360
|
return serverFile ? serverFile.blob : false;
|
|
236
361
|
}
|
|
237
362
|
const storedFile = await this.get(uri);
|
|
238
363
|
return storedFile ? storedFile.blob : false;
|
|
239
|
-
}
|
|
364
|
+
}
|
|
240
365
|
|
|
241
366
|
/**
|
|
242
367
|
* Get the file Blob from IndexedDB if stored; otherwise download and store it, then return the Blob.
|
|
243
|
-
* @
|
|
368
|
+
* @public
|
|
369
|
+
* @param {String} uri - File URI
|
|
244
370
|
* @returns {Promise<Blob|undefined|false>} Resolves with:
|
|
245
371
|
* - Blob of the stored file
|
|
246
372
|
* - undefined if the download fails
|
|
@@ -250,7 +376,7 @@ export class FileStorage {
|
|
|
250
376
|
* the file is re-downloaded from the server and stored again in IndexedDB.
|
|
251
377
|
* If the server file is not available at that time to check its age but is stored, the stored file is returned.
|
|
252
378
|
*/
|
|
253
|
-
|
|
379
|
+
async get(uri) {
|
|
254
380
|
if (!uri) {
|
|
255
381
|
return false;
|
|
256
382
|
}
|
|
@@ -269,20 +395,21 @@ export class FileStorage {
|
|
|
269
395
|
}
|
|
270
396
|
}
|
|
271
397
|
return storedFile;
|
|
272
|
-
}
|
|
398
|
+
}
|
|
273
399
|
|
|
274
400
|
/**
|
|
275
401
|
* Store the file in IndexedDB.
|
|
276
|
-
* @
|
|
402
|
+
* @public
|
|
403
|
+
* @param {String} uri - File URI
|
|
277
404
|
* @returns {Promise<boolean>} Resolves with:
|
|
278
405
|
* - true if the file was stored successfully
|
|
279
406
|
* - false if IndexedDB is not supported or storing failed
|
|
280
407
|
*/
|
|
281
|
-
|
|
408
|
+
async put(uri) {
|
|
282
409
|
if (!this.#supported) {
|
|
283
410
|
return false;
|
|
284
411
|
}
|
|
285
412
|
const fileToStore = await this.#getServerFile(uri);
|
|
286
413
|
return fileToStore ? !!(await this.#putFile(fileToStore, uri)) : false;
|
|
287
|
-
}
|
|
414
|
+
}
|
|
288
415
|
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { FileStorage } from "./file-storage.js";
|
|
2
|
+
import { initDb, loadModel } from "./gltf-storage.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GLTFResolver - Utility class for resolving, decoding, and preparing glTF/GLB assets from various sources.
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Loads glTF/GLB assets from IndexedDB, direct URLs, or base64-encoded data.
|
|
9
|
+
* - Decodes base64 data and determines file type (.gltf or .glb).
|
|
10
|
+
* - Normalizes and replaces asset URIs with resolved URLs from FileStorage.
|
|
11
|
+
* - Handles cache validation using asset size and timestamp to avoid unnecessary reloads.
|
|
12
|
+
* - Provides methods for initializing storage, decoding assets, and preparing sources for viewer components.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* - Instantiate: const resolver = new GLTFResolver();
|
|
16
|
+
* - Prepare asset: await resolver.getSource(storage, currentSize, currentTimeStamp);
|
|
17
|
+
*
|
|
18
|
+
* Public Methods:
|
|
19
|
+
* - getSource(storage, currentSize, currentTimeStamp): Resolves and prepares a glTF/GLB source for loading.
|
|
20
|
+
*
|
|
21
|
+
* Private Methods:
|
|
22
|
+
* - #initializeStorage(db, table): Ensures IndexedDB store is initialized.
|
|
23
|
+
* - #decodeBase64(base64): Decodes base64 data and determines file type.
|
|
24
|
+
* - #isURLAbsolute(url): Checks if a URL is syntactically absolute.
|
|
25
|
+
* - #saveAssetData(asset, index, parent, assetArray): Collects asset entries with external URIs.
|
|
26
|
+
* - #replaceSceneURIAsync(assetContainerJSON, assetContainerURL): Replaces internal URIs in glTF JSON with resolved URLs.
|
|
27
|
+
*
|
|
28
|
+
* Notes:
|
|
29
|
+
* - Designed for integration with PrefViewer and FileStorage.
|
|
30
|
+
* - Follows the glTF 2.0 specification for asset structure and URI handling.
|
|
31
|
+
* - All asset preparation is performed asynchronously to support large files and remote sources.
|
|
32
|
+
* - See: https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html
|
|
33
|
+
*/
|
|
34
|
+
export default class GLTFResolver {
|
|
35
|
+
#fileStorage = null;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Creates a new GLTFResolver instance and initializes the FileStorage for asset management.
|
|
39
|
+
* @public
|
|
40
|
+
* @constructor
|
|
41
|
+
* @returns {GLTFResolver}
|
|
42
|
+
* @description
|
|
43
|
+
* - Initializes an internal FileStorage instance with the database "PrefViewer" and object store "Files".
|
|
44
|
+
* - Prepares the resolver for loading, decoding, and managing glTF/GLB assets from various sources.
|
|
45
|
+
*/
|
|
46
|
+
constructor() {
|
|
47
|
+
this.#fileStorage = new FileStorage("PrefViewer", "Files");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ensure the IndexedDB object store for GLTF/SVG storage is initialized.
|
|
52
|
+
* @private
|
|
53
|
+
* @param {string} db - Database name.
|
|
54
|
+
* @param {string} table - Object store name.
|
|
55
|
+
* @returns {Promise<void>}
|
|
56
|
+
* @description
|
|
57
|
+
* If a global PrefConfigurator.db already references the requested DB and table, no action is taken. Otherwise delegates to initDb to create/open the store.
|
|
58
|
+
*/
|
|
59
|
+
async #initializeStorage(db, table) {
|
|
60
|
+
if (typeof window !== "undefined" && window.PrefConfigurator.db && window.PrefConfigurator.db.name === db && window.PrefConfigurator.db.objectStoreNames.contains(table)) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await initDb(db, table);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Decodes a base64-encoded glTF or GLB data URI or string into a Blob and determines its extension.
|
|
68
|
+
* If the decoded content is valid JSON, assumes it is a .gltf file; otherwise, treats it as .glb binary.
|
|
69
|
+
* @private
|
|
70
|
+
* @param {string} base64 - The base64-encoded data URI or string to decode.
|
|
71
|
+
* @returns {{blob: Blob|null, extension: string|null, size: number}}
|
|
72
|
+
* - blob: The decoded Blob object, or null if decoding fails.
|
|
73
|
+
* - extension: ".gltf" for JSON content, ".glb" for binary, or null if decoding fails.
|
|
74
|
+
* - size: The length of the raw base64 payload.
|
|
75
|
+
* @description
|
|
76
|
+
* - Attempts to decode the base64 payload and parse it as JSON.
|
|
77
|
+
* - If parsing succeeds, returns a Blob with MIME type "model/gltf+json" and extension ".gltf".
|
|
78
|
+
* - If parsing fails, returns a Blob with MIME type "model/gltf-binary" and extension ".glb".
|
|
79
|
+
* - If decoding fails, returns nulls for blob and extension.
|
|
80
|
+
*/
|
|
81
|
+
#decodeBase64(base64) {
|
|
82
|
+
const [, payload] = base64.split(",");
|
|
83
|
+
const raw = payload || base64;
|
|
84
|
+
let decoded = "";
|
|
85
|
+
let blob = null;
|
|
86
|
+
let extension = null;
|
|
87
|
+
let size = raw.length;
|
|
88
|
+
try {
|
|
89
|
+
decoded = atob(raw);
|
|
90
|
+
} catch {
|
|
91
|
+
return { blob, extension, size };
|
|
92
|
+
}
|
|
93
|
+
let isJson = false;
|
|
94
|
+
try {
|
|
95
|
+
JSON.parse(decoded);
|
|
96
|
+
isJson = true;
|
|
97
|
+
} catch {}
|
|
98
|
+
extension = isJson ? ".gltf" : ".glb";
|
|
99
|
+
const type = isJson ? "model/gltf+json" : "model/gltf-binary";
|
|
100
|
+
const array = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
|
|
101
|
+
blob = new Blob([array], { type });
|
|
102
|
+
return { blob, extension, size };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check whether a value is a syntactically absolute URL.
|
|
107
|
+
* @private
|
|
108
|
+
* @param {string} url - Value to test.
|
|
109
|
+
* @returns {boolean} True when `url` is a string that can be parsed by the global URL constructor (i.e. a syntactically absolute URL); false otherwise.
|
|
110
|
+
* @description
|
|
111
|
+
* - Returns false for non-string inputs.
|
|
112
|
+
* - Uses the browser's URL parser, so protocol-relative URLs ("//host/...") and relative paths are considered non-absolute.
|
|
113
|
+
* - This is a syntactic check only — it does not perform network requests or validate reachability/CORS.
|
|
114
|
+
*/
|
|
115
|
+
#isURLAbsolute(url) {
|
|
116
|
+
if (typeof url !== "string") {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
new URL(url);
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Collects asset entries that have an external URI (non-data URI) and stores a normalized absolute or relative-resolved URI for later replacement.
|
|
129
|
+
* @private
|
|
130
|
+
* @param {Object} asset - glTF asset entry (an element of buffers[] or images[]).
|
|
131
|
+
* @param {number} index - Index of the asset within its parent array.
|
|
132
|
+
* @param {Array} parent - Reference to the parent array (buffers or images).
|
|
133
|
+
* @param {string} sceneURLBase - Base path used to resolve relative URIs.
|
|
134
|
+
* @param {Array} assetArray - Array to collect asset records for later URI replacement.
|
|
135
|
+
* @returns {void}
|
|
136
|
+
* @description
|
|
137
|
+
* - Only assets with a non-data URI are collected.
|
|
138
|
+
* - The pushed record has the shape: { parent: <array>, index: <number>, uri: <string> }.
|
|
139
|
+
* - The URI is normalized (backslashes converted to forward slashes) and, if relative, is prefixed with sceneURLBase if available.
|
|
140
|
+
*/
|
|
141
|
+
#saveAssetData(asset, index, parent, sceneURLBase, assetArray) {
|
|
142
|
+
if (asset.uri && !asset.uri.startsWith("data:")) {
|
|
143
|
+
const assetData = {
|
|
144
|
+
parent: parent,
|
|
145
|
+
index: index,
|
|
146
|
+
uri: `${!this.#isURLAbsolute(asset.uri) && sceneURLBase ? sceneURLBase : ""}${asset.uri}`.replace(/\\\\|\\|\/\\/g, "/"),
|
|
147
|
+
};
|
|
148
|
+
assetArray.push(assetData);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Replace internal URIs in a glTF AssetContainer JSON with URLs pointing to files stored in IndexedDB.
|
|
154
|
+
* @private
|
|
155
|
+
* @param {JSON} assetContainerJSON - AssetContainer in glTF (JSON) (modified in-place).
|
|
156
|
+
* @param {URL} [assetContainerURL] - Optional URL of the AssetContainer. Used as the base path to resolve relative URIs.
|
|
157
|
+
* @returns {Promise<void>} Resolves when all applicable URIs have been resolved/replaced.
|
|
158
|
+
* @description
|
|
159
|
+
* - When provided, assetContainerURL is used as the base path for other scene files (binary buffers and all images).
|
|
160
|
+
* If not provided (null/undefined), it is because it is the assetContainer of the model or materials whose URIs are absolute.
|
|
161
|
+
* - According to the glTF 2.0 spec, only items inside the "buffers" and "images" arrays may have a "uri" property.
|
|
162
|
+
* - Data URIs (embedded base64) are ignored and left unchanged.
|
|
163
|
+
* - Matching asset URIs are normalized (backslashes converted to forward slashes) and passed to the FileStorage layer
|
|
164
|
+
* to obtain a usable URL (object URL or cached URL).
|
|
165
|
+
* - The function performs replacements in parallel and waits for all lookups to complete.
|
|
166
|
+
* - The JSON is updated in-place with the resolved URLs.
|
|
167
|
+
* @see {@link https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#uris|glTF™ 2.0 Specification - URIs}
|
|
168
|
+
*/
|
|
169
|
+
async #replaceSceneURIAsync(assetContainerJSON, assetContainerURL) {
|
|
170
|
+
if (!assetContainerJSON) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let sceneURLBase = assetContainerURL;
|
|
175
|
+
|
|
176
|
+
if (typeof assetContainerURL === "string") {
|
|
177
|
+
const lastIndexOfSlash = assetContainerURL.lastIndexOf("/");
|
|
178
|
+
if (lastIndexOfSlash !== -1) {
|
|
179
|
+
sceneURLBase = assetContainerURL.substring(0, lastIndexOfSlash + 1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const arrayOfAssetsWithURI = [];
|
|
184
|
+
if (assetContainerJSON.buffers) {
|
|
185
|
+
assetContainerJSON.buffers.forEach((asset, index, array) => this.#saveAssetData(asset, index, array, sceneURLBase, arrayOfAssetsWithURI));
|
|
186
|
+
}
|
|
187
|
+
if (assetContainerJSON.images) {
|
|
188
|
+
assetContainerJSON.images.forEach((asset, index, array) => this.#saveAssetData(asset, index, array, sceneURLBase, arrayOfAssetsWithURI));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Replace parallel URIs so that if files are not stored yet, they are downloaded in parallel
|
|
192
|
+
const promisesArray = arrayOfAssetsWithURI.map(async (asset) => {
|
|
193
|
+
const uri = await this.#fileStorage.getURL(asset.uri);
|
|
194
|
+
if (uri) {
|
|
195
|
+
asset.parent[asset.index].uri = uri;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
await Promise.all(promisesArray);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolves and prepares a glTF/GLB source from various storage backends.
|
|
203
|
+
* Supports IndexedDB, direct URLs, and base64-encoded data.
|
|
204
|
+
* Handles cache validation using asset size and timestamp, and updates URIs for .gltf files as needed.
|
|
205
|
+
* @public
|
|
206
|
+
* @param {object} storage - Storage descriptor containing url, db, table, and id properties.
|
|
207
|
+
* @param {number|null} currentSize - The current cached size of the asset, or null if not cached.
|
|
208
|
+
* @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}>}
|
|
210
|
+
* - 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.
|
|
212
|
+
* @description
|
|
213
|
+
* - If storage specifies IndexedDB (db, table, id), loads the asset from IndexedDB and checks for updates.
|
|
214
|
+
* - If storage specifies a direct URL or base64 data, decodes and validates the asset.
|
|
215
|
+
* - For .gltf files, replaces internal URIs with resolved URLs from FileStorage.
|
|
216
|
+
* - Performs cache validation using size and timestamp to avoid unnecessary reloads.
|
|
217
|
+
* - Returns the prepared source, size, timestamp, and extension for further processing.
|
|
218
|
+
*/
|
|
219
|
+
async getSource(storage, currentSize, currentTimeStamp) {
|
|
220
|
+
let source = storage.url || null;
|
|
221
|
+
let newSize, newTimeStamp;
|
|
222
|
+
let pending = false;
|
|
223
|
+
|
|
224
|
+
if (storage.db && storage.table && storage.id) {
|
|
225
|
+
await this.#initializeStorage(storage.db, storage.table);
|
|
226
|
+
const object = await loadModel(storage.id, storage.table);
|
|
227
|
+
if (!object || !object.data || !object.timeStamp || !object.size) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
source = object.data;
|
|
231
|
+
if (object.timeStamp === currentTimeStamp) {
|
|
232
|
+
return false;
|
|
233
|
+
} else {
|
|
234
|
+
pending = true;
|
|
235
|
+
newSize = object.size;
|
|
236
|
+
newTimeStamp = object.timeStamp;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!source) {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let file = null;
|
|
245
|
+
let { blob, extension, size } = this.#decodeBase64(source);
|
|
246
|
+
|
|
247
|
+
if (blob && extension) {
|
|
248
|
+
if (extension === ".gltf") {
|
|
249
|
+
const assetContainerJSON = JSON.parse(await blob.text());
|
|
250
|
+
await this.#replaceSceneURIAsync(assetContainerJSON, source);
|
|
251
|
+
source = `data:${JSON.stringify(assetContainerJSON)}`;
|
|
252
|
+
} else {
|
|
253
|
+
file = new File([blob], `file${extension}`, {
|
|
254
|
+
type: blob.type,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (!pending) {
|
|
258
|
+
if (currentTimeStamp === null && currentSize === size) {
|
|
259
|
+
return false;
|
|
260
|
+
} else {
|
|
261
|
+
pending = true;
|
|
262
|
+
newSize = size;
|
|
263
|
+
newTimeStamp = null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
const extMatch = source.match(/\.(gltf|glb)(\?|#|$)/i);
|
|
268
|
+
extension = extMatch ? `.${extMatch[1].toLowerCase()}` : ".gltf";
|
|
269
|
+
let [fileSize, fileTimeStamp] = [await this.#fileStorage.getSize(source), await this.#fileStorage.getTimeStamp(source)];
|
|
270
|
+
if (currentSize === fileSize && currentTimeStamp === fileTimeStamp) {
|
|
271
|
+
return false;
|
|
272
|
+
} else {
|
|
273
|
+
pending = true;
|
|
274
|
+
newSize = fileSize;
|
|
275
|
+
newTimeStamp = fileTimeStamp;
|
|
276
|
+
if (extension === ".gltf") {
|
|
277
|
+
const assetContainerBlob = await this.#fileStorage.getBlob(source);
|
|
278
|
+
const assetContainerJSON = JSON.parse(await assetContainerBlob.text());
|
|
279
|
+
await this.#replaceSceneURIAsync(assetContainerJSON, source);
|
|
280
|
+
source = `data:${JSON.stringify(assetContainerJSON)}`;
|
|
281
|
+
} else {
|
|
282
|
+
source = await this.#fileStorage.getURL(source);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return { source: file || source, size: newSize, timeStamp: newTimeStamp, extension: extension };
|
|
287
|
+
}
|
|
288
|
+
}
|