@preference-sl/pref-viewer 2.1.7 → 2.1.9
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 +1 -1
- package/src/index.js +99 -222
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,288 +1,165 @@
|
|
|
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 with updated preprocessUrl
|
|
40
|
-
* -----------------------------------------------------------------------------
|
|
41
|
-
*/
|
|
42
|
-
|
|
43
1
|
import { Engine, Scene, ArcRotateCamera, Vector3, SceneLoader, Color4, HemisphericLight, DirectionalLight } from "@babylonjs/core";
|
|
44
2
|
import "@babylonjs/loaders";
|
|
45
3
|
|
|
46
4
|
class PrefViewer extends HTMLElement {
|
|
47
5
|
constructor() {
|
|
48
6
|
super();
|
|
49
|
-
|
|
50
|
-
this.attachShadow({ mode: "open" });
|
|
51
|
-
this._createCanvas();
|
|
52
|
-
this._wrapCanvas();
|
|
7
|
+
this.attachShadow({ mode: 'open' });
|
|
53
8
|
|
|
54
|
-
//
|
|
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);
|
|
24
|
+
|
|
25
|
+
// Estado inicial
|
|
55
26
|
this.engine = null;
|
|
56
27
|
this.scene = null;
|
|
57
28
|
this.camera = null;
|
|
58
29
|
this.hemiLight = null;
|
|
59
30
|
this.dirLight = null;
|
|
60
31
|
this._onWindowResize = null;
|
|
61
|
-
|
|
62
|
-
// modelUrl might be provided via attribute before connectedCallback
|
|
63
|
-
this.modelUrl = null;
|
|
32
|
+
this.modelFile = null;
|
|
64
33
|
this._hasInitialized = false;
|
|
65
34
|
}
|
|
66
35
|
|
|
67
36
|
static get observedAttributes() {
|
|
68
|
-
return [
|
|
37
|
+
return ['model'];
|
|
69
38
|
}
|
|
70
39
|
|
|
71
40
|
attributeChangedCallback(name, _oldValue, newValue) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
this.modelUrl = newValue;
|
|
75
|
-
console.log(`PrefViewer: modelUrl set to ${this.modelUrl}`);
|
|
76
|
-
// Only reload if initialization has already happened
|
|
77
|
-
if (this._hasInitialized) {
|
|
78
|
-
this._reloadModel();
|
|
79
|
-
}
|
|
41
|
+
if (name === 'model' && newValue && this._hasInitialized) {
|
|
42
|
+
this._reloadModel(newValue);
|
|
80
43
|
}
|
|
81
44
|
}
|
|
82
45
|
|
|
83
46
|
connectedCallback() {
|
|
84
|
-
console.log("PrefViewer: connectedCallback");
|
|
85
|
-
// 1) Determine modelUrl now that element is connected
|
|
86
|
-
if (!this.hasAttribute("model")) {
|
|
87
|
-
this.modelUrl = new URL("./models/patata.gltf", import.meta.url).href;
|
|
88
|
-
console.log(`PrefViewer: no model attribute, defaulting to ${this.modelUrl}`);
|
|
89
|
-
} else {
|
|
90
|
-
this.modelUrl = this.getAttribute("model");
|
|
91
|
-
console.log(`PrefViewer: model attribute present, using ${this.modelUrl}`);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// 2) Initialize Babylon (engine + scene + camera + lights + hooks)
|
|
95
47
|
this._initializeBabylon();
|
|
96
|
-
|
|
97
|
-
// 3) Mark that initialization is done
|
|
98
48
|
this._hasInitialized = true;
|
|
99
|
-
|
|
100
|
-
// 4) Load whatever modelUrl we have
|
|
101
|
-
this._reloadModel();
|
|
102
49
|
}
|
|
103
50
|
|
|
104
51
|
disconnectedCallback() {
|
|
105
|
-
console.log("PrefViewer: disconnectedCallback - disposing engine");
|
|
106
52
|
this._disposeEngine();
|
|
107
|
-
|
|
53
|
+
if (this._onWindowResize) {
|
|
54
|
+
window.removeEventListener('resize', this._onWindowResize);
|
|
55
|
+
}
|
|
108
56
|
}
|
|
109
57
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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);
|
|
131
83
|
}
|
|
132
84
|
|
|
133
85
|
_initializeBabylon() {
|
|
134
|
-
console.log("PrefViewer: _initializeBabylon - creating engine and scene");
|
|
135
|
-
|
|
136
|
-
// 1) Create the Babylon engine & scene
|
|
137
86
|
this.engine = new Engine(this.canvas, true, { alpha: true });
|
|
138
87
|
this.scene = new Scene(this.engine);
|
|
139
88
|
this.scene.clearColor = new Color4(1, 1, 1, 1);
|
|
140
89
|
|
|
141
|
-
//
|
|
142
|
-
console.log("PrefViewer: _createCamera and _createLights");
|
|
143
|
-
this._createCamera();
|
|
144
|
-
this._createLights();
|
|
145
|
-
|
|
146
|
-
// 3) Hook up input/event handlers (e.g. wheel-to-zoom)
|
|
147
|
-
console.log("PrefViewer: _setupEventListeners");
|
|
148
|
-
this._setupEventListeners();
|
|
149
|
-
|
|
150
|
-
// 4) Start Babylon’s render loop
|
|
151
|
-
console.log("PrefViewer: Starting render loop");
|
|
152
|
-
this.engine.runRenderLoop(() => {
|
|
153
|
-
if (this.scene) {
|
|
154
|
-
this.scene.render();
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
|
-
this._onWindowResize = () => {
|
|
158
|
-
console.log("PrefViewer: Window resized - calling engine.resize()");
|
|
159
|
-
this.engine.resize();
|
|
160
|
-
};
|
|
161
|
-
window.addEventListener("resize", this._onWindowResize);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
_createCamera() {
|
|
165
|
-
console.log("PrefViewer: _createCamera");
|
|
166
|
-
// ArcRotateCamera that orbits around origin
|
|
90
|
+
// Cámara orbitadora
|
|
167
91
|
this.camera = new ArcRotateCamera(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
Math.PI / 3,
|
|
171
|
-
10,
|
|
172
|
-
Vector3.Zero(),
|
|
173
|
-
this.scene
|
|
92
|
+
'camera', Math.PI / 2, Math.PI / 3, 10,
|
|
93
|
+
Vector3.Zero(), this.scene
|
|
174
94
|
);
|
|
175
95
|
this.camera.attachControl(this.canvas, true);
|
|
176
|
-
}
|
|
177
96
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
this.hemiLight = new HemisphericLight(
|
|
181
|
-
"hemiLight",
|
|
182
|
-
new Vector3(0, 1, 0),
|
|
183
|
-
this.scene
|
|
184
|
-
);
|
|
97
|
+
// Iluminación básica
|
|
98
|
+
this.hemiLight = new HemisphericLight('hemi', new Vector3(0, 1, 0), this.scene);
|
|
185
99
|
this.hemiLight.intensity = 0.6;
|
|
186
|
-
|
|
187
|
-
this.dirLight = new DirectionalLight(
|
|
188
|
-
"dirLight",
|
|
189
|
-
new Vector3(-0.5, -1, -0.5),
|
|
190
|
-
this.scene
|
|
191
|
-
);
|
|
100
|
+
this.dirLight = new DirectionalLight('dir', new Vector3(-0.5, -1, -0.5), this.scene);
|
|
192
101
|
this.dirLight.position = new Vector3(0, 5, 0);
|
|
193
102
|
this.dirLight.intensity = 0.8;
|
|
194
|
-
}
|
|
195
103
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
// Zoom toward point-of-interest on wheel scroll
|
|
199
|
-
this.canvas.addEventListener("wheel", (evt) => {
|
|
104
|
+
// Zoom con rueda apuntando al punto
|
|
105
|
+
this.canvas.addEventListener('wheel', evt => {
|
|
200
106
|
if (!this.scene || !this.camera) return;
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
const pivotPoint = pickResult.hit
|
|
206
|
-
? pickResult.pickedPoint.clone()
|
|
207
|
-
: this.camera.target.clone();
|
|
208
|
-
this.camera.target = pivotPoint;
|
|
209
|
-
this.camera.inertialRadiusOffset +=
|
|
210
|
-
evt.deltaY * this.camera.wheelPrecision * 0.01;
|
|
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;
|
|
211
111
|
evt.preventDefault();
|
|
212
112
|
});
|
|
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);
|
|
213
120
|
}
|
|
214
121
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}
|
|
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;
|
|
222
128
|
|
|
223
|
-
// Dispose
|
|
224
|
-
this.
|
|
129
|
+
// Dispose de mallas anteriores
|
|
130
|
+
this.scene.meshes.slice().forEach(m => {
|
|
131
|
+
if (m.getClassName() === 'Mesh') m.dispose();
|
|
132
|
+
});
|
|
225
133
|
|
|
226
134
|
try {
|
|
227
|
-
console.log(`PrefViewer: ImportMeshAsync("${this.modelUrl}")`);
|
|
228
135
|
const result = await SceneLoader.ImportMeshAsync(
|
|
229
|
-
null,
|
|
230
|
-
"",
|
|
231
|
-
this.modelUrl,
|
|
232
|
-
this.scene,
|
|
233
|
-
undefined,
|
|
234
|
-
// ".gltf"
|
|
136
|
+
null, '', source, this.scene
|
|
235
137
|
);
|
|
236
|
-
console.log("PrefViewer: Model loaded, creating default camera/light if needed");
|
|
237
138
|
this.scene.createDefaultCameraOrLight(true, true, true);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
meshes: result.meshes,
|
|
244
|
-
particleSystems: result.particleSystems
|
|
245
|
-
},
|
|
246
|
-
bubbles: true,
|
|
247
|
-
composed: true
|
|
248
|
-
})
|
|
249
|
-
);
|
|
139
|
+
this.dispatchEvent(new CustomEvent('model-loaded', {
|
|
140
|
+
detail: { meshes: result.meshes, particleSystems: result.particleSystems },
|
|
141
|
+
bubbles: true,
|
|
142
|
+
composed: true
|
|
143
|
+
}));
|
|
250
144
|
} catch (err) {
|
|
251
|
-
console.error(
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
composed: true
|
|
258
|
-
})
|
|
259
|
-
);
|
|
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
|
+
}));
|
|
260
151
|
}
|
|
261
152
|
}
|
|
262
153
|
|
|
263
|
-
_disposePreviousMeshes() {
|
|
264
|
-
console.log("PrefViewer: _disposePreviousMeshes");
|
|
265
|
-
if (!this.scene) return;
|
|
266
|
-
this.scene.meshes.slice().forEach((mesh) => {
|
|
267
|
-
if (mesh.getClassName() === "Mesh") {
|
|
268
|
-
console.log(`PrefViewer: Disposing mesh ${mesh.name}`);
|
|
269
|
-
mesh.dispose();
|
|
270
|
-
}
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// ====== Cleanup ======
|
|
275
154
|
_disposeEngine() {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
this.dirLight = null;
|
|
284
|
-
}
|
|
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;
|
|
285
162
|
}
|
|
286
163
|
}
|
|
287
164
|
|
|
288
|
-
customElements.define(
|
|
165
|
+
customElements.define('pref-viewer', PrefViewer);
|