@preference-sl/pref-viewer 2.17.0-beta.0 → 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.
package/README.md CHANGED
@@ -274,6 +274,8 @@ Rapid selections retarget the transition from the displayed pose. Canvas pointer
274
274
 
275
275
  The default orbital camera starts on the **Inner (+Z)** side before the ViewCube is created, so the loading indicator does not first show Outer and then switch to Inner when model framing completes. The initial fit uses the same model bounds and projection-aware calculation as the ViewCube, with the shared framing factor (`BabylonJSController.config.camera.fitMarginFactor`) of 0.15. The resulting fit matches a cube destination for the same orientation, projection and canvas dimensions; the initial orientation is exact Inner, with no elevation, so selecting Inner immediately after loading preserves the view. The initial fit preserves its orbit angles; later model reloads preserve the user's current orientation. Explicitly selected cameras from the model or environment retain their authored view, which may differ from Inner.
276
276
 
277
+ Camera selection through `options.camera` uses `null` for the interactive default camera. A named camera such as `"Inner"` is resolved from the model first, then the environment, and is locked by default. If that name is absent from both assets, the viewer activates its default camera with `locked = false` and reconnects canvas controls, even after a previously locked imported camera. A missing name does not retain the previous imported view. Omitting `options.camera` preserves the current selection.
278
+
277
279
  The shared internal setting `BabylonJSController.config.camera.fitMarginFactor` accepts finite values from **0 to 0.5, inclusive**. It reserves a total fraction of the viewport along the limiting axis; the model's projected bounding envelope can occupy `1 - fitMarginFactor` of that axis. It is applied when automatic or ViewCube framing runs, rather than continuously changing the current zoom.
278
280
 
279
281
  | fitMarginFactor | Space available for the model | Margin at each opposing edge of the limiting axis |
@@ -319,19 +321,19 @@ These are sampled ray checks, not continuous volume collision. Small surfaces be
319
321
  | --- | --- |
320
322
  | `showModel()` / `hideModel()` | Shows or hides the loaded model. |
321
323
  | `showScene()` / `hideScene()` | Shows or hides the loaded scene/environment. |
322
- | `syncVisibility(name, isVisible)` | Synchronizes `model` or `scene` visibility with the corresponding reflected attribute. |
323
324
  | `zoomCenter()` | Centers the 2D drawing view. |
324
325
  | `zoomExtentsAll()` | Fits all 2D drawing content in view. |
325
326
  | `zoomIn()` / `zoomOut()` | Controls 2D drawing zoom. |
326
- | `focusModel()` | Moves focus to the current model interaction target when available. |
327
+
328
+ These zoom methods operate only in 2D mode. In 3D, use canvas navigation and the ViewCube to orient and refit the model. Switching modes retains the semantic selection snapshot.
327
329
 
328
330
  ### Semantic selection and turnable state
329
331
 
330
332
  | Method | Description |
331
333
  | --- | --- |
332
- | `setSelectableElements(elements)` | Defines and renders selectable SVG ids in yellow (`#FFFF00`, opacity `0.5`). Accepts strings or objects with `id`/`Id`. |
333
- | `setSelectedElements(elements)` | Defines and renders selected highlights in red (`#FF0000`, opacity `0.5`). Accepts strings or objects with `id`/`Id` and optional `highlights`/`Highlights`. |
334
- | `applySelectionState(state)` | Applies selectable and selected state to the semantic snapshot and 2D drawing in one call. Accepts `selectableIds`/`selectables` and `selectedItems`/`selectedIds`/`selected`; state supplied before a drawing is reapplied after load. |
334
+ | `setSelectableElements(elements)` | Defines and renders selectable 2D/3D ids in yellow (`#FFFF00`, opacity `0.5`). Accepts strings or objects with `id`/`Id`. |
335
+ | `setSelectedElements(elements)` | Defines and renders selected 2D/3D highlights in red (`#FF0000`, opacity `0.5`). Accepts strings or objects with `id`/`Id` and optional `highlights`/`Highlights`. |
336
+ | `applySelectionState(state)` | Applies selectable and selected state to the semantic snapshot and both renderers in one call. Accepts `selectableIds`/`selectables` and `selectedItems`/`selectedIds`/`selected`; state supplied before drawing or model load is reapplied after that renderer loads. |
335
337
  | `clearSelectedElements()` | Clears selected elements while preserving the selectable set. |
336
338
  | `clearSelectableElements()` | Clears selectable elements while preserving the selected set. |
337
339
  | `clearSelectionState()` | Clears all semantic selection state. |
@@ -344,16 +346,26 @@ These are sampled ray checks, not continuous volume collision. Small surfaces be
344
346
  | `toggleTurnable(options)` | Toggles semantic turnable state. |
345
347
  | `getTurnableState()` | Returns `{ running, speed, direction }`. |
346
348
 
347
- Selection colors are rendered through disposable SVG clones; the loaded base SVG and shared definitions are not modified. Selectable clones are inserted before selected clones so red selection remains visible above yellow availability. Reapplying state is idempotent, and replacing the drawing removes dynamic nodes and prior visual layers before the current semantic snapshot is rendered again.
349
+ In 2D, selection colors are rendered through disposable SVG clones; the loaded base SVG and shared definitions are not modified. In 3D, model meshes use Babylon overlays without mutating shared materials; every mesh instance with the semantic id is updated independently. Selected ids and their highlights take red priority over yellow selectable state. Reapplying state is idempotent, and replacing a drawing or model reapplies the current semantic snapshot.
350
+
351
+ 3D model loading disables Babylon's automatic glTF mesh instancing so repeated geometry can have independent overlays while materials remain shared. Environment loading retains instancing. This can increase draw calls for models with many repeated parts. Clearing selection restores the original overlay properties.
352
+
353
+ 3D clicks report eligible crossed ids independently of the selectable set; the host decides which ids to select. Only a final `_primitiveN` suffix is removed, and hits are sorted by distance before duplicate ids are removed.
354
+
355
+ The 3D click threshold defaults to 6 CSS pixels and is defined by the internal `BabylonJSController.config.pointerPicking.clickMaxDistancePx` setting. Movement beyond the threshold at any point cancels the click, including drags returning to their starting point. Cancelled pointers and multi-pointer gestures emit no click. Hidden, disabled, non-pickable meshes, groups and environment geometry are excluded.
356
+
357
+ The turnable methods store and publish semantic state for host integration. They do not start a camera rotation or a render-loop animation. Hosts can read the snapshot through `getTurnableState()` or consume it in `pref-viewer-3d-turnable-changed`.
348
358
 
349
359
  ### Animation controls
350
360
 
351
361
  | Method | Description |
352
362
  | --- | --- |
353
363
  | `getAnimations()` | Returns available model animations with state, progress, loop, and affected node ids. |
354
- | `playAnimation(name, action)` | Controls a named animation. Supported actions are delegated to `BabylonJSAnimationController`. |
355
- | `setAnimationProgress(name, progress)` | Sets animation progress from `0` to `1`. |
356
- | `setAnimationLoop(name, loop)` | Enables or disables looping for a named animation. |
364
+ | `playAnimation(name, action)` | Controls a named opening animation with `open`, `close`, `pause`, `goToOpened` or `goToClosed`. Returns whether the animation and action were accepted. |
365
+ | `setAnimationProgress(name, progress)` | Seeks to normalized progress from `0` to `1` and pauses playback. Returns `false` when the animation is absent. |
366
+ | `setAnimationLoop(name, loop)` | Enables or disables ping-pong looping for a named animation. Returns `false` when the animation is absent. |
367
+
368
+ Opening animations are discovered from paired glTF animation groups named `animation_open_<name>` and `animation_close_<name>`. State values are `0` (paused), `1` (closed), `2` (opened), `3` (opening) and `4` (closing). Enable `options.render.showAnimationMenu` to create the animation controller. Disabling that setting releases the controller: `getAnimations()` returns an empty array and imperative animation actions return `false`. A controller that is enabled can still receive imperative actions while its controls are closed.
357
369
 
358
370
  ### Dialogs and downloads
359
371
 
@@ -398,18 +410,18 @@ These setters are convenience bridges for framework bindings and property-based
398
410
 
399
411
  ## Events
400
412
 
401
- The component emits custom events so host applications can integrate loading indicators, error states, and viewer controls. Public event payloads do not include `source`. Only root-level events without an explicit 2D/3D scope include `detail.mode`, and that value is always the current `<pref-viewer>` mode (`"2d"` or `"3d"`).
413
+ The component emits custom events so host applications can integrate loading indicators, error states, and viewer controls. Root-dispatched lifecycle, selection-state and viewport events include `detail.timestamp` in Unix milliseconds. Generic root events also include `detail.mode`, which is always the current `<pref-viewer>` mode (`"2d"` or `"3d"`); scoped 2D/3D and legacy `drawing-*`/`scene-*` events omit it. `pref-viewer-element-click` is an exception: its detail remains exactly `{ ids: string[] }`, without mode or timestamp. Renderer events received directly from child components can have different metadata from their root forwarding.
402
414
 
403
415
  ### General Events
404
416
 
405
417
  | Event | Emitted by | Legacy alias | Description |
406
418
  | --- | --- | --- | --- |
407
- | `pref-viewer-initialized` | `<pref-viewer>` | None | The root component has been mounted and initialized. |
419
+ | `pref-viewer-initialized` | `<pref-viewer>` | None | The root component is connected and ready to process queued work. Detail includes `isInitialized`, `mode` and `timestamp`; it does not mean assets have finished loading. |
408
420
  | `pref-viewer-loading` | `<pref-viewer>` | None | A 2D or 3D load operation has started. Includes `detail.mode` with the current root mode. |
409
421
  | `pref-viewer-loaded` | `<pref-viewer>` | None | A 2D or 3D load operation completed. Includes `detail.mode` with the current root mode. |
410
422
  | `pref-viewer-error` | `<pref-viewer>` | None | A 2D or 3D operation failed. Includes `detail.mode` with the current root mode. |
411
- | `pref-viewer-selection-state-changed` | `<pref-viewer>` | None | The semantic selection state changed. |
412
- | `pref-viewer-element-click` | `<pref-viewer>` | None | A semantic SVG element was clicked or forwarded by the host. Geometry without its own id resolves the nearest id-bearing ancestor inside the drawing. `detail` is exactly `{ ids: string[] }`; clicks are emitted independently of the current selectable set. |
423
+ | `pref-viewer-selection-state-changed` | `<pref-viewer>` | None | The semantic selection state changed. Detail includes `selectableIds`, `selectedIds`, `selectedItems`, `mode` and `timestamp`. |
424
+ | `pref-viewer-element-click` | `<pref-viewer>` | None | A semantic 2D or 3D element was clicked or forwarded by the host. In 2D, geometry without its own id resolves the nearest id-bearing ancestor. In 3D, a primary click that moves no more than 6 CSS pixels reports every crossed visible, enabled, pickable model mesh, nearest first, from `mesh.id`; only a final `_primitiveN` suffix is removed and duplicate ids are omitted. Empty/background clicks do not emit. `detail` is exactly `{ ids: string[] }`, independently of the current selectable set. |
413
425
 
414
426
  ### 3D Events
415
427
 
@@ -421,8 +433,8 @@ The component emits custom events so host applications can integrate loading ind
421
433
  | `pref-viewer-3d-options-applying` | `<pref-viewer-3d>` | `scene-setting-options` | 3D viewer options are being applied. |
422
434
  | `pref-viewer-3d-options-applied` | `<pref-viewer-3d>` | `scene-set-options` | 3D viewer options were applied. |
423
435
  | `pref-viewer-3d-render-settings-change-requested` | `<pref-viewer-menu-3d>` | None | The 3D settings menu requested render-setting changes. |
424
- | `pref-viewer-3d-animation-changed` | Opening animation controller | None | A model animation state changed. |
425
- | `pref-viewer-3d-turnable-changed` | `<pref-viewer>` | None | The semantic turnable state changed. |
436
+ | `pref-viewer-3d-animation-changed` | `<pref-viewer-3d>` via its opening animation controller | None | An opening animation state, progress or loop changed. Detail is `{ name, state, progress, loop }`. The event bubbles across the shadow boundary. |
437
+ | `pref-viewer-3d-turnable-changed` | `<pref-viewer>` | None | The semantic turnable state changed. Detail includes `state: { running, speed, direction }` and `timestamp`. |
426
438
 
427
439
  ### 2D Events
428
440
 
@@ -435,7 +447,7 @@ The component emits custom events so host applications can integrate loading ind
435
447
 
436
448
  ### Internal Listeners
437
449
 
438
- `<pref-viewer>` listens to `pref-viewer-2d-viewport-changed` from `<pref-viewer-2d>`, `pref-viewer-3d-loaded` and `pref-viewer-3d-error` from `<pref-viewer-3d>`, and `pref-viewer-3d-render-settings-change-requested` from `<pref-viewer-menu-3d>`. `BabylonJSController` listens to `pref-viewer-3d-animation-changed` from the nearest `<pref-viewer-3d>` host. The root also conditionally subscribes to the internal, non-bubbling `view-cube-layout` event while the cube is active in 3D. It carries measured layout dimensions for the render menu and is not a new public root event.
450
+ `<pref-viewer>` listens to `pref-viewer-2d-viewport-changed` from `<pref-viewer-2d>`, `pref-viewer-3d-loaded` and `pref-viewer-3d-error` from `<pref-viewer-3d>`, and `pref-viewer-3d-render-settings-change-requested` from `<pref-viewer-menu-3d>`. Internal `pref-viewer-2d-element-click` and `pref-viewer-3d-element-click` events are intercepted and forwarded through the single public `pref-viewer-element-click` contract. `BabylonJSController` listens to `pref-viewer-3d-animation-changed` from the nearest `<pref-viewer-3d>` host. The root also conditionally subscribes to the internal, non-bubbling `view-cube-layout` event while the cube is active in 3D. It carries measured layout dimensions for the render menu and is not a new public root event.
439
451
 
440
452
  Example:
441
453
 
@@ -485,6 +497,8 @@ viewer.setOptions({
485
497
 
486
498
  Render settings are persisted in `localStorage` under the `pref-viewer/render-settings` key when changed through the runtime settings flow. The menu is always synchronized from the effective controller settings after changes from the menu, `setOptions`, or `loadConfig`.
487
499
 
500
+ The showroom daylight background uses a shared linear HDR color calibrated with exposure and ACES tone mapping for the current light studio gray target, `#d9d9d9`, in a scene-less view. Both the static look and time-of-day cycle use this same daylight value; the cycle retains its dark night endpoint. Scene geometry can cover the background. The internal `BabylonJSController.config.look.showroom.backgroundDay` value is not a public background-color option and must be recalibrated if tone mapping or exposure changes.
501
+
488
502
  ## Runtime styles
489
503
 
490
504
  Runtime styles can be passed through the `styles` attribute, the `styles` property setter, `setStyles(styles)`, or `loadConfig({ styles })`.
@@ -540,6 +554,18 @@ npm start
540
554
 
541
555
  The playground is available from `playground/index.html` at `http://localhost:8080/` and serves the project through `http-server` after bundling the local source. Use it for manual ViewCube, wheel-zoom and modal checks with representative model/environment assets. The separate ViewCube test HTML/script and its generated fixture bundle have been removed; automated tests remain.
542
556
 
557
+ ### 3D selection implementation and maintenance
558
+
559
+ `src/babylonjs-selection-controller.js` encapsulates semantic picking helpers as public static methods of `BabylonJSSelectionController`: `normalizeMeshId`, `isPrimaryClickGesture`, `isPickableModelMesh`, `getModelMeshSet` and `collectPickedMeshIds`. The Babylon controller and tests call these methods through the class. Instance methods retain selection state and manage reversible overlays. These internal implementation helpers do not change the `<pref-viewer>` public API.
560
+
561
+ JavaScript classes, methods and named helper functions outside tests are documented with JSDoc, including parameters and return values where applicable. This also covers existing helpers and webpack configuration callbacks. Keep those comments and this README aligned with the current implementation. Edited code files use Windows CRLF line endings.
562
+
563
+ Shared calculations live in the static `RenderMath` and `MaterialUtils` classes. `I18n` owns the shared locale and translation subscribers, with private lookup helpers and public static methods. These modules export their classes only; viewer components and tests call their static methods directly. Former named function/constant exports and utility reexports from `BabylonJSController` have been removed. These helpers are internal modules, not additional package-root API exports. `BabylonJSController` encapsulates resize application, metadata path parsing and debug serialization as private methods, passing scene mesh snapshots explicitly to material diagnostics.
564
+
565
+ `BabylonJSAnimationOverlayController` owns one opening-highlight group: its utility scene, cached overlay bases, transformed presentation meshes and observers. It creates clones directly in the utility scene, hides geometry bases used only by instances, and releases only its own resources through an idempotent `dispose()`. These animation overlays remain separate from semantic 3D selection overlays. The temporary legacy storage module and opening-control callbacks retain their existing structure.
566
+
567
+ Focused regressions for this work live in `tests/babylonjs-selection-controller.test.js`, `tests/babylonjs-animation-overlay-controller.test.js`, `tests/encapsulated-utilities.test.js`, `tests/pref-viewer-menu-3d.test.js`, `tests/babylonjs-controller-wheel.test.js` and `tests/pref-viewer.test.js`. They cover picking and repeated model parts, reversible overlays, root state synchronization, animation resource ownership, localization, menu playback and diagnostics. Use `npm test -- --run --maxWorkers=2` for a bounded full-suite run; validate both builds and actual rendering when changing overlays.
568
+
543
569
  ### Camera implementation and maintenance
544
570
 
545
571
  - `src/babylonjs-view-cube-controller.js`: dedicated `BabylonJSViewCubeController` for SVG regions, orientation/projection feedback, transitions and cleanup. It delegates model bounds and destination-fit calculations to BabylonJSCameraFraming. Cube-only helpers and constants remain private; region generation remains a public static utility. Its constructor JSDoc documents the host object and callbacks.
@@ -572,6 +598,8 @@ The following variables are defined in the root rule of `PrefViewerViewCubeStyle
572
598
 
573
599
  Both feedback colors default to `var(--pref-viewer-color-primary, ...)`, preserving supported runtime primary-color updates. Text size and spacing apply in the SVG coordinate system and scale with its viewport. Active-view borders remain transparent; hover borders remain fully opaque regardless of fill opacity. Geometry and input behavior remain owned by the controller.
574
600
 
601
+ ViewCube listener registration and hover updates are private instance methods. Surface creation, region construction, hit-region lookup, extent interpolation and SVG projection are private static helpers; per-update projection values and interpolation progress are passed explicitly. The render menu also encapsulates hue-channel calculation and lighting playback as private methods. Playback uses one callback bound to the menu instance and cancels its scheduled frame on stop or disconnection.
602
+
575
603
  ## Package scripts
576
604
 
577
605
  | Script | Description |
@@ -607,11 +635,15 @@ export function ProductViewer() {
607
635
  - The component is a Web Component, so it can be used from plain HTML, React, Vue, Angular, or any framework that supports custom elements.
608
636
  - Give the element an explicit width and height. Babylon.js needs a measurable canvas container.
609
637
  - Use object-based JavaScript APIs for complex payloads. Attributes must be valid JSON strings.
638
+ - Root load/configuration methods enqueue work and return `void` on accepted input, rather than a completion promise. Use the loading/loaded/error events for completion. Malformed JSON strings can throw synchronously.
639
+ - `loadConfig({ forceReload: true })` bypasses 3D asset cache metadata checks. A supplied `timestamp` that differs from the last supplied timestamp also forces the 3D reload; repeating the same value does not. These controls apply to the 3D configuration flow, not SVG cache invalidation.
610
640
  - Remote assets must be reachable by the browser and must satisfy the expected CORS policy.
611
641
  - glTF files with external resources are resolved and cached by the viewer when possible.
612
642
  - Avoid repeatedly replacing large assets if only visibility, camera, lighting, render, or material options changed.
613
643
  - Use `pref-viewer-initialized` only as a component-readiness signal. If a host generates model data asynchronously, wait for the host data-ready signal before calling `loadConfig()`.
614
644
  - For PrefWeb integration, consume the webpack UMD build (`bundle-umd.js`) and prefer development UMD builds while debugging source maps.
645
+ - Babylon's initial PBR BRDF preparation keeps demand rendering awake and waits at most two seconds, so a missing readiness signal does not indefinitely block a queued model load. `getDebugInfo()` provides camera, rendering and asset-readiness diagnostics.
646
+ - Release versioning and npm publication run in Bitbucket Pipelines: `develop` publishes the `beta` tag and `main` publishes `latest`. Do not run the release scripts locally.
615
647
 
616
648
  ## License
617
649
 
package/index.d.ts CHANGED
@@ -94,6 +94,7 @@ export interface PrefViewerSelectionInput {
94
94
  }
95
95
 
96
96
  export interface PrefViewerElementClickDetail {
97
+ /** Semantic 2D ids, or distance-ordered, deduplicated 3D mesh ids with final _primitiveN removed. */
97
98
  ids: string[];
98
99
  }
99
100
 
@@ -271,7 +272,7 @@ export declare class PrefViewer extends HTMLElement {
271
272
  zoomIn(): void;
272
273
  zoomOut(): void;
273
274
 
274
- // ── Semantic selection ──
275
+ // ── Semantic selection synchronized to both 2D and 3D renderers ──
275
276
  setSelectableElements(elements?: PrefViewerElementDescriptor[]): PrefViewerSelectionState;
276
277
  setSelectedElements(elements?: Array<string | PrefViewerSelectionDescriptor>): PrefViewerSelectionState;
277
278
  applySelectionState(state?: PrefViewerSelectionInput): PrefViewerSelectionState;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preference-sl/pref-viewer",
3
- "version": "2.17.0-beta.0",
3
+ "version": "2.17.0-beta.1",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -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;