@preference-sl/pref-viewer 2.13.0-beta.2 → 2.13.0-beta.21

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/Readme.md CHANGED
@@ -5,4 +5,4 @@ A Web Component for visualizing GLTF models using Babylon.js.
5
5
  ## Installation
6
6
 
7
7
  ```bash
8
- npm install @preference-sl/pref-viewer
8
+ npm install @preference-sl/pref-viewer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.13.0-beta.2",
3
+ "version": "2.13.0-beta.21",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -35,11 +35,11 @@
35
35
  "index.d.ts"
36
36
  ],
37
37
  "dependencies": {
38
- "@babylonjs/core": "^8.47.0",
39
- "@babylonjs/loaders": "^8.47.0",
40
- "@babylonjs/serializers": "^8.47.0",
38
+ "@babylonjs/core": "^8.50.5",
39
+ "@babylonjs/loaders": "^8.50.5",
40
+ "@babylonjs/serializers": "^8.50.5",
41
41
  "@panzoom/panzoom": "^4.6.0",
42
- "babylonjs-gltf2interface": "^8.47.0",
42
+ "babylonjs-gltf2interface": "^8.50.5",
43
43
  "buffer": "^6.0.3",
44
44
  "idb": "^8.0.3",
45
45
  "is-svg": "^6.1.0",
@@ -6,22 +6,32 @@ import OpeningAnimation from "./babylonjs-animation-opening.js";
6
6
  * BabylonJSAnimationController - Manages animation playback, highlighting, and interactive controls for animated nodes in Babylon.js scenes.
7
7
  *
8
8
  * Summary:
9
- * This class detects, groups, and manages opening/closing animations for scene nodes, provides interactive highlighting of animated nodes and their meshes, and displays a menu for animation control. It is designed for integration with product configurators and interactive 3D applications using Babylon.js.
9
+ * This class detects and groups opening/closing animations, manages hover highlighting for animated nodes,
10
+ * exposes whether interactive animations are available, and coordinates the contextual animation menu.
10
11
  *
11
12
  * Key features:
12
13
  * - Detects and groups opening/closing animations in the scene.
13
14
  * - Tracks animated transformation nodes and their relationships to meshes.
15
+ * - Exposes animation availability through `hasAnimations()`.
14
16
  * - Highlights animated nodes and their child meshes on pointer hover.
17
+ * - Avoids redundant highlight rebuilds using cached mesh/node IDs and order-insensitive node-id comparison.
18
+ * - Reports highlight-state deltas from `highlightMeshes()` so callers can react efficiently.
15
19
  * - Displays and disposes the animation control menu for animated nodes.
16
- * - Provides public API for highlighting, showing the animation menu, and disposing resources.
20
+ * - Provides public API for availability checks, highlighting, showing the animation menu, and disposing resources.
17
21
  * - Cleans up all resources and observers to prevent memory leaks.
18
22
  *
19
23
  * Public Methods:
20
24
  * - dispose(): Disposes all resources managed by the animation controller.
21
- * - highlightMeshes(pickingInfo): Highlights meshes that are children of an animated node when hovered.
25
+ * - hasAnimations(): Returns whether opening/closing animations are available.
26
+ * - highlightMeshes(pickingInfo): Updates hover highlighting and returns `{ changed, highlighted }`.
22
27
  * - hideMenu(): Hides and disposes the animation control menu if it exists.
23
28
  * - showMenu(pickingInfo): Displays the animation control menu for the animated node under the pointer.
24
29
  *
30
+ * Private Methods (high level):
31
+ * - #haveSameNodeIds(arr1, arr2): Compares hovered animated node sets ignoring order.
32
+ * - #addHighlight(nodeIds): Applies highlighting with HighlightLayer or overlay meshes.
33
+ * - #removeHighlight(): Clears current highlights and cached hover state.
34
+ *
25
35
  * @class
26
36
  */
27
37
  export default class BabylonJSAnimationController {
@@ -35,19 +45,18 @@ export default class BabylonJSAnimationController {
35
45
  #highlightColor = Color3.FromHexString(PrefViewerStyleVariables.colorPrimary);
36
46
  #highlightLayer = null;
37
47
  #overlayLayer = null;
38
- #useHighlightLayer = false; // Set to true to use HighlightLayer (better performance) and false to use overlay meshes (UtilityLayerRenderer - always on top)
48
+ #useHighlightLayer = true; // Set to true to use HighlightLayer (better performance) and false to use overlay meshes (UtilityLayerRenderer - always on top)
39
49
  #lastHighlightedMeshId = null; // Cache to avoid redundant highlight updates
50
+ #lastHighlightedNodeIds = []; // Cache to avoid redundant highlight updates
40
51
 
41
52
  /**
42
53
  * Creates a new BabylonJSAnimationController for a Babylon.js scene.
43
54
  * @param {AssetContainer|Scene} assetContainer - The Babylon.js asset container or scene instance.
44
55
  */
45
56
  constructor(assetContainer) {
46
- if (assetContainer instanceof AssetContainer || assetContainer instanceof Scene) {
47
- this.#scene = assetContainer.scene ? assetContainer.scene : assetContainer;
48
- this.#assetContainer = assetContainer;
49
- this.#canvas = this.#scene._engine._renderingCanvas;
50
- }
57
+ this.#scene = assetContainer.scene ? assetContainer.scene : assetContainer;
58
+ this.#assetContainer = assetContainer;
59
+ this.#canvas = this.#scene._engine._renderingCanvas;
51
60
  this.#initializeAnimations();
52
61
  }
53
62
 
@@ -128,15 +137,18 @@ export default class BabylonJSAnimationController {
128
137
  * @returns {Array<string>} Array of animated node IDs associated with the mesh.
129
138
  */
130
139
  #getNodesAnimatedByMesh(mesh) {
131
- let nodeId = [];
140
+ let nodeIds = [];
141
+ if (!mesh || !mesh.id) {
142
+ return nodeIds;
143
+ }
132
144
  let node = mesh;
133
145
  while (node.parent !== null) {
134
146
  node = node.parent;
135
- if (this.#animatedNodes.includes(node.id) && !nodeId.includes(node.id)) {
136
- nodeId.push(node.id);
147
+ if (this.#animatedNodes.includes(node.id) && !nodeIds.includes(node.id)) {
148
+ nodeIds.push(node.id);
137
149
  }
138
150
  }
139
- return nodeId;
151
+ return nodeIds;
140
152
  }
141
153
 
142
154
  /**
@@ -163,7 +175,8 @@ export default class BabylonJSAnimationController {
163
175
  const RENDERING_GROUP_ID = 0; // MUST be > 0 to render on top of main scene
164
176
 
165
177
  // Use UtilityLayerRenderer for rendering
166
- const uScene = UtilityLayerRenderer.DefaultKeepDepthUtilityLayer.utilityLayerScene;
178
+ const utilityLayer = new UtilityLayerRenderer(this.#scene, undefined, undefined);
179
+ const uScene = utilityLayer.utilityLayerScene;
167
180
 
168
181
  // Cache map: baseMesh -> overlayBaseMesh to reuse for InstancedMeshes
169
182
  const baseToOverlayBase = new Map();
@@ -304,67 +317,68 @@ export default class BabylonJSAnimationController {
304
317
  baseToOverlayBase.forEach((overlayBase) => {
305
318
  overlayBase.dispose();
306
319
  });
320
+
321
+ // Dispose utility layer scene/resources; without this each rebuild leaks GPU/JS resources.
322
+ utilityLayer.dispose();
307
323
  },
308
324
  };
309
325
  }
310
326
 
311
327
  /**
312
- * ---------------------------
313
- * Public methods
314
- * ---------------------------
328
+ * Compares two node ID arrays and checks whether they contain the same values,
329
+ * regardless of element order.
330
+ * @private
331
+ * @param {string[]} arr1 - First node ID array.
332
+ * @param {string[]} arr2 - Second node ID array.
333
+ * @returns {boolean} True when both arrays contain the same node IDs; otherwise false.
315
334
  */
335
+ #haveSameNodeIds(arr1, arr2) {
336
+ if (arr1.length !== arr2.length) {
337
+ return false;
338
+ }
339
+ const sortedArr1 = [...arr1].sort();
340
+ const sortedArr2 = [...arr2].sort();
341
+ return sortedArr1.every((value, index) => value === sortedArr2[index]);
342
+ }
316
343
 
317
344
  /**
318
- * Disposes all resources managed by the animation controller.
319
- * Cleans up the highlight layer, animation menu, and internal animation/node lists.
320
- * Should be called when the controller is no longer needed to prevent memory leaks.
321
- * @public
345
+ * Clears the current highlight state from either HighlightLayer or overlay meshes.
346
+ * Also resets cached mesh/node tracking used to avoid redundant highlight updates.
347
+ * @private
348
+ * @returns {void}
322
349
  */
323
- dispose() {
324
- if (this.#highlightLayer) {
325
- this.#highlightLayer.removeAllMeshes();
326
- this.#highlightLayer.dispose();
327
- this.#highlightLayer = null;
328
- }
329
- if (this.#overlayLayer) {
330
- this.#overlayLayer.dispose();
331
- this.#overlayLayer = null;
350
+ #removeHighlight() {
351
+ if (this.#useHighlightLayer) {
352
+ if (this.#highlightLayer) {
353
+ this.#highlightLayer.removeAllMeshes();
354
+ }
355
+ } else {
356
+ if (this.#overlayLayer) {
357
+ this.#overlayLayer.dispose();
358
+ this.#overlayLayer = null;
359
+ }
332
360
  }
333
- this.hideMenu();
334
- this.#animatedNodes = [];
335
- this.#openingAnimations.forEach((openingAnimation) => openingAnimation.dispose());
336
- this.#openingAnimations = [];
361
+ this.#lastHighlightedMeshId = null;
362
+ this.#lastHighlightedNodeIds = [];
337
363
  }
338
364
 
339
365
  /**
340
- * Highlights meshes that are children of an animated node when hovered.
341
- * @public
342
- * @param {PickingInfo} pickingInfo - Raycast info from pointer position.
366
+ * Applies highlighting for all meshes belonging to the provided animated node IDs.
367
+ * Depending on configuration, it uses Babylon's HighlightLayer or overlay meshes rendered
368
+ * in a utility layer for always-on-top outlines/fills.
369
+ * @private
370
+ * @param {string[]} nodeIds - Animated node IDs whose child meshes should be highlighted.
371
+ * @returns {void}
343
372
  */
344
- highlightMeshes(pickingInfo) {
345
- // Check if we're hovering the same mesh to avoid recreating overlays/highlights
346
- const pickedMeshId = pickingInfo?.pickedMesh?.id;
347
- if (this.#lastHighlightedMeshId === pickedMeshId) {
348
- return; // No need to update if hovering the same mesh
373
+ #addHighlight(nodeIds) {
374
+ this.#removeHighlight();
375
+ if (!nodeIds.length) {
376
+ return;
349
377
  }
350
- this.#lastHighlightedMeshId = pickedMeshId;
351
-
352
378
  if (this.#useHighlightLayer) {
353
379
  if (!this.#highlightLayer) {
354
380
  this.#highlightLayer = new HighlightLayer("hl_animations", this.#scene);
355
381
  }
356
-
357
- this.#highlightLayer.removeAllMeshes();
358
-
359
- if (!pickingInfo?.hit || !pickingInfo?.pickedMesh) {
360
- return;
361
- }
362
-
363
- const nodeIds = this.#getNodesAnimatedByMesh(pickingInfo.pickedMesh);
364
- if (!nodeIds.length) {
365
- return;
366
- }
367
-
368
382
  const transformNodes = [];
369
383
  nodeIds.forEach((nodeId) => {
370
384
  const transformNode = this.#scene.getTransformNodeByID(nodeId);
@@ -384,20 +398,6 @@ export default class BabylonJSAnimationController {
384
398
  }
385
399
  });
386
400
  } else {
387
- if (this.#overlayLayer) {
388
- this.#overlayLayer.dispose();
389
- this.#overlayLayer = null;
390
- }
391
-
392
- if (!pickingInfo?.hit || !pickingInfo?.pickedMesh) {
393
- return;
394
- }
395
-
396
- const nodeIds = this.#getNodesAnimatedByMesh(pickingInfo.pickedMesh);
397
- if (!nodeIds.length) {
398
- return;
399
- }
400
-
401
401
  const meshes = [];
402
402
  nodeIds.forEach((nodeId) => {
403
403
  const transformNode = this.#scene.getTransformNodeByID(nodeId);
@@ -409,6 +409,99 @@ export default class BabylonJSAnimationController {
409
409
 
410
410
  this.#overlayLayer = this.#addGroupOutlineOverlayForInstances(meshes);
411
411
  }
412
+ this.#lastHighlightedNodeIds = nodeIds;
413
+ }
414
+
415
+ /**
416
+ * ---------------------------
417
+ * Public methods
418
+ * ---------------------------
419
+ */
420
+
421
+ /**
422
+ * Disposes all resources managed by the animation controller.
423
+ * Cleans up the highlight layer, animation menu, and internal animation/node lists.
424
+ * Should be called when the controller is no longer needed to prevent memory leaks.
425
+ * @public
426
+ */
427
+ dispose() {
428
+ if (this.#highlightLayer) {
429
+ this.#highlightLayer.removeAllMeshes();
430
+ this.#highlightLayer.dispose();
431
+ this.#highlightLayer = null;
432
+ }
433
+ if (this.#overlayLayer) {
434
+ this.#overlayLayer.dispose();
435
+ this.#overlayLayer = null;
436
+ }
437
+ this.hideMenu();
438
+ this.#animatedNodes = [];
439
+ this.#openingAnimations.forEach((openingAnimation) => openingAnimation.dispose());
440
+ this.#openingAnimations = [];
441
+ }
442
+
443
+ /**
444
+ * Indicates whether the current asset container exposes at least one opening/closing animation pair.
445
+ * @public
446
+ * @returns {boolean} True when interactive opening animations are available; otherwise false.
447
+ */
448
+ hasAnimations() {
449
+ return this.#openingAnimations.length > 0;
450
+ }
451
+
452
+ /**
453
+ * Updates hover highlighting for meshes under animated nodes.
454
+ * Uses cached mesh/node state to avoid rebuilding highlight layers when the effective
455
+ * highlighted node set has not changed.
456
+ * @public
457
+ * @param {PickingInfo} pickingInfo - Raycast info from pointer position.
458
+ * @returns {{changed:boolean, highlighted:boolean}}
459
+ * Returns whether highlight visuals changed in this call and whether any mesh remains highlighted.
460
+ */
461
+ highlightMeshes(pickingInfo) {
462
+ let changed = false;
463
+ let highlighted = false;
464
+
465
+ // Check if we're hovering the same mesh to avoid recreating overlays/highlights
466
+ const pickedMeshId = !pickingInfo?.hit || !pickingInfo?.pickedMesh?.id ? null : pickingInfo.pickedMesh.id;
467
+ const pickedMesh = pickingInfo?.pickedMesh ? pickingInfo.pickedMesh : null;
468
+
469
+ if (this.#lastHighlightedMeshId === pickedMeshId) {
470
+ // No need to update if hovering the same mesh
471
+ if (this.#lastHighlightedMeshId !== null) {
472
+ changed = false;
473
+ highlighted = true;
474
+ } else {
475
+ changed = false;
476
+ highlighted = false;
477
+ }
478
+ } else {
479
+ const nodeIds = this.#getNodesAnimatedByMesh(pickedMesh);
480
+ if (!nodeIds.length) {
481
+ if (this.#lastHighlightedMeshId !== null) {
482
+ this.#removeHighlight();
483
+ changed = true;
484
+ highlighted = false;
485
+ } else {
486
+ changed = false;
487
+ highlighted = false;
488
+ }
489
+ } else {
490
+ if (this.#haveSameNodeIds(nodeIds, this.#lastHighlightedNodeIds)) {
491
+ // No need to update if hovering a mesh under the same animated nodes
492
+ changed = false;
493
+ highlighted = true;
494
+ } else {
495
+ // Hovering a different mesh or same mesh under different animated nodes - update highlight
496
+ this.#addHighlight(nodeIds);
497
+ changed = true;
498
+ highlighted = true;
499
+ }
500
+ this.#lastHighlightedMeshId = pickedMeshId;
501
+ }
502
+ }
503
+
504
+ return { changed: changed, highlighted: highlighted };
412
505
  }
413
506
 
414
507
  /**
@@ -423,20 +516,26 @@ export default class BabylonJSAnimationController {
423
516
 
424
517
  /**
425
518
  * Displays the animation control menu for the animated node under the pointer.
519
+ * Hides the current menu when picking info is invalid or when no animated node is found.
520
+ * Prefers a currently started animation; otherwise falls back to the first available one.
426
521
  * @public
427
- * @param {PickingInfo} pickingInfo - Raycast info from pointer position.
522
+ * @param {PickingInfo|null|undefined} pickingInfo - Raycast info from pointer position.
523
+ * Can be null/undefined when caller intentionally skips picking.
524
+ * @returns {void}
428
525
  */
429
526
  showMenu(pickingInfo) {
430
- if (!pickingInfo?.hit && !pickingInfo?.pickedMesh) {
527
+ const pickedMesh = pickingInfo?.pickedMesh;
528
+ if (!pickingInfo?.hit || !pickedMesh) {
529
+ this.hideMenu();
431
530
  return;
432
531
  }
433
532
 
434
- this.hideMenu();
435
-
436
- const nodeIds = this.#getNodesAnimatedByMesh(pickingInfo.pickedMesh);
533
+ const nodeIds = this.#getNodesAnimatedByMesh(pickedMesh);
437
534
  if (!nodeIds.length) {
535
+ this.hideMenu();
438
536
  return;
439
537
  }
538
+
440
539
  const openingAnimations = [];
441
540
  nodeIds.forEach((nodeId) => {
442
541
  const openingAnimation = this.#getOpeningAnimationByNode(nodeId);
@@ -446,7 +545,19 @@ export default class BabylonJSAnimationController {
446
545
  openingAnimations.push(openingAnimation);
447
546
  });
448
547
 
548
+ if (!openingAnimations.length) {
549
+ this.hideMenu();
550
+ return;
551
+ }
552
+
449
553
  const startedAnimation = openingAnimations.find((animation) => animation.state !== OpeningAnimation.states.closed);
450
- startedAnimation ? startedAnimation.showControls(this.#canvas, openingAnimations) : openingAnimations[0].showControls(this.#canvas, openingAnimations);
554
+ const animationToShow = startedAnimation ? startedAnimation : openingAnimations[0];
555
+
556
+ if (animationToShow.isControlsVisible()) {
557
+ return;
558
+ }
559
+
560
+ this.hideMenu();
561
+ animationToShow.showControls(this.#canvas, openingAnimations);
451
562
  }
452
563
  }
@@ -8,6 +8,7 @@ import OpeningAnimationMenu from "./babylonjs-animation-opening-menu.js";
8
8
  * - Tracks animation state (paused, closed, opened, opening, closing).
9
9
  * - Synchronizes animation progress and UI controls.
10
10
  * - Handles loop mode and progress threshold logic.
11
+ * - Dispatches `prefviewer-animation-update` events with animation state snapshots.
11
12
  * - Provides methods for play, pause, go to opened/closed, and progress control.
12
13
  * - Manages the animation control menu (OpeningAnimationMenu) and its callbacks.
13
14
  *
@@ -36,9 +37,11 @@ import OpeningAnimationMenu from "./babylonjs-animation-opening-menu.js";
36
37
  * - #getCurrentFrame(): Gets the current frame based on state.
37
38
  * - #getFrameFromProgress(progress): Calculates frame from progress value.
38
39
  * - #getProgress(): Calculates progress (0-1) from current frame.
40
+ * - #getPrefViewer3DComponent(): Resolves and caches the nearest `pref-viewer-3d` host element.
39
41
  * - #checkProgress(progress): Applies threshold logic to progress.
42
+ * - #notifyStateChange(): Emits `prefviewer-animation-update` with state/progress/loop metadata.
40
43
  * - #updateControlsSlider(): Updates the slider in the control menu.
41
- * - #updateControls(): Updates all controls in the menu.
44
+ * - #updateControls(): Emits state updates and syncs controls when the menu is visible.
42
45
  */
43
46
  export default class OpeningAnimation {
44
47
  static states = {
@@ -54,6 +57,7 @@ export default class OpeningAnimation {
54
57
  #closeAnimation = null;
55
58
  #nodes = [];
56
59
  #menu = null;
60
+ #prefViewer3D = undefined; // Reference to parent custom elements for event dispatching
57
61
 
58
62
  #state = OpeningAnimation.states.closed;
59
63
  #lastPausedFrame = 0;
@@ -271,6 +275,29 @@ export default class OpeningAnimation {
271
275
  return progress;
272
276
  }
273
277
 
278
+ /**
279
+ * Resolves and caches the closest `pref-viewer-3d` host associated with the animation canvas.
280
+ * @private
281
+ * @returns {void}
282
+ */
283
+ #getPrefViewer3DComponent() {
284
+ if (this.#prefViewer3D !== undefined) {
285
+ return;
286
+ }
287
+
288
+ const scene = this.#openAnimation?.getScene?.();
289
+ const engine = scene?.getEngine?.();
290
+ const canvas = engine?.getRenderingCanvas?.();
291
+
292
+ let prefViewer3D = canvas?.closest?.("pref-viewer-3d") || null;
293
+ if (!prefViewer3D) {
294
+ const host = canvas?.getRootNode?.()?.host;
295
+ prefViewer3D = host?.nodeName === "PREF-VIEWER-3D" ? host : null;
296
+ }
297
+
298
+ this.#prefViewer3D = prefViewer3D;
299
+ }
300
+
274
301
  /**
275
302
  * Applies threshold logic to the progress value to snap to 0 or 1 if near the ends.
276
303
  * Prevents floating point errors from leaving the animation in an in-between state.
@@ -287,6 +314,33 @@ export default class OpeningAnimation {
287
314
  return progress;
288
315
  }
289
316
 
317
+ /**
318
+ * Dispatches a `prefviewer-animation-update` CustomEvent from the nearest `pref-viewer-3d` host.
319
+ * The event bubbles and crosses shadow DOM boundaries, exposing the current animation snapshot
320
+ * (`name`, `state`, `progress`, and `loop`) in `event.detail`.
321
+ * @private
322
+ * @returns {boolean} True when the event was dispatched, false when no `pref-viewer-3d` host is available.
323
+ */
324
+ #notifyStateChange() {
325
+ this.#getPrefViewer3DComponent();
326
+ if (!this.#prefViewer3D) {
327
+ return false;
328
+ }
329
+
330
+ const customEventOptions = {
331
+ bubbles: true,
332
+ composed: true,
333
+ detail: {
334
+ name: this.name,
335
+ state: this.#state,
336
+ progress: this.#getProgress(),
337
+ loop: this.#loop,
338
+ },
339
+ };
340
+ this.#prefViewer3D.dispatchEvent(new CustomEvent("prefviewer-animation-changed", customEventOptions));
341
+ return true;
342
+ }
343
+
290
344
  /**
291
345
  * Updates the slider value in the animation control menu to match the current progress.
292
346
  * @private
@@ -299,10 +353,12 @@ export default class OpeningAnimation {
299
353
  }
300
354
 
301
355
  /**
302
- * Updates all controls in the animation menu (buttons, slider) to reflect the current state and progress.
356
+ * Broadcasts the latest animation state and, when the menu is visible, syncs its controls
357
+ * (buttons and progress slider) with the current state/progress values.
303
358
  * @private
304
359
  */
305
360
  #updateControls() {
361
+ this.#notifyStateChange();
306
362
  if (!this.isControlsVisible()) {
307
363
  return;
308
364
  }