@preference-sl/pref-viewer 2.16.2 → 2.17.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.
@@ -1,6 +1,7 @@
1
- import { AssetContainer, Color3, HighlightLayer, InstancedMesh, Mesh, PickingInfo, Quaternion, Scene, UtilityLayerRenderer, Vector3 } from "@babylonjs/core";
1
+ import { AssetContainer, Color3, HighlightLayer, Mesh, PickingInfo, Scene } from "@babylonjs/core";
2
2
  import { PrefViewerStyleVariables } from "./styles.js";
3
3
  import OpeningAnimation from "./babylonjs-animation-opening.js";
4
+ import BabylonJSAnimationOverlayController from "./babylonjs-animation-overlay-controller.js";
4
5
 
5
6
  /**
6
7
  * BabylonJSAnimationController - Manages animation playback, highlighting, and interactive controls for animated nodes in Babylon.js scenes.
@@ -48,7 +49,7 @@ export default class BabylonJSAnimationController {
48
49
  #useHighlightLayer = true;
49
50
  #lastHighlightedMeshId = null;
50
51
  #lastHighlightedNodeIds = [];
51
-
52
+
52
53
  // Static property to store global highlight enabled state
53
54
  static #globalHighlightEnabled = true;
54
55
  static #instanceId = 0;
@@ -70,6 +71,7 @@ export default class BabylonJSAnimationController {
70
71
  /**
71
72
  * Detects and stores animatable objects and animated nodes in the scene.
72
73
  * @private
74
+ * @returns {void}
73
75
  */
74
76
  #initializeAnimations() {
75
77
  this.hideMenu(); // Clean up any existing menus
@@ -83,6 +85,7 @@ export default class BabylonJSAnimationController {
83
85
  /**
84
86
  * Collects the IDs of all nodes targeted by any animation group in the scene.
85
87
  * @private
88
+ * @returns {void}
86
89
  */
87
90
  #getAnimatedNodes() {
88
91
  this.#assetContainer.animationGroups.forEach((animationGroup) => {
@@ -103,6 +106,7 @@ export default class BabylonJSAnimationController {
103
106
  * @private
104
107
  * @description
105
108
  * Uses animation group names with the pattern "animation_open_<name>" and "animation_close_<name>".
109
+ * @returns {void}
106
110
  */
107
111
  #getOpeningAnimations() {
108
112
  const openings = {};
@@ -131,7 +135,7 @@ export default class BabylonJSAnimationController {
131
135
  * Finds the OpeningAnimation instance associated with a given node ID.
132
136
  * @private
133
137
  * @param {string} nodeId - The node identifier.
134
- * @returns {OpeningAnimation|null} The matching OpeningAnimation instance or null.
138
+ * @returns {OpeningAnimation|undefined} The matching OpeningAnimation instance, or undefined when absent.
135
139
  */
136
140
  #getOpeningAnimationByNode(nodeId) {
137
141
  return this.#openingAnimations.find((openingAnimation) => openingAnimation.isAnimationForNode(nodeId));
@@ -172,163 +176,7 @@ export default class BabylonJSAnimationController {
172
176
  * - Removes overlay when mouse moves away from highlighted meshes
173
177
  */
174
178
  #addGroupOutlineOverlayForInstances(meshes) {
175
- // Configuration
176
- const OUTLINE_ENABLED = false; // Draw outline on individual meshes
177
- const OUTLINE_COLOR = this.#highlightColor;
178
- const OUTLINE_WIDTH = 1.5;
179
- const OVERLAY_ENABLED = true; // Draw semi-transparent fill
180
- const OVERLAY_COLOR = this.#highlightColor;
181
- const OVERLAY_ALPHA = 0.25;
182
- const RENDERING_GROUP_ID = 0; // MUST be > 0 to render on top of main scene
183
-
184
- // Use UtilityLayerRenderer for rendering
185
- const utilityLayer = new UtilityLayerRenderer(this.#scene, undefined, undefined);
186
- const uScene = utilityLayer.utilityLayerScene;
187
-
188
- // Cache map: baseMesh -> overlayBaseMesh to reuse for InstancedMeshes
189
- const baseToOverlayBase = new Map();
190
-
191
- // Pairs of { original mesh, overlay mesh } for tracking
192
- const pairs = [];
193
-
194
- /**
195
- * Gets the base mesh from either a regular mesh or an InstancedMesh
196
- * @param {Mesh} mesh - The mesh to check
197
- * @returns {Mesh} The base mesh or the mesh itself
198
- */
199
- function getBaseMesh(mesh) {
200
- return mesh instanceof InstancedMesh ? mesh.sourceMesh : mesh;
201
- }
202
-
203
- /**
204
- * Creates or retrieves an overlay base mesh in the utility scene
205
- * Reuses the same base for all instances of the same source mesh
206
- * @param {Mesh} baseMesh - The base mesh to clone
207
- * @returns {Mesh} The overlay base mesh
208
- */
209
- function ensureOverlayBase(baseMesh) {
210
- let overlayBase = baseToOverlayBase.get(baseMesh);
211
- if (overlayBase) {
212
- return overlayBase;
213
- }
214
-
215
- // Clone the source mesh into the utility scene (deep clone materials/geometry)
216
- overlayBase = baseMesh.clone(baseMesh.name + "_overlayBase", null, true, false);
217
- overlayBase._scene = uScene;
218
- overlayBase.isPickable = false;
219
- overlayBase.setEnabled(true);
220
-
221
- // Configure visual appearance for the overlay
222
- overlayBase.renderOutline = OUTLINE_ENABLED; // Only draw outline if enabled
223
- overlayBase.outlineColor = OUTLINE_COLOR;
224
- overlayBase.outlineWidth = OUTLINE_WIDTH;
225
-
226
- // Overlay color and transparency (semi-transparent colored fill)
227
- overlayBase.renderOverlay = OVERLAY_ENABLED;
228
- overlayBase.overlayColor = OVERLAY_COLOR;
229
- overlayBase.overlayAlpha = OVERLAY_ALPHA;
230
-
231
- overlayBase.visibility = 1;
232
-
233
- // Ensure always-on-top rendering or not
234
- overlayBase.renderingGroupId = RENDERING_GROUP_ID;
235
-
236
- baseToOverlayBase.set(baseMesh, overlayBase);
237
- return overlayBase;
238
- }
239
-
240
- // Create overlay meshes for each input mesh
241
- meshes.forEach((mesh) => {
242
- // Skip meshes without geometry
243
- if (!mesh.geometry && !mesh.getChildMeshes) {
244
- return;
245
- }
246
-
247
- const baseMesh = getBaseMesh(mesh);
248
- const overlayBase = ensureOverlayBase(baseMesh);
249
-
250
- let overlay;
251
- if (mesh instanceof InstancedMesh) {
252
- // For instances, create a new instance of the overlay base
253
- overlay = overlayBase.createInstance(mesh.name + "_overlayInst");
254
- overlay._scene = uScene;
255
- overlay.isPickable = false;
256
- } else {
257
- // For regular meshes, use the overlay base directly (avoids double cloning)
258
- overlay = overlayBase;
259
- }
260
-
261
- // Remove parent to allow independent world matrix control
262
- overlay.parent = null;
263
- overlay.renderingGroupId = RENDERING_GROUP_ID;
264
-
265
- pairs.push({ original: mesh, overlay });
266
- });
267
-
268
- // Sync world matrix transformations
269
- const pos = new Vector3();
270
- const scl = new Vector3();
271
- const rot = new Quaternion();
272
-
273
- // Track all observers for cleanup
274
- const observers = [];
275
-
276
- // Subscribe to world matrix update events
277
- // This is more efficient than updating every frame
278
- pairs.forEach(({ original, overlay }) => {
279
- // Skip if mesh has no geometry to sync
280
- if (!original.getWorldMatrix) {
281
- return;
282
- }
283
-
284
- // Initial sync: copy the current world matrix
285
- original.computeWorldMatrix(true);
286
- original.getWorldMatrix().decompose(scl, rot, pos);
287
-
288
- overlay.position.copyFrom(pos);
289
- overlay.scaling.copyFrom(scl);
290
- overlay.rotationQuaternion = overlay.rotationQuaternion || new Quaternion();
291
- overlay.rotationQuaternion.copyFrom(rot);
292
- overlay.computeWorldMatrix(true);
293
-
294
- // Subscribe to world matrix changes
295
- // Only updates when the original mesh's transformation actually changes
296
- const obs = original.onAfterWorldMatrixUpdateObservable?.add(() => {
297
- original.getWorldMatrix().decompose(scl, rot, pos);
298
-
299
- overlay.position.copyFrom(pos);
300
- overlay.scaling.copyFrom(scl);
301
- overlay.rotationQuaternion.copyFrom(rot);
302
- overlay.computeWorldMatrix(true);
303
- });
304
-
305
- if (obs) {
306
- observers.push({ mesh: original, observer: obs });
307
- }
308
- });
309
-
310
- // Cleanup API
311
- return {
312
- dispose() {
313
- // Remove world matrix update observers
314
- observers.forEach(({ mesh, observer }) => {
315
- mesh.onAfterWorldMatrixUpdateObservable?.remove(observer);
316
- });
317
-
318
- // Dispose overlay instances and meshes
319
- pairs.forEach(({ original, overlay }) => {
320
- overlay.dispose();
321
- });
322
-
323
- // Dispose overlay base meshes
324
- baseToOverlayBase.forEach((overlayBase) => {
325
- overlayBase.dispose();
326
- });
327
-
328
- // Dispose utility layer scene/resources; without this each rebuild leaks GPU/JS resources.
329
- utilityLayer.dispose();
330
- },
331
- };
179
+ return new BabylonJSAnimationOverlayController(this.#scene, meshes, this.#highlightColor);
332
180
  }
333
181
 
334
182
  /**
@@ -430,6 +278,7 @@ export default class BabylonJSAnimationController {
430
278
  * Cleans up the highlight layer, animation menu, and internal animation/node lists.
431
279
  * Should be called when the controller is no longer needed to prevent memory leaks.
432
280
  * @public
281
+ * @returns {void}
433
282
  */
434
283
  dispose() {
435
284
  if (this.#highlightLayer) {
@@ -476,14 +325,14 @@ export default class BabylonJSAnimationController {
476
325
  }
477
326
  return { changed: false, highlighted: false };
478
327
  }
479
-
328
+
480
329
  let changed = false;
481
330
  let highlighted = false;
482
331
 
483
332
  // Check if we're hovering the same mesh to avoid recreating overlays/highlights
484
333
  const pickedMeshId = !pickingInfo?.hit || !pickingInfo?.pickedMesh?.id ? null : pickingInfo.pickedMesh.id;
485
334
  const pickedMesh = pickingInfo?.pickedMesh ? pickingInfo.pickedMesh : null;
486
-
335
+
487
336
  if (this.#lastHighlightedMeshId === pickedMeshId) {
488
337
  // No need to update if hovering the same mesh
489
338
  if (this.#lastHighlightedMeshId !== null) {
@@ -651,7 +500,7 @@ export default class BabylonJSAnimationController {
651
500
  this.hideMenu();
652
501
  return;
653
502
  }
654
-
503
+
655
504
  const openingAnimations = [];
656
505
  nodeIds.forEach((nodeId) => {
657
506
  const openingAnimation = this.#getOpeningAnimationByNode(nodeId);
@@ -117,11 +117,12 @@ export default class OpeningAnimationMenu {
117
117
  * Sets button appearance and attaches the callback for user interaction.
118
118
  * @private
119
119
  * @param {string} name - Button identifier.
120
- * @param {string} imageURL - SVG image data URL for the button icon.
120
+ * @param {string} imageSVG - SVG markup for the button icon.
121
121
  * @param {boolean} enabled - Whether the button is enabled.
122
122
  * @param {boolean} active - Whether the button is visually active.
123
123
  * @param {boolean} visible - Whether the button is visible.
124
124
  * @param {function} callback - Callback to invoke on button click.
125
+ * @returns {void}
125
126
  */
126
127
  #addButton(name, imageSVG, enabled = true, active = false, visible = true, callback) {
127
128
  const stateClass = active ? "active" : enabled ? "enabled" : "disabled";
@@ -183,6 +184,7 @@ export default class OpeningAnimationMenu {
183
184
  /**
184
185
  * Creates all control buttons and sets their initial states.
185
186
  * @private
187
+ * @returns {void}
186
188
  */
187
189
  #createButtons() {
188
190
  const buttonsState = this.#getButtonsState();
@@ -198,6 +200,7 @@ export default class OpeningAnimationMenu {
198
200
  /**
199
201
  * Creates and configures the animation progress slider.
200
202
  * @private
203
+ * @returns {void}
201
204
  */
202
205
  #createSlider() {
203
206
  this.#slider = document.createElement("input");
@@ -216,6 +219,7 @@ export default class OpeningAnimationMenu {
216
219
  /**
217
220
  * Initializes and renders the menu UI, including buttons and slider.
218
221
  * @private
222
+ * @returns {void}
219
223
  */
220
224
  #createMenu() {
221
225
  this.#containerMain = document.createElement("div");
@@ -334,6 +338,7 @@ export default class OpeningAnimationMenu {
334
338
  * @param {boolean} enabled
335
339
  * @param {boolean} active
336
340
  * @param {boolean} visible
341
+ * @returns {void}
337
342
  */
338
343
  #setButtonState(name, enabled, active, visible = true) {
339
344
  const button = this.#getButtonByName(name);
@@ -350,6 +355,7 @@ export default class OpeningAnimationMenu {
350
355
  /**
351
356
  * Updates all loop control buttons according to current loop mode.
352
357
  * @private
358
+ * @returns {void}
353
359
  */
354
360
  #setLoopButtonsState() {
355
361
  const buttonsState = this.#getLoopButtonsState();
@@ -360,6 +366,7 @@ export default class OpeningAnimationMenu {
360
366
  /**
361
367
  * Updates all playback control buttons according to current animation state.
362
368
  * @private
369
+ * @returns {void}
363
370
  */
364
371
  #setPlayerButtonsState() {
365
372
  const buttonsState = this.#getPlayerButtonsState();
@@ -108,6 +108,11 @@ export default class OpeningAnimation {
108
108
  this.#goToClosed();
109
109
  }
110
110
 
111
+ /**
112
+ * Binds opening and closing completion handlers once for stable subscription and removal.
113
+ * @private
114
+ * @returns {void}
115
+ */
111
116
  #bindHandlers() {
112
117
  this.#handlers.onOpened = this.#onOpened.bind(this);
113
118
  this.#handlers.onClosed = this.#onClosed.bind(this);
@@ -118,6 +123,7 @@ export default class OpeningAnimation {
118
123
  * Collects node IDs affected by the opening and closing animation groups.
119
124
  * Populates the #nodes array with unique node identifiers.
120
125
  * @private
126
+ * @returns {void}
121
127
  */
122
128
  #getNodesFromAnimationGroups() {
123
129
  [this.#openAnimation, this.#closeAnimation].forEach((animationGroup) => {
@@ -132,6 +138,7 @@ export default class OpeningAnimation {
132
138
  /**
133
139
  * Handles the end of the opening animation group.
134
140
  * @private
141
+ * @returns {void}
135
142
  */
136
143
  #onOpened() {
137
144
  this.#goToOpened(true);
@@ -140,6 +147,7 @@ export default class OpeningAnimation {
140
147
  /**
141
148
  * Handles the end of the closing animation group.
142
149
  * @private
150
+ * @returns {void}
143
151
  */
144
152
  #onClosed() {
145
153
  this.#goToClosed(true);
@@ -149,6 +157,7 @@ export default class OpeningAnimation {
149
157
  * Moves the animation to the fully opened state, optionally looping to close.
150
158
  * @private
151
159
  * @param {boolean} useLoop - If true, starts closing animation after opening.
160
+ * @returns {void}
152
161
  */
153
162
  #goToOpened(useLoop = false) {
154
163
  this.#lastPausedFrame = this.#endFrame;
@@ -178,6 +187,7 @@ export default class OpeningAnimation {
178
187
  * Moves the animation to the fully closed state, optionally looping to open.
179
188
  * @private
180
189
  * @param {boolean} useLoop - If true, starts opening animation after closing.
190
+ * @returns {void}
181
191
  */
182
192
  #goToClosed(useLoop = false) {
183
193
  this.#lastPausedFrame = this.#startFrame;
@@ -207,6 +217,7 @@ export default class OpeningAnimation {
207
217
  * Moves the animation to a specific frame and pauses.
208
218
  * @private
209
219
  * @param {number} frame - The frame to go to and pause.
220
+ * @returns {void}
210
221
  */
211
222
  #goToFrameAndPause(frame) {
212
223
  if (!frame) {
@@ -350,6 +361,7 @@ export default class OpeningAnimation {
350
361
  /**
351
362
  * Updates the slider value in the animation control menu to match the current progress.
352
363
  * @private
364
+ * @returns {void}
353
365
  */
354
366
  #updateControlsSlider() {
355
367
  if (!this.isControlsVisible()) {
@@ -362,6 +374,7 @@ export default class OpeningAnimation {
362
374
  * Broadcasts the latest animation state and, when the menu is visible, syncs its controls
363
375
  * (buttons and progress slider) with the current state/progress values.
364
376
  * @private
377
+ * @returns {void}
365
378
  */
366
379
  #updateControls() {
367
380
  this.#notifyStateChange();
@@ -412,6 +425,7 @@ export default class OpeningAnimation {
412
425
  /**
413
426
  * Starts the opening animation.
414
427
  * @public
428
+ * @returns {void}
415
429
  */
416
430
  playOpen() {
417
431
  if (this.#state === OpeningAnimation.states.opening || this.#state === OpeningAnimation.states.opened) {
@@ -437,6 +451,7 @@ export default class OpeningAnimation {
437
451
  /**
438
452
  * Starts the closing animation.
439
453
  * @public
454
+ * @returns {void}
440
455
  */
441
456
  playClose() {
442
457
  if (this.#state === OpeningAnimation.states.closing || this.#state === OpeningAnimation.states.closed) {
@@ -462,6 +477,7 @@ export default class OpeningAnimation {
462
477
  /**
463
478
  * Pauses the current animation.
464
479
  * @public
480
+ * @returns {void}
465
481
  */
466
482
  pause() {
467
483
  if (this.#state === OpeningAnimation.states.opening) {
@@ -479,6 +495,7 @@ export default class OpeningAnimation {
479
495
  /**
480
496
  * Moves animation to the fully opened state.
481
497
  * @public
498
+ * @returns {void}
482
499
  */
483
500
  goToOpened() {
484
501
  this.#goToOpened(false);
@@ -487,6 +504,7 @@ export default class OpeningAnimation {
487
504
  /**
488
505
  * Moves animation to the fully closed state.
489
506
  * @public
507
+ * @returns {void}
490
508
  */
491
509
  goToClosed() {
492
510
  this.#goToClosed(false);
@@ -498,6 +516,7 @@ export default class OpeningAnimation {
498
516
  * @public
499
517
  * @param {HTMLCanvasElement} canvas - The canvas element for rendering.
500
518
  * @param {Array<OpeningAnimation>} openingAnimations - Array of OpeningAnimation instances to manage.
519
+ * @returns {void}
501
520
  */
502
521
  showControls(canvas, openingAnimations = []) {
503
522
  const controlCallbacks = {
@@ -562,6 +581,7 @@ export default class OpeningAnimation {
562
581
  /**
563
582
  * Hides the animation control menu and removes observers.
564
583
  * @public
584
+ * @returns {void}
565
585
  */
566
586
  hideControls() {
567
587
  if (!this.isControlsVisible()) {
@@ -590,7 +610,7 @@ export default class OpeningAnimation {
590
610
  /**
591
611
  * Returns the current animation state.
592
612
  * @public
593
- * @returns {number}
613
+ * @returns {number} 0 paused, 1 closed, 2 opened, 3 opening or 4 closing.
594
614
  */
595
615
  get state() {
596
616
  return this.#state;
@@ -0,0 +1,145 @@
1
+ import { InstancedMesh, Mesh, Quaternion, UtilityLayerRenderer, Vector3 } from "@babylonjs/core";
2
+
3
+ /**
4
+ * Owns one animated-mesh overlay group, its utility scene and transform observers.
5
+ * Groups retain independent caches and release only their own rendering resources.
6
+ * @class BabylonJSAnimationOverlayController
7
+ */
8
+ export default class BabylonJSAnimationOverlayController {
9
+ #utilityLayer;
10
+ #color;
11
+ #baseToOverlayBase = new Map();
12
+ #pairs = [];
13
+ #observers = [];
14
+ #position = new Vector3();
15
+ #scale = new Vector3();
16
+ #rotation = new Quaternion();
17
+ #disposed = false;
18
+
19
+ /**
20
+ * Creates presentation meshes and follows their original world transforms.
21
+ * @public
22
+ * @param {import("@babylonjs/core").Scene} scene - Host scene used by the utility renderer.
23
+ * @param {Array<import("@babylonjs/core").AbstractMesh>} meshes - Original meshes to highlight.
24
+ * @param {import("@babylonjs/core").Color3} color - Highlight color for the group.
25
+ */
26
+ constructor(scene, meshes, color) {
27
+ this.#color = color;
28
+ this.#utilityLayer = new UtilityLayerRenderer(scene, undefined, undefined);
29
+ for (const mesh of meshes) {
30
+ this.#createOverlay(mesh);
31
+ }
32
+ for (const pair of this.#pairs) {
33
+ if (!pair.original.getWorldMatrix) {
34
+ continue;
35
+ }
36
+ pair.original.computeWorldMatrix(true);
37
+ this.#syncPair(pair);
38
+ const observer = pair.original.onAfterWorldMatrixUpdateObservable?.add(() => this.#syncPair(pair));
39
+ if (observer) {
40
+ this.#observers.push({ mesh: pair.original, observer });
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Resolves the source geometry mesh for an instance or returns a regular mesh unchanged.
47
+ * @private
48
+ * @static
49
+ * @param {import("@babylonjs/core").AbstractMesh} mesh - Original mesh.
50
+ * @returns {import("@babylonjs/core").Mesh} Mesh supplying the overlay geometry.
51
+ */
52
+ static #getBaseMesh(mesh) {
53
+ return mesh instanceof InstancedMesh ? mesh.sourceMesh : mesh;
54
+ }
55
+
56
+ /**
57
+ * Creates or reuses the group's presentation base for a source mesh.
58
+ * @private
59
+ * @param {import("@babylonjs/core").Mesh} baseMesh - Source mesh to clone.
60
+ * @returns {import("@babylonjs/core").Mesh} Cached overlay base.
61
+ */
62
+ #ensureOverlayBase(baseMesh) {
63
+ let overlayBase = this.#baseToOverlayBase.get(baseMesh);
64
+ if (overlayBase) {
65
+ return overlayBase;
66
+ }
67
+ // Register the clone in its owning scene from creation; changing _scene later
68
+ // leaves Babylon's node and root-node registries in the original scene.
69
+ overlayBase = new Mesh(baseMesh.name + "_overlayBase", this.#utilityLayer.utilityLayerScene, null, baseMesh, true, false);
70
+ overlayBase.isPickable = false;
71
+ overlayBase.isVisible = false;
72
+ overlayBase.setEnabled(true);
73
+ overlayBase.renderOutline = false;
74
+ overlayBase.outlineColor = this.#color;
75
+ overlayBase.outlineWidth = 1.5;
76
+ overlayBase.renderOverlay = true;
77
+ overlayBase.overlayColor = this.#color;
78
+ overlayBase.overlayAlpha = 0.25;
79
+ overlayBase.visibility = 1;
80
+ overlayBase.renderingGroupId = 0;
81
+ this.#baseToOverlayBase.set(baseMesh, overlayBase);
82
+ return overlayBase;
83
+ }
84
+
85
+ /**
86
+ * Adds a presentation mesh or an instance of a cached presentation base.
87
+ * @private
88
+ * @param {import("@babylonjs/core").AbstractMesh} mesh - Original mesh to present.
89
+ * @returns {void}
90
+ */
91
+ #createOverlay(mesh) {
92
+ if (!mesh.geometry && !mesh.getChildMeshes) {
93
+ return;
94
+ }
95
+ const baseMesh = BabylonJSAnimationOverlayController.#getBaseMesh(mesh);
96
+ const overlayBase = this.#ensureOverlayBase(baseMesh);
97
+ const overlay = mesh instanceof InstancedMesh ? overlayBase.createInstance(mesh.name + "_overlayInst") : overlayBase;
98
+ if (mesh instanceof InstancedMesh) {
99
+ overlay.isPickable = false;
100
+ } else {
101
+ overlay.isVisible = true;
102
+ }
103
+ overlay.parent = null;
104
+ overlay.renderingGroupId = 0;
105
+ this.#pairs.push({ original: mesh, overlay });
106
+ }
107
+
108
+ /**
109
+ * Copies an original world transform to its overlay using reusable group-local scratch vectors.
110
+ * @private
111
+ * @param {{original:object,overlay:object}} pair - Original and presentation mesh pair.
112
+ * @returns {void}
113
+ */
114
+ #syncPair(pair) {
115
+ pair.original.getWorldMatrix().decompose(this.#scale, this.#rotation, this.#position);
116
+ pair.overlay.position.copyFrom(this.#position);
117
+ pair.overlay.scaling.copyFrom(this.#scale);
118
+ pair.overlay.rotationQuaternion = pair.overlay.rotationQuaternion || new Quaternion();
119
+ pair.overlay.rotationQuaternion.copyFrom(this.#rotation);
120
+ pair.overlay.computeWorldMatrix(true);
121
+ }
122
+
123
+ /**
124
+ * Removes transform observers and disposes each owned mesh and the utility renderer once.
125
+ * @public
126
+ * @returns {void}
127
+ */
128
+ dispose() {
129
+ if (this.#disposed) {
130
+ return;
131
+ }
132
+ this.#disposed = true;
133
+ for (const { mesh, observer } of this.#observers) {
134
+ mesh.onAfterWorldMatrixUpdateObservable?.remove(observer);
135
+ }
136
+ const overlays = new Set([...this.#pairs.map(pair => pair.overlay), ...this.#baseToOverlayBase.values()]);
137
+ for (const overlay of overlays) {
138
+ overlay.dispose();
139
+ }
140
+ this.#utilityLayer.dispose();
141
+ this.#observers.length = 0;
142
+ this.#pairs.length = 0;
143
+ this.#baseToOverlayBase.clear();
144
+ }
145
+ }