@preference-sl/pref-viewer 2.1.9 → 2.3.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.3.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,348 @@
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;
74
+ this._pluginHookSetup = false; // Track if we've already set up the plugin hook
34
75
  }
35
76
 
36
77
  static get observedAttributes() {
37
- return ['model'];
78
+ return ["model"];
38
79
  }
39
80
 
40
81
  attributeChangedCallback(name, _oldValue, newValue) {
41
- if (name === 'model' && newValue && this._hasInitialized) {
42
- this._reloadModel(newValue);
82
+ console.log(`PrefViewer: attributeChangedCallback - ${name} -> ${newValue}`);
83
+ if (name === "model" && newValue) {
84
+ this.modelUrl = newValue;
85
+ console.log(`PrefViewer: modelUrl set to ${this.modelUrl}`);
86
+ // Only reload if initialization has already happened
87
+ if (this._hasInitialized) {
88
+ this._reloadModel();
89
+ }
43
90
  }
44
91
  }
45
92
 
46
93
  connectedCallback() {
94
+ console.log("PrefViewer: connectedCallback");
95
+
96
+ // Set up the plugin hook ONCE globally before any other initialization
97
+ if (!this._pluginHookSetup) {
98
+ this._setupUrlPreprocessing();
99
+ this._pluginHookSetup = true;
100
+ }
101
+
102
+ // 1) Determine modelUrl now that element is connected
103
+ if (!this.hasAttribute("model")) {
104
+ this.modelUrl = new URL("./models/window.gltf", import.meta.url).href;
105
+ console.log(`PrefViewer: no model attribute, defaulting to ${this.modelUrl}`);
106
+ } else {
107
+ this.modelUrl = this.getAttribute("model");
108
+ console.log(`PrefViewer: model attribute present, using ${this.modelUrl}`);
109
+ }
110
+
111
+ // 2) Initialize Babylon (engine + scene + camera + lights + hooks)
47
112
  this._initializeBabylon();
113
+
114
+ // 3) Mark that initialization is done
48
115
  this._hasInitialized = true;
116
+
117
+ // 4) Load whatever modelUrl we have
118
+ this._reloadModel();
49
119
  }
50
120
 
51
121
  disconnectedCallback() {
122
+ console.log("PrefViewer: disconnectedCallback - disposing engine");
52
123
  this._disposeEngine();
53
124
  if (this._onWindowResize) {
54
- window.removeEventListener('resize', this._onWindowResize);
125
+ window.removeEventListener("resize", this._onWindowResize);
55
126
  }
56
127
  }
57
128
 
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);
129
+ // ====== URL Preprocessing Setup ======
130
+ _setupUrlPreprocessing() {
131
+ console.log("PrefViewer: Setting up URL preprocessing hook");
132
+
133
+ const transformUrl = (url) => {
134
+ // a) If the loader already prepended "blob:…", strip it out.
135
+ const stripped = url.replace(
136
+ /^blob:(?:http|https|file):\/\/[^\/]+\/(.+)/i,
137
+ "$1"
138
+ );
139
+ // b) Normalize backslashes "\" → forward slashes "/"
140
+ const fixedSlashes = stripped.replace(/\\/g, "/");
141
+ console.log(
142
+ `PrefViewer: preprocessUrl received "${url}", stripped to "${stripped}", normalized to "${fixedSlashes}"`
143
+ );
144
+ // c) If it now starts with "http://" or "https://", return it as an absolute URL:
145
+ if (/^https?:\/\//i.test(fixedSlashes)) {
146
+ console.log(`PrefViewer: preprocessUrl returning absolute URL "${fixedSlashes}"`);
147
+ return fixedSlashes;
148
+ }
149
+ // d) Otherwise, return the relative path
150
+ console.log(`PrefViewer: preprocessUrl returning relative URL "${fixedSlashes}"`);
151
+ return fixedSlashes;
152
+ };
153
+
154
+ SceneLoader.OnPluginActivatedObservable.add((plugin) => {
155
+ console.log(`PrefViewer: Plugin activated - ${plugin.name}`);
156
+ if (plugin.name === "gltf" || plugin.name === "gltf2") {
157
+ // Support both sync and async preprocessing
158
+ plugin.preprocessUrl = transformUrl;
159
+ if (typeof plugin.preprocessUrlAsync === 'function' || plugin.preprocessUrlAsync === undefined) {
160
+ plugin.preprocessUrlAsync = (url) => Promise.resolve(transformUrl(url));
161
+ }
162
+ console.log("PrefViewer: URL preprocessing hooks set up for GLTF plugin");
163
+ }
164
+ });
165
+ }
166
+
167
+ // ====== Private setup methods ======
168
+ _createCanvas() {
169
+ console.log("PrefViewer: _createCanvas");
170
+ this.canvas = document.createElement("canvas");
171
+ Object.assign(this.canvas.style, {
172
+ width: "100%",
173
+ height: "100%",
174
+ display: "block"
175
+ });
176
+ }
177
+
178
+ _wrapCanvas() {
179
+ console.log("PrefViewer: _wrapCanvas");
180
+ const wrapper = document.createElement("div");
181
+ Object.assign(wrapper.style, {
182
+ width: "100%",
183
+ height: "100%",
184
+ position: "relative"
185
+ });
186
+ wrapper.appendChild(this.canvas);
187
+ this.shadowRoot.append(wrapper);
83
188
  }
84
189
 
85
190
  _initializeBabylon() {
191
+ console.log("PrefViewer: _initializeBabylon - creating engine and scene");
192
+
193
+ // 1) Create the Babylon engine & scene
86
194
  this.engine = new Engine(this.canvas, true, { alpha: true });
87
195
  this.scene = new Scene(this.engine);
88
196
  this.scene.clearColor = new Color4(1, 1, 1, 1);
89
197
 
90
- // Cámara orbitadora
198
+ // 2) Create camera and lights
199
+ console.log("PrefViewer: _createCamera and _createLights");
200
+ this._createCamera();
201
+ this._createLights();
202
+
203
+ // 3) Hook up input/event handlers (e.g. wheel-to-zoom)
204
+ console.log("PrefViewer: _setupEventListeners");
205
+ this._setupEventListeners();
206
+
207
+ // 4) Start Babylon's render loop
208
+ console.log("PrefViewer: Starting render loop");
209
+ this.engine.runRenderLoop(() => {
210
+ if (this.scene) {
211
+ this.scene.render();
212
+ }
213
+ });
214
+
215
+ this._onWindowResize = () => {
216
+ console.log("PrefViewer: Window resized - calling engine.resize()");
217
+ if (this.engine) {
218
+ this.engine.resize();
219
+ }
220
+ };
221
+ window.addEventListener("resize", this._onWindowResize);
222
+ }
223
+
224
+ _createCamera() {
225
+ console.log("PrefViewer: _createCamera");
226
+ // ArcRotateCamera that orbits around origin
91
227
  this.camera = new ArcRotateCamera(
92
- 'camera', Math.PI / 2, Math.PI / 3, 10,
93
- Vector3.Zero(), this.scene
228
+ "camera",
229
+ Math.PI / 2,
230
+ Math.PI / 3,
231
+ 10,
232
+ Vector3.Zero(),
233
+ this.scene
94
234
  );
95
235
  this.camera.attachControl(this.canvas, true);
236
+ }
96
237
 
97
- // Iluminación básica
98
- this.hemiLight = new HemisphericLight('hemi', new Vector3(0, 1, 0), this.scene);
238
+ _createLights() {
239
+ console.log("PrefViewer: _createLights");
240
+ this.hemiLight = new HemisphericLight(
241
+ "hemiLight",
242
+ new Vector3(0, 1, 0),
243
+ this.scene
244
+ );
99
245
  this.hemiLight.intensity = 0.6;
100
- this.dirLight = new DirectionalLight('dir', new Vector3(-0.5, -1, -0.5), this.scene);
246
+
247
+ this.dirLight = new DirectionalLight(
248
+ "dirLight",
249
+ new Vector3(-0.5, -1, -0.5),
250
+ this.scene
251
+ );
101
252
  this.dirLight.position = new Vector3(0, 5, 0);
102
253
  this.dirLight.intensity = 0.8;
254
+ }
103
255
 
104
- // Zoom con rueda apuntando al punto
105
- this.canvas.addEventListener('wheel', evt => {
256
+ _setupEventListeners() {
257
+ console.log("PrefViewer: _setupEventListeners");
258
+ // Zoom toward point-of-interest on wheel scroll
259
+ this.canvas.addEventListener("wheel", (evt) => {
106
260
  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;
261
+ const pickResult = this.scene.pick(
262
+ this.scene.pointerX,
263
+ this.scene.pointerY
264
+ );
265
+ const pivotPoint = pickResult.hit
266
+ ? pickResult.pickedPoint.clone()
267
+ : this.camera.target.clone();
268
+ this.camera.target = pivotPoint;
269
+ this.camera.inertialRadiusOffset +=
270
+ evt.deltaY * this.camera.wheelPrecision * 0.01;
111
271
  evt.preventDefault();
112
272
  });
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
273
  }
121
274
 
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;
275
+ // ====== Model loading / management ======
276
+ async _reloadModel() {
277
+ console.log(`PrefViewer: _reloadModel - loading ${this.modelUrl}`);
278
+ if (!this.scene || !this.modelUrl) {
279
+ console.warn("PrefViewer: _reloadModel aborted (scene or modelUrl missing)");
280
+ return;
281
+ }
128
282
 
129
- // Dispose de mallas anteriores
130
- this.scene.meshes.slice().forEach(m => {
131
- if (m.getClassName() === 'Mesh') m.dispose();
132
- });
283
+ // Dispose previous meshes so we don't accumulate them
284
+ this._disposePreviousMeshes();
133
285
 
134
286
  try {
287
+ console.log(`PrefViewer: ImportMeshAsync("${this.modelUrl}")`);
135
288
  const result = await SceneLoader.ImportMeshAsync(
136
- null, '', source, this.scene
289
+ null,
290
+ "",
291
+ this.modelUrl,
292
+ this.scene,
293
+ undefined,
294
+ ".gltf"
137
295
  );
296
+ console.log("PrefViewer: Model loaded, creating default camera/light if needed");
138
297
  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
- }));
298
+
299
+ console.log("PrefViewer: Dispatching model-loaded event");
300
+ this.dispatchEvent(
301
+ new CustomEvent("model-loaded", {
302
+ detail: {
303
+ meshes: result.meshes,
304
+ particleSystems: result.particleSystems
305
+ },
306
+ bubbles: true,
307
+ composed: true
308
+ })
309
+ );
144
310
  } 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
- }));
311
+ console.error("PrefViewer: Error loading model:", err);
312
+ console.log("PrefViewer: Dispatching model-error event");
313
+ this.dispatchEvent(
314
+ new CustomEvent("model-error", {
315
+ detail: { error: err },
316
+ bubbles: true,
317
+ composed: true
318
+ })
319
+ );
151
320
  }
152
321
  }
153
322
 
323
+ _disposePreviousMeshes() {
324
+ console.log("PrefViewer: _disposePreviousMeshes");
325
+ if (!this.scene) return;
326
+ this.scene.meshes.slice().forEach((mesh) => {
327
+ if (mesh.getClassName() === "Mesh") {
328
+ console.log(`PrefViewer: Disposing mesh ${mesh.name}`);
329
+ mesh.dispose();
330
+ }
331
+ });
332
+ }
333
+
334
+ // ====== Cleanup ======
154
335
  _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;
336
+ console.log("PrefViewer: _disposeEngine");
337
+ if (this.engine) {
338
+ this.engine.dispose();
339
+ this.engine = null;
340
+ this.scene = null;
341
+ this.camera = null;
342
+ this.hemiLight = null;
343
+ this.dirLight = null;
344
+ }
162
345
  }
163
346
  }
164
347
 
165
- customElements.define('pref-viewer', PrefViewer);
348
+ customElements.define("pref-viewer", PrefViewer);