@preference-sl/pref-viewer 2.13.0-beta.6 → 2.13.0-beta.8

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.6",
3
+ "version": "2.13.0-beta.8",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -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,6 +47,7 @@ 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.
@@ -126,15 +137,18 @@ export default class BabylonJSAnimationController {
126
137
  * @returns {Array<string>} Array of animated node IDs associated with the mesh.
127
138
  */
128
139
  #getNodesAnimatedByMesh(mesh) {
129
- let nodeId = [];
140
+ let nodeIds = [];
141
+ if (!mesh || !mesh.id) {
142
+ return nodeIds;
143
+ }
130
144
  let node = mesh;
131
145
  while (node.parent !== null) {
132
146
  node = node.parent;
133
- if (this.#animatedNodes.includes(node.id) && !nodeId.includes(node.id)) {
134
- nodeId.push(node.id);
147
+ if (this.#animatedNodes.includes(node.id) && !nodeIds.includes(node.id)) {
148
+ nodeIds.push(node.id);
135
149
  }
136
150
  }
137
- return nodeId;
151
+ return nodeIds;
138
152
  }
139
153
 
140
154
  /**
@@ -308,62 +322,60 @@ export default class BabylonJSAnimationController {
308
322
  }
309
323
 
310
324
  /**
311
- * ---------------------------
312
- * Public methods
313
- * ---------------------------
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.
314
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
+ }
315
340
 
316
341
  /**
317
- * Disposes all resources managed by the animation controller.
318
- * Cleans up the highlight layer, animation menu, and internal animation/node lists.
319
- * Should be called when the controller is no longer needed to prevent memory leaks.
320
- * @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}
321
346
  */
322
- dispose() {
323
- if (this.#highlightLayer) {
324
- this.#highlightLayer.removeAllMeshes();
325
- this.#highlightLayer.dispose();
326
- this.#highlightLayer = null;
327
- }
328
- if (this.#overlayLayer) {
329
- this.#overlayLayer.dispose();
330
- 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
+ }
331
357
  }
332
- this.hideMenu();
333
- this.#animatedNodes = [];
334
- this.#openingAnimations.forEach((openingAnimation) => openingAnimation.dispose());
335
- this.#openingAnimations = [];
358
+ this.#lastHighlightedMeshId = null;
359
+ this.#lastHighlightedNodeIds = [];
336
360
  }
337
361
 
338
362
  /**
339
- * Highlights meshes that are children of an animated node when hovered.
340
- * @public
341
- * @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}
342
369
  */
343
- highlightMeshes(pickingInfo) {
344
- // Check if we're hovering the same mesh to avoid recreating overlays/highlights
345
- const pickedMeshId = pickingInfo?.pickedMesh?.id;
346
- if (this.#lastHighlightedMeshId === pickedMeshId) {
347
- return; // No need to update if hovering the same mesh
370
+ #addHighlight(nodeIds) {
371
+ this.#removeHighlight();
372
+ if (!nodeIds.length) {
373
+ return;
348
374
  }
349
- this.#lastHighlightedMeshId = pickedMeshId;
350
-
351
375
  if (this.#useHighlightLayer) {
352
376
  if (!this.#highlightLayer) {
353
377
  this.#highlightLayer = new HighlightLayer("hl_animations", this.#scene);
354
378
  }
355
-
356
- this.#highlightLayer.removeAllMeshes();
357
-
358
- if (!pickingInfo?.hit || !pickingInfo?.pickedMesh) {
359
- return;
360
- }
361
-
362
- const nodeIds = this.#getNodesAnimatedByMesh(pickingInfo.pickedMesh);
363
- if (!nodeIds.length) {
364
- return;
365
- }
366
-
367
379
  const transformNodes = [];
368
380
  nodeIds.forEach((nodeId) => {
369
381
  const transformNode = this.#scene.getTransformNodeByID(nodeId);
@@ -383,20 +395,6 @@ export default class BabylonJSAnimationController {
383
395
  }
384
396
  });
385
397
  } else {
386
- if (this.#overlayLayer) {
387
- this.#overlayLayer.dispose();
388
- this.#overlayLayer = null;
389
- }
390
-
391
- if (!pickingInfo?.hit || !pickingInfo?.pickedMesh) {
392
- return;
393
- }
394
-
395
- const nodeIds = this.#getNodesAnimatedByMesh(pickingInfo.pickedMesh);
396
- if (!nodeIds.length) {
397
- return;
398
- }
399
-
400
398
  const meshes = [];
401
399
  nodeIds.forEach((nodeId) => {
402
400
  const transformNode = this.#scene.getTransformNodeByID(nodeId);
@@ -408,6 +406,99 @@ export default class BabylonJSAnimationController {
408
406
 
409
407
  this.#overlayLayer = this.#addGroupOutlineOverlayForInstances(meshes);
410
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 };
411
502
  }
412
503
 
413
504
  /**
@@ -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
  }