@preference-sl/pref-viewer 1.1.0 → 2.1.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": "1.1.0",
3
+ "version": "2.1.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,30 +1,107 @@
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/patata.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
40
+ * -----------------------------------------------------------------------------
41
+ */
42
+
1
43
  import {
2
44
  Engine,
3
45
  Scene,
4
46
  ArcRotateCamera,
5
47
  Vector3,
6
48
  SceneLoader,
7
- Color4
49
+ Color4,
50
+ HemisphericLight,
51
+ DirectionalLight
8
52
  } from "@babylonjs/core";
9
53
  import "@babylonjs/loaders";
10
54
 
11
55
  class PrefViewer extends HTMLElement {
12
- canvas;
13
- engine;
14
- scene;
15
- modelUrl = null;
16
-
17
56
  constructor() {
18
57
  super();
19
58
  this.attachShadow({ mode: "open" });
59
+ this._createCanvas();
60
+ this._wrapCanvas();
61
+ this.engine = null;
62
+ this.scene = null;
63
+ this.camera = null;
64
+ this.hemiLight = null;
65
+ this.dirLight = null;
66
+ this.modelUrl = null;
67
+ this._onWindowResize = null;
68
+ }
20
69
 
70
+ static get observedAttributes() {
71
+ return ["model"];
72
+ }
73
+
74
+ attributeChangedCallback(name, _oldValue, newValue) {
75
+ if (name === "model" && newValue) {
76
+ this.modelUrl = newValue;
77
+ this._reloadModel();
78
+ }
79
+ }
80
+
81
+ connectedCallback() {
82
+ // If no `model` attribute is present, use default bundled GLTF
83
+ if (!this.hasAttribute("model")) {
84
+ this.modelUrl = new URL("./models/patata.gltf", import.meta.url).href;
85
+ }
86
+ this._initializeBabylon();
87
+ }
88
+
89
+ disconnectedCallback() {
90
+ this._disposeEngine();
91
+ window.removeEventListener("resize", this._onWindowResize);
92
+ }
93
+
94
+ // ====== Private setup methods ======
95
+ _createCanvas() {
21
96
  this.canvas = document.createElement("canvas");
22
97
  Object.assign(this.canvas.style, {
23
98
  width: "100%",
24
99
  height: "100%",
25
100
  display: "block"
26
101
  });
102
+ }
27
103
 
104
+ _wrapCanvas() {
28
105
  const wrapper = document.createElement("div");
29
106
  Object.assign(wrapper.style, {
30
107
  width: "100%",
@@ -35,63 +112,110 @@ class PrefViewer extends HTMLElement {
35
112
  this.shadowRoot.append(wrapper);
36
113
  }
37
114
 
38
- static get observedAttributes() {
39
- return ["model"];
40
- }
115
+ _initializeBabylon() {
116
+ // 1) Create engine and scene
117
+ this.engine = new Engine(this.canvas, true, { alpha: true });
118
+ this.scene = new Scene(this.engine);
119
+ this.scene.clearColor = new Color4(1, 1, 1, 1);
41
120
 
42
- attributeChangedCallback(name, _old, value) {
43
- if (name === "model" && value) {
44
- this.modelUrl = value;
45
- this.loadModel();
46
- }
47
- }
121
+ // 2) Hook into Babylon’s GLTF loader so "https://..." URIs aren't prefixed with blob:
122
+ SceneLoader.OnPluginActivatedObservable.add((plugin) => {
123
+ if (plugin.name === "gltf" || plugin.name === "gltf2") {
124
+ plugin.preprocessUrl = (url) => {
125
+ // Normalize backslashes to forward slashes
126
+ const fixed = url.replace(/\\/g, "/");
127
+ // If it starts with http:// or https://, return as-is:
128
+ if (/^https?:\/\//i.test(fixed)) {
129
+ return fixed;
130
+ }
131
+ // Otherwise, leave it (Babylon will treat it relative to the blob)
132
+ return fixed;
133
+ };
134
+ }
135
+ });
48
136
 
49
- connectedCallback() {
50
- if (!this.hasAttribute("model")) {
51
- this.modelUrl = new URL("./models/patata.gltf", import.meta.url).href;
52
- }
53
- this.initBabylon();
54
- }
137
+ // 3) Create camera and lights
138
+ this._createCamera();
139
+ this._createLights();
55
140
 
56
- disconnectedCallback() {
57
- this.engine?.dispose();
58
- window.removeEventListener("resize", this._resizeHandler);
59
- }
141
+ // 4) Hook up input/event handlers
142
+ this._setupEventListeners();
60
143
 
61
- initBabylon() {
62
- this.engine = new Engine(this.canvas, true, { alpha: true });
63
- this.scene = new Scene(this.engine);
64
- this.scene.clearColor = new Color4(1, 1, 1, 1);
144
+ // 5) Start render loop
145
+ this.engine.runRenderLoop(() => {
146
+ if (this.scene) {
147
+ this.scene.render();
148
+ }
149
+ });
150
+ this._onWindowResize = () => this.engine.resize();
151
+ window.addEventListener("resize", this._onWindowResize);
152
+
153
+ // 6) Load the initial model (if modelUrl is already set)
154
+ this._reloadModel();
155
+ }
65
156
 
66
- const camera = new ArcRotateCamera(
67
- "cam",
157
+ _createCamera() {
158
+ // ArcRotateCamera that orbits around origin
159
+ this.camera = new ArcRotateCamera(
160
+ "camera",
68
161
  Math.PI / 2,
69
162
  Math.PI / 3,
70
163
  10,
71
164
  Vector3.Zero(),
72
165
  this.scene
73
166
  );
74
- camera.attachControl(this.canvas, true);
167
+ this.camera.attachControl(this.canvas, true);
168
+ }
169
+
170
+ _createLights() {
171
+ // Simple hemispheric + directional as a starting point
172
+ this.hemiLight = new HemisphericLight(
173
+ "hemiLight",
174
+ new Vector3(0, 1, 0),
175
+ this.scene
176
+ );
177
+ this.hemiLight.intensity = 0.6;
178
+
179
+ this.dirLight = new DirectionalLight(
180
+ "dirLight",
181
+ new Vector3(-0.5, -1, -0.5),
182
+ this.scene
183
+ );
184
+ this.dirLight.position = new Vector3(0, 5, 0);
185
+ this.dirLight.intensity = 0.8;
186
+ }
75
187
 
188
+ _setupEventListeners() {
189
+ // Zoom toward point-of-interest on wheel scroll
76
190
  this.canvas.addEventListener("wheel", (evt) => {
77
- const pick = this.scene.pick(this.scene.pointerX, this.scene.pointerY);
78
- const pivot = pick.hit ? pick.pickedPoint.clone() : camera.target.clone();
79
- camera.target = pivot;
80
- camera.inertialRadiusOffset += evt.deltaY * camera.wheelPrecision * 0.01;
191
+ if (!this.scene || !this.camera) return;
192
+
193
+ const pickResult = this.scene.pick(
194
+ this.scene.pointerX,
195
+ this.scene.pointerY
196
+ );
197
+ const pivotPoint = pickResult.hit
198
+ ? pickResult.pickedPoint.clone()
199
+ : this.camera.target.clone();
200
+
201
+ this.camera.target = pivotPoint;
202
+ this.camera.inertialRadiusOffset +=
203
+ evt.deltaY * this.camera.wheelPrecision * 0.01;
81
204
  evt.preventDefault();
82
205
  });
206
+ }
83
207
 
84
- this.engine.runRenderLoop(() => this.scene.render());
85
- this._resizeHandler = () => this.engine.resize();
86
- window.addEventListener("resize", this._resizeHandler);
208
+ // ====== Model loading / management ======
209
+ async _reloadModel() {
210
+ if (!this.scene || !this.modelUrl) {
211
+ return;
212
+ }
87
213
 
88
- this.loadModel();
89
- }
214
+ // Dispose previous meshes so we don’t accumulate them
215
+ this._disposePreviousMeshes();
90
216
 
91
- async loadModel() {
92
- if (!this.scene || !this.modelUrl) return;
93
217
  try {
94
- await SceneLoader.ImportMeshAsync(
218
+ const result = await SceneLoader.ImportMeshAsync(
95
219
  null,
96
220
  "",
97
221
  this.modelUrl,
@@ -99,11 +223,53 @@ class PrefViewer extends HTMLElement {
99
223
  undefined,
100
224
  ".gltf"
101
225
  );
226
+ // After loading, create defaults if none exist
102
227
  this.scene.createDefaultCameraOrLight(true, true, true);
228
+
229
+ // Dispatch "model-loaded" event
230
+ this.dispatchEvent(
231
+ new CustomEvent("model-loaded", {
232
+ detail: {
233
+ meshes: result.meshes,
234
+ particleSystems: result.particleSystems
235
+ },
236
+ bubbles: true,
237
+ composed: true
238
+ })
239
+ );
103
240
  } catch (err) {
104
241
  console.error("Error loading model:", err);
242
+ // Dispatch "model-error" event
243
+ this.dispatchEvent(
244
+ new CustomEvent("model-error", {
245
+ detail: { error: err },
246
+ bubbles: true,
247
+ composed: true
248
+ })
249
+ );
250
+ }
251
+ }
252
+
253
+ _disposePreviousMeshes() {
254
+ if (!this.scene) return;
255
+ this.scene.meshes.slice().forEach((mesh) => {
256
+ if (mesh.getClassName() === "Mesh") {
257
+ mesh.dispose();
258
+ }
259
+ });
260
+ }
261
+
262
+ // ====== Cleanup ======
263
+ _disposeEngine() {
264
+ if (this.engine) {
265
+ this.engine.dispose();
266
+ this.engine = null;
267
+ this.scene = null;
268
+ this.camera = null;
269
+ this.hemiLight = null;
270
+ this.dirLight = null;
105
271
  }
106
272
  }
107
273
  }
108
274
 
109
- customElements.define("pref-viewer", PrefViewer);
275
+ customElements.define("pref-viewer", PrefViewer);
@@ -0,0 +1,240 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>PrefViewer Test (Global Babylon)</title>
6
+ <style>
7
+ html, body {
8
+ margin: 0;
9
+ padding: 0;
10
+ width: 100%;
11
+ height: 100%;
12
+ overflow: hidden;
13
+ }
14
+ #viewer-container {
15
+ width: 100%;
16
+ height: 100%;
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ background: #f0f0f0;
21
+ }
22
+ </style>
23
+
24
+ <!-- 1) Load Babylon Core (Global) -->
25
+ <script src="https://cdn.babylonjs.com/babylon.js"></script>
26
+ <!-- 2) Load Babylon glTF loader so SceneLoader.ImportMeshAsync can parse .gltf -->
27
+ <script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
28
+ </head>
29
+ <body>
30
+ <div id="viewer-container">
31
+ <!-- Omit "model" attribute: PrefViewer will default to "./models/patata.gltf" -->
32
+ <pref-viewer style="width: 800px; height: 600px; border: 1px solid #ccc;"></pref-viewer>
33
+ </div>
34
+
35
+ <!-- 3) Define PrefViewer using the global `BABYLON` object -->
36
+ <script>
37
+ (function () {
38
+ const { Engine, Scene, ArcRotateCamera, Vector3, SceneLoader, Color4, HemisphericLight, DirectionalLight } = BABYLON;
39
+
40
+ class PrefViewer extends HTMLElement {
41
+ constructor() {
42
+ super();
43
+ this.attachShadow({ mode: "open" });
44
+ this.canvas = document.createElement("canvas");
45
+ Object.assign(this.canvas.style, {
46
+ width: "100%",
47
+ height: "100%",
48
+ display: "block",
49
+ });
50
+
51
+ const wrapper = document.createElement("div");
52
+ Object.assign(wrapper.style, {
53
+ width: "100%",
54
+ height: "100%",
55
+ position: "relative",
56
+ });
57
+ wrapper.appendChild(this.canvas);
58
+ this.shadowRoot.append(wrapper);
59
+
60
+ this.engine = null;
61
+ this.scene = null;
62
+ this.camera = null;
63
+ this.hemiLight = null;
64
+ this.dirLight = null;
65
+ this.modelUrl = null;
66
+ this._onWindowResize = null;
67
+ }
68
+
69
+ static get observedAttributes() {
70
+ return ["model"];
71
+ }
72
+
73
+ attributeChangedCallback(name, _oldValue, newValue) {
74
+ if (name === "model" && newValue) {
75
+ this.modelUrl = newValue;
76
+ this._reloadModel();
77
+ }
78
+ }
79
+
80
+ connectedCallback() {
81
+ // If no `model` attribute is provided, default to "models/patata.gltf"
82
+ if (!this.hasAttribute("model")) {
83
+ // Note: NO import.meta.url here—just use a relative path.
84
+ this.modelUrl = "models/patata.gltf";
85
+ }
86
+ this._initializeBabylon();
87
+ }
88
+
89
+ disconnectedCallback() {
90
+ this._disposeEngine();
91
+ if (this._onWindowResize) {
92
+ window.removeEventListener("resize", this._onWindowResize);
93
+ this._onWindowResize = null;
94
+ }
95
+ }
96
+
97
+ // —— Private setup methods ——
98
+
99
+ _initializeBabylon() {
100
+ // 1) Create engine & scene
101
+ this.engine = new Engine(this.canvas, true, { preserveDrawingBuffer: true, stencil: true, disableWebGL2Support: false });
102
+ this.scene = new Scene(this.engine);
103
+ this.scene.clearColor = new Color4(1, 1, 1, 1);
104
+
105
+ // 2) Create camera & lights
106
+ this._createCamera();
107
+ this._createLights();
108
+
109
+ // 3) Hook up events
110
+ this._setupEventListeners();
111
+
112
+ // 4) Start render loop
113
+ this.engine.runRenderLoop(() => {
114
+ if (this.scene) {
115
+ this.scene.render();
116
+ }
117
+ });
118
+ this._onWindowResize = () => this.engine.resize();
119
+ window.addEventListener("resize", this._onWindowResize);
120
+
121
+ // 5) Load the initial model
122
+ this._reloadModel();
123
+ }
124
+
125
+ _createCamera() {
126
+ // ArcRotateCamera around (0,0,0)
127
+ const camera = new ArcRotateCamera(
128
+ "camera",
129
+ Math.PI / 2,
130
+ Math.PI / 3,
131
+ 10,
132
+ Vector3.Zero(),
133
+ this.scene
134
+ );
135
+ camera.attachControl(this.canvas, true);
136
+ this.camera = camera;
137
+ }
138
+
139
+ _createLights() {
140
+ // Hemispheric light (fills in shadows)
141
+ const hemiLight = new HemisphericLight("hemiLight", new Vector3(0, 1, 0), this.scene);
142
+ hemiLight.intensity = 0.8;
143
+
144
+ // Directional key light
145
+ const dirLight = new DirectionalLight("dirLight", new Vector3(-0.5, -1, -0.5), this.scene);
146
+ dirLight.position = new Vector3(0, 5, 0);
147
+ dirLight.intensity = 0.8;
148
+
149
+ this.hemiLight = hemiLight;
150
+ this.dirLight = dirLight;
151
+ }
152
+
153
+ _setupEventListeners() {
154
+ // Zoom towards the pointer's picked point on wheel scroll:
155
+ this.canvas.addEventListener("wheel", (evt) => {
156
+ if (!this.scene || !this.camera) return;
157
+ const pickResult = this.scene.pick(this.scene.pointerX, this.scene.pointerY);
158
+ const pivotPoint = pickResult.hit
159
+ ? pickResult.pickedPoint.clone()
160
+ : this.camera.target.clone();
161
+ this.camera.target = pivotPoint;
162
+ this.camera.inertialRadiusOffset += evt.deltaY * this.camera.wheelPrecision * 0.01;
163
+ evt.preventDefault();
164
+ });
165
+ }
166
+
167
+ // —— Model loading / management ——
168
+
169
+ async _reloadModel() {
170
+ if (!this.scene || !this.modelUrl) return;
171
+
172
+ // Dispose previous meshes (to avoid stacking them up)
173
+ this._disposePreviousMeshes();
174
+
175
+ try {
176
+ const result = await SceneLoader.ImportMeshAsync(
177
+ null,
178
+ "",
179
+ this.modelUrl,
180
+ this.scene,
181
+ undefined,
182
+ ".gltf"
183
+ );
184
+
185
+ // Ensure there's at least one camera / light if glTF had none
186
+ this.scene.createDefaultCameraOrLight(true, true, true);
187
+
188
+ // Turn off back-face culling so you can see both sides
189
+ result.meshes.forEach((mesh) => {
190
+ if (mesh.material) {
191
+ mesh.material.backFaceCulling = false;
192
+ }
193
+ });
194
+
195
+ this.dispatchEvent(
196
+ new CustomEvent("model-loaded", {
197
+ detail: { meshes: result.meshes, particleSystems: result.particleSystems },
198
+ bubbles: true,
199
+ composed: true,
200
+ })
201
+ );
202
+ } catch (err) {
203
+ console.error("Error loading model:", err);
204
+ this.dispatchEvent(
205
+ new CustomEvent("model-error", {
206
+ detail: { error: err },
207
+ bubbles: true,
208
+ composed: true,
209
+ })
210
+ );
211
+ }
212
+ }
213
+
214
+ _disposePreviousMeshes() {
215
+ if (!this.scene) return;
216
+ this.scene.meshes.slice().forEach((mesh) => {
217
+ if (mesh.getClassName && mesh.getClassName() === "Mesh") {
218
+ mesh.dispose();
219
+ }
220
+ });
221
+ }
222
+
223
+ // —— Cleanup ——
224
+
225
+ _disposeEngine() {
226
+ if (this.engine) {
227
+ this.engine.dispose();
228
+ this.engine = null;
229
+ this.scene = null;
230
+ this.camera = null;
231
+ }
232
+ }
233
+ }
234
+
235
+ customElements.define("pref-viewer", PrefViewer);
236
+ })();
237
+ </script>
238
+
239
+ </body>
240
+ </html>
package/src/test.html ADDED
@@ -0,0 +1,62 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>PrefViewer Test (Reuse Index.js)</title>
6
+ <style>
7
+ html, body {
8
+ margin: 0;
9
+ padding: 0;
10
+ width: 100%;
11
+ height: 100%;
12
+ overflow: hidden;
13
+ }
14
+ #viewer-container {
15
+ width: 100%;
16
+ height: 100%;
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ background: #f0f0f0;
21
+ }
22
+ </style>
23
+
24
+ <!--
25
+ IMPORT MAP
26
+ ──────────
27
+ This tells the browser where to fetch "@babylonjs/core" and "@babylonjs/loaders".
28
+ We use jsDelivr's "?module" query so that it automatically serves the ESM build
29
+ corresponding to each package's "module" field in package.json.
30
+
31
+ Make sure the version (5.0.1 here) matches what you have in package.json.
32
+ If you have "@babylonjs/core": "^5.0.2" in your package.json, change both URLs
33
+ to "@5.0.2?module", etc.
34
+
35
+ This import-map must appear *before* you load index.js as a module.
36
+ -->
37
+ <script type="importmap">
38
+ {
39
+ "imports": {
40
+ "@babylonjs/core": "https://cdn.jsdelivr.net/npm/@babylonjs/core@5.0.1?module",
41
+ "@babylonjs/loaders": "https://cdn.jsdelivr.net/npm/@babylonjs/loaders@5.0.1?module"
42
+ }
43
+ }
44
+ </script>
45
+ </head>
46
+ <body>
47
+ <div id="viewer-container">
48
+ <!--
49
+ By omitting the `model` attribute, PrefViewer’s connectedCallback()
50
+ will fall back to "./models/patata.gltf" (relative to index.js).
51
+ -->
52
+ <pref-viewer style="width: 800px; height: 600px; border: 1px solid #ccc;"></pref-viewer>
53
+ </div>
54
+
55
+ <!--
56
+ Load your existing ES-module index.js. Because of the import-map above,
57
+ any `import { … } from "@babylonjs/core"` inside index.js will resolve to
58
+ "https://cdn.jsdelivr.net/npm/@babylonjs/core@5.0.1?module", etc.
59
+ -->
60
+ <script type="module" src="./index.js"></script>
61
+ </body>
62
+ </html>
File without changes