@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 +1 -1
- package/src/babylonjs-controller.js +88 -41
- package/src/file-storage.js +382 -21
- package/src/gltf-resolver.js +52 -8
- package/src/pref-viewer-3d-data.js +60 -2
- package/src/pref-viewer-3d.js +11 -4
package/package.json
CHANGED
|
@@ -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?.
|
|
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
|
-
*
|
|
651
|
-
*
|
|
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
|
-
|
|
661
|
-
|
|
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
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
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?.
|
|
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
|
|
package/src/file-storage.js
CHANGED
|
@@ -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
|
|
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
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
* @
|
|
285
|
-
*
|
|
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
|
|
297
|
-
const store =
|
|
298
|
-
|
|
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
|
|
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
|
-
* -
|
|
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
|
}
|
package/src/gltf-resolver.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
|
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
|
|
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 {
|
|
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
|
}
|
package/src/pref-viewer-3d.js
CHANGED
|
@@ -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
|
|
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
|
|
774
|
+
* Reports whether an IBL environment map is currently available.
|
|
775
775
|
* @public
|
|
776
|
-
* @returns {boolean} True when
|
|
776
|
+
* @returns {boolean} True when a validated IBL texture exists or a pending cached URL is available.
|
|
777
777
|
*/
|
|
778
778
|
isIBLAvailable() {
|
|
779
|
-
const
|
|
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
|
|