@preference-sl/pref-viewer 2.5.4 → 2.6.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 +1 -1
- package/src/index.js +62 -155
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -6,25 +6,28 @@
|
|
|
6
6
|
* Overview
|
|
7
7
|
* --------
|
|
8
8
|
* `PrefViewer` is a self-contained Web Component built with Babylon.js that:
|
|
9
|
-
* •
|
|
10
|
-
* •
|
|
11
|
-
* •
|
|
12
|
-
* •
|
|
13
|
-
* • Dispatches “model-loaded” and “model-error” CustomEvents so host pages can react.
|
|
9
|
+
* • Renders glTF or GLB models in a <canvas> inside its shadow DOM.
|
|
10
|
+
* • Supports loading via remote URLs (`model` attribute) or Base64 data URIs (`model-data` attribute).
|
|
11
|
+
* • Automatically handles scene creation (engine, camera, lighting) and resource cleanup.
|
|
12
|
+
* • Emits `model-loaded` and `model-error` events for integration.
|
|
14
13
|
*
|
|
15
14
|
* Usage
|
|
16
15
|
* -----
|
|
16
|
+
* Load from a URL:
|
|
17
|
+
* ```html
|
|
17
18
|
* <pref-viewer
|
|
18
|
-
* model="https://example.com/models/myModel.
|
|
19
|
+
* model="https://example.com/models/myModel.glb"
|
|
19
20
|
* style="width:800px; height:600px;">
|
|
20
21
|
* </pref-viewer>
|
|
22
|
+
* ```
|
|
21
23
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
+
* Load from Base64 data:
|
|
25
|
+
* ```html
|
|
24
26
|
* <pref-viewer
|
|
25
27
|
* model-data="data:application/gltf+json;base64,UEsDB..."
|
|
26
28
|
* style="width:800px; height:600px;">
|
|
27
29
|
* </pref-viewer>
|
|
30
|
+
* ```
|
|
28
31
|
*/
|
|
29
32
|
import {
|
|
30
33
|
Engine,
|
|
@@ -53,84 +56,60 @@ class PrefViewer extends HTMLElement {
|
|
|
53
56
|
this.dirLight = null;
|
|
54
57
|
this._onWindowResize = null;
|
|
55
58
|
|
|
56
|
-
// Model
|
|
59
|
+
// Model sources
|
|
57
60
|
this.modelUrl = null;
|
|
58
61
|
this.modelBase64 = null;
|
|
59
62
|
|
|
60
|
-
this.
|
|
61
|
-
this.
|
|
63
|
+
this._initialized = false;
|
|
64
|
+
this._urlHooked = false;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
static get observedAttributes() {
|
|
65
68
|
return ["model", "model-data"];
|
|
66
69
|
}
|
|
67
70
|
|
|
68
|
-
attributeChangedCallback(name,
|
|
71
|
+
attributeChangedCallback(name, _old, value) {
|
|
69
72
|
if (name === "model") {
|
|
70
|
-
this.modelUrl =
|
|
73
|
+
this.modelUrl = value;
|
|
71
74
|
this.modelBase64 = null;
|
|
72
|
-
|
|
75
|
+
this._initialized && this._reloadModel();
|
|
73
76
|
} else if (name === "model-data") {
|
|
74
|
-
this.modelBase64 =
|
|
77
|
+
this.modelBase64 = value;
|
|
75
78
|
this.modelUrl = null;
|
|
76
|
-
|
|
79
|
+
this._initialized && this._reloadModel();
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
connectedCallback() {
|
|
81
|
-
|
|
82
|
-
if (!this._pluginHookSetup) {
|
|
84
|
+
if (!this._urlHooked) {
|
|
83
85
|
this._setupUrlPreprocessing();
|
|
84
|
-
this.
|
|
86
|
+
this._urlHooked = true;
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
// Require either a model URL or Base64 data
|
|
88
89
|
if (!this.hasAttribute("model") && !this.hasAttribute("model-data")) {
|
|
89
|
-
const
|
|
90
|
-
console.error(
|
|
90
|
+
const err = 'PrefViewer: provide either "model" or "model-data" attribute.';
|
|
91
|
+
console.error(err);
|
|
91
92
|
this.dispatchEvent(
|
|
92
|
-
new CustomEvent("model-error", {
|
|
93
|
-
detail: { error: new Error(errorMsg) },
|
|
94
|
-
bubbles: true,
|
|
95
|
-
composed: true
|
|
96
|
-
})
|
|
93
|
+
new CustomEvent("model-error", { detail: { error: new Error(err) }, bubbles: true, composed: true })
|
|
97
94
|
);
|
|
98
95
|
return;
|
|
99
96
|
}
|
|
100
97
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
if (this.hasAttribute("model-data")) {
|
|
105
|
-
this.modelBase64 = this.getAttribute("model-data");
|
|
106
|
-
}
|
|
98
|
+
this.modelUrl = this.getAttribute("model");
|
|
99
|
+
this.modelBase64 = this.getAttribute("model-data");
|
|
107
100
|
|
|
108
|
-
// Initialize Babylon (engine + scene + camera + lights + hooks)
|
|
109
101
|
this._initializeBabylon();
|
|
110
|
-
this.
|
|
111
|
-
|
|
112
|
-
// Load the specified model
|
|
102
|
+
this._initialized = true;
|
|
113
103
|
this._reloadModel();
|
|
114
104
|
}
|
|
115
105
|
|
|
116
106
|
disconnectedCallback() {
|
|
117
107
|
this._disposeEngine();
|
|
118
|
-
|
|
119
|
-
window.removeEventListener("resize", this._onWindowResize);
|
|
120
|
-
}
|
|
108
|
+
this._onWindowResize && window.removeEventListener("resize", this._onWindowResize);
|
|
121
109
|
}
|
|
122
110
|
|
|
123
|
-
// ====== URL Preprocessing ======
|
|
124
111
|
_setupUrlPreprocessing() {
|
|
125
|
-
const transformUrl = (url) =>
|
|
126
|
-
const stripped = url.replace(
|
|
127
|
-
/^blob:(?:http|https|file):\/\/[^\/]+\/(.+)/i,
|
|
128
|
-
"$1"
|
|
129
|
-
);
|
|
130
|
-
const fixedSlashes = stripped.replace(/\\/g, "/");
|
|
131
|
-
return /^https?:\/\//i.test(fixedSlashes) ? fixedSlashes : fixedSlashes;
|
|
132
|
-
};
|
|
133
|
-
|
|
112
|
+
const transformUrl = (url) => url.replace(/^blob:[^/]+\//i, "").replace(/\\/g, "/");
|
|
134
113
|
SceneLoader.OnPluginActivatedObservable.add((plugin) => {
|
|
135
114
|
if (plugin.name === "gltf" || plugin.name === "gltf2") {
|
|
136
115
|
plugin.preprocessUrl = transformUrl;
|
|
@@ -139,23 +118,14 @@ class PrefViewer extends HTMLElement {
|
|
|
139
118
|
});
|
|
140
119
|
}
|
|
141
120
|
|
|
142
|
-
// ====== Setup Helpers ======
|
|
143
121
|
_createCanvas() {
|
|
144
122
|
this.canvas = document.createElement("canvas");
|
|
145
|
-
Object.assign(this.canvas.style, {
|
|
146
|
-
width: "100%",
|
|
147
|
-
height: "100%",
|
|
148
|
-
display: "block"
|
|
149
|
-
});
|
|
123
|
+
Object.assign(this.canvas.style, { width: "100%", height: "100%", display: "block" });
|
|
150
124
|
}
|
|
151
125
|
|
|
152
126
|
_wrapCanvas() {
|
|
153
127
|
const wrapper = document.createElement("div");
|
|
154
|
-
Object.assign(wrapper.style, {
|
|
155
|
-
width: "100%",
|
|
156
|
-
height: "100%",
|
|
157
|
-
position: "relative"
|
|
158
|
-
});
|
|
128
|
+
Object.assign(wrapper.style, { width: "100%", height: "100%", position: "relative" });
|
|
159
129
|
wrapper.appendChild(this.canvas);
|
|
160
130
|
this.shadowRoot.append(wrapper);
|
|
161
131
|
}
|
|
@@ -164,10 +134,9 @@ class PrefViewer extends HTMLElement {
|
|
|
164
134
|
this.engine = new Engine(this.canvas, true, { alpha: true });
|
|
165
135
|
this.scene = new Scene(this.engine);
|
|
166
136
|
this.scene.clearColor = new Color4(1, 1, 1, 1);
|
|
167
|
-
|
|
168
137
|
this._createCamera();
|
|
169
138
|
this._createLights();
|
|
170
|
-
this.
|
|
139
|
+
this._setupInteraction();
|
|
171
140
|
|
|
172
141
|
this.engine.runRenderLoop(() => this.scene && this.scene.render());
|
|
173
142
|
this._onWindowResize = () => this.engine && this.engine.resize();
|
|
@@ -187,84 +156,47 @@ class PrefViewer extends HTMLElement {
|
|
|
187
156
|
}
|
|
188
157
|
|
|
189
158
|
_createLights() {
|
|
190
|
-
this.hemiLight = new HemisphericLight(
|
|
191
|
-
"hemiLight",
|
|
192
|
-
new Vector3(0, 1, 0),
|
|
193
|
-
this.scene
|
|
194
|
-
);
|
|
159
|
+
this.hemiLight = new HemisphericLight("hemiLight", new Vector3(0, 1, 0), this.scene);
|
|
195
160
|
this.hemiLight.intensity = 0.6;
|
|
196
|
-
this.dirLight = new DirectionalLight(
|
|
197
|
-
"dirLight",
|
|
198
|
-
new Vector3(-0.5, -1, -0.5),
|
|
199
|
-
this.scene
|
|
200
|
-
);
|
|
161
|
+
this.dirLight = new DirectionalLight("dirLight", new Vector3(-0.5, -1, -0.5), this.scene);
|
|
201
162
|
this.dirLight.position = new Vector3(0, 5, 0);
|
|
202
163
|
this.dirLight.intensity = 0.8;
|
|
203
164
|
}
|
|
204
165
|
|
|
205
|
-
|
|
166
|
+
_setupInteraction() {
|
|
206
167
|
this.canvas.addEventListener("wheel", (evt) => {
|
|
207
168
|
if (!this.scene || !this.camera) return;
|
|
208
169
|
const pick = this.scene.pick(this.scene.pointerX, this.scene.pointerY);
|
|
209
|
-
this.camera.target = pick.hit
|
|
210
|
-
? pick.pickedPoint.clone()
|
|
211
|
-
: this.camera.target;
|
|
170
|
+
this.camera.target = pick.hit ? pick.pickedPoint.clone() : this.camera.target;
|
|
212
171
|
this.camera.inertialRadiusOffset += evt.deltaY * this.camera.wheelPrecision * 0.01;
|
|
213
172
|
evt.preventDefault();
|
|
214
173
|
});
|
|
215
174
|
}
|
|
216
175
|
|
|
217
|
-
// ====== Model Management ======
|
|
218
176
|
async _reloadModel() {
|
|
219
|
-
if (!this.scene || (!this.modelUrl && !this.modelBase64))
|
|
220
|
-
console.warn("PrefViewer: _reloadModel aborted (no scene or no model)");
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
177
|
+
if (!this.scene || (!this.modelUrl && !this.modelBase64)) return;
|
|
223
178
|
this._disposePreviousMeshes();
|
|
224
179
|
|
|
225
180
|
try {
|
|
226
181
|
let result;
|
|
227
182
|
if (this.modelBase64) {
|
|
228
|
-
const blob = this.
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
const file = new File([blob], fileName, { type: blob.type });
|
|
232
|
-
console.log('[PrefViewer] Importing from File:', fileName, blob);
|
|
233
|
-
result = await SceneLoader.ImportMeshAsync(
|
|
234
|
-
null,
|
|
235
|
-
"",
|
|
236
|
-
file,
|
|
237
|
-
this.scene,
|
|
238
|
-
undefined,
|
|
239
|
-
ext
|
|
240
|
-
);
|
|
183
|
+
const { blob, extension } = this._decodeBase64(this.modelBase64);
|
|
184
|
+
const file = new File([blob], `model${extension}`, { type: blob.type });
|
|
185
|
+
result = await SceneLoader.ImportMeshAsync(null, "", file, this.scene, undefined, extension);
|
|
241
186
|
} else {
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
"",
|
|
246
|
-
this.modelUrl,
|
|
247
|
-
this.scene,
|
|
248
|
-
undefined,
|
|
249
|
-
ext
|
|
250
|
-
);
|
|
187
|
+
const extMatch = this.modelUrl.match(/\.(gltf|glb)(\?|#|$)/i);
|
|
188
|
+
const extension = extMatch ? `.${extMatch[1].toLowerCase()}` : ".gltf";
|
|
189
|
+
result = await SceneLoader.ImportMeshAsync(null, "", this.modelUrl, this.scene, undefined, extension);
|
|
251
190
|
}
|
|
191
|
+
|
|
252
192
|
this.scene.createDefaultCameraOrLight(true, true, true);
|
|
253
193
|
this.dispatchEvent(
|
|
254
|
-
new CustomEvent("model-loaded", {
|
|
255
|
-
detail: { meshes: result.meshes, particleSystems: result.particleSystems },
|
|
256
|
-
bubbles: true,
|
|
257
|
-
composed: true
|
|
258
|
-
})
|
|
194
|
+
new CustomEvent("model-loaded", { detail: result, bubbles: true, composed: true })
|
|
259
195
|
);
|
|
260
196
|
} catch (err) {
|
|
261
|
-
console.error("PrefViewer:
|
|
197
|
+
console.error("PrefViewer: failed to load model", err);
|
|
262
198
|
this.dispatchEvent(
|
|
263
|
-
new CustomEvent("model-error", {
|
|
264
|
-
detail: { error: err },
|
|
265
|
-
bubbles: true,
|
|
266
|
-
composed: true
|
|
267
|
-
})
|
|
199
|
+
new CustomEvent("model-error", { detail: { error: err }, bubbles: true, composed: true })
|
|
268
200
|
);
|
|
269
201
|
}
|
|
270
202
|
}
|
|
@@ -274,49 +206,24 @@ class PrefViewer extends HTMLElement {
|
|
|
274
206
|
this.scene.meshes.slice().forEach((m) => m.getClassName() === "Mesh" && m.dispose());
|
|
275
207
|
}
|
|
276
208
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
let
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
try {
|
|
289
|
-
const binary = atob(raw);
|
|
290
|
-
const array = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
291
|
-
const blob = new Blob([array], { type: mimeType });
|
|
292
|
-
console.log('[PrefViewer] Blob created:', blob);
|
|
293
|
-
return blob;
|
|
294
|
-
} catch (err) {
|
|
295
|
-
console.error('[PrefViewer] Failed to decode Base64 or create Blob:', err);
|
|
296
|
-
this.dispatchEvent(
|
|
297
|
-
new CustomEvent('model-error', {
|
|
298
|
-
detail: { error: err },
|
|
299
|
-
bubbles: true,
|
|
300
|
-
composed: true
|
|
301
|
-
})
|
|
302
|
-
);
|
|
303
|
-
throw err;
|
|
304
|
-
}
|
|
209
|
+
_decodeBase64(base64) {
|
|
210
|
+
const [, payload] = base64.split(",");
|
|
211
|
+
const raw = payload || base64;
|
|
212
|
+
const decoded = atob(raw);
|
|
213
|
+
let isJson = false;
|
|
214
|
+
try { JSON.parse(decoded); isJson = true; } catch {}
|
|
215
|
+
const extension = isJson ? ".gltf" : ".glb";
|
|
216
|
+
const type = isJson ? "model/gltf+json" : "model/gltf-binary";
|
|
217
|
+
const array = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
|
|
218
|
+
const blob = new Blob([array], { type });
|
|
219
|
+
return { blob, extension };
|
|
305
220
|
}
|
|
306
221
|
|
|
307
|
-
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
222
|
+
_disposeEngine() {
|
|
223
|
+
if (!this.engine) return;
|
|
224
|
+
this.engine.dispose();
|
|
225
|
+
this.engine = this.scene = this.camera = this.hemiLight = this.dirLight = null;
|
|
311
226
|
}
|
|
312
|
-
|
|
313
|
-
_getExtensionFromUrl(url) {
|
|
314
|
-
const match = url.match(/\.(gltf|glb)(\?|#|$)/i);
|
|
315
|
-
return match ? `.${match[1].toLowerCase()}` : ".gltf";
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// ====== Cleanup ======
|
|
319
|
-
_disposeEngine() { if (this.engine) { this.engine.dispose(); this.engine = null; this.scene = null; this.camera = null; this.hemiLight = null; this.dirLight = null; } }
|
|
320
227
|
}
|
|
321
228
|
|
|
322
229
|
customElements.define("pref-viewer", PrefViewer);
|