@preference-sl/pref-viewer 2.5.5 → 2.7.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 +81 -71
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.5.5",
3
+ "version": "2.7.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,
@@ -34,7 +37,9 @@ import {
34
37
  SceneLoader,
35
38
  Color4,
36
39
  HemisphericLight,
37
- DirectionalLight
40
+ DirectionalLight,
41
+ PointLight,
42
+ ShadowGenerator
38
43
  } from "@babylonjs/core";
39
44
  import "@babylonjs/loaders";
40
45
 
@@ -53,38 +58,38 @@ class PrefViewer extends HTMLElement {
53
58
  this.dirLight = null;
54
59
  this._onWindowResize = null;
55
60
 
56
- // Model data sources
61
+ // Model sources
57
62
  this.modelUrl = null;
58
63
  this.modelBase64 = null;
59
64
 
60
- this._hasInitialized = false;
61
- this._pluginHookSetup = false;
65
+ this._initialized = false;
66
+ this._urlHooked = false;
62
67
  }
63
68
 
64
69
  static get observedAttributes() {
65
70
  return ["model", "model-data"];
66
71
  }
67
72
 
68
- attributeChangedCallback(name, _oldValue, newValue) {
73
+ attributeChangedCallback(name, _old, value) {
69
74
  if (name === "model") {
70
- this.modelUrl = newValue;
75
+ this.modelUrl = value;
71
76
  this.modelBase64 = null;
72
- if (this._hasInitialized) this._reloadModel();
77
+ this._initialized && this._reloadModel();
73
78
  } else if (name === "model-data") {
74
- this.modelBase64 = newValue;
79
+ this.modelBase64 = value;
75
80
  this.modelUrl = null;
76
- if (this._hasInitialized) this._reloadModel();
81
+ this._initialized && this._reloadModel();
77
82
  }
78
83
  }
79
84
 
80
85
  connectedCallback() {
81
- if (!this._pluginHookSetup) {
86
+ if (!this._urlHooked) {
82
87
  this._setupUrlPreprocessing();
83
- this._pluginHookSetup = true;
88
+ this._urlHooked = true;
84
89
  }
85
90
 
86
91
  if (!this.hasAttribute("model") && !this.hasAttribute("model-data")) {
87
- const err = 'PrefViewer: No "model" or "model-data" attribute provided.';
92
+ const err = 'PrefViewer: provide either "model" or "model-data" attribute.';
88
93
  console.error(err);
89
94
  this.dispatchEvent(
90
95
  new CustomEvent("model-error", { detail: { error: new Error(err) }, bubbles: true, composed: true })
@@ -92,28 +97,21 @@ class PrefViewer extends HTMLElement {
92
97
  return;
93
98
  }
94
99
 
95
- if (this.hasAttribute("model")) {
96
- this.modelUrl = this.getAttribute("model");
97
- }
98
- if (this.hasAttribute("model-data")) {
99
- this.modelBase64 = this.getAttribute("model-data");
100
- }
100
+ this.modelUrl = this.getAttribute("model");
101
+ this.modelBase64 = this.getAttribute("model-data");
101
102
 
102
103
  this._initializeBabylon();
103
- this._hasInitialized = true;
104
+ this._initialized = true;
104
105
  this._reloadModel();
105
106
  }
106
107
 
107
108
  disconnectedCallback() {
108
109
  this._disposeEngine();
109
- if (this._onWindowResize) window.removeEventListener("resize", this._onWindowResize);
110
+ this._onWindowResize && window.removeEventListener("resize", this._onWindowResize);
110
111
  }
111
112
 
112
113
  _setupUrlPreprocessing() {
113
- const transformUrl = (url) => {
114
- const stripped = url.replace(/^blob:(?:http|https|file):\/\/[^\/]+\/(.+)/i, "$1");
115
- return stripped.replace(/\\/g, "/");
116
- };
114
+ const transformUrl = (url) => url.replace(/^blob:[^/]+\//i, "").replace(/\\/g, "/");
117
115
  SceneLoader.OnPluginActivatedObservable.add((plugin) => {
118
116
  if (plugin.name === "gltf" || plugin.name === "gltf2") {
119
117
  plugin.preprocessUrl = transformUrl;
@@ -140,7 +138,8 @@ class PrefViewer extends HTMLElement {
140
138
  this.scene.clearColor = new Color4(1, 1, 1, 1);
141
139
  this._createCamera();
142
140
  this._createLights();
143
- this._setupEventListeners();
141
+ this._setupInteraction();
142
+
144
143
  this.engine.runRenderLoop(() => this.scene && this.scene.render());
145
144
  this._onWindowResize = () => this.engine && this.engine.resize();
146
145
  window.addEventListener("resize", this._onWindowResize);
@@ -148,20 +147,39 @@ class PrefViewer extends HTMLElement {
148
147
 
149
148
  _createCamera() {
150
149
  this.camera = new ArcRotateCamera(
151
- "camera", Math.PI/2, Math.PI/3, 10, Vector3.Zero(), this.scene
150
+ "camera",
151
+ Math.PI / 2,
152
+ Math.PI / 3,
153
+ 10,
154
+ Vector3.Zero(),
155
+ this.scene
152
156
  );
153
157
  this.camera.attachControl(this.canvas, true);
154
158
  }
155
159
 
156
160
  _createLights() {
157
- this.hemiLight = new HemisphericLight("hemiLight", new Vector3(0,1,0), this.scene);
158
- this.hemiLight.intensity = 0.6;
159
- this.dirLight = new DirectionalLight("dirLight", new Vector3(-0.5,-1,-0.5), this.scene);
160
- this.dirLight.position = new Vector3(0,5,0);
161
- this.dirLight.intensity = 0.8;
161
+ // Ambient hemisphere for general fill
162
+ this.hemiLight = new HemisphericLight("hemiLight", new Vector3(0, 1, 0), this.scene);
163
+ this.hemiLight.intensity = 0.8; // stronger ambient
164
+
165
+ // Main “sun” light, but softer
166
+ this.dirLight = new DirectionalLight("dirLight", new Vector3(-0.5, -1, -0.5), this.scene);
167
+ this.dirLight.position = new Vector3(0, 5, 0);
168
+ this.dirLight.intensity = 0.3; // much lower than before
169
+
170
+ // Optional: very light, blurred shadows just for form (darkness: 0 → no shadows, 1 → full)
171
+ const shadowGen = new ShadowGenerator(1024, this.dirLight);
172
+ shadowGen.useBlurExponentialShadowMap = true; // soften edges
173
+ shadowGen.blurKernel = 16;
174
+ shadowGen.darkness = 0.2; // practically “skimmed milk” shadows
175
+
176
+ // Headlight: ensure the camera always shines on what you’re looking at
177
+ this.cameraLight = new PointLight("cameraLight", this.camera.position, this.scene);
178
+ this.cameraLight.parent = this.camera;
179
+ this.cameraLight.intensity = 0.5;
162
180
  }
163
181
 
164
- _setupEventListeners() {
182
+ _setupInteraction() {
165
183
  this.canvas.addEventListener("wheel", (evt) => {
166
184
  if (!this.scene || !this.camera) return;
167
185
  const pick = this.scene.pick(this.scene.pointerX, this.scene.pointerY);
@@ -174,54 +192,46 @@ class PrefViewer extends HTMLElement {
174
192
  async _reloadModel() {
175
193
  if (!this.scene || (!this.modelUrl && !this.modelBase64)) return;
176
194
  this._disposePreviousMeshes();
195
+
177
196
  try {
178
197
  let result;
179
198
  if (this.modelBase64) {
180
- const { blob, extension } = this._createBlobFromBase64(this.modelBase64);
181
- const fileName = `model${extension}`;
182
- const file = new File([blob], fileName, { type: blob.type });
183
- console.log('[PrefViewer] Loading from Base64 as File:', fileName);
199
+ const { blob, extension } = this._decodeBase64(this.modelBase64);
200
+ const file = new File([blob], `model${extension}`, { type: blob.type });
184
201
  result = await SceneLoader.ImportMeshAsync(null, "", file, this.scene, undefined, extension);
185
202
  } else {
186
- const ext = (this.modelUrl.match(/\.(gltf|glb)(\?|#|$)/i) || [])[1]?.toLowerCase() || 'gltf';
187
- result = await SceneLoader.ImportMeshAsync(null, "", this.modelUrl, this.scene, undefined, `.${ext}`);
203
+ const extMatch = this.modelUrl.match(/\.(gltf|glb)(\?|#|$)/i);
204
+ const extension = extMatch ? `.${extMatch[1].toLowerCase()}` : ".gltf";
205
+ result = await SceneLoader.ImportMeshAsync(null, "", this.modelUrl, this.scene, undefined, extension);
188
206
  }
207
+
189
208
  this.scene.createDefaultCameraOrLight(true, true, true);
190
- this.dispatchEvent(new CustomEvent("model-loaded", { detail: result, bubbles: true, composed: true }));
209
+ this.dispatchEvent(
210
+ new CustomEvent("model-loaded", { detail: result, bubbles: true, composed: true })
211
+ );
191
212
  } catch (err) {
192
- console.error("PrefViewer: Error loading model:", err);
193
- this.dispatchEvent(new CustomEvent("model-error", { detail: { error: err }, bubbles: true, composed: true }));
213
+ console.error("PrefViewer: failed to load model", err);
214
+ this.dispatchEvent(
215
+ new CustomEvent("model-error", { detail: { error: err }, bubbles: true, composed: true })
216
+ );
194
217
  }
195
218
  }
196
219
 
197
220
  _disposePreviousMeshes() {
198
221
  if (!this.scene) return;
199
- this.scene.meshes.slice().forEach(m => m.getClassName()==="Mesh" && m.dispose());
222
+ this.scene.meshes.slice().forEach((m) => m.getClassName() === "Mesh" && m.dispose());
200
223
  }
201
224
 
202
- _createBlobFromBase64(base64) {
203
- console.log('[PrefViewer] Decoding Base64...');
204
- const [prefix, payload] = base64.split(',');
205
- const raw = payload ?? base64;
206
- let decoded;
207
- try {
208
- decoded = atob(raw);
209
- } catch (e) {
210
- console.error('[PrefViewer] atob failed:', e);
211
- throw e;
212
- }
213
- // detect JSON vs binary glb
225
+ _decodeBase64(base64) {
226
+ const [, payload] = base64.split(",");
227
+ const raw = payload || base64;
228
+ const decoded = atob(raw);
214
229
  let isJson = false;
215
- try {
216
- JSON.parse(decoded);
217
- isJson = true;
218
- } catch {}
219
- const extension = isJson ? '.gltf' : '.glb';
220
- const type = isJson ? 'model/gltf+json' : 'model/gltf-binary';
221
- console.log('[PrefViewer] Detected format:', extension, type);
222
- const array = Uint8Array.from(decoded, c => c.charCodeAt(0));
230
+ try { JSON.parse(decoded); isJson = true; } catch { }
231
+ const extension = isJson ? ".gltf" : ".glb";
232
+ const type = isJson ? "model/gltf+json" : "model/gltf-binary";
233
+ const array = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
223
234
  const blob = new Blob([array], { type });
224
- console.log('[PrefViewer] Created Blob of size', blob.size);
225
235
  return { blob, extension };
226
236
  }
227
237