@preference-sl/pref-viewer 2.16.0-beta.1 → 2.16.0-beta.11

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.
@@ -129,10 +129,6 @@ export default class FileStorage {
129
129
  #minimumFreeSpaceRatio = 0.05; // try to keep at least 5% of quota free
130
130
  #maxQuotaEvictionRetries = 5; // max retries after quota error
131
131
  #lastCleanupAt = 0; // timestamp of last automatic cleanup run
132
- // In-memory blob cache — eliminates repeated IDB round-trips for the same URI within a session.
133
- // Keyed by URI; values are normalized records (blob kept in RAM). Avoids both the readonly read
134
- // and the readwrite #touchFile transaction on every getURL() call during #replaceSceneURIAsync.
135
- #memoryCache = new Map();
136
132
 
137
133
  constructor(dbName = "FilesDB", osName = "FilesObjectStore") {
138
134
  this.dbName = dbName; // database name
@@ -262,7 +258,6 @@ export default class FileStorage {
262
258
  const store = transaction.objectStore(this.osName);
263
259
  keys.forEach((key) => {
264
260
  store.delete(key);
265
- this.#memoryCache.delete(String(key));
266
261
  });
267
262
  await transaction.done;
268
263
  return keys.length;
@@ -613,7 +608,6 @@ export default class FileStorage {
613
608
  const store = transaction.objectStore(this.osName);
614
609
  const result = await store.put(record, uri);
615
610
  await transaction.done;
616
- this.#memoryCache.set(uri, record);
617
611
  return result;
618
612
  } catch (error) {
619
613
  if (!this.#isQuotaExceededError(error) || attempt === this.#maxQuotaEvictionRetries) {
@@ -641,19 +635,6 @@ export default class FileStorage {
641
635
  * - false: if the database could not be opened (IndexedDB unsupported or open failure)
642
636
  */
643
637
  async #getFile(uri, { touch = true, allowExpired = false } = {}) {
644
- // In-memory cache hit: skip IDB entirely, update lastAccess in RAM only.
645
- const inMemory = this.#memoryCache.get(uri);
646
- if (inMemory) {
647
- if (!allowExpired && this.#isExpired(inMemory)) {
648
- this.#memoryCache.delete(uri);
649
- } else {
650
- if (touch) {
651
- inMemory.lastAccess = this.#now();
652
- }
653
- return inMemory;
654
- }
655
- }
656
-
657
638
  if (this.#db === undefined) {
658
639
  this.#db = await this.#openDB();
659
640
  }
@@ -670,68 +651,13 @@ export default class FileStorage {
670
651
  await this.#deleteKeys([uri]);
671
652
  return undefined;
672
653
  }
673
- this.#memoryCache.set(uri, record);
674
654
  if (touch) {
675
- // Fire-and-forget: don't block the caller on a readwrite IDB transaction just to update lastAccess.
676
- void this.#touchFile(uri, record);
655
+ await this.#touchFile(uri, record);
677
656
  record.lastAccess = this.#now();
678
657
  }
679
658
  return record;
680
659
  }
681
660
 
682
- /**
683
- * Retrieves multiple file records from the in-memory cache and IDB in a single readonly
684
- * transaction, populating the in-memory cache for all hits.
685
- * @private
686
- * @param {string[]} uris - URIs to look up.
687
- * @returns {Promise<Map<string, object>>} Map of uri → normalized record for found entries.
688
- */
689
- async #getFileBatch(uris) {
690
- const resultMap = new Map();
691
- if (!uris.length) return resultMap;
692
-
693
- const idbMisses = [];
694
- for (const uri of uris) {
695
- const cached = this.#memoryCache.get(uri);
696
- if (cached) {
697
- if (this.#isExpired(cached)) {
698
- this.#memoryCache.delete(uri);
699
- idbMisses.push(uri);
700
- } else {
701
- resultMap.set(uri, cached);
702
- }
703
- } else {
704
- idbMisses.push(uri);
705
- }
706
- }
707
-
708
- if (!idbMisses.length) return resultMap;
709
-
710
- if (this.#db === undefined) {
711
- this.#db = await this.#openDB();
712
- }
713
- if (this.#db === null) return resultMap;
714
-
715
- // One readonly transaction for all IDB misses — single lock acquisition vs N transactions.
716
- const tx = this.#db.transaction(this.osName, "readonly");
717
- const store = tx.objectStore(this.osName);
718
- const rawRecords = await Promise.all(idbMisses.map((uri) => store.get(uri)));
719
-
720
- const now = this.#now();
721
- idbMisses.forEach((uri, i) => {
722
- const record = this.#normalizeRecord(rawRecords[i]);
723
- if (!record) return;
724
- if (this.#isExpired(record, now)) {
725
- void this.#deleteKeys([uri]);
726
- return;
727
- }
728
- this.#memoryCache.set(uri, record);
729
- resultMap.set(uri, record);
730
- });
731
-
732
- return resultMap;
733
- }
734
-
735
661
  /**
736
662
  * ---------------------------
737
663
  * Public methods
@@ -756,44 +682,6 @@ export default class FileStorage {
756
682
  return storedFile ? URL.createObjectURL(storedFile.blob) : (await this.#getServerFileTimeStamp(uri)) ? uri : false;
757
683
  }
758
684
 
759
- /**
760
- * Resolve multiple URIs to object URLs in a single IDB batch transaction.
761
- * Hits in-memory cache first, then IDB in one transaction, then falls back to individual
762
- * server downloads for any URIs not found. Returns a Map<uri, objectURL|uri>.
763
- * @public
764
- * @param {string[]} uris - URIs to resolve.
765
- * @returns {Promise<Map<string, string>>} Map of uri → resolved URL (blob: or original URI).
766
- */
767
- async getURLBatch(uris) {
768
- if (!uris.length) return new Map();
769
- if (!this.#supported) {
770
- return new Map(uris.map((uri) => [uri, uri]));
771
- }
772
- await this.#runCleanupIfDue();
773
- const records = await this.#getFileBatch(uris);
774
- const urlMap = new Map();
775
- const serverFetches = [];
776
-
777
- for (const uri of uris) {
778
- const record = records.get(uri);
779
- if (record?.blob) {
780
- urlMap.set(uri, URL.createObjectURL(record.blob));
781
- } else {
782
- serverFetches.push(uri);
783
- }
784
- }
785
-
786
- // For URIs absent from IDB, fall back to individual get() which downloads and caches them.
787
- await Promise.all(
788
- serverFetches.map(async (uri) => {
789
- const url = await this.getURL(uri);
790
- if (url) urlMap.set(uri, url);
791
- })
792
- );
793
-
794
- return urlMap;
795
- }
796
-
797
685
  /**
798
686
  * Get the cached or server Last-Modified timestamp for a file.
799
687
  * @public
@@ -874,18 +762,10 @@ export default class FileStorage {
874
762
  }
875
763
  if (!storedFile) {
876
764
  const fileToStore = await this.#getServerFile(uri);
877
- if (fileToStore) {
878
- // Populate in-memory cache immediately after download so subsequent calls
879
- // within this session are instant — even if the IDB write fails later.
880
- const freshRecord = this.#createRecordFromFile(fileToStore);
881
- if (freshRecord) {
882
- this.#memoryCache.set(uri, freshRecord);
883
- }
884
- if (!!(await this.#putFile(fileToStore, uri))) {
885
- storedFile = await this.#getFile(uri);
886
- } else {
887
- storedFile = freshRecord ?? fileToStore;
888
- }
765
+ if (fileToStore && !!(await this.#putFile(fileToStore, uri))) {
766
+ storedFile = await this.#getFile(uri);
767
+ } else {
768
+ storedFile = fileToStore;
889
769
  }
890
770
  }
891
771
  return storedFile;
@@ -921,6 +801,5 @@ export default class FileStorage {
921
801
  } catch {}
922
802
  }
923
803
  this.#db = undefined;
924
- this.#memoryCache.clear();
925
804
  }
926
805
  }
@@ -82,27 +82,27 @@ export default class GLTFResolver {
82
82
  * - If parsing fails, returns a Blob with MIME type "model/gltf-binary" and extension ".glb".
83
83
  * - If decoding fails, returns nulls for blob and extension.
84
84
  */
85
- async #decodeBase64(base64) {
85
+ #decodeBase64(base64) {
86
86
  const [, payload] = base64.split(",");
87
87
  const raw = payload || base64;
88
- const size = raw.length;
89
-
90
- // Detect format from first base64 chars — O(1), no full decode needed.
91
- // GLB magic bytes 0x676c5446 ("glTF") encode to "Z2x" in base64.
92
- // GLTF JSON always starts with '{' (0x7B), which encodes to "ey" in base64.
93
- const isGlb = raw.startsWith("Z2x");
94
- const extension = isGlb ? ".glb" : ".gltf";
95
- const type = isGlb ? "model/gltf-binary" : "model/gltf+json";
96
-
88
+ let decoded = "";
97
89
  let blob = null;
90
+ let extension = null;
91
+ let size = raw.length;
98
92
  try {
99
- // Native browser decode: runs in C++ off the JS call stack, does not block the
100
- // main thread. Avoids the per-character Uint8Array loop and the full JSON.parse
101
- // that the previous sync implementation used to detect the file type.
102
- blob = await fetch(`data:${type};base64,${raw}`).then((r) => r.blob());
93
+ decoded = atob(raw);
103
94
  } catch {
104
- return { blob: null, extension: null, size };
95
+ return { blob, extension, size };
105
96
  }
97
+ let isJson = false;
98
+ try {
99
+ JSON.parse(decoded);
100
+ isJson = true;
101
+ } catch {}
102
+ extension = isJson ? ".gltf" : ".glb";
103
+ const type = isJson ? "model/gltf+json" : "model/gltf-binary";
104
+ const array = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
105
+ blob = new Blob([array], { type });
106
106
  return { blob, extension, size };
107
107
  }
108
108
 
@@ -138,27 +138,6 @@ export default class GLTFResolver {
138
138
  return typeof url === "string" && url.startsWith("blob:");
139
139
  }
140
140
 
141
- /**
142
- * Check whether a source should be treated as embedded base64/data URI.
143
- * @private
144
- * @param {string} source - Value to test.
145
- * @returns {boolean} True when the source is a data URI or raw base64 payload.
146
- */
147
- #isEmbeddedBase64Source(source) {
148
- if (typeof source !== "string") {
149
- return false;
150
- }
151
- const normalized = source.trim();
152
- if (!normalized) {
153
- return false;
154
- }
155
- if (normalized.startsWith("data:")) {
156
- return true;
157
- }
158
- // Raw base64 payloads use the standard alphabet and have no URL separators.
159
- return /^[A-Za-z0-9+/=_-]+$/.test(normalized);
160
- }
161
-
162
141
  /**
163
142
  * Collects asset entries that have an external URI (non-data URI) and stores a normalized absolute or relative-resolved URI for later replacement.
164
143
  * @private
@@ -225,19 +204,17 @@ export default class GLTFResolver {
225
204
  assetContainerJSON.images.forEach((asset, index, array) => this.#saveAssetData(asset, index, array, sceneURLBase, arrayOfAssetsWithURI));
226
205
  }
227
206
 
228
- // Batch-resolve all asset URIs in a single IDB transaction to avoid per-URI transaction
229
- // overhead. Falls back to individual getURL() for URIs not found in IDB.
230
- const urisToResolve = arrayOfAssetsWithURI.map((asset) => asset.uri);
231
- const urlMap = await this.#fileStorage.getURLBatch(urisToResolve);
232
- arrayOfAssetsWithURI.forEach((asset) => {
233
- const resolvedUrl = urlMap.get(asset.uri);
234
- if (resolvedUrl) {
235
- asset.parent[asset.index].uri = resolvedUrl;
236
- if (this.#isBlobURL(resolvedUrl)) {
237
- objectURLs.push(resolvedUrl);
207
+ // Replace parallel URIs so that if files are not stored yet, they are downloaded in parallel
208
+ const promisesArray = arrayOfAssetsWithURI.map(async (asset) => {
209
+ const uri = await this.#fileStorage.getURL(asset.uri);
210
+ if (uri) {
211
+ asset.parent[asset.index].uri = uri;
212
+ if (this.#isBlobURL(uri)) {
213
+ objectURLs.push(uri);
238
214
  }
239
215
  }
240
216
  });
217
+ await Promise.all(promisesArray);
241
218
  }
242
219
 
243
220
  /**
@@ -293,9 +270,7 @@ export default class GLTFResolver {
293
270
 
294
271
  if (storage.db && storage.table && storage.id) {
295
272
  await this.#initializeStorage(storage.db, storage.table);
296
- const tIdb = performance.now();
297
273
  const object = await loadModel(storage.id, storage.table);
298
- console.log(`[perf][gltf:${storage.id}] IDB read ${Math.round(performance.now() - tIdb)}ms`);
299
274
  if (!object || !object.data || !object.timeStamp || !object.size) {
300
275
  return false;
301
276
  }
@@ -315,21 +290,12 @@ export default class GLTFResolver {
315
290
  }
316
291
 
317
292
  let file = null;
318
- let blob = null;
319
- let extension = null;
320
- let size = 0;
321
- if (this.#isEmbeddedBase64Source(source)) {
322
- const tDecode = performance.now();
323
- ({ blob, extension, size } = await this.#decodeBase64(source));
324
- console.log(`[perf][gltf:${storage.id ?? "url"}] decode(${extension}) ${Math.round(performance.now() - tDecode)}ms`);
325
- }
293
+ let { blob, extension, size } = this.#decodeBase64(source);
326
294
 
327
295
  if (blob && extension) {
328
296
  if (extension === ".gltf") {
329
297
  const assetContainerJSON = JSON.parse(await blob.text());
330
- const tReplace = performance.now();
331
298
  await this.#replaceSceneURIAsync(assetContainerJSON, source, objectURLs);
332
- console.log(`[perf][gltf:${storage.id ?? "url"}] replaceURIs ${Math.round(performance.now() - tReplace)}ms (${objectURLs.length} assets)`);
333
299
  source = `data:${JSON.stringify(assetContainerJSON)}`;
334
300
  } else {
335
301
  file = new File([blob], `file${extension}`, {
@@ -3,18 +3,12 @@ export const translations = {
3
3
  prefViewer: {
4
4
  menu3d: {
5
5
  title: "3D render settings",
6
- subtitle: "Toggle visual effects on or off.",
7
- pendingLabel: "Pending changes",
6
+ subtitle: "Modify the visual effects.",
8
7
  actions: {
9
- apply: "Apply",
10
- applying: "Applying...",
11
8
  play: "Play",
12
9
  pause: "Pause",
13
10
  },
14
- status: {
15
- error: "Couldn't apply the changes.",
16
- },
17
- switches: {
11
+ features: {
18
12
  antiAliasingEnabled: {
19
13
  label: "Antialiasing",
20
14
  helper: "Softens jagged edges and lines.",
@@ -70,18 +64,12 @@ export const translations = {
70
64
  prefViewer: {
71
65
  menu3d: {
72
66
  title: "Ajustes de render 3D",
73
- subtitle: "Activa o desactiva los efectos visuales.",
74
- pendingLabel: "Pendiente de aplicar",
67
+ subtitle: "Modifica los efectos visuales.",
75
68
  actions: {
76
- apply: "Aplicar",
77
- applying: "Aplicando...",
78
69
  play: "Reproducir",
79
70
  pause: "Pausar",
80
71
  },
81
- status: {
82
- error: "No se pudo aplicar los cambios.",
83
- },
84
- switches: {
72
+ features: {
85
73
  antiAliasingEnabled: {
86
74
  label: "Antialiasing",
87
75
  helper: "Suaviza aristas y líneas.",
@@ -100,7 +88,7 @@ export const translations = {
100
88
  },
101
89
  highlightEnabled: {
102
90
  label: "Resaltado",
103
- helper: "Resaltar elementos animados al pasar el ratón.",
91
+ helper: "Resalta elementos animados al pasar el ratón.",
104
92
  },
105
93
  highlightColor: {
106
94
  label: "Color de resaltado",
@@ -0,0 +1,72 @@
1
+ import { PBRMaterial } from "@babylonjs/core";
2
+
3
+ /**
4
+ * Utilities for classifying and tagging PBR materials by their transparency/transmission mode.
5
+ * Used by BabylonJSController at load time and by pref-viewer-3d for render-order decisions.
6
+ */
7
+
8
+ const PREF_VIEWER_SIMPLIFIED_TRANSMISSION_METADATA_KEY = "prefViewerSimplifiedTransmission";
9
+
10
+ /**
11
+ * Tags a PBR material as having been converted from physical KHR_materials_transmission to simple
12
+ * alpha blending. The tag is stored in `mat.metadata` under an internal key so that downstream
13
+ * render-order passes can still identify these materials as transparent candidates even after
14
+ * their `subSurface.isRefractionEnabled` flag has been cleared.
15
+ *
16
+ * Returns the same material instance (or the original value when `mat` is falsy) so calls can be
17
+ * chained.
18
+ * @param {import("@babylonjs/core").PBRMaterial|null|undefined} mat - Material to tag.
19
+ * @returns {import("@babylonjs/core").PBRMaterial|null|undefined} The same `mat` value that was passed in.
20
+ */
21
+ export function markPrefViewerSimplifiedTransmissionMaterial(mat) {
22
+ if (!mat) {
23
+ return mat;
24
+ }
25
+
26
+ mat.metadata = {
27
+ ...(mat.metadata ?? {}),
28
+ [PREF_VIEWER_SIMPLIFIED_TRANSMISSION_METADATA_KEY]: true,
29
+ };
30
+ return mat;
31
+ }
32
+
33
+ /**
34
+ * Returns true when the material uses Babylon.js physical glass rendering — i.e. either
35
+ * KHR_materials_transmission (refraction) or KHR_materials_translucency is active.
36
+ *
37
+ * This is intentionally narrower than `isPrefViewerTransparentRenderOrderMaterial`: it only
38
+ * matches full physical transmission, not plain alpha blending or simplified environment glass.
39
+ * @param {import("@babylonjs/core").Material|null|undefined} mat - Material to test.
40
+ * @returns {boolean}
41
+ */
42
+ export function isPrefViewerPhysicalGlassMaterial(mat) {
43
+ return Boolean(mat?.subSurface?.isRefractionEnabled || mat?.subSurface?.isTranslucencyEnabled);
44
+ }
45
+
46
+ /**
47
+ * Returns true when a material should be placed in the transparent rendering group (renderingGroupId=1).
48
+ *
49
+ * A material qualifies when any of the following is true:
50
+ * - It is a physical glass material (KHR_materials_transmission / translucency).
51
+ * - It has been tagged as simplified environment transmission via `markPrefViewerSimplifiedTransmissionMaterial`.
52
+ * - Its effective alpha is below 1, its `transparencyMode` is alpha-blend or alpha-test-and-blend,
53
+ * or it self-reports as needing blending/testing through the Babylon.js material API.
54
+ * @param {import("@babylonjs/core").Material|null|undefined} mat - Material to test.
55
+ * @returns {boolean}
56
+ */
57
+ export function isPrefViewerTransparentRenderOrderMaterial(mat) {
58
+ if (!mat) {
59
+ return false;
60
+ }
61
+ const isTransmissive = isPrefViewerPhysicalGlassMaterial(mat);
62
+ const isSimplifiedTransmission = mat.metadata?.[PREF_VIEWER_SIMPLIFIED_TRANSMISSION_METADATA_KEY] === true;
63
+ const alpha = typeof mat.alpha === "number" ? mat.alpha : 1;
64
+ const isAlphaBlended =
65
+ alpha < 1 ||
66
+ mat.transparencyMode === PBRMaterial.PBRMATERIAL_ALPHABLEND ||
67
+ mat.transparencyMode === PBRMaterial.PBRMATERIAL_ALPHATESTANDBLEND ||
68
+ mat.needAlphaBlending?.() === true ||
69
+ mat.needAlphaTesting?.() === true;
70
+
71
+ return Boolean(isTransmissive || isSimplifiedTransmission || isAlphaBlended);
72
+ }
@@ -19,20 +19,20 @@ import { PrefViewer2DStyles } from "./styles.js";
19
19
  * - visible: "true" | "false" (controls visibility and event subscription)
20
20
  *
21
21
  * Public methods
22
- * - async load(svgConfig) : Promise<boolean> — accept SVG input/config and prepare for render
22
+ * - async load(svgConfig) : Promise<{success: boolean, error: Error|null}> — accept SVG input/config and prepare for render
23
23
  * - show(), hide() — toggle visibility and event subscriptions
24
24
  * - zoomIn(), zoomOut(), zoomCenter(), zoomExtentsAll() — pan/zoom controls
25
25
  *
26
- * * Public Properties:
26
+ * Public Properties:
27
27
  * - isInitialized: Indicates whether the component has completed initialization.
28
28
  * - isLoaded: Indicates whether the SVG content is loaded and ready.
29
29
  * - isVisible: Indicates whether the component is currently visible.
30
30
  *
31
31
  * Events
32
- * - "drawing-loading": emitted before loading starts
33
- * - "drawing-loaded": emitted after content and Panzoom are ready
34
- * - "drawing-error": emitted on validation/fetch errors (detail.error: Error)
35
- * - "drawing-zoom-changed": emitted when pan/zoom state changes (detail: { moved, scaled, maximized, minimized })
32
+ * - "pref-viewer-2d-loading": emitted before loading starts. Legacy alias: "drawing-loading"
33
+ * - "pref-viewer-2d-loaded": emitted after content and Panzoom are ready. Legacy alias: "drawing-loaded"
34
+ * - "pref-viewer-2d-error": emitted on validation/fetch errors (detail.error: Error). Legacy alias: "drawing-error"
35
+ * - "pref-viewer-2d-viewport-changed": emitted when pan/zoom state changes (detail: { moved, scaled, maximized, minimized })
36
36
  *
37
37
  * Implementation notes
38
38
  * - Keeps internal state in private fields (#svg, #panzoom, #panzoomOptions, ...).
@@ -235,7 +235,8 @@ export default class PrefViewer2D extends HTMLElement {
235
235
  composed: false, // indicates whether or not the event will propagate across the shadow DOM boundary into the standard DOM
236
236
  detail: detail,
237
237
  };
238
- this.dispatchEvent(new CustomEvent("drawing-error", customEventOptions));
238
+ this.dispatchEvent(new CustomEvent("pref-viewer-2d-error", customEventOptions));
239
+ this.dispatchEvent(new CustomEvent("drawing-error", customEventOptions)); // Legacy alias for backward compatibility
239
240
  return detail;
240
241
  }
241
242
 
@@ -250,7 +251,8 @@ export default class PrefViewer2D extends HTMLElement {
250
251
  cancelable: true,
251
252
  composed: false,
252
253
  };
253
- this.dispatchEvent(new CustomEvent("drawing-loaded", customEventOptions));
254
+ this.dispatchEvent(new CustomEvent("pref-viewer-2d-loaded", customEventOptions));
255
+ this.dispatchEvent(new CustomEvent("drawing-loaded", customEventOptions)); // Legacy alias for backward compatibility
254
256
 
255
257
  this.removeAttribute("loading");
256
258
  this.setAttribute("loaded", "");
@@ -270,7 +272,8 @@ export default class PrefViewer2D extends HTMLElement {
270
272
  cancelable: true,
271
273
  composed: false,
272
274
  };
273
- this.dispatchEvent(new CustomEvent("drawing-loading", customEventOptions));
275
+ this.dispatchEvent(new CustomEvent("pref-viewer-2d-loading", customEventOptions));
276
+ this.dispatchEvent(new CustomEvent("drawing-loading", customEventOptions)); // Legacy alias for backward compatibility
274
277
 
275
278
  this.removeAttribute("loaded");
276
279
  this.setAttribute("loading", "");
@@ -283,7 +286,7 @@ export default class PrefViewer2D extends HTMLElement {
283
286
  }
284
287
 
285
288
  /**
286
- * Handles Panzoom state changes by dispatching a "prefviewer2d-zoomchanged" custom event.
289
+ * Handles Panzoom state changes by dispatching the public "pref-viewer-2d-viewport-changed" event.
287
290
  * @private
288
291
  * @param {object} state - The current Panzoom state object.
289
292
  * @returns {void}
@@ -299,17 +302,7 @@ export default class PrefViewer2D extends HTMLElement {
299
302
  detail: state,
300
303
  };
301
304
  customEventOptions.detail.desde2d = "true";
302
- this.dispatchEvent(new CustomEvent("drawing-zoom-changed", customEventOptions));
303
- }
304
-
305
- /**
306
- * Subscribe DOM events required by Panzoom and keyboard interactions.
307
- * @private
308
- * @returns {void}
309
- composed: true,
310
- detail: state,
311
- })
312
- );
305
+ this.dispatchEvent(new CustomEvent("pref-viewer-2d-viewport-changed", customEventOptions));
313
306
  }
314
307
 
315
308
  /**