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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }