@preference-sl/pref-viewer 2.13.0-beta.1 → 2.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.13.0-beta.1",
3
+ "version": "2.13.0-beta.11",
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 {
@@ -37,17 +47,16 @@ export default class BabylonJSAnimationController {
37
47
  #overlayLayer = null;
38
48
  #useHighlightLayer = false; // 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();
@@ -309,62 +322,60 @@ export default class BabylonJSAnimationController {
309
322
  }
310
323
 
311
324
  /**
312
- * ---------------------------
313
- * Public methods
314
- * ---------------------------
325
+ * Compares two node ID arrays and checks whether they contain the same values,
326
+ * regardless of element order.
327
+ * @private
328
+ * @param {string[]} arr1 - First node ID array.
329
+ * @param {string[]} arr2 - Second node ID array.
330
+ * @returns {boolean} True when both arrays contain the same node IDs; otherwise false.
315
331
  */
332
+ #haveSameNodeIds(arr1, arr2) {
333
+ if (arr1.length !== arr2.length) {
334
+ return false;
335
+ }
336
+ const sortedArr1 = [...arr1].sort();
337
+ const sortedArr2 = [...arr2].sort();
338
+ return sortedArr1.every((value, index) => value === sortedArr2[index]);
339
+ }
316
340
 
317
341
  /**
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
342
+ * Clears the current highlight state from either HighlightLayer or overlay meshes.
343
+ * Also resets cached mesh/node tracking used to avoid redundant highlight updates.
344
+ * @private
345
+ * @returns {void}
322
346
  */
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;
347
+ #removeHighlight() {
348
+ if (this.#useHighlightLayer) {
349
+ if (this.#highlightLayer) {
350
+ this.#highlightLayer.removeAllMeshes();
351
+ }
352
+ } else {
353
+ if (this.#overlayLayer) {
354
+ this.#overlayLayer.dispose();
355
+ this.#overlayLayer = null;
356
+ }
332
357
  }
333
- this.hideMenu();
334
- this.#animatedNodes = [];
335
- this.#openingAnimations.forEach((openingAnimation) => openingAnimation.dispose());
336
- this.#openingAnimations = [];
358
+ this.#lastHighlightedMeshId = null;
359
+ this.#lastHighlightedNodeIds = [];
337
360
  }
338
361
 
339
362
  /**
340
- * Highlights meshes that are children of an animated node when hovered.
341
- * @public
342
- * @param {PickingInfo} pickingInfo - Raycast info from pointer position.
363
+ * Applies highlighting for all meshes belonging to the provided animated node IDs.
364
+ * Depending on configuration, it uses Babylon's HighlightLayer or overlay meshes rendered
365
+ * in a utility layer for always-on-top outlines/fills.
366
+ * @private
367
+ * @param {string[]} nodeIds - Animated node IDs whose child meshes should be highlighted.
368
+ * @returns {void}
343
369
  */
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
370
+ #addHighlight(nodeIds) {
371
+ this.#removeHighlight();
372
+ if (!nodeIds.length) {
373
+ return;
349
374
  }
350
- this.#lastHighlightedMeshId = pickedMeshId;
351
-
352
375
  if (this.#useHighlightLayer) {
353
376
  if (!this.#highlightLayer) {
354
377
  this.#highlightLayer = new HighlightLayer("hl_animations", this.#scene);
355
378
  }
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
379
  const transformNodes = [];
369
380
  nodeIds.forEach((nodeId) => {
370
381
  const transformNode = this.#scene.getTransformNodeByID(nodeId);
@@ -384,20 +395,6 @@ export default class BabylonJSAnimationController {
384
395
  }
385
396
  });
386
397
  } 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
398
  const meshes = [];
402
399
  nodeIds.forEach((nodeId) => {
403
400
  const transformNode = this.#scene.getTransformNodeByID(nodeId);
@@ -409,6 +406,99 @@ export default class BabylonJSAnimationController {
409
406
 
410
407
  this.#overlayLayer = this.#addGroupOutlineOverlayForInstances(meshes);
411
408
  }
409
+ this.#lastHighlightedNodeIds = nodeIds;
410
+ }
411
+
412
+ /**
413
+ * ---------------------------
414
+ * Public methods
415
+ * ---------------------------
416
+ */
417
+
418
+ /**
419
+ * Disposes all resources managed by the animation controller.
420
+ * Cleans up the highlight layer, animation menu, and internal animation/node lists.
421
+ * Should be called when the controller is no longer needed to prevent memory leaks.
422
+ * @public
423
+ */
424
+ dispose() {
425
+ if (this.#highlightLayer) {
426
+ this.#highlightLayer.removeAllMeshes();
427
+ this.#highlightLayer.dispose();
428
+ this.#highlightLayer = null;
429
+ }
430
+ if (this.#overlayLayer) {
431
+ this.#overlayLayer.dispose();
432
+ this.#overlayLayer = null;
433
+ }
434
+ this.hideMenu();
435
+ this.#animatedNodes = [];
436
+ this.#openingAnimations.forEach((openingAnimation) => openingAnimation.dispose());
437
+ this.#openingAnimations = [];
438
+ }
439
+
440
+ /**
441
+ * Indicates whether the current asset container exposes at least one opening/closing animation pair.
442
+ * @public
443
+ * @returns {boolean} True when interactive opening animations are available; otherwise false.
444
+ */
445
+ hasAnimations() {
446
+ return this.#openingAnimations.length > 0;
447
+ }
448
+
449
+ /**
450
+ * Updates hover highlighting for meshes under animated nodes.
451
+ * Uses cached mesh/node state to avoid rebuilding highlight layers when the effective
452
+ * highlighted node set has not changed.
453
+ * @public
454
+ * @param {PickingInfo} pickingInfo - Raycast info from pointer position.
455
+ * @returns {{changed:boolean, highlighted:boolean}}
456
+ * Returns whether highlight visuals changed in this call and whether any mesh remains highlighted.
457
+ */
458
+ highlightMeshes(pickingInfo) {
459
+ let changed = false;
460
+ let highlighted = false;
461
+
462
+ // Check if we're hovering the same mesh to avoid recreating overlays/highlights
463
+ const pickedMeshId = !pickingInfo?.hit || !pickingInfo?.pickedMesh?.id ? null : pickingInfo.pickedMesh.id;
464
+ const pickedMesh = pickingInfo?.pickedMesh ? pickingInfo.pickedMesh : null;
465
+
466
+ if (this.#lastHighlightedMeshId === pickedMeshId) {
467
+ // No need to update if hovering the same mesh
468
+ if (this.#lastHighlightedMeshId !== null) {
469
+ changed = false;
470
+ highlighted = true;
471
+ } else {
472
+ changed = false;
473
+ highlighted = false;
474
+ }
475
+ } else {
476
+ const nodeIds = this.#getNodesAnimatedByMesh(pickedMesh);
477
+ if (!nodeIds.length) {
478
+ if (this.#lastHighlightedMeshId !== null) {
479
+ this.#removeHighlight();
480
+ changed = true;
481
+ highlighted = false;
482
+ } else {
483
+ changed = false;
484
+ highlighted = false;
485
+ }
486
+ } else {
487
+ if (this.#haveSameNodeIds(nodeIds, this.#lastHighlightedNodeIds)) {
488
+ // No need to update if hovering a mesh under the same animated nodes
489
+ changed = false;
490
+ highlighted = true;
491
+ } else {
492
+ // Hovering a different mesh or same mesh under different animated nodes - update highlight
493
+ this.#addHighlight(nodeIds);
494
+ changed = true;
495
+ highlighted = true;
496
+ }
497
+ this.#lastHighlightedMeshId = pickedMeshId;
498
+ }
499
+ }
500
+
501
+ return { changed: changed, highlighted: highlighted };
412
502
  }
413
503
 
414
504
  /**
@@ -423,20 +513,26 @@ export default class BabylonJSAnimationController {
423
513
 
424
514
  /**
425
515
  * Displays the animation control menu for the animated node under the pointer.
516
+ * Hides the current menu when picking info is invalid or when no animated node is found.
517
+ * Prefers a currently started animation; otherwise falls back to the first available one.
426
518
  * @public
427
- * @param {PickingInfo} pickingInfo - Raycast info from pointer position.
519
+ * @param {PickingInfo|null|undefined} pickingInfo - Raycast info from pointer position.
520
+ * Can be null/undefined when caller intentionally skips picking.
521
+ * @returns {void}
428
522
  */
429
523
  showMenu(pickingInfo) {
430
- if (!pickingInfo?.hit && !pickingInfo?.pickedMesh) {
524
+ const pickedMesh = pickingInfo?.pickedMesh;
525
+ if (!pickingInfo?.hit || !pickedMesh) {
526
+ this.hideMenu();
431
527
  return;
432
528
  }
433
529
 
434
- this.hideMenu();
435
-
436
- const nodeIds = this.#getNodesAnimatedByMesh(pickingInfo.pickedMesh);
530
+ const nodeIds = this.#getNodesAnimatedByMesh(pickedMesh);
437
531
  if (!nodeIds.length) {
532
+ this.hideMenu();
438
533
  return;
439
534
  }
535
+
440
536
  const openingAnimations = [];
441
537
  nodeIds.forEach((nodeId) => {
442
538
  const openingAnimation = this.#getOpeningAnimationByNode(nodeId);
@@ -446,7 +542,19 @@ export default class BabylonJSAnimationController {
446
542
  openingAnimations.push(openingAnimation);
447
543
  });
448
544
 
545
+ if (!openingAnimations.length) {
546
+ this.hideMenu();
547
+ return;
548
+ }
549
+
449
550
  const startedAnimation = openingAnimations.find((animation) => animation.state !== OpeningAnimation.states.closed);
450
- startedAnimation ? startedAnimation.showControls(this.#canvas, openingAnimations) : openingAnimations[0].showControls(this.#canvas, openingAnimations);
551
+ const animationToShow = startedAnimation ? startedAnimation : openingAnimations[0];
552
+
553
+ if (animationToShow.isControlsVisible()) {
554
+ return;
555
+ }
556
+
557
+ this.hideMenu();
558
+ animationToShow.showControls(this.#canvas, openingAnimations);
451
559
  }
452
560
  }
@@ -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
  }