@preference-sl/pref-viewer 2.1.9 → 2.2.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.1.9",
3
+ "version": "2.2.0",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "license": "MIT",
package/src/index.js CHANGED
@@ -1,165 +1,328 @@
1
- import { Engine, Scene, ArcRotateCamera, Vector3, SceneLoader, Color4, HemisphericLight, DirectionalLight } from "@babylonjs/core";
1
+ /**
2
+ * =============================================================================
3
+ * PrefViewer Web Component (JavaScript)
4
+ * =============================================================================
5
+ *
6
+ * Overview
7
+ * --------
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 a `model` attribute to load different glTF files (defaults to "./models/window.gltf").
12
+ * • Automatically disposes previous meshes when switching models.
13
+ * • Dispatches “model-loaded” and “model-error” CustomEvents so host pages can react.
14
+ *
15
+ * Usage
16
+ * -----
17
+ * 1. **Import the script (module)**
18
+ * <script type="module" src="path/to/pref-viewer.js"></script>
19
+ *
20
+ * 2. **Place the custom element in your HTML**
21
+ * <pref-viewer
22
+ * model="https://example.com/models/myModel.gltf"
23
+ * style="width:800px; height:600px;">
24
+ * </pref-viewer>
25
+ *
26
+ * 3. **Listen for loading events (optional)**
27
+ * const viewer = document.querySelector("pref-viewer");
28
+ * viewer.addEventListener("model-loaded", (evt) => {
29
+ * console.log("Loaded meshes:", evt.detail.meshes);
30
+ * });
31
+ * viewer.addEventListener("model-error", (evt) => {
32
+ * console.error("Failed to load model:", evt.detail.error);
33
+ * });
34
+ *
35
+ * 4. **Change models at runtime**
36
+ * viewer.setAttribute("model", "https://example.com/models/anotherModel.glb");
37
+ *
38
+ * -----------------------------------------------------------------------------
39
+ * Implementation code below with updated preprocessUrl
40
+ * -----------------------------------------------------------------------------
41
+ */
42
+
43
+ import {
44
+ Engine,
45
+ Scene,
46
+ ArcRotateCamera,
47
+ Vector3,
48
+ SceneLoader,
49
+ Color4,
50
+ HemisphericLight,
51
+ DirectionalLight
52
+ } from "@babylonjs/core";
2
53
  import "@babylonjs/loaders";
3
54
 
4
55
  class PrefViewer extends HTMLElement {
5
56
  constructor() {
6
57
  super();
7
- this.attachShadow({ mode: 'open' });
8
-
9
- // Crear y envolver canvas
10
- this.canvas = document.createElement('canvas');
11
- Object.assign(this.canvas.style, {
12
- width: '100%',
13
- height: '100%',
14
- display: 'block',
15
- });
16
- const wrapper = document.createElement('div');
17
- Object.assign(wrapper.style, {
18
- width: '100%',
19
- height: '100%',
20
- position: 'relative',
21
- });
22
- wrapper.appendChild(this.canvas);
23
- this.shadowRoot.append(wrapper);
58
+ console.log("PrefViewer: constructor");
59
+ this.attachShadow({ mode: "open" });
60
+ this._createCanvas();
61
+ this._wrapCanvas();
24
62
 
25
- // Estado inicial
63
+ // These will be set in _initializeBabylon()
26
64
  this.engine = null;
27
65
  this.scene = null;
28
66
  this.camera = null;
29
67
  this.hemiLight = null;
30
68
  this.dirLight = null;
31
69
  this._onWindowResize = null;
32
- this.modelFile = null;
70
+
71
+ // modelUrl might be provided via attribute before connectedCallback
72
+ this.modelUrl = null;
33
73
  this._hasInitialized = false;
34
74
  }
35
75
 
36
76
  static get observedAttributes() {
37
- return ['model'];
77
+ return ["model"];
38
78
  }
39
79
 
40
80
  attributeChangedCallback(name, _oldValue, newValue) {
41
- if (name === 'model' && newValue && this._hasInitialized) {
42
- this._reloadModel(newValue);
81
+ console.log(`PrefViewer: attributeChangedCallback - ${name} -> ${newValue}`);
82
+ if (name === "model" && newValue) {
83
+ this.modelUrl = newValue;
84
+ console.log(`PrefViewer: modelUrl set to ${this.modelUrl}`);
85
+ // Only reload if initialization has already happened
86
+ if (this._hasInitialized) {
87
+ this._reloadModel();
88
+ }
43
89
  }
44
90
  }
45
91
 
46
92
  connectedCallback() {
93
+ console.log("PrefViewer: connectedCallback");
94
+ // 1) Determine modelUrl now that element is connected
95
+ if (!this.hasAttribute("model")) {
96
+ this.modelUrl = new URL("./models/window.gltf", import.meta.url).href;
97
+ console.log(`PrefViewer: no model attribute, defaulting to ${this.modelUrl}`);
98
+ } else {
99
+ this.modelUrl = this.getAttribute("model");
100
+ console.log(`PrefViewer: model attribute present, using ${this.modelUrl}`);
101
+ }
102
+
103
+ // 2) Initialize Babylon (engine + scene + camera + lights + hooks)
47
104
  this._initializeBabylon();
105
+
106
+ // 3) Mark that initialization is done
48
107
  this._hasInitialized = true;
108
+
109
+ // 4) Load whatever modelUrl we have
110
+ this._reloadModel();
49
111
  }
50
112
 
51
113
  disconnectedCallback() {
114
+ console.log("PrefViewer: disconnectedCallback - disposing engine");
52
115
  this._disposeEngine();
53
- if (this._onWindowResize) {
54
- window.removeEventListener('resize', this._onWindowResize);
55
- }
116
+ window.removeEventListener("resize", this._onWindowResize);
56
117
  }
57
118
 
58
- /**
59
- * API pública: cargar bytes como modelo (.gltf/.glb)
60
- * @param {Uint8Array} bytes
61
- * @param {boolean} isJson - true si el contenido es glTF JSON
62
- */
63
- loadFromBytes(bytes, isJson) {
64
- // Detectar GLB por magic number
65
- const isGlb = bytes.length > 4
66
- && bytes[0] === 0x67
67
- && bytes[1] === 0x6c
68
- && bytes[2] === 0x54
69
- && bytes[3] === 0x46;
70
- const mimeType = isGlb
71
- ? 'model/gltf-binary'
72
- : isJson
73
- ? 'model/gltf+json'
74
- : 'application/octet-stream';
75
- const extension = isGlb ? 'glb' : 'gltf';
76
-
77
- // Empaquetar en File con nombre
78
- const file = new File([bytes], `model.${extension}`, { type: mimeType });
79
- this.modelFile = file;
80
-
81
- // Recargar modelo usando el File
82
- this._reloadModel(file);
119
+ // ====== Private setup methods ======
120
+ _createCanvas() {
121
+ console.log("PrefViewer: _createCanvas");
122
+ this.canvas = document.createElement("canvas");
123
+ Object.assign(this.canvas.style, {
124
+ width: "100%",
125
+ height: "100%",
126
+ display: "block"
127
+ });
128
+ }
129
+
130
+ _wrapCanvas() {
131
+ console.log("PrefViewer: _wrapCanvas");
132
+ const wrapper = document.createElement("div");
133
+ Object.assign(wrapper.style, {
134
+ width: "100%",
135
+ height: "100%",
136
+ position: "relative"
137
+ });
138
+ wrapper.appendChild(this.canvas);
139
+ this.shadowRoot.append(wrapper);
83
140
  }
84
141
 
85
142
  _initializeBabylon() {
143
+ console.log("PrefViewer: _initializeBabylon - creating engine and scene");
144
+
145
+ // 1) Create the Babylon engine & scene
86
146
  this.engine = new Engine(this.canvas, true, { alpha: true });
87
147
  this.scene = new Scene(this.engine);
88
148
  this.scene.clearColor = new Color4(1, 1, 1, 1);
89
149
 
90
- // Cámara orbitadora
150
+ // 2) Hook into Babylon’s GLTF loader so that any URIs starting with "blob:…"
151
+ // get stripped off before we check for an absolute "https://".
152
+ console.log("PrefViewer: Adding preprocessUrl hook");
153
+ SceneLoader.OnPluginActivatedObservable.add((plugin) => {
154
+ console.log(`PrefViewer: Plugin activated - ${plugin.name}`);
155
+ if (plugin.name === "gltf" || plugin.name === "gltf2") {
156
+ plugin.preprocessUrl = (url) => {
157
+ // a) If the loader already prepended "blob:…", strip it out.
158
+ // Regex explanation: ^blob:(?:file|https?|ftp):\/\/[^\/]+\/(.*)
159
+ // basically removes the entire "blob:http://localhost:3000/" prefix.
160
+ const stripped = url.replace(
161
+ /^blob:(?:http|https|file):\/\/[^\/]+\/(.+)/i,
162
+ "$1"
163
+ );
164
+ // b) Normalize backslashes "\" → forward slashes "/"
165
+ const fixedSlashes = stripped.replace(/\\/g, "/");
166
+ console.log(
167
+ `PrefViewer: preprocessUrl received "${url}", stripped to "${stripped}", normalized to "${fixedSlashes}"`
168
+ );
169
+ // c) If it now starts with "http://" or "https://", return it as an absolute URL:
170
+ if (/^https?:\/\//i.test(fixedSlashes)) {
171
+ console.log(`PrefViewer: preprocessUrl returning absolute URL "${fixedSlashes}"`);
172
+ return fixedSlashes;
173
+ }
174
+ // d) Otherwise, return the relative path (Babylon will resolve it relative to the blob if needed)
175
+ console.log(`PrefViewer: preprocessUrl returning relative URL "${fixedSlashes}"`);
176
+ return fixedSlashes;
177
+ };
178
+ }
179
+ });
180
+
181
+ // 3) Create camera and lights
182
+ console.log("PrefViewer: _createCamera and _createLights");
183
+ this._createCamera();
184
+ this._createLights();
185
+
186
+ // 4) Hook up input/event handlers (e.g. wheel-to-zoom)
187
+ console.log("PrefViewer: _setupEventListeners");
188
+ this._setupEventListeners();
189
+
190
+ // 5) Start Babylon’s render loop
191
+ console.log("PrefViewer: Starting render loop");
192
+ this.engine.runRenderLoop(() => {
193
+ if (this.scene) {
194
+ this.scene.render();
195
+ }
196
+ });
197
+ this._onWindowResize = () => {
198
+ console.log("PrefViewer: Window resized - calling engine.resize()");
199
+ this.engine.resize();
200
+ };
201
+ window.addEventListener("resize", this._onWindowResize);
202
+ }
203
+
204
+ _createCamera() {
205
+ console.log("PrefViewer: _createCamera");
206
+ // ArcRotateCamera that orbits around origin
91
207
  this.camera = new ArcRotateCamera(
92
- 'camera', Math.PI / 2, Math.PI / 3, 10,
93
- Vector3.Zero(), this.scene
208
+ "camera",
209
+ Math.PI / 2,
210
+ Math.PI / 3,
211
+ 10,
212
+ Vector3.Zero(),
213
+ this.scene
94
214
  );
95
215
  this.camera.attachControl(this.canvas, true);
216
+ }
96
217
 
97
- // Iluminación básica
98
- this.hemiLight = new HemisphericLight('hemi', new Vector3(0, 1, 0), this.scene);
218
+ _createLights() {
219
+ console.log("PrefViewer: _createLights");
220
+ this.hemiLight = new HemisphericLight(
221
+ "hemiLight",
222
+ new Vector3(0, 1, 0),
223
+ this.scene
224
+ );
99
225
  this.hemiLight.intensity = 0.6;
100
- this.dirLight = new DirectionalLight('dir', new Vector3(-0.5, -1, -0.5), this.scene);
226
+
227
+ this.dirLight = new DirectionalLight(
228
+ "dirLight",
229
+ new Vector3(-0.5, -1, -0.5),
230
+ this.scene
231
+ );
101
232
  this.dirLight.position = new Vector3(0, 5, 0);
102
233
  this.dirLight.intensity = 0.8;
234
+ }
103
235
 
104
- // Zoom con rueda apuntando al punto
105
- this.canvas.addEventListener('wheel', evt => {
236
+ _setupEventListeners() {
237
+ console.log("PrefViewer: _setupEventListeners");
238
+ // Zoom toward point-of-interest on wheel scroll
239
+ this.canvas.addEventListener("wheel", (evt) => {
106
240
  if (!this.scene || !this.camera) return;
107
- const pick = this.scene.pick(this.scene.pointerX, this.scene.pointerY);
108
- const pivot = pick.hit ? pick.pickedPoint.clone() : this.camera.target.clone();
109
- this.camera.target = pivot;
110
- this.camera.inertialRadiusOffset += evt.deltaY * this.camera.wheelPrecision * 0.01;
241
+ const pickResult = this.scene.pick(
242
+ this.scene.pointerX,
243
+ this.scene.pointerY
244
+ );
245
+ const pivotPoint = pickResult.hit
246
+ ? pickResult.pickedPoint.clone()
247
+ : this.camera.target.clone();
248
+ this.camera.target = pivotPoint;
249
+ this.camera.inertialRadiusOffset +=
250
+ evt.deltaY * this.camera.wheelPrecision * 0.01;
111
251
  evt.preventDefault();
112
252
  });
113
-
114
- // Render loop
115
- this.engine.runRenderLoop(() => this.scene && this.scene.render());
116
-
117
- // Ajuste en resize
118
- this._onWindowResize = () => this.engine.resize();
119
- window.addEventListener('resize', this._onWindowResize);
120
253
  }
121
254
 
122
- /**
123
- * Recarga el modelo desde URL string o File
124
- * @param {string|File} source
125
- */
126
- async _reloadModel(source) {
127
- if (!this.scene) return;
255
+ // ====== Model loading / management ======
256
+ async _reloadModel() {
257
+ console.log(`PrefViewer: _reloadModel - loading ${this.modelUrl}`);
258
+ if (!this.scene || !this.modelUrl) {
259
+ console.warn("PrefViewer: _reloadModel aborted (scene or modelUrl missing)");
260
+ return;
261
+ }
128
262
 
129
- // Dispose de mallas anteriores
130
- this.scene.meshes.slice().forEach(m => {
131
- if (m.getClassName() === 'Mesh') m.dispose();
132
- });
263
+ // Dispose previous meshes so we don’t accumulate them
264
+ this._disposePreviousMeshes();
133
265
 
134
266
  try {
267
+ console.log(`PrefViewer: ImportMeshAsync("${this.modelUrl}")`);
135
268
  const result = await SceneLoader.ImportMeshAsync(
136
- null, '', source, this.scene
269
+ null,
270
+ "",
271
+ this.modelUrl,
272
+ this.scene,
273
+ undefined,
274
+ ".gltf"
137
275
  );
276
+ console.log("PrefViewer: Model loaded, creating default camera/light if needed");
138
277
  this.scene.createDefaultCameraOrLight(true, true, true);
139
- this.dispatchEvent(new CustomEvent('model-loaded', {
140
- detail: { meshes: result.meshes, particleSystems: result.particleSystems },
141
- bubbles: true,
142
- composed: true
143
- }));
278
+
279
+ console.log("PrefViewer: Dispatching model-loaded event");
280
+ this.dispatchEvent(
281
+ new CustomEvent("model-loaded", {
282
+ detail: {
283
+ meshes: result.meshes,
284
+ particleSystems: result.particleSystems
285
+ },
286
+ bubbles: true,
287
+ composed: true
288
+ })
289
+ );
144
290
  } catch (err) {
145
- console.error('PrefViewer: Error loading model:', err);
146
- this.dispatchEvent(new CustomEvent('model-error', {
147
- detail: { error: err },
148
- bubbles: true,
149
- composed: true
150
- }));
291
+ console.error("PrefViewer: Error loading model:", err);
292
+ console.log("PrefViewer: Dispatching model-error event");
293
+ this.dispatchEvent(
294
+ new CustomEvent("model-error", {
295
+ detail: { error: err },
296
+ bubbles: true,
297
+ composed: true
298
+ })
299
+ );
151
300
  }
152
301
  }
153
302
 
303
+ _disposePreviousMeshes() {
304
+ console.log("PrefViewer: _disposePreviousMeshes");
305
+ if (!this.scene) return;
306
+ this.scene.meshes.slice().forEach((mesh) => {
307
+ if (mesh.getClassName() === "Mesh") {
308
+ console.log(`PrefViewer: Disposing mesh ${mesh.name}`);
309
+ mesh.dispose();
310
+ }
311
+ });
312
+ }
313
+
314
+ // ====== Cleanup ======
154
315
  _disposeEngine() {
155
- if (!this.engine) return;
156
- this.engine.dispose();
157
- this.engine = null;
158
- this.scene = null;
159
- this.camera = null;
160
- this.hemiLight = null;
161
- this.dirLight = null;
316
+ console.log("PrefViewer: _disposeEngine");
317
+ if (this.engine) {
318
+ this.engine.dispose();
319
+ this.engine = null;
320
+ this.scene = null;
321
+ this.camera = null;
322
+ this.hemiLight = null;
323
+ this.dirLight = null;
324
+ }
162
325
  }
163
326
  }
164
327
 
165
- customElements.define('pref-viewer', PrefViewer);
328
+ customElements.define("pref-viewer", PrefViewer);