@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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +62 -155
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.5.4",
3
+ "version": "2.6.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": {
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
- * • Inserts a <canvas> into its shadow DOM to render a glTF model.
10
- * • Creates and manages a Babylon Engine, Scene, ArcRotateCamera, and basic lighting.
11
- * • Listens for `model` (URL) or `model-data` (Base64) attributes to load different glTF files.
12
- * • Automatically disposes previous meshes when switching models.
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.gltf"
19
+ * model="https://example.com/models/myModel.glb"
19
20
  * style="width:800px; height:600px;">
20
21
  * </pref-viewer>
22
+ * ```
21
23
  *
22
- * or:
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 data sources
59
+ // Model sources
57
60
  this.modelUrl = null;
58
61
  this.modelBase64 = null;
59
62
 
60
- this._hasInitialized = false;
61
- this._pluginHookSetup = false;
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, _oldValue, newValue) {
71
+ attributeChangedCallback(name, _old, value) {
69
72
  if (name === "model") {
70
- this.modelUrl = newValue;
73
+ this.modelUrl = value;
71
74
  this.modelBase64 = null;
72
- if (this._hasInitialized) this._reloadModel();
75
+ this._initialized && this._reloadModel();
73
76
  } else if (name === "model-data") {
74
- this.modelBase64 = newValue;
77
+ this.modelBase64 = value;
75
78
  this.modelUrl = null;
76
- if (this._hasInitialized) this._reloadModel();
79
+ this._initialized && this._reloadModel();
77
80
  }
78
81
  }
79
82
 
80
83
  connectedCallback() {
81
- // Set up URL preprocessing once
82
- if (!this._pluginHookSetup) {
84
+ if (!this._urlHooked) {
83
85
  this._setupUrlPreprocessing();
84
- this._pluginHookSetup = true;
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 errorMsg = 'PrefViewer: No "model" or "model-data" attribute provided.';
90
- console.error(errorMsg);
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
- if (this.hasAttribute("model")) {
102
- this.modelUrl = this.getAttribute("model");
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._hasInitialized = true;
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
- if (this._onWindowResize) {
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._setupEventListeners();
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
- _setupEventListeners() {
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._createBlobFromBase64(this.modelBase64);
229
- const ext = this._getExtensionFromMimeType(blob.type);
230
- const fileName = `model${ext}`;
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 ext = this._getExtensionFromUrl(this.modelUrl);
243
- result = await SceneLoader.ImportMeshAsync(
244
- null,
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: Error loading model:", err);
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
- _createBlobFromBase64(base64) {
278
- console.log('[PrefViewer] _createBlobFromBase64 called');
279
- const [prefix, data] = base64.split(',');
280
- console.log('[PrefViewer] prefix:', prefix);
281
- let mimeType = 'application/octet-stream';
282
- if (prefix && prefix.startsWith('data:')) {
283
- const end = prefix.indexOf(';');
284
- mimeType = prefix.substring(5, end >= 0 ? end : prefix.length) || mimeType;
285
- }
286
- console.log('[PrefViewer] inferred mimeType:', mimeType);
287
- const raw = data ?? base64;
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
- _getExtensionFromMimeType(mimeType) {
308
- if (mimeType.includes("json")) return ".gltf";
309
- if (mimeType.includes("glb") || mimeType === "application/octet-stream") return ".glb";
310
- return ".gltf";
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);