@preference-sl/pref-viewer 2.11.0-beta.0 → 2.11.0-beta.1

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/src/index.js CHANGED
@@ -1,225 +1,144 @@
1
+ import { PrefViewer2D } from "./pref-viewer-2d.js";
2
+ import { PrefViewer3D } from "./pref-viewer-3d.js";
3
+ import {PrefViewerTask} from "./pref-viewer-task.js";
4
+
1
5
  /**
2
- * =============================================================================
3
- * PrefViewer Web Component (JavaScript)
4
- * =============================================================================
6
+ * PrefViewer - Custom Web Component for rendering and managing 2D and 3D product visualizations.
7
+ *
8
+ * Overview:
9
+ * - Encapsulates both 2D and 3D viewers using Babylon.js, supporting glTF/GLB models and environments.
10
+ * - Handles loading from remote URLs, Base64 data URIs, and IndexedDB sources.
11
+ * - Provides a unified API for loading models, scenes, drawings, materials, and configuration via attributes or methods.
12
+ * - Manages an internal task queue for sequential processing of viewer operations.
13
+ * - Emits custom events for loading, errors, and state changes to facilitate integration.
5
14
  *
6
- * Overview
7
- * --------
8
- * `PrefViewer` is a self-contained Web Component built with Babylon.js that:
9
- * Renders the glTF or GLB model of the product ('model') and merges it with the glTF or GLB model of environment ('scene') into a <canvas> inside its shadow DOM.
10
- * • Supports loading via remote URL (storage.url), Base64 data URI (storage.url), or from a Base64 stored entry in IndexedDB (storage.db, storage.table, storage.id).
11
- * • The data for loading both models is provided via the 'config' (both model and scene), 'model' and 'scene' attributes. The 'config' attribute can carry more initial configurations than just the models.
12
- * • Exposes methods for making changes to the scene that replicate what the attribute observables do: 'loadConfig', 'loadModel', 'loadScene'.
13
- * • Automatically handles scene creation (engine, camera, lighting) and resource cleanup.
14
- * • Emits 'model-loaded' and 'model-error' events for integration.
15
+ * Usage:
16
+ * - Use as a custom HTML element: <pref-viewer ...>
17
+ * - Configure via attributes (e.g., config, model, scene, materials, drawing, options).
18
+ * - Control visibility, downloads, and viewer mode via public methods.
15
19
  *
16
- * Usage
17
- * -----
18
- * Load model from IndexedDB:
19
- * ```html
20
- * <pref-viewer
21
- * model='{ "storage": { "db": "PrefConfiguratorDB", "table": "gltfModels", "id": "1234-1234-1234-1234-1234" }, "visible": true" }'
22
- * style="width:800px; height:600px;">
23
- * </pref-viewer>
24
- * ```
20
+ * Public Methods:
21
+ * - loadConfig(config), loadModel(model), loadScene(scene), loadMaterials(materials), loadDrawing(drawing)
22
+ * - setOptions(options)
23
+ * - setMode(mode): Sets the viewer mode to "2d" or "3d" and updates component visibility.
24
+ * - showModel(), hideModel(), showScene(), hideScene()
25
+ * - downloadModelGLB(), downloadModelUSDZ(), downloadModelAndSceneGLB(), downloadModelAndSceneUSDZ()
25
26
  *
26
- * Load scene a URL:
27
- * ```html
28
- * <pref-viewer
29
- * scene='{ "storage": { "url" : "https://example.com/scenes/scene1.gltf" }, "visible": true" }'
30
- * style="width:800px; height:600px;">
31
- * </pref-viewer>
32
- * ```
27
+ * Public Properties:
28
+ * - initialized: Indicates if the viewer is initialized.
29
+ * - loaded: Indicates if the viewer has finished loading.
30
+ * - loading: Indicates if the viewer is currently loading.
33
31
  *
34
- * Load model from Base64 data:
35
- * ```html
36
- * <pref-viewer
37
- * model='{ "storage": { "url" : "data:model/gltf+json;base64,UEsDB..." }, "visible": true" }'
38
- * style="width:800px; height:600px;">
39
- * </pref-viewer>
40
- * ```
32
+ * Events:
33
+ * - "scene-loading": Dispatched when a loading operation starts.
34
+ * - "scene-loaded": Dispatched when a loading operation completes.
35
+ * - "scene-error": Dispatched when initialization fails.
36
+ *
37
+ * Notes:
38
+ * - Automatically creates and manages 2D and 3D viewer components in its shadow DOM.
39
+ * - Processes tasks sequentially to ensure consistent state.
40
+ * - Designed for extensibility and integration in product configurators and visualization tools.
41
41
  */
42
- import { Engine, Scene, ArcRotateCamera, Vector3, Color4, HemisphericLight, DirectionalLight, PointLight, ShadowGenerator, LoadAssetContainerAsync, Tools, WebXRSessionManager, WebXRDefaultExperience, MeshBuilder, WebXRFeatureName, HDRCubeTexture, IblShadowsRenderPipeline } from "@babylonjs/core";
43
- import "@babylonjs/loaders";
44
- import { USDZExportAsync, GLTF2Export } from "@babylonjs/serializers";
45
- import "@babylonjs/loaders/glTF/2.0/Extensions/KHR_draco_mesh_compression";
46
- import { DracoCompression } from "@babylonjs/core/Meshes/Compression/dracoCompression";
47
- import { initDb, loadModel } from "./gltf-storage.js";
48
- import { FileStorage } from "./file-storage.js";
49
-
50
- class PrefViewerTask {
51
- static Types = Object.freeze({
52
- Config: "config",
53
- Environment: "environment",
54
- Materials: "materials",
55
- Model: "model",
56
- Options: "options",
57
- });
58
-
59
- /**
60
- * value: any payload for the task
61
- * type: must match one of PrefViewerTask.Types values (case-insensitive)
62
- */
63
- constructor(value, type) {
64
- this.value = value;
65
-
66
- const t = typeof type === "string" ? type.toLowerCase() : String(type).toLowerCase();
67
- const allowed = Object.values(PrefViewerTask.Types);
68
- if (!allowed.includes(t)) {
69
- throw new TypeError(`PrefViewerTask: invalid type "${type}". Allowed types: ${allowed.join(", ")}`);
70
- }
71
- this.type = t;
72
-
73
- Object.freeze(this);
74
- }
75
- }
76
-
77
42
  class PrefViewer extends HTMLElement {
78
- initialized = false;
79
- loaded = false;
80
- loading = false;
81
- #taskQueue = [];
82
- #fileStorage = new FileStorage("PrefViewer", "Files");
83
-
84
- #data = {
85
- containers: {
86
- model: {
87
- name: "model",
88
- assetContainer: null,
89
- show: true,
90
- storage: null,
91
- visible: false,
92
- size: null,
93
- timeStamp: null,
94
- changed: { pending: false, success: false },
95
- },
96
- environment: {
97
- name: "environment",
98
- assetContainer: null,
99
- show: true,
100
- storage: null,
101
- visible: false,
102
- size: null,
103
- timeStamp: null,
104
- changed: { pending: false, success: false },
105
- },
106
- materials: {
107
- name: "materials",
108
- assetContainer: null,
109
- storage: null,
110
- show: true,
111
- visible: false,
112
- size: null,
113
- timeStamp: null,
114
- changed: { pending: false, success: false },
115
- },
116
- },
117
- options: {
118
- camera: {
119
- value: null,
120
- locked: true,
121
- changed: { pending: false, success: false },
122
- },
123
- materials: {
124
- innerWall: {
125
- value: null,
126
- prefix: "innerWall",
127
- changed: { pending: false, success: false },
128
- },
129
- outerWall: {
130
- value: null,
131
- prefix: "outerWall",
132
- changed: { pending: false, success: false },
133
- },
134
- innerFloor: {
135
- value: null,
136
- prefix: "innerFloor",
137
- changed: { pending: false, success: false },
138
- },
139
- outerFloor: {
140
- value: null,
141
- prefix: "outerFloor",
142
- changed: { pending: false, success: false },
143
- },
144
- },
145
- },
146
- };
43
+ #isInitialized = false;
44
+ #isLoaded = false;
45
+ #isLoading = false;
46
+ #mode = "3d";
147
47
 
148
- // DOM elements
149
- #wrapper = null;
150
- #canvas = null;
48
+ #taskQueue = [];
151
49
 
152
- // Babylon.js core objects
153
- #engine = null;
154
- #scene = null;
155
- #camera = null;
156
- #hemiLight = null;
157
- #dirLight = null;
158
- #cameraLight = null;
159
- #shadowGen = null;
160
- #XRExperience = null;
50
+ #component2D = null;
51
+ #component3D = null;
161
52
 
53
+ /**
54
+ * Creates a new PrefViewer instance and attaches a shadow DOM.
55
+ * Initializes internal state and component references.
56
+ * @public
57
+ */
162
58
  constructor() {
163
59
  super();
164
60
  this.attachShadow({ mode: "open" });
165
- this.#createCanvas();
166
- this.#wrapCanvas();
167
- // Point to whichever version you packaged or want to use:
168
- const DRACO_BASE = "https://www.gstatic.com/draco/versioned/decoders/1.5.7";
169
- DracoCompression.Configuration.decoder = {
170
- // loader for the “wrapper” that pulls in the real WASM
171
- wasmUrl: `${DRACO_BASE}/draco_wasm_wrapper_gltf.js`,
172
- // the raw WebAssembly binary
173
- wasmBinaryUrl: `${DRACO_BASE}/draco_decoder_gltf.wasm`,
174
- // JS fallback if WASM isn’t available
175
- fallbackUrl: `${DRACO_BASE}/draco_decoder_gltf.js`,
176
- };
177
61
  }
178
62
 
63
+ /**
64
+ * Returns the list of attributes to observe for changes.
65
+ * @public
66
+ * @returns {string[]} Array of attribute names to observe.
67
+ */
179
68
  static get observedAttributes() {
180
- return ["config", "model", "scene", "show-model", "show-scene"];
69
+ return ["config", "drawing", "materials", "mode", "model", "scene", "options", "show-model", "show-scene"];
181
70
  }
182
71
 
72
+ /**
73
+ * Observes changes to specific attributes and triggers corresponding actions.
74
+ * Loads configuration, drawing, model, scene, materials, or options when their attributes change.
75
+ * Toggles model or scene visibility when "show-model" or "show-scene" attributes change.
76
+ * @public
77
+ * @param {string} name - The name of the changed attribute.
78
+ * @param {*} _old - The previous value of the attribute (unused).
79
+ * @param {*} value - The new value of the attribute.
80
+ * @returns {void}
81
+ */
183
82
  attributeChangedCallback(name, _old, value) {
184
- let data = null;
185
83
  switch (name) {
186
84
  case "config":
187
85
  this.loadConfig(value);
188
86
  break;
87
+ case "drawing":
88
+ this.loadDrawing(value);
89
+ break;
90
+ case "materials":
91
+ this.loadMaterials(value);
92
+ break;
93
+ case "mode":
94
+ if (_old === value || value.toLowerCase() === this.#mode) {
95
+ return;
96
+ }
97
+ this.setMode(value.toLowerCase());
98
+ break;
189
99
  case "model":
190
100
  this.loadModel(value);
191
101
  break;
192
102
  case "scene":
193
103
  this.loadScene(value);
194
104
  break;
195
- case "materials":
196
- this.loadMaterials(value);
197
- break;
198
105
  case "options":
199
106
  this.setOptions(value);
200
107
  break;
201
108
  case "show-model":
202
- data = value.toLowerCase?.() === "true";
203
- if (this.initialized) {
204
- data ? this.showModel() : this.hideModel();
205
- } else {
206
- this.#data.containers.model.show = data;
109
+ if (_old === value) {
110
+ return;
207
111
  }
112
+ const showModel = value.toLowerCase() === "true";
113
+ showModel ? this.showModel() : this.hideModel();
208
114
  break;
209
115
  case "show-scene":
210
- data = value.toLowerCase?.() === "true";
211
- if (this.initialized) {
212
- data ? this.showScene() : this.hideScene();
213
- } else {
214
- this.#data.containers.environment.show = data;
116
+ if (_old === value) {
117
+ return;
215
118
  }
119
+ const showScene = value.toLowerCase() === "true";
120
+ showScene ? this.showScene() : this.hideScene();
216
121
  break;
217
122
  }
218
123
  }
219
124
 
125
+ /**
126
+ * Called when the element is inserted into the DOM.
127
+ * Initializes the 3D and 2D viewer components and starts processing tasks.
128
+ * If the "config" attribute is missing, dispatches a "scene-error" event and stops initialization.
129
+ * @public
130
+ * @returns {void|boolean} Returns false if initialization fails; otherwise void.
131
+ */
220
132
  connectedCallback() {
133
+ this.#createComponent3D();
134
+ this.#createComponent2D();
135
+
136
+ if (!this.hasAttribute("mode")) {
137
+ this.setMode();
138
+ }
139
+
221
140
  if (!this.hasAttribute("config")) {
222
- const error = 'PrefViewer: provide "models" as array of model and environment';
141
+ const error = 'PrefViewer: provide "config" as a configuration object to initialize the viewer.';
223
142
  console.error(error);
224
143
  this.dispatchEvent(
225
144
  new CustomEvent("scene-error", {
@@ -232,45 +151,98 @@ class PrefViewer extends HTMLElement {
232
151
  return false;
233
152
  }
234
153
 
235
- this.#initializeBabylon();
236
- this.initialized = true;
154
+ this.#isInitialized = true;
237
155
  this.#processNextTask();
238
156
  }
239
157
 
240
- disconnectedCallback() {
241
- this.#disposeEngine();
242
- this.#canvasResizeObserver.disconnect();
158
+ /**
159
+ * Creates and appends the 2D viewer component to the shadow DOM.
160
+ * Sets the "visible" attribute to true by default.
161
+ * @private
162
+ * @returns {void}
163
+ */
164
+ #createComponent2D() {
165
+ this.#component2D = document.createElement("pref-viewer-2d");
166
+ this.#component2D.setAttribute("visible", "false");
167
+ this.shadowRoot.appendChild(this.#component2D);
243
168
  }
244
169
 
245
- // Web Component
246
- #createCanvas() {
247
- this.#canvas = document.createElement("canvas");
248
- Object.assign(this.#canvas.style, {
249
- width: "100%",
250
- height: "100%",
251
- display: "block",
252
- outline: "none",
253
- });
170
+ /**
171
+ * Creates and appends the 3D viewer component to the shadow DOM.
172
+ * Sets the "visible" attribute to true by default.
173
+ * @private
174
+ * @returns {void}
175
+ */
176
+ #createComponent3D() {
177
+ this.#component3D = document.createElement("pref-viewer-3d");
178
+ this.#component3D.setAttribute("visible", "false");
179
+ this.shadowRoot.appendChild(this.#component3D);
254
180
  }
255
181
 
256
- #wrapCanvas() {
257
- this.#wrapper = document.createElement("div");
258
- Object.assign(this.#wrapper.style, {
259
- width: "100%",
260
- height: "100%",
261
- position: "relative",
262
- });
263
- this.#wrapper.appendChild(this.#canvas);
264
- this.shadowRoot.append(this.#wrapper);
182
+ /**
183
+ * Adds a new task to the internal queue for processing.
184
+ * If the viewer is initialized and not currently loading, immediately processes the next task.
185
+ * @private
186
+ * @param {*} value - The payload or data for the task.
187
+ * @param {string} type - The type of task (see PrefViewerTask.Types).
188
+ * @returns {void}
189
+ */
190
+ #addTaskToQueue(value, type) {
191
+ this.#taskQueue.push(new PrefViewerTask(value, type));
192
+ if (this.#isInitialized && !this.#isLoading) {
193
+ this.#processNextTask();
194
+ }
265
195
  }
266
196
 
267
- #setStatusLoading() {
268
- this.loaded = false;
269
- this.loading = true;
270
- if (this.hasAttribute("loaded")) {
271
- this.removeAttribute("loaded");
197
+ /**
198
+ * Processes the next task in the queue, if any.
199
+ * Dispatches the task to the appropriate handler based on its type.
200
+ * @private
201
+ * @returns {boolean|void} Returns false if the queue is empty; otherwise void.
202
+ */
203
+ #processNextTask() {
204
+ if (!this.#taskQueue.length) {
205
+ return false;
272
206
  }
273
- this.setAttribute("loading", "");
207
+ const task = this.#taskQueue[0];
208
+ this.#taskQueue.shift();
209
+ switch (task.type) {
210
+ case PrefViewerTask.Types.Config:
211
+ this.#processConfig(task.value);
212
+ break;
213
+ case PrefViewerTask.Types.Drawing:
214
+ this.#processDrawing(task.value);
215
+ break;
216
+ case PrefViewerTask.Types.Environment:
217
+ this.#processEnvironment(task.value);
218
+ break;
219
+ case PrefViewerTask.Types.Materials:
220
+ this.#processMaterials(task.value);
221
+ break;
222
+ case PrefViewerTask.Types.Model:
223
+ this.#processModel(task.value);
224
+ break;
225
+ case PrefViewerTask.Types.Options:
226
+ this.#processOptions(task.value);
227
+ break;
228
+ case PrefViewerTask.Types.Visibility:
229
+ this.#processVisibility(task.value);
230
+ break;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Handles the start of a 3D loading operation.
236
+ * Updates loading state, sets attributes, and dispatches a "scene-loading" event.
237
+ * @private
238
+ * @returns {void}
239
+ */
240
+ #on3DLoading() {
241
+ this.#isLoaded = false;
242
+ this.#isLoading = true;
243
+
244
+ this.removeAttribute("loaded-3d");
245
+ this.setAttribute("loading-3d", "");
274
246
  this.dispatchEvent(
275
247
  new CustomEvent("scene-loading", {
276
248
  bubbles: true,
@@ -278,35 +250,16 @@ class PrefViewer extends HTMLElement {
278
250
  composed: true,
279
251
  })
280
252
  );
281
- this.#engine.stopRenderLoop(this.#renderLoop);
282
253
  }
283
254
 
284
- async #setStatusLoaded() {
285
- const toLoadDetail = {
286
- container_model: !!this.#data.containers.model.changed.pending,
287
- container_environment: !!this.#data.containers.environment.changed.pending,
288
- container_materials: !!this.#data.containers.materials.changed.pending,
289
- options_camera: !!this.#data.options.camera.changed.pending,
290
- options_innerWallMaterial: !!this.#data.options.materials.innerWall.changed.pending,
291
- options_outerWallMaterial: !!this.#data.options.materials.outerWall.changed.pending,
292
- options_innerFloorMaterial: !!this.#data.options.materials.innerFloor.changed.pending,
293
- options_outerFloorMaterial: !!this.#data.options.materials.outerFloor.changed.pending,
294
- };
295
- const loadedDetail = {
296
- container_model: !!this.#data.containers.model.changed.success,
297
- container_environment: !!this.#data.containers.environment.changed.success,
298
- container_materials: !!this.#data.containers.materials.changed.success,
299
- options_camera: !!this.#data.options.camera.changed.success,
300
- options_innerWallMaterial: !!this.#data.options.materials.innerWall.changed.success,
301
- options_outerWallMaterial: !!this.#data.options.materials.outerWall.changed.success,
302
- options_innerFloorMaterial: !!this.#data.options.materials.innerFloor.changed.success,
303
- options_outerFloorMaterial: !!this.#data.options.materials.outerFloor.changed.success,
304
- };
305
- const detail = {
306
- tried: toLoadDetail,
307
- success: loadedDetail,
308
- };
309
-
255
+ /**
256
+ * Handles the completion of a 3D loading operation.
257
+ * Updates loading state, sets attributes, dispatches a "scene-loaded" event, and processes the next task.
258
+ * @private
259
+ * @param {object} [detail={}] - Optional details to include in the event.
260
+ * @returns {void}
261
+ */
262
+ #on3DLoaded(detail = {}) {
310
263
  this.dispatchEvent(
311
264
  new CustomEvent("scene-loaded", {
312
265
  bubbles: true,
@@ -316,857 +269,224 @@ class PrefViewer extends HTMLElement {
316
269
  })
317
270
  );
318
271
 
319
- await this.#scene.whenReadyAsync();
320
- this.#engine.runRenderLoop(this.#renderLoop);
321
-
322
- this.#resetChangedFlags();
323
-
324
- if (this.hasAttribute("loading")) {
325
- this.removeAttribute("loading");
326
- }
327
- this.setAttribute("loaded", "");
328
-
329
- this.loaded = true;
330
- this.loading = false;
331
-
332
- this.#processNextTask();
333
- }
334
-
335
- // Data
336
- #checkCameraChanged(options) {
337
- if (!options || !options.camera) {
338
- return false;
339
- }
340
- const prev = this.#data.options.camera.value;
341
- const changed = options.camera !== prev;
342
-
343
- this.#data.options.camera.changed.pending = changed;
344
- this.#data.options.camera.changed.success = false;
345
- if (changed) {
346
- this.#data.options.camera.changed.value = prev;
347
- this.#data.options.camera.changed.locked = this.#data.options.camera.locked;
348
- this.#data.options.camera.value = options.camera;
349
- }
350
- return changed;
351
- }
352
-
353
- #checkMaterialsChanged(options) {
354
- if (!options) {
355
- return false;
356
- }
357
- let someChanged = false;
358
- Object.keys(this.#data.options.materials).forEach((material) => {
359
- const key = `${material}Material`;
360
- const state = this.#data.options.materials[material];
361
- const prev = state.value;
362
- const incoming = options[key];
363
- const changed = !!incoming && incoming !== prev;
364
-
365
- state.changed.pending = changed;
366
- state.changed.success = false;
367
- if (changed) {
368
- state.changed.value = prev;
369
- state.value = incoming;
370
- }
371
- someChanged = someChanged || changed;
372
- });
373
- return someChanged;
374
- }
375
-
376
- #storeChangedFlagsForContainer(container, success) {
377
- if (success) {
378
- container.timeStamp = container.changed.timeStamp;
379
- container.size = container.changed.size;
380
- container.changed.success = true;
381
- } else {
382
- container.changed.success = false;
383
- }
384
- }
385
-
386
- #resetChangedFlags() {
387
- const reset = (node) => {
388
- node.changed = { pending: false, success: false };
389
- };
390
- Object.values(this.#data.containers).forEach(reset);
391
- Object.values(this.#data.options.materials).forEach(reset);
392
- reset(this.#data.options.camera);
393
- }
394
-
395
- // Babylon.js
396
- async #initializeBabylon() {
397
- this.#engine = new Engine(this.#canvas, true, { alpha: true });
398
- this.#engine.disableUniformBuffers = true;
399
- this.#scene = new Scene(this.#engine);
400
- this.#scene.clearColor = new Color4(1, 1, 1, 1);
401
- this.#createCamera();
402
- this.#createLights();
403
- this.#setupInteraction();
404
- await this.#createXRExperience();
405
- this.#engine.runRenderLoop(this.#renderLoop);
406
- this.#canvasResizeObserver.observe(this.#canvas);
407
- }
408
-
409
- // If this function is defined as '#renderLoop() {}' it is not executed in 'this.#engine.runRenderLoop(this.#renderLoop)'
410
- #renderLoop = () => {
411
- this.#scene && this.#scene.render();
412
- };
413
-
414
- #addStylesToARButton() {
415
- const css = '.babylonVRicon { color: #868686; border-color: #868686; border-style: solid; margin-left: 10px; height: 50px; width: 80px; background-color: rgba(51,51,51,0.7); background-image: url(data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%222048%22%20height%3D%221152%22%20viewBox%3D%220%200%202048%201152%22%20version%3D%221.1%22%3E%3Cpath%20transform%3D%22rotate%28180%201024%2C576.0000000000001%29%22%20d%3D%22m1109%2C896q17%2C0%2030%2C-12t13%2C-30t-12.5%2C-30.5t-30.5%2C-12.5l-170%2C0q-18%2C0%20-30.5%2C12.5t-12.5%2C30.5t13%2C30t30%2C12l170%2C0zm-85%2C256q59%2C0%20132.5%2C-1.5t154.5%2C-5.5t164.5%2C-11.5t163%2C-20t150%2C-30t124.5%2C-41.5q23%2C-11%2042%2C-24t38%2C-30q27%2C-25%2041%2C-61.5t14%2C-72.5l0%2C-257q0%2C-123%20-47%2C-232t-128%2C-190t-190%2C-128t-232%2C-47l-81%2C0q-37%2C0%20-68.5%2C14t-60.5%2C34.5t-55.5%2C45t-53%2C45t-53%2C34.5t-55.5%2C14t-55.5%2C-14t-53%2C-34.5t-53%2C-45t-55.5%2C-45t-60.5%2C-34.5t-68.5%2C-14l-81%2C0q-123%2C0%20-232%2C47t-190%2C128t-128%2C190t-47%2C232l0%2C257q0%2C68%2038%2C115t97%2C73q54%2C24%20124.5%2C41.5t150%2C30t163%2C20t164.5%2C11.5t154.5%2C5.5t132.5%2C1.5zm939%2C-298q0%2C39%20-24.5%2C67t-58.5%2C42q-54%2C23%20-122%2C39.5t-143.5%2C28t-155.5%2C19t-157%2C11t-148.5%2C5t-129.5%2C1.5q-59%2C0%20-130%2C-1.5t-148%2C-5t-157%2C-11t-155.5%2C-19t-143.5%2C-28t-122%2C-39.5q-34%2C-14%20-58.5%2C-42t-24.5%2C-67l0%2C-257q0%2C-106%2040.5%2C-199t110%2C-162.5t162.5%2C-109.5t199%2C-40l81%2C0q27%2C0%2052%2C14t50%2C34.5t51%2C44.5t55.5%2C44.5t63.5%2C34.5t74%2C14t74%2C-14t63.5%2C-34.5t55.5%2C-44.5t51%2C-44.5t50%2C-34.5t52%2C-14l14%2C0q37%2C0%2070%2C0.5t64.5%2C4.5t63.5%2C12t68%2C23q71%2C30%20128.5%2C78.5t98.5%2C110t63.5%2C133.5t22.5%2C149l0%2C257z%22%20fill%3D%22white%22%20/%3E%3C/svg%3E%0A); background-size: 80%; background-repeat:no-repeat; background-position: center; border: none; outline: none; transition: transform 0.125s ease-out } .babylonVRicon:hover { transform: scale(1.05) } .babylonVRicon:active {background-color: rgba(51,51,51,1) } .babylonVRicon:focus {background-color: rgba(51,51,51,1) }.babylonVRicon.vrdisplaypresenting { background-image: none;} .vrdisplaypresenting::after { content: "EXIT"} .xr-error::after { content: "ERROR"}';
416
- const style = document.createElement("style");
417
- style.appendChild(document.createTextNode(css));
418
- this.#wrapper.appendChild(style);
419
- }
420
-
421
- async #createXRExperience() {
422
- if (this.#XRExperience) {
423
- return true;
424
- }
425
-
426
- const sessionMode = "immersive-ar";
427
- const sessionSupported = await WebXRSessionManager.IsSessionSupportedAsync(sessionMode);
428
- if (!sessionSupported) {
429
- console.info("PrefViewer: WebXR in mode AR is not supported");
430
- return false;
431
- }
432
-
433
- try {
434
- const ground = MeshBuilder.CreateGround("ground", { width: 1000, height: 1000 }, this.#scene);
435
- ground.isVisible = false;
436
-
437
- const options = {
438
- floorMeshes: [ground],
439
- uiOptions: {
440
- sessionMode: sessionMode,
441
- renderTarget: "xrLayer",
442
- referenceSpaceType: "local",
443
- },
444
- optionalFeatures: true,
445
- };
446
-
447
- this.#XRExperience = await WebXRDefaultExperience.CreateAsync(this.#scene, options);
448
-
449
- const featuresManager = this.#XRExperience.baseExperience.featuresManager;
450
- featuresManager.enableFeature(WebXRFeatureName.TELEPORTATION, "stable", {
451
- xrInput: this.#XRExperience.input,
452
- floorMeshes: [ground],
453
- timeToTeleport: 1500,
454
- });
455
-
456
- this.#XRExperience.baseExperience.sessionManager.onXRReady.add(() => {
457
- // Set the initial position of xrCamera: use nonVRCamera, which contains a copy of the original this.#scene.activeCamera before entering XR
458
- this.#XRExperience.baseExperience.camera.setTransformationFromNonVRCamera(this.#XRExperience.baseExperience._nonVRCamera);
459
- this.#XRExperience.baseExperience.camera.setTarget(Vector3.Zero());
460
- this.#XRExperience.baseExperience.onInitialXRPoseSetObservable.notifyObservers(this.#XRExperience.baseExperience.camera);
461
- });
462
-
463
- this.#addStylesToARButton();
464
- } catch (error) {
465
- console.warn("PrefViewer: failed to create WebXR experience", error);
466
- this.#XRExperience = null;
467
- }
468
- }
272
+ this.removeAttribute("loading-3d");
273
+ this.setAttribute("loaded-3d", "");
469
274
 
470
- #canvasResizeObserver = new ResizeObserver(() => this.#engine && this.#engine.resize());
471
-
472
- #createCamera() {
473
- this.#camera = new ArcRotateCamera("camera", (3 * Math.PI) / 2, Math.PI * 0.47, 10, Vector3.Zero(), this.#scene);
474
- this.#camera.upperBetaLimit = Math.PI * 0.48;
475
- this.#camera.lowerBetaLimit = Math.PI * 0.25;
476
- this.#camera.lowerRadiusLimit = 5;
477
- this.#camera.upperRadiusLimit = 20;
478
- this.#camera.metadata = { locked: false };
479
- this.#camera.attachControl(this.#canvas, true);
480
- this.#scene.activeCamera = this.#camera;
275
+ this.#isLoaded = true;
276
+ this.#isLoading = false;
481
277
  }
482
278
 
483
- #createLights() {
484
- this.#initEnvironmentTexture();
485
-
486
- if (this.#scene.environmentTexture) {
487
- return true;
488
- }
489
-
490
- // 1) Stronger ambient fill
491
- this.#hemiLight = new HemisphericLight("hemiLight", new Vector3(-10, 10, -10), this.#scene);
492
- this.#hemiLight.intensity = 0.6;
493
-
494
- // 2) Directional light from the front-right, angled slightly down
495
- this.#dirLight = new DirectionalLight("dirLight", new Vector3(-10, 10, -10), this.#scene);
496
- this.#dirLight.position = new Vector3(5, 4, 5); // light is IN FRONT + ABOVE + to the RIGHT
497
- this.#dirLight.intensity = 0.6;
498
-
499
- // // 3) Soft shadows
500
- this.#shadowGen = new ShadowGenerator(1024, this.#dirLight);
501
- this.#shadowGen.useBlurExponentialShadowMap = true;
502
- this.#shadowGen.blurKernel = 16;
503
- this.#shadowGen.darkness = 0.5;
504
-
505
- // 4) Camera‐attached headlight
506
- this.#cameraLight = new PointLight("pl", this.#camera.position, this.#scene);
507
- this.#cameraLight.parent = this.#camera;
508
- this.#cameraLight.intensity = 0.3;
509
- }
279
+ /**
280
+ * Handles the start of a 2D loading operation.
281
+ * Updates loading state, sets attributes, and dispatches a "drawing-loading" event.
282
+ * @private
283
+ * @returns {void}
284
+ */
285
+ #on2DLoading() {
286
+ this.#isLoaded = false;
287
+ this.#isLoading = true;
510
288
 
511
- #initEnvironmentTexture() {
512
- return false;
513
- if (this.#scene.environmentTexture) {
514
- return true;
515
- }
516
- const hdrTextureURI = "../src/environments/noon_grass.hdr";
517
- const hdrTexture = new HDRCubeTexture(hdrTextureURI, this.#scene, 128);
518
- hdrTexture.gammaSpace = true;
519
- hdrTexture._noMipmap = false;
520
- hdrTexture.level = 2.0;
521
- this.#scene.environmentTexture = hdrTexture;
289
+ this.removeAttribute("loaded-2d");
290
+ this.setAttribute("loading-2d", "");
291
+ this.dispatchEvent(
292
+ new CustomEvent("drawing-loading", {
293
+ bubbles: true,
294
+ cancelable: false,
295
+ composed: true,
296
+ })
297
+ );
522
298
  }
523
299
 
524
- #initIBLShadows() {
525
- if (!this.#scene.environmentTexture) {
526
- return false;
527
- }
528
-
529
- let createIBLShadowPipeline = function (scene) {
530
- const pipeline = new IblShadowsRenderPipeline(
531
- "iblShadowsPipeline",
532
- scene,
533
- {
534
- resolutionExp: 7,
535
- sampleDirections: 2,
536
- ssShadowsEnabled: true,
537
- shadowRemanence: 0.8,
538
- triPlanarVoxelization: true,
539
- shadowOpacity: 0.8,
540
- },
541
- [scene.activeCamera]
542
- );
543
- pipeline.allowDebugPasses = false;
544
- pipeline.gbufferDebugEnabled = true;
545
- pipeline.importanceSamplingDebugEnabled = false;
546
- pipeline.voxelDebugEnabled = false;
547
- pipeline.voxelDebugDisplayMip = 1;
548
- pipeline.voxelDebugAxis = 2;
549
- pipeline.voxelTracingDebugEnabled = false;
550
- pipeline.spatialBlurPassDebugEnabled = false;
551
- pipeline.accumulationPassDebugEnabled = false;
552
- return pipeline;
553
- };
554
-
555
- let iblShadowsPipeline = createIBLShadowPipeline(this.#scene);
300
+ /**
301
+ * Handles the completion of a 2D loading operation.
302
+ * Updates loading state, sets attributes, dispatches a "drawing-loaded" event, and processes the next task.
303
+ * @private
304
+ * @returns {void}
305
+ */
306
+ #on2DLoaded() {
307
+ this.dispatchEvent(
308
+ new CustomEvent("drawing-loaded", {
309
+ bubbles: true,
310
+ cancelable: false,
311
+ composed: true,
312
+ })
313
+ );
556
314
 
557
- this.#scene.meshes.forEach((mesh) => {
558
- if (mesh.id.startsWith("__root__") || mesh.name === "hdri") {
559
- return false;
560
- }
561
- iblShadowsPipeline.addShadowCastingMesh(mesh);
562
- iblShadowsPipeline.updateSceneBounds();
563
- });
315
+ this.removeAttribute("loading-2d");
316
+ this.setAttribute("loaded-2d", "");
564
317
 
565
- this.#scene.materials.forEach((material) => {
566
- iblShadowsPipeline.addShadowReceivingMaterial(material);
567
- });
318
+ this.#isLoaded = true;
319
+ this.#isLoading = false;
568
320
  }
569
321
 
570
- #initShadows() {
571
- if (!this.#scene.environmentTexture) {
572
- this.#initIBLShadows();
573
- return true;
322
+ /**
323
+ * Processes a configuration object by loading it into the 3D component.
324
+ * Dispatches loading events and processes the next task when finished.
325
+ * @private
326
+ * @param {object} config - The configuration object to process.
327
+ * @returns {void}
328
+ */
329
+ #processConfig(config) {
330
+ if (!this.#component3D) {
331
+ return;
574
332
  }
575
333
 
576
- this.#scene.meshes.forEach((mesh) => {
577
- if (mesh.id.startsWith("__root__")) {
578
- return false;
579
- }
580
- mesh.receiveShadows = true;
581
- if (!mesh.name === "hdri") {
582
- this.#shadowGen.addShadowCaster(mesh, true);
583
- }
334
+ this.#on3DLoading();
335
+ this.#component3D.load(config).then((detail) => {
336
+ this.#on3DLoaded(detail);
337
+ this.#processNextTask();
584
338
  });
585
339
  }
586
340
 
587
- #setMaxSimultaneousLights() {
588
- let lightsNumber = 1; // Como mínimo una luz correspondiente a la textura de environmentTexture
589
- this.#scene.lights.forEach((light) => {
590
- if (light.isEnabled()) {
591
- ++lightsNumber;
592
- }
593
- });
594
- if (this.#scene.materials) {
595
- this.#scene.materials.forEach((material) => (material.maxSimultaneousLights = lightsNumber));
341
+ /**
342
+ * Processes a drawing object by loading it into the 2D component.
343
+ * Processes the next task when finished.
344
+ * @private
345
+ * @param {object} drawing - The drawing object to process.
346
+ * @returns {void}
347
+ */
348
+ #processDrawing(drawing) {
349
+ if (!this.#component2D) {
350
+ return;
596
351
  }
597
- }
598
352
 
599
- #setupInteraction() {
600
- this.#canvas.addEventListener("wheel", (event) => {
601
- if (!this.#scene || !this.#camera) {
602
- return false;
603
- }
604
- //const pick = this.#scene.pick(this.#scene.pointerX, this.#scene.pointerY);
605
- //this.#camera.target = pick.hit ? pick.pickedPoint.clone() : this.#camera.target;
606
- if (!this.#scene.activeCamera.metadata?.locked) {
607
- this.#scene.activeCamera.inertialRadiusOffset -= event.deltaY * this.#scene.activeCamera.wheelPrecision * 0.001;
608
- }
609
- event.preventDefault();
610
- });
611
- }
612
-
613
- #disposeEngine() {
614
- if (!this.#engine) return;
615
- this.#engine.dispose();
616
- this.#engine = this.#scene = this.#camera = null;
617
- this.#hemiLight = this.#dirLight = this.#cameraLight = null;
618
- this.#shadowGen = null;
619
- }
620
-
621
- // Utility methods for loading gltf/glb
622
- async #getServerFileDataHeader(uri) {
623
- return new Promise((resolve) => {
624
- const xhr = new XMLHttpRequest();
625
- xhr.open("HEAD", uri, true);
626
- xhr.responseType = "blob";
627
- xhr.onload = () => {
628
- if (xhr.status === 200) {
629
- const size = parseInt(xhr.getResponseHeader("Content-Length"));
630
- const timeStamp = new Date(xhr.getResponseHeader("Last-Modified")).toISOString();
631
- resolve([size, timeStamp]);
632
- } else {
633
- resolve([0, null]);
634
- }
635
- };
636
- xhr.onerror = () => {
637
- resolve([0, null]);
638
- };
639
- xhr.send();
640
- });
641
- }
642
-
643
- #transformUrl(url) {
644
- return new Promise((resolve) => {
645
- resolve(url.replace(/^blob:[^/]+\//i, "").replace(/\\/g, "/"));
353
+ this.#on2DLoading();
354
+ this.#component2D.load(drawing).then(() => {
355
+ this.#on2DLoaded();
356
+ this.#processNextTask();
646
357
  });
647
358
  }
648
359
 
649
- #decodeBase64(base64) {
650
- const [, payload] = base64.split(",");
651
- const raw = payload || base64;
652
- let decoded = "";
653
- let blob = null;
654
- let extension = null;
655
- let size = raw.length;
656
- try {
657
- decoded = atob(raw);
658
- } catch {
659
- return { blob, extension, size };
660
- }
661
- let isJson = false;
662
- try {
663
- JSON.parse(decoded);
664
- isJson = true;
665
- } catch { }
666
- extension = isJson ? ".gltf" : ".glb";
667
- const type = isJson ? "model/gltf+json" : "model/gltf-binary";
668
- const array = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
669
- blob = new Blob([array], { type });
670
- return { blob, extension, size };
671
- }
672
-
673
- async #initStorage(db, table) {
674
- if (window.gltfDB && window.gltfDB.name === db && window.gltfDB.objectStoreNames.contains(table)) {
675
- return true;
676
- }
677
- await initDb(db, table);
678
- }
679
-
680
- // Methods for managing Asset Containers
681
- #setVisibilityOfWallAndFloorInModel(show) {
682
- if (!this.#data.containers.model.assetContainer || !this.#data.containers.model.visible) {
683
- return false;
684
- }
685
- show = show !== undefined ? show : this.#data.containers.environment.visible;
686
- const prefixes = Object.values(this.#data.options.materials).map((material) => material.prefix);
687
- this.#data.containers.model.assetContainer.meshes.filter((meshToFilter) => prefixes.some((prefix) => meshToFilter.name.startsWith(prefix))).forEach((mesh) => mesh.setEnabled(show));
688
- }
689
-
690
- #setOptionsMaterial(optionMaterial) {
691
- if (!optionMaterial || !optionMaterial.prefix || !optionMaterial.value) {
692
- return false;
693
- }
694
-
695
- const material = this.#data.containers.materials.assetContainer?.materials.find((mat) => mat.name === optionMaterial.value) || null;
696
- if (!material) {
697
- return false;
698
- }
699
-
700
- const containers = [];
701
- if (this.#data.containers.model.assetContainer && (this.#data.containers.model.changed.pending || this.#data.containers.materials.changed.pending || optionMaterial.changed.pending)) {
702
- containers.push(this.#data.containers.model.assetContainer);
703
- }
704
- if (this.#data.containers.environment.assetContainer && (this.#data.containers.environment.changed.pending || this.#data.containers.materials.changed.pending || optionMaterial.changed.pending)) {
705
- containers.push(this.#data.containers.environment.assetContainer);
706
- }
707
- if (containers.length === 0) {
708
- return false;
709
- }
710
-
711
- let someSetted = false;
712
- containers.forEach((container) =>
713
- container.meshes
714
- .filter((meshToFilter) => meshToFilter.name.startsWith(optionMaterial.prefix))
715
- .forEach((mesh) => {
716
- mesh.material = material;
717
- someSetted = true;
718
- })
719
- );
720
-
721
- if (someSetted) {
722
- optionMaterial.changed.success = true;
723
- } else if (optionMaterial.changed.pending) {
724
- optionMaterial.value = optionMaterial.changed.value;
725
- optionMaterial.changed.success = false;
726
- }
727
-
728
- return someSetted;
729
- }
730
-
731
- #setOptionsMaterials() {
732
- let someSetted = false;
733
- Object.values(this.#data.options.materials).forEach((material) => {
734
- let settedMaterial = this.#setOptionsMaterial(material);
735
- someSetted = someSetted || settedMaterial;
736
- });
737
- return someSetted;
360
+ /**
361
+ * Processes an environment (scene) object by wrapping it in a config and loading it.
362
+ * @private
363
+ * @param {object} environment - The environment/scene object to process.
364
+ * @returns {void}
365
+ */
366
+ #processEnvironment(environment) {
367
+ const config = {};
368
+ config.scene = environment;
369
+ this.#processConfig(config);
738
370
  }
739
371
 
740
- #setOptionsCamera() {
741
- if (!this.#data.options.camera.value && !this.#data.options.camera.changed.pending && !this.#data.containers.model.changed.pending && !this.#data.containers.environment.changed.pending) {
742
- return false;
743
- }
744
-
745
- let camera = this.#data.containers.model.assetContainer?.cameras.find((thisCamera) => thisCamera.name === this.#data.options.camera.value) || this.#data.containers.environment.assetContainer?.cameras.find((thisCamera) => thisCamera.name === this.#data.options.camera.value) || null;
746
- if (!camera) {
747
- if (this.#data.options.camera.changed.value && this.#data.options.camera.changed.value !== this.#data.options.camera.value) {
748
- camera = this.#data.containers.model.assetContainer?.cameras.find((thisCamera) => thisCamera.name === this.#data.options.camera.changed.value) || this.#data.containers.environment.assetContainer?.cameras.find((thisCamera) => thisCamera.name === this.#data.options.camera.changed.value) || null;
749
- }
750
- if (camera) {
751
- camera.metadata = { locked: this.#data.options.camera.changed.locked };
752
- this.#data.options.camera.value = this.#data.options.camera.changed.value;
753
- this.#data.options.camera.locked = this.#data.options.camera.changed.locked;
754
- this.#data.options.camera.changed.success = false;
755
- } else {
756
- camera = this.#camera;
757
- this.#data.options.camera.value = null;
758
- this.#data.options.camera.locked = this.#camera.metadata.locked;
759
- this.#data.options.camera.changed.success = false;
760
- }
761
- } else {
762
- camera.metadata = { locked: this.#data.options.camera.locked };
763
- if (this.#data.options.camera.changed.pending) {
764
- this.#data.options.camera.changed.success = true;
765
- }
766
- }
767
- if (!this.#data.options.camera.locked && this.#data.options.camera.value !== null) {
768
- camera.attachControl(this.#canvas, true);
769
- }
770
- this.#scene.activeCamera = camera;
771
- return true;
372
+ /**
373
+ * Processes a materials object by wrapping it in a config and loading it.
374
+ * @private
375
+ * @param {object} materials - The materials object to process.
376
+ * @returns {void}
377
+ */
378
+ #processMaterials(materials) {
379
+ const config = {};
380
+ config.materials = materials;
381
+ this.#processConfig(config);
772
382
  }
773
383
 
774
- #addContainer(container) {
775
- if (!container.assetContainer || container.visible || !container.show) {
776
- return false;
777
- }
778
-
779
- container.assetContainer.addAllToScene();
780
- container.visible = true;
781
- return true;
384
+ /**
385
+ * Processes a model object by wrapping it in a config and loading it.
386
+ * @private
387
+ * @param {object} model - The model object to process.
388
+ * @returns {void}
389
+ */
390
+ #processModel(model) {
391
+ const config = {};
392
+ config.model = model;
393
+ this.#processConfig(config);
782
394
  }
783
395
 
784
- #removeContainer(container) {
785
- if (!container.assetContainer || !container.visible) {
786
- return false;
396
+ /**
397
+ * Processes viewer options by loading them into the 3D component.
398
+ * Dispatches loading events and processes the next task.
399
+ * @private
400
+ * @param {object} options - The options object to process.
401
+ * @returns {void}
402
+ */
403
+ #processOptions(options) {
404
+ if (!this.#component3D) {
405
+ return;
787
406
  }
788
407
 
789
- container.assetContainer.removeAllFromScene();
790
- container.visible = false;
791
- return true;
792
- }
793
-
794
- #replaceContainer(container, newAssetContainer) {
795
- if (container.assetContainer) {
796
- this.#removeContainer(container);
797
- container.assetContainer.dispose();
798
- container.assetContainer = null;
799
- }
800
- this.#scene.getEngine().releaseEffects();
801
- container.assetContainer = newAssetContainer;
802
- this.#addContainer(container);
803
- return true;
408
+ this.#on3DLoading();
409
+ const detail = this.#component3D.setOptions(options);
410
+ this.#on3DLoaded(detail);
411
+ this.#processNextTask();
804
412
  }
805
413
 
806
414
  /**
807
- * Replace internal URIs in a glTF AssetContainer JSON with URLs pointing to files stored in IndexedDB.
808
- * @param {JSON} assetContainerJSON AssetContainer in glTF (JSON) (modified in-place).
809
- * @param {URL} [assetContainerURL] Optional URL of the AssetContainer. Used as the base path to resolve relative URIs.
810
- * @returns {Promise<void>} Resolves when all applicable URIs have been resolved/replaced.
811
- * @description
812
- * - When provided, assetContainerURL is used as the base path for other scene files (binary buffers and all images).
813
- * If not provided (null/undefined), it is because it is the assetContainer of the model or materials whose URIs are absolute.
814
- * - According to the glTF 2.0 spec, only items inside the "buffers" and "images" arrays may have a "uri" property.
815
- * - Data URIs (embedded base64) are ignored and left unchanged.
816
- * - Matching asset URIs are normalized (backslashes converted to forward slashes) and passed to the FileStorage layer
817
- * to obtain a usable URL (object URL or cached URL).
818
- * - The function performs replacements in parallel and waits for all lookups to complete.
819
- * - The JSON is updated in-place with the resolved URLs.
820
- * @see {@link https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#uris|glTF™ 2.0 Specification - URIs}
415
+ * Processes visibility configuration for the model and scene.
416
+ * Shows or hides the model and/or scene based on the config, then processes the next task.
417
+ * @private
418
+ * @param {object} config - The visibility configuration object.
419
+ * @returns {void}
821
420
  */
822
- async #replaceSceneURIAsync(assetContainerJSON, assetContainerURL) {
823
- if (!assetContainerJSON) {
421
+ #processVisibility(config) {
422
+ if (!this.#component3D) {
824
423
  return;
825
424
  }
826
-
827
- let sceneURLBase = assetContainerURL;
828
-
829
- if (typeof assetContainerURL === "string") {
830
- const lastIndexOfSlash = assetContainerURL.lastIndexOf("/");
831
- if (lastIndexOfSlash !== -1) {
832
- sceneURLBase = assetContainerURL.substring(0, lastIndexOfSlash + 1);
833
- }
425
+ const showModel = config.model?.visible;
426
+ const showScene = config.scene?.visible;
427
+ if (showModel !== undefined) {
428
+ showModel ? this.#component3D.showModel() : this.#component3D.hideModel();
834
429
  }
835
-
836
- const arrayOfAssetsWithURI = [];
837
-
838
- /**
839
- * Check whether a value is a syntactically absolute URL.
840
- * @param {string} url Value to test.
841
- * @returns {boolean} True when `url` is a string that can be parsed by the global URL constructor (i.e. a syntactically absolute URL); false otherwise.
842
- * @description
843
- * - Returns false for non-string inputs.
844
- * - Uses the browser's URL parser, so protocol-relative URLs ("//host/...") and relative paths are considered non-absolute.
845
- * - This is a syntactic check only — it does not perform network requests or validate reachability/CORS.
846
- */
847
- var isURLAbsolute = function (url) {
848
- if (typeof url !== "string") {
849
- return false;
850
- }
851
- try {
852
- new URL(url);
853
- return true;
854
- } catch {
855
- return false;
856
- }
857
- };
858
-
859
- /**
860
- * Collect asset entries that have an external URI (non-data URI) and store a normalized absolute/relative-resolved URI for later replacement.
861
- * @param {Object} asset glTF asset entry (an element of buffers[] or images[]).
862
- * @param {number} index Index of the asset within its parent array.
863
- * @param {Array} array Reference to the parent array (buffers or images).
864
- * @returns {void} Side-effect: pushes a record into arrayOfAssetsWithURI when applicable { parent: <array>, index: <number>, uri: <string> }.
865
- */
866
- var saveAssetData = function (asset, index, array) {
867
- if (asset.uri && !asset.uri.startsWith("data:")) {
868
- const assetData = {
869
- parent: array,
870
- index: index,
871
- uri: `${!isURLAbsolute(asset.uri) && sceneURLBase ? sceneURLBase : ""}${asset.uri}`.replace(/\\\\|\\|\/\\/g, "/"),
872
- };
873
- arrayOfAssetsWithURI.push(assetData);
874
- }
875
- };
876
-
877
- if (assetContainerJSON.buffers) {
878
- assetContainerJSON.buffers.forEach((asset, index, array) => saveAssetData(asset, index, array));
430
+ if (showScene !== undefined) {
431
+ showScene ? this.#component3D.showEnvironment() : this.#component3D.hideEnvironment();
879
432
  }
880
- if (assetContainerJSON.images) {
881
- assetContainerJSON.images.forEach((asset, index, array) => saveAssetData(asset, index, array));
882
- }
883
-
884
- // Replace parallel URIs so that if files are not stored yet, they are downloaded in parallel
885
- const promisesArray = arrayOfAssetsWithURI.map(async (asset) => {
886
- const uri = await this.#fileStorage.getURL(asset.uri);
887
- if (uri) {
888
- asset.parent[asset.index].uri = uri;
889
- }
890
- });
891
- await Promise.all(promisesArray);
433
+ this.#processNextTask();
892
434
  }
893
435
 
894
- async #loadAssetContainer(container) {
895
- let storage = container?.storage;
896
-
897
- if (!storage) {
898
- return false;
899
- }
900
-
901
- let source = storage.url || null;
902
-
903
- if (storage.db && storage.table && storage.id) {
904
- await this.#initStorage(storage.db, storage.table);
905
- const object = await loadModel(storage.id, storage.table);
906
- source = object.data;
907
- if (object.timeStamp === container.timeStamp) {
908
- container.changed = { pending: false, success: false };
909
- return false;
910
- } else {
911
- container.changed = { pending: true, size: object.size, success: false, timeStamp: object.timeStamp };
912
- }
913
- }
914
-
915
- if (!source) {
916
- return false;
436
+ /**
437
+ * ---------------------------
438
+ * Public methods
439
+ * ---------------------------
440
+ */
441
+
442
+ /**
443
+ * Sets the viewer mode to "2d" or "3d" and updates component visibility accordingly.
444
+ * @public
445
+ * @param {string} [mode=this.#mode] - The mode to set ("2d" or "3d").
446
+ * @returns {void}
447
+ */
448
+ setMode(mode = this.#mode) {
449
+ if (mode !== "2d" && mode !== "3d") {
450
+ console.warn(`PrefViewer: invalid mode "${mode}". Allowed modes are "2d" and "3d".`);
451
+ return;
917
452
  }
918
-
919
- let file = null;
920
-
921
- let { blob, extension, size } = this.#decodeBase64(source);
922
- if (blob && extension) {
923
- if ((container.name === "model" || container.name === "materials") && extension === ".gltf") {
924
- const assetContainerJSON = JSON.parse(await blob.text());
925
- await this.#replaceSceneURIAsync(assetContainerJSON, source);
926
- source = `data:${JSON.stringify(assetContainerJSON)}`;
927
- } else {
928
- file = new File([blob], `${container.name}${extension}`, {
929
- type: blob.type,
930
- });
931
- }
932
- if (!container.changed.pending) {
933
- if (container.timeStamp === null && container.size === size) {
934
- container.changed = { pending: false, success: false };
935
- return false;
936
- } else {
937
- container.changed = { pending: true, size: size, success: false, timeStamp: null };
938
- }
939
- }
453
+ this.#mode = mode;
454
+ if (mode === "2d") {
455
+ this.#component3D?.hide();
456
+ this.#component2D?.show();
940
457
  } else {
941
- const extMatch = source.match(/\.(gltf|glb)(\?|#|$)/i);
942
- extension = extMatch ? `.${extMatch[1].toLowerCase()}` : ".gltf";
943
- let [fileSize, fileTimeStamp] = [await this.#fileStorage.getSize(source), await this.#fileStorage.getTimeStamp(source)];
944
- if (container.size === fileSize && container.timeStamp === fileTimeStamp) {
945
- container.changed = { pending: false, success: false };
946
- return false;
947
- } else {
948
- container.changed = { pending: true, size: fileSize, success: false, timeStamp: fileTimeStamp };
949
- if (extension === ".gltf") {
950
- const assetContainerBlob = await this.#fileStorage.getBlob(source);
951
- const assetContainerJSON = JSON.parse(await assetContainerBlob.text());
952
- await this.#replaceSceneURIAsync(assetContainerJSON, source);
953
- source = `data:${JSON.stringify(assetContainerJSON)}`;
954
- } else {
955
- source = await this.#fileStorage.getURL(source);
956
- }
957
- }
958
- }
959
-
960
- // https://doc.babylonjs.com/typedoc/interfaces/BABYLON.LoadAssetContainerOptions
961
- let options = {
962
- pluginExtension: extension,
963
- pluginOptions: {
964
- gltf: {
965
- compileMaterials: true,
966
- loadAllMaterials: true,
967
- loadOnlyMaterials: container.name === "materials",
968
- //preprocessUrlAsync: this.#transformUrl,
969
- },
970
- },
971
- };
972
-
973
- return LoadAssetContainerAsync(file || source, this.#scene, options);
974
- }
975
-
976
- async #loadContainers(loadModel = true, loadEnvironment = true, loadMaterials = true) {
977
- this.#engine.stopRenderLoop(this.#renderLoop);
978
-
979
- const promiseArray = [];
980
- promiseArray.push(loadModel ? this.#loadAssetContainer(this.#data.containers.model) : false);
981
- promiseArray.push(loadEnvironment ? this.#loadAssetContainer(this.#data.containers.environment) : false);
982
- promiseArray.push(loadMaterials ? this.#loadAssetContainer(this.#data.containers.materials) : false);
983
-
984
- Promise.allSettled(promiseArray)
985
- .then((values) => {
986
- const modelContainer = values[0];
987
- const environmentContainer = values[1];
988
- const materialsContainer = values[2];
989
-
990
- if (modelContainer.status === "fulfilled" && modelContainer.value) {
991
- modelContainer.value.lights = [];
992
- this.#replaceContainer(this.#data.containers.model, modelContainer.value);
993
- this.#storeChangedFlagsForContainer(this.#data.containers.model, true);
994
- } else {
995
- if (this.#data.containers.model.assetContainer && this.#data.containers.model.show !== this.#data.containers.model.visible) {
996
- this.#data.containers.model.show ? this.#addContainer(this.#data.containers.model) : this.#removeContainer(this.#data.containers.model);
997
- }
998
- this.#storeChangedFlagsForContainer(this.#data.containers.model, false);
999
- }
1000
-
1001
- if (environmentContainer.status === "fulfilled" && environmentContainer.value) {
1002
- this.#replaceContainer(this.#data.containers.environment, environmentContainer.value);
1003
- this.#storeChangedFlagsForContainer(this.#data.containers.environment, true);
1004
- } else {
1005
- if (this.#data.containers.environment.assetContainer && this.#data.containers.environment.show !== this.#data.containers.environment.visible) {
1006
- this.#data.containers.environment.show ? this.#addContainer(this.#data.containers.environment) : this.#removeContainer(this.#data.containers.environment);
1007
- }
1008
- this.#storeChangedFlagsForContainer(this.#data.containers.environment, false);
1009
- }
1010
-
1011
- if (materialsContainer.status === "fulfilled" && materialsContainer.value) {
1012
- this.#replaceContainer(this.#data.containers.materials, materialsContainer.value);
1013
- this.#storeChangedFlagsForContainer(this.#data.containers.materials, true);
1014
- } else {
1015
- this.#storeChangedFlagsForContainer(this.#data.containers.materials, false);
1016
- }
1017
-
1018
- this.#setOptionsMaterials();
1019
- this.#setOptionsCamera();
1020
- this.#setVisibilityOfWallAndFloorInModel();
1021
- })
1022
- .catch((error) => {
1023
- this.loaded = true;
1024
- console.error("PrefViewer: failed to load model", error);
1025
- this.dispatchEvent(
1026
- new CustomEvent("scene-error", {
1027
- bubbles: true,
1028
- cancelable: false,
1029
- composed: true,
1030
- detail: { error: error },
1031
- })
1032
- );
1033
- })
1034
- .finally(async () => {
1035
- this.#setMaxSimultaneousLights();
1036
- this.#initShadows();
1037
- await this.#setStatusLoaded();
1038
- });
1039
- }
1040
-
1041
- // Tasks
1042
- #addTaskToQueue(value, type) {
1043
- this.#taskQueue.push(new PrefViewerTask(value, type));
1044
- if (this.initialized && !this.loading) {
1045
- this.#processNextTask();
1046
- }
1047
- }
1048
-
1049
- #processNextTask() {
1050
- if (!this.#taskQueue.length) {
1051
- return false;
1052
- }
1053
- const task = this.#taskQueue[0];
1054
- this.#taskQueue.shift();
1055
- switch (task.type) {
1056
- case PrefViewerTask.Types.Config:
1057
- this.#processConfig(task.value);
1058
- break;
1059
- case PrefViewerTask.Types.Model:
1060
- this.#processModel(task.value);
1061
- break;
1062
- case PrefViewerTask.Types.Environment:
1063
- this.#processEnvironment(task.value);
1064
- break;
1065
- case PrefViewerTask.Types.Materials:
1066
- this.#processMaterials(task.value);
1067
- break;
1068
- case PrefViewerTask.Types.Options:
1069
- this.#processOptions(task.value);
1070
- break;
1071
- }
1072
- }
1073
-
1074
- #processConfig(config) {
1075
- this.#setStatusLoading();
1076
-
1077
- // Containers
1078
- const loadModel = !!config.model?.storage;
1079
- this.#data.containers.model.changed.pending = loadModel;
1080
- this.#data.containers.model.changed.success = false;
1081
- this.#data.containers.model.changed.storage = this.#data.containers.model.storage;
1082
- this.#data.containers.model.storage = loadModel ? config.model.storage : this.#data.containers.model.storage;
1083
- this.#data.containers.model.show = config.model?.visible !== undefined ? config.model.visible : this.#data.containers.model.show;
1084
-
1085
- const loadEnvironment = !!config.scene?.storage;
1086
- this.#data.containers.environment.changed.pending = loadEnvironment;
1087
- this.#data.containers.environment.changed.success = false;
1088
- this.#data.containers.environment.changed.storage = this.#data.containers.environment.storage;
1089
- this.#data.containers.environment.storage = loadEnvironment ? config.scene.storage : this.#data.containers.environment.storage;
1090
- this.#data.containers.environment.show = config.scene?.visible !== undefined ? config.scene.visible : this.#data.containers.environment.show;
1091
-
1092
- const loadMaterials = !!config.materials?.storage;
1093
- this.#data.containers.materials.changed.pending = loadMaterials;
1094
- this.#data.containers.materials.changed.success = false;
1095
- this.#data.containers.materials.changed.storage = this.#data.containers.materials.storage;
1096
- this.#data.containers.materials.storage = loadMaterials ? config.materials.storage : this.#data.containers.materials.storage;
1097
-
1098
- // Options
1099
- if (config.options) {
1100
- this.#checkCameraChanged(config.options);
1101
- this.#checkMaterialsChanged(config.options);
1102
- }
1103
-
1104
- this.#loadContainers(loadModel, loadEnvironment, loadMaterials);
1105
- }
1106
-
1107
- #processModel(model) {
1108
- this.#setStatusLoading();
1109
-
1110
- const loadModel = !!model.storage;
1111
- this.#data.containers.model.changed.pending = loadModel;
1112
- this.#data.containers.model.changed.success = false;
1113
- this.#data.containers.model.changed.storage = this.#data.containers.model.storage;
1114
- this.#data.containers.model.storage = loadModel ? model.storage : this.#data.containers.model.storage;
1115
- this.#data.containers.model.show = model.visible !== undefined ? model.visible : this.#data.containers.model.show;
1116
-
1117
- this.initialized && this.#loadContainers(loadModel, false, false);
1118
- }
1119
-
1120
- #processEnvironment(environment) {
1121
- this.#setStatusLoading();
1122
-
1123
- const loadEnvironment = !!environment.storage;
1124
- this.#data.containers.environment.changed.pending = loadEnvironment;
1125
- this.#data.containers.environment.changed.success = false;
1126
- this.#data.containers.environment.changed.storage = this.#data.containers.environment.storage;
1127
- this.#data.containers.environment.storage = loadEnvironment ? environment.storage : this.#data.containers.environment.storage;
1128
- this.#data.containers.environment.show = environment.visible !== undefined ? environment.visible : this.#data.containers.environment.show;
1129
-
1130
- this.#loadContainers(false, loadEnvironment, false);
1131
- }
1132
-
1133
- #processMaterials(materials) {
1134
- this.#setStatusLoading();
1135
-
1136
- const loadMaterials = !!materials.storage;
1137
- this.#data.containers.materials.changed.pending = loadMaterials;
1138
- this.#data.containers.materials.changed.success = false;
1139
- this.#data.containers.materials.changed.storage = this.#data.containers.materials.storage;
1140
- this.#data.containers.materials.storage = loadMaterials ? materials.storage : this.#data.containers.materials.storage;
1141
-
1142
- this.#loadContainers(false, false, loadMaterials);
1143
- }
1144
-
1145
- async #processOptions(options) {
1146
- this.#setStatusLoading();
1147
-
1148
- let someSetted = false;
1149
- if (this.#checkCameraChanged(options)) {
1150
- someSetted = someSetted || this.#setOptionsCamera();
458
+ this.#component3D?.show();
459
+ this.#component2D?.hide();
1151
460
  }
1152
- if (this.#checkMaterialsChanged(options)) {
1153
- someSetted = someSetted || this.#setOptionsMaterials();
461
+ if (this.getAttribute("mode") !== mode) {
462
+ this.setAttribute("mode", mode);
1154
463
  }
1155
-
1156
- await this.#setStatusLoaded();
1157
-
1158
- return someSetted;
1159
464
  }
1160
465
 
1161
- // Public Methods
466
+ /**
467
+ * Loads a configuration object or JSON string and adds it to the task queue.
468
+ * If the config contains a drawing, adds a drawing task as well.
469
+ * @public
470
+ * @param {object|string} config - Configuration object or JSON string.
471
+ * @returns {boolean|void} Returns false if config is invalid; otherwise void.
472
+ */
1162
473
  loadConfig(config) {
1163
474
  config = typeof config === "string" ? JSON.parse(config) : config;
1164
475
  if (!config) {
1165
476
  return false;
1166
477
  }
478
+ if (config.drawing) {
479
+ this.#addTaskToQueue(config.drawing, "drawing");
480
+ }
1167
481
  this.#addTaskToQueue(config, "config");
1168
482
  }
1169
483
 
484
+ /**
485
+ * Loads a model object or JSON string and adds it to the task queue.
486
+ * @public
487
+ * @param {object|string} model - Model object or JSON string.
488
+ * @returns {boolean|void} Returns false if model is invalid; otherwise void.
489
+ */
1170
490
  loadModel(model) {
1171
491
  model = typeof model === "string" ? JSON.parse(model) : model;
1172
492
  if (!model) {
@@ -1175,6 +495,12 @@ class PrefViewer extends HTMLElement {
1175
495
  this.#addTaskToQueue(model, "model");
1176
496
  }
1177
497
 
498
+ /**
499
+ * Loads a scene/environment object or JSON string and adds it to the task queue.
500
+ * @public
501
+ * @param {object|string} scene - Scene object or JSON string.
502
+ * @returns {boolean|void} Returns false if scene is invalid; otherwise void.
503
+ */
1178
504
  loadScene(scene) {
1179
505
  scene = typeof scene === "string" ? JSON.parse(scene) : scene;
1180
506
  if (!scene) {
@@ -1183,6 +509,12 @@ class PrefViewer extends HTMLElement {
1183
509
  this.#addTaskToQueue(scene, "environment");
1184
510
  }
1185
511
 
512
+ /**
513
+ * Loads materials object or JSON string and adds it to the task queue.
514
+ * @public
515
+ * @param {object|string} materials - Materials object or JSON string.
516
+ * @returns {boolean|void} Returns false if materials are invalid; otherwise void.
517
+ */
1186
518
  loadMaterials(materials) {
1187
519
  materials = typeof materials === "string" ? JSON.parse(materials) : materials;
1188
520
  if (!materials) {
@@ -1191,6 +523,26 @@ class PrefViewer extends HTMLElement {
1191
523
  this.#addTaskToQueue(materials, "materials");
1192
524
  }
1193
525
 
526
+ /**
527
+ * Loads a drawing object or JSON string and adds it to the task queue.
528
+ * @public
529
+ * @param {object|string} drawing - Drawing object or JSON string.
530
+ * @returns {boolean|void} Returns false if drawing is invalid; otherwise void.
531
+ */
532
+ loadDrawing(drawing) {
533
+ drawing = typeof drawing === "string" ? JSON.parse(drawing) : drawing;
534
+ if (!drawing) {
535
+ return false;
536
+ }
537
+ this.#addTaskToQueue(drawing, "drawing");
538
+ }
539
+
540
+ /**
541
+ * Sets viewer options from an object or JSON string and adds them to the task queue.
542
+ * @public
543
+ * @param {object|string} options - Options object or JSON string.
544
+ * @returns {boolean|void} Returns false if options are invalid; otherwise void.
545
+ */
1194
546
  setOptions(options) {
1195
547
  options = typeof options === "string" ? JSON.parse(options) : options;
1196
548
  if (!options) {
@@ -1199,55 +551,131 @@ class PrefViewer extends HTMLElement {
1199
551
  this.#addTaskToQueue(options, "options");
1200
552
  }
1201
553
 
554
+ /**
555
+ * Shows the 3D model by setting its visibility to true.
556
+ * Adds a visibility task to the queue for processing.
557
+ * @public
558
+ * @returns {void}
559
+ */
1202
560
  showModel() {
1203
- this.#data.containers.model.show = true;
1204
- this.#addContainer(this.#data.containers.model);
561
+ const config = { model: { visible: true } };
562
+ this.#addTaskToQueue(config, "visibility");
1205
563
  }
1206
564
 
565
+ /**
566
+ * Hides the 3D model by setting its visibility to false.
567
+ * Adds a visibility task to the queue for processing.
568
+ * @public
569
+ * @returns {void}
570
+ */
1207
571
  hideModel() {
1208
- this.#data.containers.model.show = false;
1209
- this.#removeContainer(this.#data.containers.model);
572
+ const config = { model: { visible: false } };
573
+ this.#addTaskToQueue(config, "visibility");
1210
574
  }
1211
575
 
576
+ /**
577
+ * Shows the scene/environment by setting its visibility to true.
578
+ * Adds a visibility task to the queue for processing.
579
+ * @public
580
+ * @returns {void}
581
+ */
1212
582
  showScene() {
1213
- this.#data.containers.environment.show = true;
1214
- this.#addContainer(this.#data.containers.environment);
1215
- this.#setVisibilityOfWallAndFloorInModel();
583
+ const config = { scene: { visible: true } };
584
+ this.#addTaskToQueue(config, "visibility");
1216
585
  }
1217
586
 
587
+ /**
588
+ * Hides the scene/environment by setting its visibility to false.
589
+ * Adds a visibility task to the queue for processing.
590
+ * @public
591
+ * @returns {void}
592
+ */
1218
593
  hideScene() {
1219
- this.#data.containers.environment.show = false;
1220
- this.#removeContainer(this.#data.containers.environment);
1221
- this.#setVisibilityOfWallAndFloorInModel();
594
+ const config = { scene: { visible: false } };
595
+ this.#addTaskToQueue(config, "visibility");
1222
596
  }
1223
597
 
598
+
599
+ /**
600
+ * Initiates download of the current 3D model in GLB format.
601
+ * @public
602
+ * @returns {void}
603
+ */
1224
604
  downloadModelGLB() {
1225
- const fileName = "model";
1226
- GLTF2Export.GLBAsync(this.#data.containers.model.assetContainer, fileName, { exportWithoutWaitingForScene: true }).then((glb) => glb.downloadFiles());
605
+ if (!this.#component3D) {
606
+ return;
607
+ }
608
+
609
+ this.#component3D.downloadModelGLB();
1227
610
  }
1228
611
 
612
+ /**
613
+ * Initiates download of the current 3D model in USDZ format.
614
+ * @public
615
+ * @returns {void}
616
+ */
1229
617
  downloadModelUSDZ() {
1230
- const fileName = "model";
1231
- USDZExportAsync(this.#data.containers.model.assetContainer).then((response) => {
1232
- if (response) {
1233
- Tools.Download(new Blob([response], { type: "model/vnd.usdz+zip" }), `${fileName}.usdz`);
1234
- }
1235
- });
618
+ if (!this.#component3D) {
619
+ return;
620
+ }
621
+
622
+ this.#component3D.downloadModelUSDZ();
1236
623
  }
1237
624
 
625
+ /**
626
+ * Initiates download of both the 3D model and scene in USDZ format.
627
+ * @public
628
+ * @returns {void}
629
+ */
1238
630
  downloadModelAndSceneUSDZ() {
1239
- const fileName = "scene";
1240
- USDZExportAsync(this.#scene).then((response) => {
1241
- if (response) {
1242
- Tools.Download(new Blob([response], { type: "model/vnd.usdz+zip" }), `${fileName}.usdz`);
1243
- }
1244
- });
631
+ if (!this.#component3D) {
632
+ return;
633
+ }
634
+
635
+ this.#component3D.downloadModelAndSceneUSDZ();
1245
636
  }
1246
637
 
638
+ /**
639
+ * Initiates download of both the 3D model and scene in GLB format.
640
+ * @public
641
+ * @returns {void}
642
+ */
1247
643
  downloadModelAndSceneGLB() {
1248
- const fileName = "scene";
1249
- GLTF2Export.GLBAsync(this.#scene, fileName, { exportWithoutWaitingForScene: true }).then((glb) => glb.downloadFiles());
644
+ if (!this.#component3D) {
645
+ return;
646
+ }
647
+
648
+ this.#component3D.downloadModelAndSceneGLB();
649
+ }
650
+
651
+ /**
652
+ * Indicates whether the viewer has been initialized.
653
+ * @public
654
+ * @returns {boolean} True if initialized; otherwise, false.
655
+ */
656
+ get initialized() {
657
+ return this.#isInitialized;
658
+ }
659
+
660
+ /**
661
+ * Indicates whether the viewer has finished loading.
662
+ * @public
663
+ * @returns {boolean} True if loaded; otherwise, false.
664
+ */
665
+ get loaded() {
666
+ return this.#isLoaded;
667
+ }
668
+
669
+ /**
670
+ * Indicates whether the viewer is currently loading.
671
+ * @public
672
+ * @returns {boolean} True if loading; otherwise, false.
673
+ */
674
+ get loading() {
675
+ return this.#isLoading;
1250
676
  }
1251
677
  }
1252
678
 
679
+ customElements.define("pref-viewer-2d", PrefViewer2D);
680
+ customElements.define("pref-viewer-3d", PrefViewer3D);
1253
681
  customElements.define("pref-viewer", PrefViewer);