@preference-sl/pref-viewer 2.9.3-beta.0 → 2.10.0-beta.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.9.3-beta.0",
3
+ "version": "2.10.0-beta.0",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -34,8 +34,10 @@
34
34
  "index.d.ts"
35
35
  ],
36
36
  "dependencies": {
37
- "@babylonjs/core": "^8.0.0",
38
- "@babylonjs/loaders": "^8.0.0"
37
+ "@babylonjs/core": "^8.28.2",
38
+ "@babylonjs/loaders": "^8.28.2",
39
+ "@babylonjs/serializers": "^8.28.2",
40
+ "babylonjs-gltf2interface": "^8.28.2"
39
41
  },
40
42
  "devDependencies": {
41
43
  "esbuild": "^0.25.10",
@@ -0,0 +1,148 @@
1
+ // Inicializar IndexedDB
2
+ export async function initDb(dbName, storeName) {
3
+ return new Promise((resolve, reject) => {
4
+ const request = indexedDB.open(dbName, 1);
5
+
6
+ request.onerror = () => reject(request.error);
7
+ request.onsuccess = () => {
8
+ window.gltfDB = request.result;
9
+ resolve();
10
+ };
11
+
12
+ request.onupgradeneeded = (event) => {
13
+ const db = event.target.result;
14
+ if (!db.objectStoreNames.contains(storeName)) {
15
+ const store = db.createObjectStore(storeName, { keyPath: "id" });
16
+ store.createIndex("type", "type", { unique: false });
17
+ store.createIndex("timestamp", "timestamp", { unique: false });
18
+ }
19
+ };
20
+ });
21
+ }
22
+
23
+ // Guardar modelo
24
+ export async function saveModel(modelDataStr, storeName) {
25
+ return new Promise((resolve, reject) => {
26
+ if (!window.gltfDB) {
27
+ reject(new Error("Database not initialized"));
28
+ return;
29
+ }
30
+ let modelData = JSON.parse(modelDataStr);
31
+
32
+ const dataToStore = {
33
+ ...modelData,
34
+ data: modelData.data,
35
+ size: modelData.data.length,
36
+ timestamp: new Date().toISOString(),
37
+ };
38
+
39
+ const transaction = window.gltfDB.transaction([storeName], "readwrite");
40
+ const store = transaction.objectStore(storeName);
41
+ const request = store.put(dataToStore);
42
+
43
+ request.onerror = () => reject(request.error);
44
+ request.onsuccess = () => resolve();
45
+ });
46
+ }
47
+
48
+ // Cargar modelo
49
+ export function loadModel(modelId, storeName) {
50
+ return new Promise((resolve, reject) => {
51
+ if (!globalThis.gltfDB) {
52
+ reject(new Error("Database not initialized"));
53
+ return;
54
+ }
55
+ const tx = globalThis.gltfDB.transaction([storeName], "readonly");
56
+ const store = tx.objectStore(storeName);
57
+ const req = store.get(modelId);
58
+ req.onerror = () => reject(req.error);
59
+ req.onsuccess = () => resolve(req.result ?? null);
60
+ });
61
+ }
62
+
63
+ export function downloadFileFromBytes(fileName, bytesBase64, mimeType) {
64
+ const link = document.createElement("a");
65
+ link.download = fileName;
66
+ link.href = `data:${mimeType};base64,${bytesBase64}`;
67
+ document.body.appendChild(link);
68
+ link.click();
69
+ document.body.removeChild(link);
70
+ }
71
+
72
+ // Obtener todos los modelos (solo metadata)
73
+ export async function getAllModels(storeName) {
74
+ return new Promise((resolve, reject) => {
75
+ if (!window.gltfDB) {
76
+ reject(new Error("Database not initialized"));
77
+ return;
78
+ }
79
+
80
+ const transaction = window.gltfDB.transaction([storeName], "readonly");
81
+ const store = transaction.objectStore(storeName);
82
+ const request = store.getAll();
83
+
84
+ request.onerror = () => reject(request.error);
85
+ request.onsuccess = () => {
86
+ // Excluir los datos binarios para evitar transferir demasiados datos
87
+ const results = request.result.map((item) => ({
88
+ id: item.id,
89
+ metadata: item.metadata,
90
+ timestamp: item.timestamp,
91
+ size: item.size,
92
+ type: item.type,
93
+ }));
94
+ resolve(JSON.stringify(results));
95
+ };
96
+ });
97
+ }
98
+
99
+ // Eliminar modelo
100
+ export async function deleteModel(modelId, storeName) {
101
+ return new Promise((resolve, reject) => {
102
+ if (!window.gltfDB) {
103
+ reject(new Error("Database not initialized"));
104
+ return;
105
+ }
106
+
107
+ const transaction = window.gltfDB.transaction([storeName], "readwrite");
108
+ const store = transaction.objectStore(storeName);
109
+ const request = store.delete(modelId);
110
+
111
+ request.onerror = () => reject(request.error);
112
+ request.onsuccess = () => resolve();
113
+ });
114
+ }
115
+
116
+ // Limpiar toda la base de datos
117
+ export async function clearAll(storeName) {
118
+ return new Promise((resolve, reject) => {
119
+ if (!window.gltfDB) {
120
+ reject(new Error("Database not initialized"));
121
+ return;
122
+ }
123
+
124
+ const transaction = window.gltfDB.transaction([storeName], "readwrite");
125
+ const store = transaction.objectStore(storeName);
126
+ const request = store.clear();
127
+
128
+ request.onerror = () => reject(request.error);
129
+ request.onsuccess = () => resolve();
130
+ });
131
+ }
132
+
133
+ (function attachPublicAPI(global) {
134
+ const root = (global.PrefConfigurator ??= {});
135
+ const storage = {
136
+ initDb,
137
+ saveModel,
138
+ loadModel,
139
+ getAllModels,
140
+ deleteModel,
141
+ clearAll,
142
+ downloadFileFromBytes,
143
+ };
144
+
145
+ // versionado del módulo público
146
+ root.version = root.version ?? "1.0.0";
147
+ root.storage = Object.freeze(storage);
148
+ })(globalThis);
package/src/index.js CHANGED
@@ -29,241 +29,431 @@
29
29
  * </pref-viewer>
30
30
  * ```
31
31
  */
32
- import { Engine, Scene, ArcRotateCamera, Vector3, SceneLoader, Color4, HemisphericLight, DirectionalLight, PointLight, ShadowGenerator } from "@babylonjs/core";
32
+ import { Engine, Scene, ArcRotateCamera, Vector3, Color4, HemisphericLight, DirectionalLight, PointLight, ShadowGenerator, LoadAssetContainerAsync, Tools } from "@babylonjs/core";
33
33
  import "@babylonjs/loaders";
34
+ import { USDZExportAsync, GLTF2Export } from "@babylonjs/serializers";
34
35
  import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_draco_mesh_compression";
35
36
  import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression";
37
+ import { initDb, loadModel } from "./gltf-storage.js";
38
+
36
39
  class PrefViewer extends HTMLElement {
37
- constructor() {
38
- super();
39
- this.attachShadow({ mode: "open" });
40
- this._createCanvas();
41
- this._wrapCanvas();
42
- // Point to whichever version you packaged or want to use:
43
- const DRACO_BASE = "https://www.gstatic.com/draco/versioned/decoders/1.5.7";
44
- DracoCompression.Configuration.decoder = {
45
- // loader for the “wrapper” that pulls in the real WASM
46
- wasmUrl: `${DRACO_BASE}/draco_wasm_wrapper_gltf.js`,
47
- // the raw WebAssembly binary
48
- wasmBinaryUrl: `${DRACO_BASE}/draco_decoder_gltf.wasm`,
49
- // JS fallback if WASM isn’t available
50
- fallbackUrl: `${DRACO_BASE}/draco_decoder_gltf.js`,
40
+ #initialized = false;
41
+
42
+ #model = {
43
+ container: null,
44
+ show: true,
45
+ storage: null,
46
+ visible: false,
51
47
  };
52
48
 
49
+ #environment = {
50
+ container: null,
51
+ show: true,
52
+ storage: null,
53
+ visible: false,
54
+ };
55
+
56
+ #canvas = null;
57
+
53
58
  // Babylon.js core objects
54
- this.engine = null;
55
- this.scene = null;
56
- this.camera = null;
57
- this.hemiLight = null;
58
- this.dirLight = null;
59
- this.cameraLight = null;
60
- this.shadowGen = null;
61
- this._onWindowResize = null;
62
-
63
- // Model sources
64
- this.modelUrl = null;
65
- this.modelBase64 = null;
66
-
67
- this._initialized = false;
68
- this._urlHooked = false;
69
- }
70
-
71
- static get observedAttributes() {
72
- return ["model", "model-data"];
73
- }
74
-
75
- attributeChangedCallback(name, _old, value) {
76
- if (name === "model") {
77
- this.modelUrl = value;
78
- this.modelBase64 = null;
79
- this._initialized && this._reloadModel();
80
- } else if (name === "model-data") {
81
- this.modelBase64 = value;
82
- this.modelUrl = null;
83
- this._initialized && this._reloadModel();
59
+ #engine = null;
60
+ #scene = null;
61
+ #camera = null;
62
+ #hemiLight = null;
63
+ #dirLight = null;
64
+ #cameraLight = null;
65
+ #shadowGen = null;
66
+
67
+ constructor() {
68
+ super();
69
+ this.attachShadow({ mode: "open" });
70
+ this.#createCanvas();
71
+ this.#wrapCanvas();
72
+ // Point to whichever version you packaged or want to use:
73
+ const DRACO_BASE = "https://www.gstatic.com/draco/versioned/decoders/1.5.7";
74
+ DracoCompression.Configuration.decoder = {
75
+ // loader for the “wrapper” that pulls in the real WASM
76
+ wasmUrl: `${DRACO_BASE}/draco_wasm_wrapper_gltf.js`,
77
+ // the raw WebAssembly binary
78
+ wasmBinaryUrl: `${DRACO_BASE}/draco_decoder_gltf.wasm`,
79
+ // JS fallback if WASM isn’t available
80
+ fallbackUrl: `${DRACO_BASE}/draco_decoder_gltf.js`,
81
+ };
82
+ }
83
+
84
+ static get observedAttributes() {
85
+ return ["config", "model", "scene", "show-model", "show-scene"];
86
+ }
87
+
88
+ attributeChangedCallback(name, _old, value) {
89
+ let data = null;
90
+ switch (name) {
91
+ case "config":
92
+ data = JSON.parse(value);
93
+ if (!data) {
94
+ return false;
95
+ }
96
+ this.#model.storage = data.model?.storage || null;
97
+ this.#model.show = data.model?.visible || true;
98
+ this.#environment.storage = data.scene?.storage || null;
99
+ this.#environment.show = data.scene?.visible || true;
100
+ this.#initialized && this.#loadContainers(true, true);
101
+ break;
102
+ case "model":
103
+ data = JSON.parse(value);
104
+ if (!data) {
105
+ return false;
106
+ }
107
+ this.#model.storage = data.model?.storage || null;
108
+ this.#model.show = data.model?.visible || true;
109
+ this.#initialized && this.#loadContainers(true, false);
110
+ break;
111
+ case "scene":
112
+ data = JSON.parse(value);
113
+ if (!data) {
114
+ return false;
115
+ }
116
+ this.#environment.storage = data.scene?.storage || null;
117
+ this.#environment.show = data.scene?.visible || true;
118
+ this.#initialized && this.#loadContainers(false, true);
119
+ break;
120
+ case "show-model":
121
+ data = value.toLowerCase?.() === "true";
122
+ if (this.#initialized) {
123
+ data ? this.showModel() : this.hideModel();
124
+ } else {
125
+ this.#model.show = data;
126
+ }
127
+ break;
128
+ case "show-scene":
129
+ data = value.toLowerCase?.() === "true";
130
+ if (this.#initialized) {
131
+ data ? this.showScene() : this.hideScene();
132
+ } else {
133
+ this.#environment.show = data;
134
+ }
135
+ break;
136
+ }
137
+ }
138
+
139
+ connectedCallback() {
140
+ if (!this.hasAttribute("config")) {
141
+ const error = 'PrefViewer: provide "models" as array of model and environment';
142
+ console.error(error);
143
+ this.dispatchEvent(
144
+ new CustomEvent("model-error", {
145
+ detail: { error: new Error(error) },
146
+ bubbles: true,
147
+ composed: true,
148
+ })
149
+ );
150
+ return false;
151
+ }
152
+
153
+ this.#initializeBabylon();
154
+ this.#loadContainers(true, true);
155
+ this.#initialized = true;
156
+ }
157
+
158
+ disconnectedCallback() {
159
+ this.#disposeEngine();
160
+ window.removeEventListener("resize", this.#onWindowResize);
161
+ }
162
+
163
+ // Web Component
164
+ #createCanvas() {
165
+ this.#canvas = document.createElement("canvas");
166
+ Object.assign(this.#canvas.style, {
167
+ width: "100%",
168
+ height: "100%",
169
+ display: "block",
170
+ outline: "none",
171
+ });
172
+ }
173
+
174
+ #wrapCanvas() {
175
+ const wrapper = document.createElement("div");
176
+ Object.assign(wrapper.style, {
177
+ width: "100%",
178
+ height: "100%",
179
+ position: "relative",
180
+ });
181
+ wrapper.appendChild(this.#canvas);
182
+ this.shadowRoot.append(wrapper);
183
+ }
184
+
185
+ // Bbylon.js
186
+ #initializeBabylon() {
187
+ this.#engine = new Engine(this.#canvas, true, { alpha: true });
188
+ this.#scene = new Scene(this.#engine);
189
+ this.#scene.clearColor = new Color4(1, 1, 1, 1);
190
+ this.#createCamera();
191
+ this.#createLights();
192
+ this.#setupInteraction();
193
+
194
+ this.#engine.runRenderLoop(() => this.#scene && this.#scene.render());
195
+ window.addEventListener("resize", this.#onWindowResize);
196
+ }
197
+
198
+ #onWindowResize() {
199
+ this.#engine && this.#engine.resize();
200
+ }
201
+
202
+ #createCamera() {
203
+ this.#camera = new ArcRotateCamera("camera", Math.PI / 2, Math.PI / 3, 10, Vector3.Zero(), this.#scene);
204
+ this.#camera.attachControl(this.#canvas, true);
205
+ }
206
+
207
+ #createLights() {
208
+ // 1) Stronger ambient fill
209
+ this.#hemiLight = new HemisphericLight("hemiLight", new Vector3(-10, 10, -10), this.#scene);
210
+ this.#hemiLight.intensity = 0.6;
211
+
212
+ // 2) Directional light from the front-right, angled slightly down
213
+ this.#dirLight = new DirectionalLight("dirLight", new Vector3(-10, 10, -10), this.#scene);
214
+ this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
215
+ this.#dirLight.intensity = 0.6;
216
+
217
+ // 3) Soft shadows
218
+ this.#shadowGen = new ShadowGenerator(1024, this.#dirLight);
219
+ this.#shadowGen.useBlurExponentialShadowMap = true;
220
+ this.#shadowGen.blurKernel = 16;
221
+ this.#shadowGen.darkness = 0.5;
222
+
223
+ // 4) Camera‐attached headlight
224
+ this.#cameraLight = new PointLight("pl", this.#camera.position, this.#scene);
225
+ this.#cameraLight.parent = this.#camera;
226
+ this.#cameraLight.intensity = 0.3;
227
+ }
228
+
229
+ #setupInteraction() {
230
+ this.#canvas.addEventListener("wheel", (event) => {
231
+ if (!this.#scene || !this.#camera) return;
232
+ const pick = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
233
+ this.#camera.target = pick.hit ? pick.pickedPoint.clone() : this.#camera.target;
234
+ this.#camera.inertialRadiusOffset += event.deltaY * this.#camera.wheelPrecision * 0.01;
235
+ event.preventDefault();
236
+ });
237
+ }
238
+
239
+ #disposeEngine() {
240
+ if (!this.#engine) return;
241
+ this.#engine.dispose();
242
+ this.#engine = this.#scene = this.#camera = null;
243
+ this.#hemiLight = this.#dirLight = this.#cameraLight = null;
244
+ this.#shadowGen = null;
245
+ }
246
+
247
+ // Utility methods for loading gltf/glb
248
+ #transformUrl(url) {
249
+ return new Promise((resolve) => {
250
+ resolve(url.replace(/^blob:[^/]+\//i, "").replace(/\\/g, "/"));
251
+ });
252
+ }
253
+
254
+ #decodeBase64(base64) {
255
+ const [, payload] = base64.split(",");
256
+ const raw = payload || base64;
257
+ let decoded = "";
258
+ let blob = null;
259
+ let extension = null;
260
+ try {
261
+ decoded = atob(raw);
262
+ } catch {
263
+ return { blob, extension };
264
+ }
265
+ let isJson = false;
266
+ try {
267
+ JSON.parse(decoded);
268
+ isJson = true;
269
+ } catch {}
270
+ extension = isJson ? ".gltf" : ".glb";
271
+ const type = isJson ? "model/gltf+json" : "model/gltf-binary";
272
+ const array = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
273
+ blob = new Blob([array], { type });
274
+ return { blob, extension };
275
+ }
276
+
277
+ async #initStorage(db, table) {
278
+ if (window.gltfDB && window.gltfDB.name === db && window.gltfDB.objectStoreNames.contains(table)) {
279
+ return true;
280
+ }
281
+ await initDb(db, table);
282
+ }
283
+
284
+ // Methods for managing Asset Containers
285
+ #setVisibilityOfWallAndFloorInModel(show) {
286
+ if (this.#model.container && this.#model.visible) {
287
+ const names = ["outer_0", "inner_1", "outerFloor", "innerFloor"];
288
+ const nodes = this.#model.container.getNodes();
289
+ this.#model.container
290
+ .getNodes()
291
+ .filter((filter) => names.includes(filter.name))
292
+ .forEach((node) => node.setEnabled(show !== undefined ? show : this.#environment.show));
293
+ }
294
+ }
295
+
296
+ #addContainer(group) {
297
+ if (group.container && !group.visible && group.show) {
298
+ group.container.addAllToScene();
299
+ group.visible = true;
300
+ }
301
+ }
302
+
303
+ #removeContainer(group) {
304
+ if (group.container && group.visible) {
305
+ group.container.removeAllFromScene();
306
+ group.visible = false;
307
+ }
308
+ }
309
+
310
+ #replaceContainer(group, newContainer) {
311
+ this.#removeContainer(group);
312
+ group.container = newContainer;
313
+ group.container.meshes.forEach((mesh) => {
314
+ mesh.receiveShadows = true;
315
+ this.#shadowGen.addShadowCaster(mesh, true);
316
+ });
317
+ this.#addContainer(group);
318
+ }
319
+
320
+ async #loadAssetContainer(storage) {
321
+ if (!storage) {
322
+ return false;
323
+ }
324
+
325
+ let source = storage.url || null;
326
+
327
+ if (storage.db && storage.table && storage.id) {
328
+ await this.#initStorage(storage.db, storage.table);
329
+ const object = await loadModel(storage.id, storage.table);
330
+ source = object.data;
331
+ }
332
+
333
+ if (!source) {
334
+ return false;
335
+ }
336
+
337
+ let file = null;
338
+
339
+ let { blob, extension } = this.#decodeBase64(source);
340
+ if (blob && extension) {
341
+ file = new File([blob], `model${extension}`, {
342
+ type: blob.type,
343
+ });
344
+ } else {
345
+ const extMatch = source.match(/\.(gltf|glb)(\?|#|$)/i);
346
+ extension = extMatch ? `.${extMatch[1].toLowerCase()}` : ".gltf";
347
+ }
348
+
349
+ let options = {
350
+ pluginExtension: extension,
351
+ pluginOptions: {
352
+ gltf: {
353
+ preprocessUrlAsync: this.#transformUrl,
354
+ },
355
+ },
356
+ };
357
+
358
+ return LoadAssetContainerAsync(file || source, this.#scene, options);
359
+ }
360
+
361
+ async #loadContainers(loadModel = true, loadEnvironment = true) {
362
+ const promiseArray = [];
363
+
364
+ promiseArray.push(loadModel ? this.#loadAssetContainer(this.#model.storage) : false);
365
+ promiseArray.push(loadEnvironment ? this.#loadAssetContainer(this.#environment.storage) : false);
366
+
367
+ Promise.allSettled(promiseArray)
368
+ .then(async (values) => {
369
+ const modelContainer = values[0];
370
+ const environmentContainer = values[1];
371
+
372
+ if (modelContainer.status === "fulfilled" && modelContainer.value) {
373
+ this.#replaceContainer(this.#model, modelContainer.value);
374
+ }
375
+
376
+ if (environmentContainer.status === "fulfilled" && environmentContainer.value) {
377
+ this.#replaceContainer(this.#environment, environmentContainer.value);
378
+ }
379
+
380
+ this.#scene.createDefaultCamera(true, true, true);
381
+ this.#setVisibilityOfWallAndFloorInModel();
382
+
383
+ this.dispatchEvent(
384
+ new CustomEvent("model-loaded", {
385
+ detail: { success: "" },
386
+ bubbles: true,
387
+ composed: true,
388
+ })
389
+ );
390
+ })
391
+ .catch((error) => {
392
+ console.error("PrefViewer: failed to load model", error);
393
+ this.dispatchEvent(
394
+ new CustomEvent("model-error", {
395
+ detail: { error: error },
396
+ bubbles: true,
397
+ composed: true,
398
+ })
399
+ );
400
+ });
401
+ }
402
+
403
+ // Public Methods
404
+ showModel() {
405
+ this.#model.show = true;
406
+ this.#addContainer(this.#model);
407
+ }
408
+
409
+ hideModel() {
410
+ this.#model.show = false;
411
+ this.#removeContainer(this.#model);
412
+ }
413
+
414
+ showScene() {
415
+ this.#environment.show = true;
416
+ this.#addContainer(this.#environment);
417
+ this.#setVisibilityOfWallAndFloorInModel();
418
+ }
419
+
420
+ hideScene() {
421
+ this.#environment.show = false;
422
+ this.#removeContainer(this.#environment);
423
+ this.#setVisibilityOfWallAndFloorInModel();
424
+ }
425
+
426
+ downloadModelGLB() {
427
+ const fileName = "model";
428
+ GLTF2Export.GLBAsync(this.#model.container, fileName, { exportWithoutWaitingForScene: true }).then((glb) => {
429
+ glb.downloadFiles();
430
+ });
84
431
  }
85
- }
86
432
 
87
- connectedCallback() {
88
- if (!this._urlHooked) {
89
- this._setupUrlPreprocessing();
90
- this._urlHooked = true;
433
+ downloadModelUSDZ() {
434
+ const fileName = "model";
435
+ USDZExportAsync(this.#model.container).then((response) => {
436
+ if (response) {
437
+ Tools.Download(new Blob([response], { type: "model/vnd.usdz+zip" }), `${fileName}.usdz`);
438
+ }
439
+ });
91
440
  }
92
441
 
93
- if (!this.hasAttribute("model") && !this.hasAttribute("model-data")) {
94
- const err = 'PrefViewer: provide either "model" or "model-data" attribute.';
95
- console.error(err);
96
- this.dispatchEvent(
97
- new CustomEvent("model-error", { detail: { error: new Error(err) }, bubbles: true, composed: true })
98
- );
99
- return;
442
+ downloadModelAndSceneUSDZ() {
443
+ const fileName = "scene";
444
+ USDZExportAsync(this.#scene).then((response) => {
445
+ if (response) {
446
+ Tools.Download(new Blob([response], { type: "model/vnd.usdz+zip" }), `${fileName}.usdz`);
447
+ }
448
+ });
100
449
  }
101
450
 
102
- this.modelUrl = this.getAttribute("model");
103
- this.modelBase64 = this.getAttribute("model-data");
104
-
105
- this._initializeBabylon();
106
- this._initialized = true;
107
- this._reloadModel();
108
- }
109
-
110
- disconnectedCallback() {
111
- this._disposeEngine();
112
- this._onWindowResize && window.removeEventListener("resize", this._onWindowResize);
113
- }
114
-
115
- _setupUrlPreprocessing() {
116
- const transformUrl = (url) =>
117
- url.replace(/^blob:[^/]+\//i, "").replace(/\\/g, "/");
118
- SceneLoader.OnPluginActivatedObservable.add((plugin) => {
119
- if (plugin.name === "gltf" || plugin.name === "gltf2") {
120
- plugin.preprocessUrl = transformUrl;
121
- plugin.preprocessUrlAsync = (url) => Promise.resolve(transformUrl(url));
122
- }
123
- });
124
- }
125
-
126
- _createCanvas() {
127
- this.canvas = document.createElement("canvas");
128
- Object.assign(this.canvas.style, { width: "100%", height: "100%", display: "block" });
129
- }
130
-
131
- _wrapCanvas() {
132
- const wrapper = document.createElement("div");
133
- Object.assign(wrapper.style, { width: "100%", height: "100%", position: "relative" });
134
- wrapper.appendChild(this.canvas);
135
- this.shadowRoot.append(wrapper);
136
- }
137
-
138
- _initializeBabylon() {
139
- this.engine = new Engine(this.canvas, true, { alpha: true });
140
- this.scene = new Scene(this.engine);
141
- this.scene.clearColor = new Color4(1, 1, 1, 1);
142
- this._createCamera();
143
- this._createLights();
144
- this._setupInteraction();
145
-
146
- this.engine.runRenderLoop(() => this.scene && this.scene.render());
147
- this._onWindowResize = () => this.engine && this.engine.resize();
148
- window.addEventListener("resize", this._onWindowResize);
149
- }
150
-
151
- _createCamera() {
152
- this.camera = new ArcRotateCamera(
153
- "camera",
154
- Math.PI / 2,
155
- Math.PI / 3,
156
- 10,
157
- Vector3.Zero(),
158
- this.scene
159
- );
160
- this.camera.attachControl(this.canvas, true);
161
- }
162
-
163
- _createLights() {
164
- // 1) Stronger ambient fill
165
- this.hemiLight = new HemisphericLight(
166
- "hemiLight",
167
- new Vector3(-10, 10, -10),
168
- this.scene
169
- );
170
- this.hemiLight.intensity = 0.6;
171
-
172
- // 2) Directional light from the front-right, angled slightly down
173
- this.dirLight = new DirectionalLight(
174
- "dirLight",
175
- new Vector3(-10, 10, -10),
176
- this.scene
177
- );
178
- this.dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
179
- this.dirLight.intensity = 0.6;
180
-
181
- // 3) Soft shadows
182
- this.shadowGen = new ShadowGenerator(1024, this.dirLight);
183
- this.shadowGen.useBlurExponentialShadowMap = true;
184
- this.shadowGen.blurKernel = 16;
185
- this.shadowGen.darkness = 0.5;
186
-
187
- // 4) Camera‐attached headlight
188
- this.cameraLight = new PointLight(
189
- "pl",
190
- this.camera.position,
191
- this.scene
192
- );
193
- this.cameraLight.parent = this.camera;
194
- this.cameraLight.intensity = 0.3;
195
- }
196
-
197
- _setupInteraction() {
198
- this.canvas.addEventListener("wheel", (evt) => {
199
- if (!this.scene || !this.camera) return;
200
- const pick = this.scene.pick(this.scene.pointerX, this.scene.pointerY);
201
- this.camera.target = pick.hit ? pick.pickedPoint.clone() : this.camera.target;
202
- this.camera.inertialRadiusOffset += evt.deltaY * this.camera.wheelPrecision * 0.01;
203
- evt.preventDefault();
204
- });
205
- }
206
-
207
- async _reloadModel() {
208
- if (!this.scene || (!this.modelUrl && !this.modelBase64)) return;
209
- this._disposePreviousMeshes();
210
-
211
- try {
212
- let result;
213
- if (this.modelBase64) {
214
- const { blob, extension } = this._decodeBase64(this.modelBase64);
215
- const file = new File([blob], `model${extension}`, { type: blob.type });
216
- result = await SceneLoader.ImportMeshAsync(null, "", file, this.scene, undefined, extension);
217
- } else {
218
- const extMatch = this.modelUrl.match(/\.(gltf|glb)(\?|#|$)/i);
219
- const extension = extMatch ? `.${extMatch[1].toLowerCase()}` : ".gltf";
220
- result = await SceneLoader.ImportMeshAsync(null, "", this.modelUrl, this.scene, undefined, extension);
221
- }
222
-
223
- this.scene.createDefaultCamera(true, true, true);
224
-
225
- // Hook up our soft shadows to every mesh
226
- result.meshes.forEach((mesh) => {
227
- mesh.receiveShadows = true;
228
- this.shadowGen.addShadowCaster(mesh, true);
229
- });
230
-
231
- this.dispatchEvent(
232
- new CustomEvent("model-loaded", { detail: result, bubbles: true, composed: true })
233
- );
234
- } catch (err) {
235
- console.error("PrefViewer: failed to load model", err);
236
- this.dispatchEvent(
237
- new CustomEvent("model-error", { detail: { error: err }, bubbles: true, composed: true })
238
- );
451
+ downloadModelAndSceneGLB() {
452
+ const fileName = "scene";
453
+ GLTF2Export.GLBAsync(this.#scene, fileName, { exportWithoutWaitingForScene: true }).then((glb) => {
454
+ glb.downloadFiles();
455
+ });
239
456
  }
240
- }
241
-
242
- _disposePreviousMeshes() {
243
- if (!this.scene) return;
244
- this.scene.meshes.slice().forEach((m) => m.getClassName() === "Mesh" && m.dispose());
245
- }
246
-
247
- _decodeBase64(base64) {
248
- const [, payload] = base64.split(",");
249
- const raw = payload || base64;
250
- const decoded = atob(raw);
251
- let isJson = false;
252
- try { JSON.parse(decoded); isJson = true; } catch { }
253
- const extension = isJson ? ".gltf" : ".glb";
254
- const type = isJson ? "model/gltf+json" : "model/gltf-binary";
255
- const array = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
256
- const blob = new Blob([array], { type });
257
- return { blob, extension };
258
- }
259
-
260
- _disposeEngine() {
261
- if (!this.engine) return;
262
- this.engine.dispose();
263
- this.engine = this.scene = this.camera = null;
264
- this.hemiLight = this.dirLight = this.cameraLight = null;
265
- this.shadowGen = null;
266
- }
267
457
  }
268
458
 
269
459
  customElements.define("pref-viewer", PrefViewer);