@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 +5 -3
- package/src/gltf-storage.js +148 -0
- package/src/index.js +411 -221
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preference-sl/pref-viewer",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
38
|
-
"@babylonjs/loaders": "^8.
|
|
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,
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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);
|