@preference-sl/pref-viewer 2.16.0-beta.0 → 2.16.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 +1 -1
- package/src/babylonjs-controller.js +815 -36
- package/src/file-storage.js +126 -5
- package/src/gltf-resolver.js +58 -24
- package/src/pref-viewer-3d.js +104 -0
- package/src/pref-viewer.js +147 -1
- package/src/styles.js +1 -0
package/src/file-storage.js
CHANGED
|
@@ -129,6 +129,10 @@ export default class FileStorage {
|
|
|
129
129
|
#minimumFreeSpaceRatio = 0.05; // try to keep at least 5% of quota free
|
|
130
130
|
#maxQuotaEvictionRetries = 5; // max retries after quota error
|
|
131
131
|
#lastCleanupAt = 0; // timestamp of last automatic cleanup run
|
|
132
|
+
// In-memory blob cache — eliminates repeated IDB round-trips for the same URI within a session.
|
|
133
|
+
// Keyed by URI; values are normalized records (blob kept in RAM). Avoids both the readonly read
|
|
134
|
+
// and the readwrite #touchFile transaction on every getURL() call during #replaceSceneURIAsync.
|
|
135
|
+
#memoryCache = new Map();
|
|
132
136
|
|
|
133
137
|
constructor(dbName = "FilesDB", osName = "FilesObjectStore") {
|
|
134
138
|
this.dbName = dbName; // database name
|
|
@@ -258,6 +262,7 @@ export default class FileStorage {
|
|
|
258
262
|
const store = transaction.objectStore(this.osName);
|
|
259
263
|
keys.forEach((key) => {
|
|
260
264
|
store.delete(key);
|
|
265
|
+
this.#memoryCache.delete(String(key));
|
|
261
266
|
});
|
|
262
267
|
await transaction.done;
|
|
263
268
|
return keys.length;
|
|
@@ -608,6 +613,7 @@ export default class FileStorage {
|
|
|
608
613
|
const store = transaction.objectStore(this.osName);
|
|
609
614
|
const result = await store.put(record, uri);
|
|
610
615
|
await transaction.done;
|
|
616
|
+
this.#memoryCache.set(uri, record);
|
|
611
617
|
return result;
|
|
612
618
|
} catch (error) {
|
|
613
619
|
if (!this.#isQuotaExceededError(error) || attempt === this.#maxQuotaEvictionRetries) {
|
|
@@ -635,6 +641,19 @@ export default class FileStorage {
|
|
|
635
641
|
* - false: if the database could not be opened (IndexedDB unsupported or open failure)
|
|
636
642
|
*/
|
|
637
643
|
async #getFile(uri, { touch = true, allowExpired = false } = {}) {
|
|
644
|
+
// In-memory cache hit: skip IDB entirely, update lastAccess in RAM only.
|
|
645
|
+
const inMemory = this.#memoryCache.get(uri);
|
|
646
|
+
if (inMemory) {
|
|
647
|
+
if (!allowExpired && this.#isExpired(inMemory)) {
|
|
648
|
+
this.#memoryCache.delete(uri);
|
|
649
|
+
} else {
|
|
650
|
+
if (touch) {
|
|
651
|
+
inMemory.lastAccess = this.#now();
|
|
652
|
+
}
|
|
653
|
+
return inMemory;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
638
657
|
if (this.#db === undefined) {
|
|
639
658
|
this.#db = await this.#openDB();
|
|
640
659
|
}
|
|
@@ -651,13 +670,68 @@ export default class FileStorage {
|
|
|
651
670
|
await this.#deleteKeys([uri]);
|
|
652
671
|
return undefined;
|
|
653
672
|
}
|
|
673
|
+
this.#memoryCache.set(uri, record);
|
|
654
674
|
if (touch) {
|
|
655
|
-
|
|
675
|
+
// Fire-and-forget: don't block the caller on a readwrite IDB transaction just to update lastAccess.
|
|
676
|
+
void this.#touchFile(uri, record);
|
|
656
677
|
record.lastAccess = this.#now();
|
|
657
678
|
}
|
|
658
679
|
return record;
|
|
659
680
|
}
|
|
660
681
|
|
|
682
|
+
/**
|
|
683
|
+
* Retrieves multiple file records from the in-memory cache and IDB in a single readonly
|
|
684
|
+
* transaction, populating the in-memory cache for all hits.
|
|
685
|
+
* @private
|
|
686
|
+
* @param {string[]} uris - URIs to look up.
|
|
687
|
+
* @returns {Promise<Map<string, object>>} Map of uri → normalized record for found entries.
|
|
688
|
+
*/
|
|
689
|
+
async #getFileBatch(uris) {
|
|
690
|
+
const resultMap = new Map();
|
|
691
|
+
if (!uris.length) return resultMap;
|
|
692
|
+
|
|
693
|
+
const idbMisses = [];
|
|
694
|
+
for (const uri of uris) {
|
|
695
|
+
const cached = this.#memoryCache.get(uri);
|
|
696
|
+
if (cached) {
|
|
697
|
+
if (this.#isExpired(cached)) {
|
|
698
|
+
this.#memoryCache.delete(uri);
|
|
699
|
+
idbMisses.push(uri);
|
|
700
|
+
} else {
|
|
701
|
+
resultMap.set(uri, cached);
|
|
702
|
+
}
|
|
703
|
+
} else {
|
|
704
|
+
idbMisses.push(uri);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (!idbMisses.length) return resultMap;
|
|
709
|
+
|
|
710
|
+
if (this.#db === undefined) {
|
|
711
|
+
this.#db = await this.#openDB();
|
|
712
|
+
}
|
|
713
|
+
if (this.#db === null) return resultMap;
|
|
714
|
+
|
|
715
|
+
// One readonly transaction for all IDB misses — single lock acquisition vs N transactions.
|
|
716
|
+
const tx = this.#db.transaction(this.osName, "readonly");
|
|
717
|
+
const store = tx.objectStore(this.osName);
|
|
718
|
+
const rawRecords = await Promise.all(idbMisses.map((uri) => store.get(uri)));
|
|
719
|
+
|
|
720
|
+
const now = this.#now();
|
|
721
|
+
idbMisses.forEach((uri, i) => {
|
|
722
|
+
const record = this.#normalizeRecord(rawRecords[i]);
|
|
723
|
+
if (!record) return;
|
|
724
|
+
if (this.#isExpired(record, now)) {
|
|
725
|
+
void this.#deleteKeys([uri]);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
this.#memoryCache.set(uri, record);
|
|
729
|
+
resultMap.set(uri, record);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
return resultMap;
|
|
733
|
+
}
|
|
734
|
+
|
|
661
735
|
/**
|
|
662
736
|
* ---------------------------
|
|
663
737
|
* Public methods
|
|
@@ -682,6 +756,44 @@ export default class FileStorage {
|
|
|
682
756
|
return storedFile ? URL.createObjectURL(storedFile.blob) : (await this.#getServerFileTimeStamp(uri)) ? uri : false;
|
|
683
757
|
}
|
|
684
758
|
|
|
759
|
+
/**
|
|
760
|
+
* Resolve multiple URIs to object URLs in a single IDB batch transaction.
|
|
761
|
+
* Hits in-memory cache first, then IDB in one transaction, then falls back to individual
|
|
762
|
+
* server downloads for any URIs not found. Returns a Map<uri, objectURL|uri>.
|
|
763
|
+
* @public
|
|
764
|
+
* @param {string[]} uris - URIs to resolve.
|
|
765
|
+
* @returns {Promise<Map<string, string>>} Map of uri → resolved URL (blob: or original URI).
|
|
766
|
+
*/
|
|
767
|
+
async getURLBatch(uris) {
|
|
768
|
+
if (!uris.length) return new Map();
|
|
769
|
+
if (!this.#supported) {
|
|
770
|
+
return new Map(uris.map((uri) => [uri, uri]));
|
|
771
|
+
}
|
|
772
|
+
await this.#runCleanupIfDue();
|
|
773
|
+
const records = await this.#getFileBatch(uris);
|
|
774
|
+
const urlMap = new Map();
|
|
775
|
+
const serverFetches = [];
|
|
776
|
+
|
|
777
|
+
for (const uri of uris) {
|
|
778
|
+
const record = records.get(uri);
|
|
779
|
+
if (record?.blob) {
|
|
780
|
+
urlMap.set(uri, URL.createObjectURL(record.blob));
|
|
781
|
+
} else {
|
|
782
|
+
serverFetches.push(uri);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// For URIs absent from IDB, fall back to individual get() which downloads and caches them.
|
|
787
|
+
await Promise.all(
|
|
788
|
+
serverFetches.map(async (uri) => {
|
|
789
|
+
const url = await this.getURL(uri);
|
|
790
|
+
if (url) urlMap.set(uri, url);
|
|
791
|
+
})
|
|
792
|
+
);
|
|
793
|
+
|
|
794
|
+
return urlMap;
|
|
795
|
+
}
|
|
796
|
+
|
|
685
797
|
/**
|
|
686
798
|
* Get the cached or server Last-Modified timestamp for a file.
|
|
687
799
|
* @public
|
|
@@ -762,10 +874,18 @@ export default class FileStorage {
|
|
|
762
874
|
}
|
|
763
875
|
if (!storedFile) {
|
|
764
876
|
const fileToStore = await this.#getServerFile(uri);
|
|
765
|
-
if (fileToStore
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
877
|
+
if (fileToStore) {
|
|
878
|
+
// Populate in-memory cache immediately after download so subsequent calls
|
|
879
|
+
// within this session are instant — even if the IDB write fails later.
|
|
880
|
+
const freshRecord = this.#createRecordFromFile(fileToStore);
|
|
881
|
+
if (freshRecord) {
|
|
882
|
+
this.#memoryCache.set(uri, freshRecord);
|
|
883
|
+
}
|
|
884
|
+
if (!!(await this.#putFile(fileToStore, uri))) {
|
|
885
|
+
storedFile = await this.#getFile(uri);
|
|
886
|
+
} else {
|
|
887
|
+
storedFile = freshRecord ?? fileToStore;
|
|
888
|
+
}
|
|
769
889
|
}
|
|
770
890
|
}
|
|
771
891
|
return storedFile;
|
|
@@ -801,5 +921,6 @@ export default class FileStorage {
|
|
|
801
921
|
} catch {}
|
|
802
922
|
}
|
|
803
923
|
this.#db = undefined;
|
|
924
|
+
this.#memoryCache.clear();
|
|
804
925
|
}
|
|
805
926
|
}
|
package/src/gltf-resolver.js
CHANGED
|
@@ -82,27 +82,27 @@ export default class GLTFResolver {
|
|
|
82
82
|
* - If parsing fails, returns a Blob with MIME type "model/gltf-binary" and extension ".glb".
|
|
83
83
|
* - If decoding fails, returns nulls for blob and extension.
|
|
84
84
|
*/
|
|
85
|
-
#decodeBase64(base64) {
|
|
85
|
+
async #decodeBase64(base64) {
|
|
86
86
|
const [, payload] = base64.split(",");
|
|
87
87
|
const raw = payload || base64;
|
|
88
|
-
|
|
88
|
+
const size = raw.length;
|
|
89
|
+
|
|
90
|
+
// Detect format from first base64 chars — O(1), no full decode needed.
|
|
91
|
+
// GLB magic bytes 0x676c5446 ("glTF") encode to "Z2x" in base64.
|
|
92
|
+
// GLTF JSON always starts with '{' (0x7B), which encodes to "ey" in base64.
|
|
93
|
+
const isGlb = raw.startsWith("Z2x");
|
|
94
|
+
const extension = isGlb ? ".glb" : ".gltf";
|
|
95
|
+
const type = isGlb ? "model/gltf-binary" : "model/gltf+json";
|
|
96
|
+
|
|
89
97
|
let blob = null;
|
|
90
|
-
let extension = null;
|
|
91
|
-
let size = raw.length;
|
|
92
98
|
try {
|
|
93
|
-
|
|
99
|
+
// Native browser decode: runs in C++ off the JS call stack, does not block the
|
|
100
|
+
// main thread. Avoids the per-character Uint8Array loop and the full JSON.parse
|
|
101
|
+
// that the previous sync implementation used to detect the file type.
|
|
102
|
+
blob = await fetch(`data:${type};base64,${raw}`).then((r) => r.blob());
|
|
94
103
|
} catch {
|
|
95
|
-
return { blob, extension, size };
|
|
104
|
+
return { blob: null, extension: null, size };
|
|
96
105
|
}
|
|
97
|
-
let isJson = false;
|
|
98
|
-
try {
|
|
99
|
-
JSON.parse(decoded);
|
|
100
|
-
isJson = true;
|
|
101
|
-
} catch {}
|
|
102
|
-
extension = isJson ? ".gltf" : ".glb";
|
|
103
|
-
const type = isJson ? "model/gltf+json" : "model/gltf-binary";
|
|
104
|
-
const array = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
|
|
105
|
-
blob = new Blob([array], { type });
|
|
106
106
|
return { blob, extension, size };
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -138,6 +138,27 @@ export default class GLTFResolver {
|
|
|
138
138
|
return typeof url === "string" && url.startsWith("blob:");
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Check whether a source should be treated as embedded base64/data URI.
|
|
143
|
+
* @private
|
|
144
|
+
* @param {string} source - Value to test.
|
|
145
|
+
* @returns {boolean} True when the source is a data URI or raw base64 payload.
|
|
146
|
+
*/
|
|
147
|
+
#isEmbeddedBase64Source(source) {
|
|
148
|
+
if (typeof source !== "string") {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
const normalized = source.trim();
|
|
152
|
+
if (!normalized) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (normalized.startsWith("data:")) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
// Raw base64 payloads use the standard alphabet and have no URL separators.
|
|
159
|
+
return /^[A-Za-z0-9+/=_-]+$/.test(normalized);
|
|
160
|
+
}
|
|
161
|
+
|
|
141
162
|
/**
|
|
142
163
|
* Collects asset entries that have an external URI (non-data URI) and stores a normalized absolute or relative-resolved URI for later replacement.
|
|
143
164
|
* @private
|
|
@@ -204,17 +225,19 @@ export default class GLTFResolver {
|
|
|
204
225
|
assetContainerJSON.images.forEach((asset, index, array) => this.#saveAssetData(asset, index, array, sceneURLBase, arrayOfAssetsWithURI));
|
|
205
226
|
}
|
|
206
227
|
|
|
207
|
-
//
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
228
|
+
// Batch-resolve all asset URIs in a single IDB transaction to avoid per-URI transaction
|
|
229
|
+
// overhead. Falls back to individual getURL() for URIs not found in IDB.
|
|
230
|
+
const urisToResolve = arrayOfAssetsWithURI.map((asset) => asset.uri);
|
|
231
|
+
const urlMap = await this.#fileStorage.getURLBatch(urisToResolve);
|
|
232
|
+
arrayOfAssetsWithURI.forEach((asset) => {
|
|
233
|
+
const resolvedUrl = urlMap.get(asset.uri);
|
|
234
|
+
if (resolvedUrl) {
|
|
235
|
+
asset.parent[asset.index].uri = resolvedUrl;
|
|
236
|
+
if (this.#isBlobURL(resolvedUrl)) {
|
|
237
|
+
objectURLs.push(resolvedUrl);
|
|
214
238
|
}
|
|
215
239
|
}
|
|
216
240
|
});
|
|
217
|
-
await Promise.all(promisesArray);
|
|
218
241
|
}
|
|
219
242
|
|
|
220
243
|
/**
|
|
@@ -270,7 +293,9 @@ export default class GLTFResolver {
|
|
|
270
293
|
|
|
271
294
|
if (storage.db && storage.table && storage.id) {
|
|
272
295
|
await this.#initializeStorage(storage.db, storage.table);
|
|
296
|
+
const tIdb = performance.now();
|
|
273
297
|
const object = await loadModel(storage.id, storage.table);
|
|
298
|
+
console.log(`[perf][gltf:${storage.id}] IDB read ${Math.round(performance.now() - tIdb)}ms`);
|
|
274
299
|
if (!object || !object.data || !object.timeStamp || !object.size) {
|
|
275
300
|
return false;
|
|
276
301
|
}
|
|
@@ -290,12 +315,21 @@ export default class GLTFResolver {
|
|
|
290
315
|
}
|
|
291
316
|
|
|
292
317
|
let file = null;
|
|
293
|
-
let
|
|
318
|
+
let blob = null;
|
|
319
|
+
let extension = null;
|
|
320
|
+
let size = 0;
|
|
321
|
+
if (this.#isEmbeddedBase64Source(source)) {
|
|
322
|
+
const tDecode = performance.now();
|
|
323
|
+
({ blob, extension, size } = await this.#decodeBase64(source));
|
|
324
|
+
console.log(`[perf][gltf:${storage.id ?? "url"}] decode(${extension}) ${Math.round(performance.now() - tDecode)}ms`);
|
|
325
|
+
}
|
|
294
326
|
|
|
295
327
|
if (blob && extension) {
|
|
296
328
|
if (extension === ".gltf") {
|
|
297
329
|
const assetContainerJSON = JSON.parse(await blob.text());
|
|
330
|
+
const tReplace = performance.now();
|
|
298
331
|
await this.#replaceSceneURIAsync(assetContainerJSON, source, objectURLs);
|
|
332
|
+
console.log(`[perf][gltf:${storage.id ?? "url"}] replaceURIs ${Math.round(performance.now() - tReplace)}ms (${objectURLs.length} assets)`);
|
|
299
333
|
source = `data:${JSON.stringify(assetContainerJSON)}`;
|
|
300
334
|
} else {
|
|
301
335
|
file = new File([blob], `file${extension}`, {
|
package/src/pref-viewer-3d.js
CHANGED
|
@@ -62,6 +62,7 @@ import FileStorage from "./file-storage.js";
|
|
|
62
62
|
* Events:
|
|
63
63
|
* - "scene-loading": Dispatched when a loading operation starts.
|
|
64
64
|
* - "scene-loaded": Dispatched when a loading operation completes.
|
|
65
|
+
* - "model-first-painted": Dispatched when the model-only preview has actually painted a frame.
|
|
65
66
|
* - "scene-setting-options": Dispatched when viewer options are being set.
|
|
66
67
|
* - "scene-set-options": Dispatched when viewer options have been set.
|
|
67
68
|
*
|
|
@@ -76,6 +77,7 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
76
77
|
#isLoaded = false;
|
|
77
78
|
#isVisible = false;
|
|
78
79
|
#lastLoadTimestamp = undefined;
|
|
80
|
+
#suppressVisibilityAttributeCallback = false;
|
|
79
81
|
|
|
80
82
|
#wrapper = null;
|
|
81
83
|
#canvas = null;
|
|
@@ -112,6 +114,9 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
112
114
|
* - "visible": shows or hides the component according to the attribute value ("true"/"false").
|
|
113
115
|
*/
|
|
114
116
|
attributeChangedCallback(name, _old, value) {
|
|
117
|
+
if (this.#suppressVisibilityAttributeCallback && (name === "show-model" || name === "show-scene")) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
115
120
|
switch (name) {
|
|
116
121
|
case "show-model":
|
|
117
122
|
if (_old === value) {
|
|
@@ -716,6 +721,62 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
716
721
|
return { success: someSetted, detail: detail };
|
|
717
722
|
}
|
|
718
723
|
|
|
724
|
+
/**
|
|
725
|
+
* Loads the model container independently in the background while the render loop keeps running.
|
|
726
|
+
* Merges the model into the scene atomically when ready, without stopping camera-dependent effects.
|
|
727
|
+
* @public
|
|
728
|
+
* @param {object} payload - Model payload with storage pointer and optional timestamp.
|
|
729
|
+
* @returns {Promise<{success: boolean, error: any, loadedContainers: string[]}>}
|
|
730
|
+
*/
|
|
731
|
+
async loadModelIndependent(payload) {
|
|
732
|
+
if (!this.#babylonJSController) {
|
|
733
|
+
return { success: false, error: "Controller not initialized", loadedContainers: [] };
|
|
734
|
+
}
|
|
735
|
+
// #checkNeedToUpdateContainers passed as callback — called AFTER any in-progress #loadContainers
|
|
736
|
+
// finishes so the scene load doesn't also pick up and consume the model container.
|
|
737
|
+
return await this.#babylonJSController.loadContainerIndependent("model", () => {
|
|
738
|
+
this.#checkNeedToUpdateContainers({ model: payload });
|
|
739
|
+
}, { loadSessionId: payload?.loadSessionId ?? null });
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Loads the environment/scene container independently in the background, in parallel with any
|
|
744
|
+
* ongoing model load. Applies IBL (HDR) if the scene payload includes an ibl property.
|
|
745
|
+
* Dispatches "scene-loading" and "scene-loaded" (or "scene-error") events directly.
|
|
746
|
+
* @public
|
|
747
|
+
* @param {object|string} scene - Scene payload: { storage, ibl?, visible? }
|
|
748
|
+
* @returns {Promise<{success: boolean, error: any, loadedContainers: string[]}>}
|
|
749
|
+
*/
|
|
750
|
+
async loadSceneIndependent(scene) {
|
|
751
|
+
if (!this.#babylonJSController) {
|
|
752
|
+
return { success: false, error: "Controller not initialized", loadedContainers: [] };
|
|
753
|
+
}
|
|
754
|
+
// Pre-mark IBL as pending before entering loadContainerIndependent — the onReady callback
|
|
755
|
+
// is synchronous so async IBL resolution must happen here, not inside it.
|
|
756
|
+
if (scene?.ibl !== undefined) {
|
|
757
|
+
await this.#checkNeedToUpdateIBL({ ibl: scene.ibl });
|
|
758
|
+
}
|
|
759
|
+
return await this.#babylonJSController.loadContainerIndependent("environment", () => {
|
|
760
|
+
this.#checkNeedToUpdateContainers({ scene });
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Loads the materials container independently in the background, in parallel with any
|
|
766
|
+
* ongoing model or environment load.
|
|
767
|
+
* @public
|
|
768
|
+
* @param {object|string} materials - Materials payload: { storage }
|
|
769
|
+
* @returns {Promise<{success: boolean, error: any, loadedContainers: string[]}>}
|
|
770
|
+
*/
|
|
771
|
+
async loadMaterialsIndependent(materials) {
|
|
772
|
+
if (!this.#babylonJSController) {
|
|
773
|
+
return { success: false, error: "Controller not initialized", loadedContainers: [] };
|
|
774
|
+
}
|
|
775
|
+
return await this.#babylonJSController.loadContainerIndependent("materials", () => {
|
|
776
|
+
this.#checkNeedToUpdateContainers({ materials });
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
719
780
|
/**
|
|
720
781
|
* Shows the 3D model within the viewer.
|
|
721
782
|
* @public
|
|
@@ -764,6 +825,31 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
764
825
|
this.#babylonJSController.setContainerVisibility("environment", false);
|
|
765
826
|
}
|
|
766
827
|
|
|
828
|
+
/**
|
|
829
|
+
* Reflects the current visibility of a container without re-triggering the command queue.
|
|
830
|
+
* Used by BabylonJSController when the visibility change originated from the renderer itself.
|
|
831
|
+
* @public
|
|
832
|
+
* @param {string} name - Container name ("model" or "environment").
|
|
833
|
+
* @param {boolean} isVisible - Whether the container should be reflected as visible.
|
|
834
|
+
* @returns {void}
|
|
835
|
+
*/
|
|
836
|
+
syncVisibility(name, isVisible) {
|
|
837
|
+
const attrName = name === "model" ? "show-model" : name === "environment" ? "show-scene" : null;
|
|
838
|
+
if (!attrName) {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const nextValue = isVisible ? "true" : "false";
|
|
842
|
+
if (this.getAttribute(attrName) === nextValue) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
this.#suppressVisibilityAttributeCallback = true;
|
|
846
|
+
try {
|
|
847
|
+
this.setAttribute(attrName, nextValue);
|
|
848
|
+
} finally {
|
|
849
|
+
this.#suppressVisibilityAttributeCallback = false;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
767
853
|
/**
|
|
768
854
|
* Downloads the current 3D model as a GLB file.
|
|
769
855
|
* @public
|
|
@@ -1015,4 +1101,22 @@ export default class PrefViewer3D extends HTMLElement {
|
|
|
1015
1101
|
get isVisible() {
|
|
1016
1102
|
return this.#isVisible;
|
|
1017
1103
|
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Forces the active camera to frame the visible model.
|
|
1107
|
+
* @public
|
|
1108
|
+
* @returns {boolean}
|
|
1109
|
+
*/
|
|
1110
|
+
focusModel() {
|
|
1111
|
+
return this.#babylonJSController?.focusModel?.() ?? false;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Returns a debug snapshot of the internal 3D state, including camera data.
|
|
1116
|
+
* @public
|
|
1117
|
+
* @returns {object|null}
|
|
1118
|
+
*/
|
|
1119
|
+
getDebugState() {
|
|
1120
|
+
return this.#babylonJSController?.getDebugState?.() ?? null;
|
|
1121
|
+
}
|
|
1018
1122
|
}
|
package/src/pref-viewer.js
CHANGED
|
@@ -66,6 +66,7 @@ export default class PrefViewer extends HTMLElement {
|
|
|
66
66
|
#cultureList = SUPPORTED_LOCALES;
|
|
67
67
|
#culture = "";
|
|
68
68
|
#cultureSuppressAttribute = false;
|
|
69
|
+
#suppressVisibilityAttributeCallback = false;
|
|
69
70
|
|
|
70
71
|
// References to internal components
|
|
71
72
|
#wrapper = null;
|
|
@@ -120,6 +121,9 @@ export default class PrefViewer extends HTMLElement {
|
|
|
120
121
|
* @returns {void}
|
|
121
122
|
*/
|
|
122
123
|
attributeChangedCallback(name, _old, value) {
|
|
124
|
+
if (this.#suppressVisibilityAttributeCallback && (name === "show-model" || name === "show-scene")) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
123
127
|
switch (name) {
|
|
124
128
|
case "config":
|
|
125
129
|
this.loadConfig(value);
|
|
@@ -627,20 +631,47 @@ export default class PrefViewer extends HTMLElement {
|
|
|
627
631
|
/**
|
|
628
632
|
* Handles the completion of a 3D loading operation.
|
|
629
633
|
* Updates loading state, sets attributes, dispatches a "scene-loaded" event, and processes the next task.
|
|
634
|
+
* Dispatches granular container events ("model-loaded", "environment-loaded", "materials-loaded")
|
|
635
|
+
* for each successfully loaded container before firing "scene-loaded".
|
|
636
|
+
* The independent model path may additionally emit "model-first-painted" when the first painted
|
|
637
|
+
* frame is acknowledged by the Babylon controller.
|
|
630
638
|
* @private
|
|
631
639
|
* @param {object} [detail] - Optional details to include in the event.
|
|
640
|
+
* @param {string[]} [detail.loadedContainers] - Names of containers that loaded successfully.
|
|
632
641
|
* @returns {void}
|
|
633
642
|
*/
|
|
634
643
|
#on3DLoaded(detail) {
|
|
635
644
|
this.#isLoaded = true;
|
|
636
645
|
this.#isLoading = false;
|
|
637
|
-
|
|
646
|
+
|
|
638
647
|
this.#menu3DSyncSettings();
|
|
639
648
|
this.#menu3DUpdateSwitchAvailability();
|
|
640
649
|
|
|
641
650
|
this.removeAttribute("loading-3d");
|
|
642
651
|
this.setAttribute("loaded-3d", "");
|
|
643
652
|
|
|
653
|
+
// Dispatch granular container events before the aggregate "scene-loaded".
|
|
654
|
+
// This lets consumers react as soon as individual assets are ready without
|
|
655
|
+
// waiting for the full scene-loaded signal.
|
|
656
|
+
const granularEventMap = {
|
|
657
|
+
model: "model-loaded",
|
|
658
|
+
environment: "environment-loaded",
|
|
659
|
+
materials: "materials-loaded",
|
|
660
|
+
};
|
|
661
|
+
if (Array.isArray(detail?.loadedContainers)) {
|
|
662
|
+
const granularEventOptions = {
|
|
663
|
+
bubbles: true,
|
|
664
|
+
cancelable: false,
|
|
665
|
+
composed: true,
|
|
666
|
+
};
|
|
667
|
+
detail.loadedContainers.forEach((containerName) => {
|
|
668
|
+
const eventName = granularEventMap[containerName];
|
|
669
|
+
if (eventName) {
|
|
670
|
+
this.dispatchEvent(new CustomEvent(eventName, granularEventOptions));
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
644
675
|
const customEventOptions = {
|
|
645
676
|
bubbles: true,
|
|
646
677
|
cancelable: true,
|
|
@@ -1121,6 +1152,78 @@ export default class PrefViewer extends HTMLElement {
|
|
|
1121
1152
|
this.#addTaskToQueue(model, "model");
|
|
1122
1153
|
}
|
|
1123
1154
|
|
|
1155
|
+
/**
|
|
1156
|
+
* Loads the model container in parallel with any ongoing scene load, bypassing the task queue.
|
|
1157
|
+
* Dispatches "model-loading", "model-first-painted", and "model-loaded" events directly.
|
|
1158
|
+
* @public
|
|
1159
|
+
* @param {object|string} model - Model payload with storage pointer.
|
|
1160
|
+
* @returns {Promise<void>}
|
|
1161
|
+
*/
|
|
1162
|
+
async loadModelParallel(model) {
|
|
1163
|
+
model = typeof model === "string" ? JSON.parse(model) : model;
|
|
1164
|
+
if (!model || !this.#component3D) return;
|
|
1165
|
+
const eventOptions = { bubbles: true, cancelable: false, composed: true };
|
|
1166
|
+
this.dispatchEvent(new CustomEvent("model-loading", eventOptions));
|
|
1167
|
+
try {
|
|
1168
|
+
const detail = await this.#component3D.loadModelIndependent(model);
|
|
1169
|
+
if (detail?.success) {
|
|
1170
|
+
this.dispatchEvent(new CustomEvent("model-loaded", { ...eventOptions, detail }));
|
|
1171
|
+
} else {
|
|
1172
|
+
this.dispatchEvent(new CustomEvent("model-error", { ...eventOptions, detail }));
|
|
1173
|
+
}
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
this.dispatchEvent(new CustomEvent("model-error", { ...eventOptions, detail: { error } }));
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Loads the environment/scene container in parallel with any ongoing model load, bypassing the
|
|
1181
|
+
* task queue. Dispatches "scene-loading" and "scene-loaded" (or "scene-error") events directly.
|
|
1182
|
+
* @public
|
|
1183
|
+
* @param {object|string} scene - Scene payload: { storage, ibl?, visible? }
|
|
1184
|
+
* @returns {Promise<void>}
|
|
1185
|
+
*/
|
|
1186
|
+
async loadSceneParallel(scene) {
|
|
1187
|
+
scene = typeof scene === "string" ? JSON.parse(scene) : scene;
|
|
1188
|
+
if (!scene || !this.#component3D) return;
|
|
1189
|
+
const eventOptions = { bubbles: true, cancelable: false, composed: true };
|
|
1190
|
+
this.dispatchEvent(new CustomEvent("scene-loading", eventOptions));
|
|
1191
|
+
try {
|
|
1192
|
+
const detail = await this.#component3D.loadSceneIndependent(scene);
|
|
1193
|
+
if (detail?.success) {
|
|
1194
|
+
this.dispatchEvent(new CustomEvent("scene-loaded", { ...eventOptions, detail }));
|
|
1195
|
+
} else {
|
|
1196
|
+
this.dispatchEvent(new CustomEvent("scene-error", { ...eventOptions, detail }));
|
|
1197
|
+
}
|
|
1198
|
+
} catch (error) {
|
|
1199
|
+
this.dispatchEvent(new CustomEvent("scene-error", { ...eventOptions, detail: { error } }));
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Loads the materials container in parallel with any ongoing model or environment load,
|
|
1205
|
+
* bypassing the task queue. Dispatches "materials-loading" and "materials-loaded" events.
|
|
1206
|
+
* @public
|
|
1207
|
+
* @param {object|string} materials - Materials payload: { storage }
|
|
1208
|
+
* @returns {Promise<void>}
|
|
1209
|
+
*/
|
|
1210
|
+
async loadMaterialsParallel(materials) {
|
|
1211
|
+
materials = typeof materials === "string" ? JSON.parse(materials) : materials;
|
|
1212
|
+
if (!materials || !this.#component3D) return;
|
|
1213
|
+
const eventOptions = { bubbles: true, cancelable: false, composed: true };
|
|
1214
|
+
this.dispatchEvent(new CustomEvent("materials-loading", eventOptions));
|
|
1215
|
+
try {
|
|
1216
|
+
const detail = await this.#component3D.loadMaterialsIndependent(materials);
|
|
1217
|
+
if (detail?.success) {
|
|
1218
|
+
this.dispatchEvent(new CustomEvent("materials-loaded", { ...eventOptions, detail }));
|
|
1219
|
+
} else {
|
|
1220
|
+
this.dispatchEvent(new CustomEvent("materials-error", { ...eventOptions, detail }));
|
|
1221
|
+
}
|
|
1222
|
+
} catch (error) {
|
|
1223
|
+
this.dispatchEvent(new CustomEvent("materials-error", { ...eventOptions, detail: { error } }));
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1124
1227
|
/**
|
|
1125
1228
|
* Loads a scene/environment object or JSON string and adds it to the task queue.
|
|
1126
1229
|
* @public
|
|
@@ -1265,6 +1368,31 @@ export default class PrefViewer extends HTMLElement {
|
|
|
1265
1368
|
this.#addTaskToQueue(config, "visibility");
|
|
1266
1369
|
}
|
|
1267
1370
|
|
|
1371
|
+
/**
|
|
1372
|
+
* Reflects the current visibility of a container without feeding the task queue.
|
|
1373
|
+
* Used by the Babylon controller to keep host attributes in sync with renderer state.
|
|
1374
|
+
* @public
|
|
1375
|
+
* @param {string} name - Container name ("model" or "environment").
|
|
1376
|
+
* @param {boolean} isVisible - Whether the container should be reflected as visible.
|
|
1377
|
+
* @returns {void}
|
|
1378
|
+
*/
|
|
1379
|
+
syncVisibility(name, isVisible) {
|
|
1380
|
+
const attrName = name === "model" ? "show-model" : name === "environment" ? "show-scene" : null;
|
|
1381
|
+
if (!attrName) {
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const nextValue = isVisible ? "true" : "false";
|
|
1385
|
+
if (this.getAttribute(attrName) === nextValue) {
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
this.#suppressVisibilityAttributeCallback = true;
|
|
1389
|
+
try {
|
|
1390
|
+
this.setAttribute(attrName, nextValue);
|
|
1391
|
+
} finally {
|
|
1392
|
+
this.#suppressVisibilityAttributeCallback = false;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1268
1396
|
/**
|
|
1269
1397
|
* Centers the 2D drawing view in the viewer.
|
|
1270
1398
|
* @public
|
|
@@ -1519,4 +1647,22 @@ export default class PrefViewer extends HTMLElement {
|
|
|
1519
1647
|
get isMode3D() {
|
|
1520
1648
|
return this.#mode === "3d";
|
|
1521
1649
|
}
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Forces the 3D camera to frame the current model, when supported.
|
|
1653
|
+
* @public
|
|
1654
|
+
* @returns {boolean}
|
|
1655
|
+
*/
|
|
1656
|
+
focusModel() {
|
|
1657
|
+
return this.#component3D?.focusModel?.() ?? false;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* Exposes a debug snapshot of the 3D runtime when available.
|
|
1662
|
+
* @public
|
|
1663
|
+
* @returns {object|null}
|
|
1664
|
+
*/
|
|
1665
|
+
getDebugState() {
|
|
1666
|
+
return this.#component3D?.getDebugState?.() ?? null;
|
|
1667
|
+
}
|
|
1522
1668
|
}
|