@preference-sl/pref-viewer 2.14.0-beta.2 → 2.14.0-beta.3

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.14.0-beta.2",
3
+ "version": "2.14.0-beta.3",
4
4
  "description": "Web Component to preview GLTF models with Babylon.js",
5
5
  "author": "Alex Moreno Palacio <amoreno@preference.es>",
6
6
  "scripts": {
@@ -45,15 +45,22 @@ export default class BabylonJSAnimationController {
45
45
  #highlightColor = Color3.FromHexString(PrefViewerStyleVariables.colorPrimary);
46
46
  #highlightLayer = null;
47
47
  #overlayLayer = null;
48
- #useHighlightLayer = true; // Set to true to use HighlightLayer (better performance) and false to use overlay meshes (UtilityLayerRenderer - always on top)
49
- #lastHighlightedMeshId = null; // Cache to avoid redundant highlight updates
50
- #lastHighlightedNodeIds = []; // Cache to avoid redundant highlight updates
48
+ #useHighlightLayer = true;
49
+ #lastHighlightedMeshId = null;
50
+ #lastHighlightedNodeIds = [];
51
+
52
+ // Static property to store global highlight enabled state
53
+ static #globalHighlightEnabled = true;
54
+ static #instanceId = 0;
55
+ #thisInstanceId = 0;
51
56
 
52
57
  /**
53
58
  * Creates a new BabylonJSAnimationController for a Babylon.js scene.
54
59
  * @param {AssetContainer|Scene} assetContainer - The Babylon.js asset container or scene instance.
55
60
  */
56
61
  constructor(assetContainer) {
62
+ BabylonJSAnimationController.#instanceId++;
63
+ this.#thisInstanceId = BabylonJSAnimationController.#instanceId;
57
64
  this.#scene = assetContainer.scene ? assetContainer.scene : assetContainer;
58
65
  this.#assetContainer = assetContainer;
59
66
  this.#canvas = this.#scene._engine._renderingCanvas;
@@ -459,6 +466,17 @@ export default class BabylonJSAnimationController {
459
466
  * Returns whether highlight visuals changed in this call and whether any mesh remains highlighted.
460
467
  */
461
468
  highlightMeshes(pickingInfo) {
469
+ // If highlight is disabled globally, just remove any existing highlight and return
470
+ if (!BabylonJSAnimationController.#globalHighlightEnabled) {
471
+ if (this.#lastHighlightedMeshId !== null) {
472
+ this.#removeHighlight();
473
+ this.#lastHighlightedMeshId = null;
474
+ this.#lastHighlightedNodeIds = [];
475
+ return { changed: true, highlighted: false };
476
+ }
477
+ return { changed: false, highlighted: false };
478
+ }
479
+
462
480
  let changed = false;
463
481
  let highlighted = false;
464
482
 
@@ -504,6 +522,40 @@ export default class BabylonJSAnimationController {
504
522
  return { changed: changed, highlighted: highlighted };
505
523
  }
506
524
 
525
+ /**
526
+ * Configures the highlight settings for animated nodes on hover.
527
+ * @public
528
+ * @param {{enabled?: boolean, color?: string}} config - Configuration object.
529
+ * @param {boolean} [config.enabled] - Whether to enable highlight on hover. If false, removes highlight.
530
+ * @param {string} [config.color] - Hex color string for the highlight (e.g., "#FF0000"). Only used if enabled is not false.
531
+ * @returns {void}
532
+ */
533
+ setHighlightConfig(config = {}) {
534
+ // Handle enabled state
535
+ if (config.enabled !== undefined) {
536
+ BabylonJSAnimationController.#globalHighlightEnabled = config.enabled;
537
+ if (config.enabled === false) {
538
+ this.#removeHighlight();
539
+ this.#lastHighlightedMeshId = null;
540
+ this.#lastHighlightedNodeIds = [];
541
+ }
542
+ }
543
+
544
+ // Update color regardless of enabled state so it's ready when re-enabled
545
+ if (config.color) {
546
+ try {
547
+ this.#highlightColor = Color3.FromHexString(config.color);
548
+ } catch (e) {
549
+ console.warn("Invalid highlight color:", config.color);
550
+ return;
551
+ }
552
+ // Re-apply highlight with new color if meshes are currently highlighted
553
+ if (BabylonJSAnimationController.#globalHighlightEnabled && this.#lastHighlightedNodeIds.length > 0) {
554
+ this.#addHighlight(this.#lastHighlightedNodeIds);
555
+ }
556
+ }
557
+ }
558
+
507
559
  /**
508
560
  * Hides and disposes the animation control menu if it exists.
509
561
  * @public
@@ -514,6 +566,70 @@ export default class BabylonJSAnimationController {
514
566
  this.#canvas?.parentElement?.querySelectorAll("div.pref-viewer-3d.animation-menu").forEach((menu) => menu.remove());
515
567
  }
516
568
 
569
+ /**
570
+ * Returns a snapshot of all available animations and their current state.
571
+ * @public
572
+ * @returns {Array<{name: string, state: number, progress: number, loop: boolean, nodes: string[]}>}
573
+ */
574
+ getAnimations() {
575
+ return this.#openingAnimations.map((anim) => ({
576
+ name: anim.name,
577
+ state: anim.state,
578
+ progress: anim.progress,
579
+ loop: anim.loop,
580
+ nodes: anim.nodes ?? [],
581
+ }));
582
+ }
583
+
584
+ /**
585
+ * Controls a named animation by applying the specified action.
586
+ * @public
587
+ * @param {string} name - The name of the animation to control.
588
+ * @param {"open"|"close"|"pause"|"goToOpened"|"goToClosed"} action - The action to apply.
589
+ * @returns {boolean} True if the animation was found and the action was applied; otherwise false.
590
+ */
591
+ playAnimation(name, action) {
592
+ const anim = this.#openingAnimations.find((a) => a.name === name);
593
+ if (!anim) return false;
594
+ switch (action) {
595
+ case "open": anim.playOpen(); break;
596
+ case "close": anim.playClose(); break;
597
+ case "pause": anim.pause(); break;
598
+ case "goToOpened": anim.goToOpened(); break;
599
+ case "goToClosed": anim.goToClosed(); break;
600
+ default: return false;
601
+ }
602
+ return true;
603
+ }
604
+
605
+ /**
606
+ * Seeks a named animation to the given normalized progress position (0–1).
607
+ * @public
608
+ * @param {string} name - The name of the animation to seek.
609
+ * @param {number} progress - Progress value between 0 and 1.
610
+ * @returns {boolean} True if the animation was found and seeked; otherwise false.
611
+ */
612
+ setAnimationProgress(name, progress) {
613
+ const anim = this.#openingAnimations.find((a) => a.name === name);
614
+ if (!anim) return false;
615
+ anim.setProgress(progress);
616
+ return true;
617
+ }
618
+
619
+ /**
620
+ * Enables or disables loop mode for a named animation.
621
+ * @public
622
+ * @param {string} name - The name of the animation to configure.
623
+ * @param {boolean} loop - True to enable looping; false to disable.
624
+ * @returns {boolean} True if the animation was found and loop mode was set; otherwise false.
625
+ */
626
+ setAnimationLoop(name, loop) {
627
+ const anim = this.#openingAnimations.find((a) => a.name === name);
628
+ if (!anim) return false;
629
+ anim.setLoop(loop);
630
+ return true;
631
+ }
632
+
517
633
  /**
518
634
  * Displays the animation control menu for the animated node under the pointer.
519
635
  * Hides the current menu when picking info is invalid or when no animated node is found.
@@ -589,4 +589,63 @@ export default class OpeningAnimation {
589
589
  get state() {
590
590
  return this.#state;
591
591
  }
592
+
593
+ /**
594
+ * Returns the normalized progress (0–1) of the current animation.
595
+ * @public
596
+ * @returns {number}
597
+ */
598
+ get progress() {
599
+ return this.#getProgress();
600
+ }
601
+
602
+ /**
603
+ * Returns whether loop mode is currently enabled for this animation.
604
+ * @public
605
+ * @returns {boolean}
606
+ */
607
+ get loop() {
608
+ return this.#loop;
609
+ }
610
+
611
+ /**
612
+ * Returns the node IDs affected by this animation.
613
+ * @public
614
+ * @returns {string[]}
615
+ */
616
+ get nodes() {
617
+ return [...this.#nodes];
618
+ }
619
+
620
+ /**
621
+ * Seeks the animation to the given normalized progress position (0–1).
622
+ * Mirrors the same threshold/snap logic used by the animation control menu.
623
+ * @public
624
+ * @param {number} progress - Progress value between 0 and 1.
625
+ * @returns {void}
626
+ */
627
+ setProgress(progress) {
628
+ progress = this.#checkProgress(progress);
629
+ if (progress === 0) {
630
+ this.goToClosed();
631
+ } else if (progress === 1) {
632
+ this.goToOpened();
633
+ } else {
634
+ const frame = this.#getFrameFromProgress(progress);
635
+ this.#goToFrameAndPause(frame);
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Enables or disables loop mode for this animation.
641
+ * @public
642
+ * @param {boolean} loop - True to enable looping; false to disable.
643
+ * @returns {void}
644
+ */
645
+ setLoop(loop) {
646
+ this.#loop = Boolean(loop);
647
+ if (this.#menu) {
648
+ this.#menu.animationLoop = this.#loop;
649
+ }
650
+ }
592
651
  }
@@ -85,6 +85,9 @@ export default class BabylonJSController {
85
85
  ambientOcclusionEnabled: true,
86
86
  iblEnabled: true,
87
87
  shadowsEnabled: false,
88
+ // Highlight settings
89
+ highlightEnabled: true,
90
+ highlightColor: "#ff6700",
88
91
  };
89
92
 
90
93
  // Canvas HTML element
@@ -496,10 +499,16 @@ export default class BabylonJSController {
496
499
 
497
500
  let changed = false;
498
501
  Object.keys(settings).forEach((key) => {
502
+ // Handle boolean settings
499
503
  if (typeof settings[key] === "boolean" && this.#settings[key] !== settings[key]) {
500
504
  this.#settings[key] = settings[key];
501
505
  changed = true;
502
506
  }
507
+ // Handle string settings (like illuminationColor)
508
+ if (typeof settings[key] === "string" && this.#settings[key] !== settings[key]) {
509
+ this.#settings[key] = settings[key];
510
+ changed = true;
511
+ }
503
512
  });
504
513
 
505
514
  if (changed) {
@@ -526,9 +535,14 @@ export default class BabylonJSController {
526
535
  }
527
536
  const parsed = JSON.parse(serialized);
528
537
  Object.keys(BabylonJSController.DEFAULT_RENDER_SETTINGS).forEach((key) => {
538
+ // Handle boolean settings
529
539
  if (typeof parsed?.[key] === "boolean") {
530
540
  this.#settings[key] = parsed[key];
531
541
  }
542
+ // Handle string settings (like illuminationColor)
543
+ if (typeof parsed?.[key] === "string") {
544
+ this.#settings[key] = parsed[key];
545
+ }
532
546
  });
533
547
  } catch (error) {
534
548
  console.warn("PrefViewer: unable to load render settings", error);
@@ -2458,6 +2472,11 @@ export default class BabylonJSController {
2458
2472
  this.#checkModelMetadata(oldModelMetadata, newModelMetadata);
2459
2473
  this.#setMaxSimultaneousLights();
2460
2474
  this.#babylonJSAnimationController = new BabylonJSAnimationController(this.#containers.model.assetContainer);
2475
+ // Apply stored highlight settings so fresh page loads respect persisted state
2476
+ this.#setHighlightConfig({
2477
+ enabled: this.#settings.highlightEnabled,
2478
+ color: this.#settings.highlightColor,
2479
+ });
2461
2480
  if (this.#babylonJSAnimationController?.hasAnimations?.()) {
2462
2481
  this.#attachAnimationChangedListener();
2463
2482
  }
@@ -3019,4 +3038,98 @@ export default class BabylonJSController {
3019
3038
  async reloadWithCurrentSettings() {
3020
3039
  return await this.#loadContainers();
3021
3040
  }
3041
+
3042
+ /**
3043
+ * Sets highlight configuration for animation hover effects.
3044
+ * @public
3045
+ * @param {{showAnimationMenu?:boolean, highlightColor?:string, highlightEnabled?:boolean}} config - Highlight configuration.
3046
+ * @returns {void}
3047
+ */
3048
+ setIlluminationConfig(config = {}) {
3049
+
3050
+ if (config.showAnimationMenu !== undefined) {
3051
+ // Handle show-animation-menu via the 3D component's parent
3052
+ // This is handled in pref-viewer-3d.js attributeChangedCallback
3053
+ }
3054
+
3055
+ // Handle highlight color for animation hover
3056
+ if (config.highlightColor !== undefined || config.highlightEnabled !== undefined) {
3057
+ // Normalize highlightEnabled: accept boolean, string "true"/"false", or null
3058
+ let enabled = config.highlightEnabled;
3059
+ if (typeof enabled === "string") {
3060
+ enabled = enabled.toLowerCase() !== "false";
3061
+ } else if (enabled === null) {
3062
+ enabled = undefined;
3063
+ }
3064
+ // Keep #settings in sync so getRenderSettings() always reflects the real state
3065
+ if (enabled !== undefined) {
3066
+ this.#settings.highlightEnabled = enabled;
3067
+ }
3068
+ if (config.highlightColor !== undefined && config.highlightColor !== null) {
3069
+ this.#settings.highlightColor = config.highlightColor;
3070
+ }
3071
+ this.#setHighlightConfig({
3072
+ color: config.highlightColor ?? undefined,
3073
+ enabled,
3074
+ });
3075
+ }
3076
+
3077
+ // Persist settings
3078
+ this.#storeRenderSettings();
3079
+ }
3080
+
3081
+ /**
3082
+ * Returns a snapshot of all available animations and their current state.
3083
+ * @public
3084
+ * @returns {Array<{name: string, state: number, progress: number, loop: boolean, nodes: string[]}>}
3085
+ */
3086
+ getAnimations() {
3087
+ return this.#babylonJSAnimationController?.getAnimations() ?? [];
3088
+ }
3089
+
3090
+ /**
3091
+ * Controls a named animation by applying the specified action.
3092
+ * @public
3093
+ * @param {string} name - The name of the animation to control.
3094
+ * @param {"open"|"close"|"pause"|"goToOpened"|"goToClosed"} action - The action to apply.
3095
+ * @returns {boolean} True if the animation was found and the action was applied; otherwise false.
3096
+ */
3097
+ playAnimation(name, action) {
3098
+ return this.#babylonJSAnimationController?.playAnimation(name, action) ?? false;
3099
+ }
3100
+
3101
+ /**
3102
+ * Seeks a named animation to the given normalized progress position (0–1).
3103
+ * @public
3104
+ * @param {string} name - The name of the animation to seek.
3105
+ * @param {number} progress - Progress value between 0 and 1.
3106
+ * @returns {boolean} True if the animation was found and seeked; otherwise false.
3107
+ */
3108
+ setAnimationProgress(name, progress) {
3109
+ return this.#babylonJSAnimationController?.setAnimationProgress(name, progress) ?? false;
3110
+ }
3111
+
3112
+ /**
3113
+ * Enables or disables loop mode for a named animation.
3114
+ * @public
3115
+ * @param {string} name - The name of the animation to configure.
3116
+ * @param {boolean} loop - True to enable looping; false to disable.
3117
+ * @returns {boolean} True if the animation was found and loop mode was set; otherwise false.
3118
+ */
3119
+ setAnimationLoop(name, loop) {
3120
+ return this.#babylonJSAnimationController?.setAnimationLoop(name, loop) ?? false;
3121
+ }
3122
+
3123
+ /**
3124
+ * Configures the highlight settings for animated nodes on hover.
3125
+ * @private
3126
+ * @param {{enabled?: boolean, color?: string}} config - Configuration object.
3127
+ * @returns {void}
3128
+ */
3129
+ #setHighlightConfig(config = {}) {
3130
+ if (this.#babylonJSAnimationController && typeof this.#babylonJSAnimationController.setHighlightConfig === "function") {
3131
+ this.#babylonJSAnimationController.setHighlightConfig(config);
3132
+ this.#requestRender({ frames: 2 });
3133
+ }
3134
+ }
3022
3135
  }
@@ -29,6 +29,14 @@ export const translations = {
29
29
  label: "Shadows",
30
30
  helper: "Enables shadows.",
31
31
  },
32
+ highlightEnabled: {
33
+ label: "Highlight",
34
+ helper: "Highlight animated elements on hover.",
35
+ },
36
+ highlightColor: {
37
+ label: "Highlight Color",
38
+ helper: "Color for hover highlight on animations.",
39
+ },
32
40
  },
33
41
  },
34
42
  downloadDialog: {
@@ -79,6 +87,14 @@ export const translations = {
79
87
  label: "Sombras",
80
88
  helper: "Activa sombras.",
81
89
  },
90
+ highlightEnabled: {
91
+ label: "Resaltado",
92
+ helper: "Resaltar elementos animados al pasar el ratón.",
93
+ },
94
+ highlightColor: {
95
+ label: "Color de resaltado",
96
+ helper: "Color del resaltado al pasar sobre animaciones.",
97
+ },
82
98
  },
83
99
  },
84
100
  downloadDialog: {
@@ -97,7 +97,7 @@ export default class PrefViewer3D extends HTMLElement {
97
97
  * @returns {string[]} Array of attribute names that trigger attributeChangedCallback.
98
98
  */
99
99
  static get observedAttributes() {
100
- return ["show-model", "show-scene", "visible"];
100
+ return ["show-model", "show-scene", "visible", "show-animation-menu", "highlight-color", "highlight-enabled"];
101
101
  }
102
102
 
103
103
  /**
@@ -137,6 +137,18 @@ export default class PrefViewer3D extends HTMLElement {
137
137
  this.hide();
138
138
  }
139
139
  break;
140
+ case "show-animation-menu":
141
+ case "highlight-color":
142
+ case "highlight-enabled":
143
+ // Pass these to BabylonJSController if initialized
144
+ if (this.#babylonJSController && typeof this.#babylonJSController.setIlluminationConfig === "function") {
145
+ this.#babylonJSController.setIlluminationConfig({
146
+ showAnimationMenu: this.getAttribute("show-animation-menu"),
147
+ highlightColor: this.getAttribute("highlight-color"),
148
+ highlightEnabled: this.getAttribute("highlight-enabled"),
149
+ });
150
+ }
151
+ break;
140
152
  }
141
153
  }
142
154
 
@@ -229,6 +241,16 @@ export default class PrefViewer3D extends HTMLElement {
229
241
  #initializeBabylonJS() {
230
242
  this.#babylonJSController = new BabylonJSController(this.#canvas, this.#data.containers, this.#data.options);
231
243
  this.#babylonJSController.enable();
244
+
245
+ // Apply highlight attributes that were set before the controller was ready
246
+ const highlightColor = this.getAttribute("highlight-color");
247
+ const highlightEnabled = this.getAttribute("highlight-enabled");
248
+ if (highlightColor !== null || highlightEnabled !== null) {
249
+ this.#babylonJSController.setIlluminationConfig({
250
+ highlightColor: highlightColor ?? undefined,
251
+ highlightEnabled: highlightEnabled ?? undefined,
252
+ });
253
+ }
232
254
  }
233
255
 
234
256
  /**
@@ -805,6 +827,48 @@ export default class PrefViewer3D extends HTMLElement {
805
827
  return this.#babylonJSController.getRenderSettings();
806
828
  }
807
829
 
830
+ /**
831
+ * Returns a snapshot of all available animations and their current state.
832
+ * @public
833
+ * @returns {Array<{name: string, state: number, progress: number, loop: boolean, nodes: string[]}>}
834
+ */
835
+ getAnimations() {
836
+ return this.#babylonJSController?.getAnimations() ?? [];
837
+ }
838
+
839
+ /**
840
+ * Controls a named animation by applying the specified action.
841
+ * @public
842
+ * @param {string} name - The name of the animation to control.
843
+ * @param {"open"|"close"|"pause"|"goToOpened"|"goToClosed"} action - The action to apply.
844
+ * @returns {boolean} True if the animation was found and the action was applied; otherwise false.
845
+ */
846
+ playAnimation(name, action) {
847
+ return this.#babylonJSController?.playAnimation(name, action) ?? false;
848
+ }
849
+
850
+ /**
851
+ * Seeks a named animation to the given normalized progress position (0–1).
852
+ * @public
853
+ * @param {string} name - The name of the animation to seek.
854
+ * @param {number} progress - Progress value between 0 and 1.
855
+ * @returns {boolean} True if the animation was found and seeked; otherwise false.
856
+ */
857
+ setAnimationProgress(name, progress) {
858
+ return this.#babylonJSController?.setAnimationProgress(name, progress) ?? false;
859
+ }
860
+
861
+ /**
862
+ * Enables or disables loop mode for a named animation.
863
+ * @public
864
+ * @param {string} name - The name of the animation to configure.
865
+ * @param {boolean} loop - True to enable looping; false to disable.
866
+ * @returns {boolean} True if the animation was found and loop mode was set; otherwise false.
867
+ */
868
+ setAnimationLoop(name, loop) {
869
+ return this.#babylonJSController?.setAnimationLoop(name, loop) ?? false;
870
+ }
871
+
808
872
  /**
809
873
  * Reports whether an IBL environment map is currently available.
810
874
  * @public
@@ -832,7 +896,22 @@ export default class PrefViewer3D extends HTMLElement {
832
896
  if (!this.#babylonJSController) {
833
897
  return { changed: false, success: false };
834
898
  }
835
- const scheduled = this.#babylonJSController.scheduleRenderSettingsReload(settings);
899
+
900
+ // Handle highlight settings separately for immediate effect without reload
901
+ if (settings.highlightEnabled !== undefined || settings.highlightColor !== undefined) {
902
+ if (typeof this.#babylonJSController.setIlluminationConfig === "function") {
903
+ // Convert string "false" to boolean false
904
+ const highlightEnabled = settings.highlightEnabled === "false" ? false : settings.highlightEnabled;
905
+ this.#babylonJSController.setIlluminationConfig({
906
+ highlightEnabled: highlightEnabled,
907
+ highlightColor: settings.highlightColor,
908
+ });
909
+ }
910
+ }
911
+
912
+ // Exclude highlight settings from the reload — they are handled above without requiring a scene reload
913
+ const { highlightEnabled: _he, highlightColor: _hc, ...reloadSettings } = settings;
914
+ const scheduled = this.#babylonJSController.scheduleRenderSettingsReload(reloadSettings);
836
915
  if (!scheduled.changed) {
837
916
  return { changed: false, success: true };
838
917
  }
@@ -31,6 +31,9 @@ import { DEFAULT_LOCALE, resolveLocale, translate } from "./localization/i18n.js
31
31
  export default class PrefViewerMenu3D extends HTMLElement {
32
32
  #elements = {
33
33
  applyButton: null,
34
+ colorPicker: null,
35
+ colorSlider: null,
36
+ colorPickerPreview: null,
34
37
  panel: null,
35
38
  pendingLabel: null,
36
39
  status: null,
@@ -44,6 +47,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
44
47
 
45
48
  #handles = {
46
49
  onApplyClick: null,
50
+ onColorSliderInput: null,
47
51
  onHostLeave: null,
48
52
  onPanelEnter: null,
49
53
  onSwitchChange: null,
@@ -51,7 +55,10 @@ export default class PrefViewerMenu3D extends HTMLElement {
51
55
  };
52
56
 
53
57
  #DEFAULT_RENDER_SETTINGS = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
54
- #MENU_SWITCHES = Object.keys(this.#DEFAULT_RENDER_SETTINGS);
58
+ // Only include boolean settings as switches (exclude illuminationColor which is handled by color picker)
59
+ #MENU_SWITCHES = Object.keys(this.#DEFAULT_RENDER_SETTINGS).filter(key =>
60
+ typeof this.#DEFAULT_RENDER_SETTINGS[key] === "boolean"
61
+ );
55
62
  #appliedSettings = { ...this.#DEFAULT_RENDER_SETTINGS };
56
63
  #draftSettings = { ...this.#DEFAULT_RENDER_SETTINGS };
57
64
  #switchAvailability = {};
@@ -72,7 +79,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
72
79
  * @returns {string[]} Array of attribute names to observe.
73
80
  */
74
81
  static get observedAttributes() {
75
- return ["culture"];
82
+ return ["culture", "show-illumination", "illumination-color"];
76
83
  }
77
84
 
78
85
  /**
@@ -86,6 +93,20 @@ export default class PrefViewerMenu3D extends HTMLElement {
86
93
  attributeChangedCallback(name, _old, value) {
87
94
  if (name === "culture") {
88
95
  this.#setCultureInternal(value || DEFAULT_LOCALE);
96
+ } else if (name === "show-illumination" || name === "illumination-color") {
97
+ // Update draft settings when attributes change
98
+ if (name === "show-illumination") {
99
+ this.#draftSettings.illuminationEnabled = value === "true" || value === true;
100
+ // Update the corresponding switch if it exists
101
+ const switchEl = this.#elements.switches?.illuminationEnabled;
102
+ if (switchEl) {
103
+ switchEl.checked = this.#draftSettings.illuminationEnabled;
104
+ }
105
+ } else if (name === "illumination-color") {
106
+ this.#draftSettings.illuminationColor = value;
107
+ // Update color picker preview if exists
108
+ this.#updateColorPickerPreview(value);
109
+ }
89
110
  }
90
111
  }
91
112
 
@@ -139,10 +160,16 @@ export default class PrefViewerMenu3D extends HTMLElement {
139
160
  <div class="menu-switches">
140
161
  ${this.#MENU_SWITCHES.map((key) => this.#renderSwitchRow(key, this.#texts)).join("")}
141
162
  </div>
163
+ <div class="menu-color-picker" data-setting="highlightColor">
164
+ <div class="color-slider-row">
165
+ <span class="color-picker-label">${this.#texts.switches?.highlightColor?.label || "Highlight Color"}</span>
166
+ <span class="color-preview" style="background-color: ${this.#draftSettings.highlightColor || '#ff6700'}"></span>
167
+ </div>
168
+ <input type="range" class="color-slider" min="0" max="359" value="${PrefViewerMenu3D.#hexToHue(this.#draftSettings.highlightColor || '#ff6700')}">
169
+ </div>
142
170
  <div class="menu-footer">
143
171
  <span class="menu-pending" aria-live="polite">${this.#texts.pendingLabel}</span>
144
172
  <span class="menu-status" aria-live="assertive"></span>
145
- <button class="menu-apply" type="button" disabled>${this.#texts.actions.apply}</button>
146
173
  </div>
147
174
  </div>
148
175
  `;
@@ -271,6 +298,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
271
298
  this.#elements.applyButton = this.querySelector(".menu-apply");
272
299
  this.#elements.pendingLabel = this.querySelector(".menu-pending");
273
300
  this.#elements.status = this.querySelector(".menu-status");
301
+
302
+ // Cache color picker elements
303
+ this.#elements.colorPicker = this.querySelector(".menu-color-picker");
304
+ this.#elements.colorSlider = this.querySelector(".color-slider");
305
+ this.#elements.colorPickerPreview = this.querySelector(".color-preview");
306
+
274
307
  this.#MENU_SWITCHES.forEach((key) => {
275
308
  this.#elements.switches[key] = this.querySelector(`input[data-setting="${key}"]`);
276
309
  const wrapper = this.querySelector(`.menu-switch[data-setting="${key}"]`);
@@ -301,13 +334,15 @@ export default class PrefViewerMenu3D extends HTMLElement {
301
334
  };
302
335
  this.#handles.onSwitchChange = (event) => this.#handleSwitchChange(event);
303
336
  this.#handles.onApplyClick = () => this.#emitApply();
337
+ this.#handles.onColorSliderInput = (event) => this.#handleColorSliderInput(event);
304
338
 
305
339
  this.#elements.toggleButton.addEventListener("mouseenter", this.#handles.onToggleEnter);
306
340
  this.#elements.toggleButton.addEventListener("focus", this.#handles.onToggleEnter);
307
341
  this.#elements.panel.addEventListener("mouseenter", this.#handles.onPanelEnter);
308
342
  this.addEventListener("mouseleave", this.#handles.onHostLeave);
309
343
  Object.values(this.#elements.switches).forEach((input) => input?.addEventListener("change", this.#handles.onSwitchChange));
310
- this.#elements.applyButton.addEventListener("click", this.#handles.onApplyClick);
344
+ this.#elements.applyButton?.addEventListener("click", this.#handles.onApplyClick);
345
+ this.#elements.colorSlider?.addEventListener("input", this.#handles.onColorSliderInput);
311
346
  }
312
347
 
313
348
  /**
@@ -325,6 +360,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
325
360
  this.removeEventListener("mouseleave", this.#handles.onHostLeave);
326
361
  Object.values(this.#elements.switches).forEach((input) => input?.removeEventListener("change", this.#handles.onSwitchChange));
327
362
  this.#elements.applyButton?.removeEventListener("click", this.#handles.onApplyClick);
363
+
364
+ this.#elements.colorSlider?.removeEventListener("input", this.#handles.onColorSliderInput);
328
365
  }
329
366
 
330
367
  /**
@@ -364,6 +401,77 @@ export default class PrefViewerMenu3D extends HTMLElement {
364
401
  }
365
402
  this.#draftSettings[key] = event.currentTarget.checked;
366
403
  this.#updatePendingState();
404
+
405
+ // For highlight toggle, apply immediately without waiting for user to click Apply
406
+ if (key === "highlightEnabled") {
407
+ this.#emitApply(true);
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Handles clicks on the color picker buttons.
413
+ * @private
414
+ * @param {Event} event - Click event.
415
+ * @returns {void}
416
+ */
417
+ #handleColorSliderInput(event) {
418
+ const hue = parseInt(event.target.value, 10);
419
+ const color = PrefViewerMenu3D.#hueToHex(hue);
420
+ this.#draftSettings.highlightColor = color;
421
+ this.#updateColorPickerPreview(color);
422
+ this.#updatePendingState();
423
+ this.#emitApply(true);
424
+ }
425
+
426
+ static #hueToHex(hue) {
427
+ const h = ((hue % 360) + 360) % 360;
428
+ const a = Math.min(0.5, 1 - 0.5);
429
+ const f = (n) => {
430
+ const k = (n + h / 30) % 12;
431
+ return 0.5 - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
432
+ };
433
+ return '#' + [f(0), f(8), f(4)].map((v) => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
434
+ }
435
+
436
+ static #hexToHue(hex) {
437
+ if (!hex || hex.length < 7) return 24;
438
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
439
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
440
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
441
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
442
+ if (max === min) return 0;
443
+ const d = max - min;
444
+ const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
445
+ return Math.round(h * 60);
446
+ }
447
+
448
+ /**
449
+ * Updates the color picker UI (input value, preview, selected button).
450
+ * @private
451
+ * @param {string} color - Selected color.
452
+ * @returns {void}
453
+ */
454
+ #updateColorPickerUI(color) {
455
+ this.#updateColorPickerPreview(color);
456
+ if (this.#elements.colorSlider) {
457
+ const hue = PrefViewerMenu3D.#hexToHue(color);
458
+ // Only update slider position if not actively focused (user dragging)
459
+ if (document.activeElement !== this.#elements.colorSlider) {
460
+ this.#elements.colorSlider.value = hue;
461
+ }
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Updates the color preview element.
467
+ * @private
468
+ * @param {string} color - Color to preview.
469
+ * @returns {void}
470
+ */
471
+ #updateColorPickerPreview(color) {
472
+ if (this.#elements.colorPickerPreview) {
473
+ this.#elements.colorPickerPreview.style.backgroundColor = color;
474
+ }
367
475
  }
368
476
 
369
477
  /**
@@ -371,8 +479,9 @@ export default class PrefViewerMenu3D extends HTMLElement {
371
479
  * @private
372
480
  * @returns {void}
373
481
  */
374
- #emitApply() {
375
- if (this.#isApplying || !this.#hasPendingChanges()) {
482
+ #emitApply(force = false) {
483
+ // For illumination/color changes, we apply immediately regardless of pending state
484
+ if (!force && this.#isApplying) {
376
485
  return;
377
486
  }
378
487
 
@@ -554,6 +663,16 @@ export default class PrefViewerMenu3D extends HTMLElement {
554
663
  this.#draftSettings = { ...this.#appliedSettings };
555
664
  this.#updateSwitches();
556
665
  this.#updatePendingState();
666
+
667
+ // Sync color slider with current highlightColor
668
+ if (this.#draftSettings.highlightColor) {
669
+ this.#updateColorPickerUI(this.#draftSettings.highlightColor);
670
+ }
671
+ // Update illumination color picker if present
672
+ if (this.#draftSettings.illuminationColor) {
673
+ this.#updateColorPickerUI(this.#draftSettings.illuminationColor);
674
+ }
675
+
557
676
  this.setApplying(false);
558
677
  this.#setStatusMessage("");
559
678
  }
@@ -106,7 +106,7 @@ export default class PrefViewer extends HTMLElement {
106
106
  * @returns {string[]} Array of attribute names to observe.
107
107
  */
108
108
  static get observedAttributes() {
109
- return ["config", "culture", "drawing", "materials", "mode", "model", "scene", "options", "show-model", "show-scene", "show-menu"];
109
+ return ["config", "culture", "drawing", "materials", "mode", "model", "scene", "options", "show-model", "show-scene", "show-menu", "accent-color", "show-animation-menu", "highlight-color", "highlight-enabled"];
110
110
  }
111
111
 
112
112
  /**
@@ -178,6 +178,13 @@ export default class PrefViewer extends HTMLElement {
178
178
  // Recreate menu to apply show/hide setting
179
179
  this.#createMenu3D();
180
180
  break;
181
+ case "accent-color":
182
+ case "show-animation-menu":
183
+ case "highlight-color":
184
+ case "highlight-enabled":
185
+ // Pass these attributes to the 3D component and menu
186
+ this.#updateComponentAttributes(name, value);
187
+ break;
181
188
  }
182
189
  }
183
190
 
@@ -260,6 +267,15 @@ export default class PrefViewer extends HTMLElement {
260
267
  #createComponent3D() {
261
268
  this.#component3D = document.createElement("pref-viewer-3d");
262
269
  this.#component3D.setAttribute("visible", "false");
270
+
271
+ // Pass new configuration attributes to 3D component
272
+ const newAttrs = ["show-animation-menu", "highlight-color", "highlight-enabled"];
273
+ for (const attr of newAttrs) {
274
+ if (this.hasAttribute(attr)) {
275
+ this.#component3D.setAttribute(attr, this.getAttribute(attr));
276
+ }
277
+ }
278
+
263
279
  this.#handlers.on3DSceneLoaded = () => {
264
280
  this.#menu3DSyncSettings();
265
281
  this.#menu3D?.setApplying(false);
@@ -304,6 +320,14 @@ export default class PrefViewer extends HTMLElement {
304
320
  this.#menu3D.setAttribute("culture", this.#culture);
305
321
  }
306
322
 
323
+ // Pass illumination-related attributes to menu
324
+ const illumAttrs = ["show-illumination", "illumination-color"];
325
+ for (const attr of illumAttrs) {
326
+ if (this.hasAttribute(attr)) {
327
+ this.#menu3D.setAttribute(attr, this.getAttribute(attr));
328
+ }
329
+ }
330
+
307
331
  if (!this.#handlers.onMenuApply) {
308
332
  this.#handlers.onMenuApply = this.#onMenuApply.bind(this);
309
333
  }
@@ -320,6 +344,33 @@ export default class PrefViewer extends HTMLElement {
320
344
  this.#menu3DUpdateAvailability();
321
345
  }
322
346
 
347
+ /**
348
+ * Updates attributes on child components (3D viewer and menu) based on parent attributes.
349
+ * @private
350
+ * @param {string} name - Attribute name.
351
+ * @param {*} value - Attribute value.
352
+ * @returns {void}
353
+ */
354
+ #updateComponentAttributes(name, value) {
355
+ // Pass to 3D component
356
+ if (this.#component3D) {
357
+ if (value === null) {
358
+ this.#component3D.removeAttribute(name);
359
+ } else {
360
+ this.#component3D.setAttribute(name, value);
361
+ }
362
+ }
363
+
364
+ // Pass to menu
365
+ if (this.#menu3D) {
366
+ if (value === null) {
367
+ this.#menu3D.removeAttribute(name);
368
+ } else {
369
+ this.#menu3D.setAttribute(name, value);
370
+ }
371
+ }
372
+ }
373
+
323
374
  /**
324
375
  * Reads the last persisted locale from localStorage so the viewer restores user preference.
325
376
  * @private
@@ -1258,6 +1309,48 @@ export default class PrefViewer extends HTMLElement {
1258
1309
  this.#component2D.zoomOut();
1259
1310
  }
1260
1311
 
1312
+ /**
1313
+ * Returns a snapshot of all available animations and their current state.
1314
+ * @public
1315
+ * @returns {Array<{name: string, state: number, progress: number, loop: boolean, nodes: string[]}>}
1316
+ */
1317
+ getAnimations() {
1318
+ return this.#component3D?.getAnimations() ?? [];
1319
+ }
1320
+
1321
+ /**
1322
+ * Controls a named animation by applying the specified action.
1323
+ * @public
1324
+ * @param {string} name - The name of the animation to control.
1325
+ * @param {"open"|"close"|"pause"|"goToOpened"|"goToClosed"} action - The action to apply.
1326
+ * @returns {boolean} True if the animation was found and the action was applied; otherwise false.
1327
+ */
1328
+ playAnimation(name, action) {
1329
+ return this.#component3D?.playAnimation(name, action) ?? false;
1330
+ }
1331
+
1332
+ /**
1333
+ * Seeks a named animation to the given normalized progress position (0–1).
1334
+ * @public
1335
+ * @param {string} name - The name of the animation to seek.
1336
+ * @param {number} progress - Progress value between 0 and 1.
1337
+ * @returns {boolean} True if the animation was found and seeked; otherwise false.
1338
+ */
1339
+ setAnimationProgress(name, progress) {
1340
+ return this.#component3D?.setAnimationProgress(name, progress) ?? false;
1341
+ }
1342
+
1343
+ /**
1344
+ * Enables or disables loop mode for a named animation.
1345
+ * @public
1346
+ * @param {string} name - The name of the animation to configure.
1347
+ * @param {boolean} loop - True to enable looping; false to disable.
1348
+ * @returns {boolean} True if the animation was found and loop mode was set; otherwise false.
1349
+ */
1350
+ setAnimationLoop(name, loop) {
1351
+ return this.#component3D?.setAnimationLoop(name, loop) ?? false;
1352
+ }
1353
+
1261
1354
  /**
1262
1355
  * ---------------------------
1263
1356
  * Public properties (setters)
@@ -1336,6 +1429,25 @@ export default class PrefViewer extends HTMLElement {
1336
1429
  this.loadScene(value);
1337
1430
  }
1338
1431
 
1432
+ /**
1433
+ * Enables or disables the hover highlight effect at runtime.
1434
+ * Accepts boolean or string "true"/"false".
1435
+ * @public
1436
+ * @param {boolean|string} value
1437
+ */
1438
+ set highlightEnabled(value) {
1439
+ this.setAttribute("highlight-enabled", String(value));
1440
+ }
1441
+
1442
+ /**
1443
+ * Sets the hover highlight color at runtime.
1444
+ * @public
1445
+ * @param {string} value - Hex color string, e.g. "#ff6700".
1446
+ */
1447
+ set highlightColor(value) {
1448
+ this.setAttribute("highlight-color", value);
1449
+ }
1450
+
1339
1451
  /**
1340
1452
  * ---------------------------
1341
1453
  * Public properties (getters)
package/src/styles.js CHANGED
@@ -1,8 +1,24 @@
1
1
  export const PrefViewerStyleVariables = {
2
- colorPrimary: "#ff6700", // Always specify in hexadecimal
2
+ colorPrimary: "#ff6700", // Default accent color - can be overridden via attribute
3
3
  fontFamily: "'Roboto', ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji",
4
4
  };
5
5
 
6
+ // Default palette colors for illumination color picker
7
+ export const DEFAULT_ILLUMINATION_PALETTE = [
8
+ "#ffffff", // White
9
+ "#ff6700", // Orange (default)
10
+ "#ff0000", // Red
11
+ "#00ff00", // Green
12
+ "#0000ff", // Blue
13
+ "#ffff00", // Yellow
14
+ "#ff00ff", // Magenta
15
+ "#00ffff", // Cyan
16
+ "#ffb6c1", // Light Pink
17
+ "#98fb98", // Light Green
18
+ "#87ceeb", // Light Blue
19
+ "#dda0dd", // Plum
20
+ ];
21
+
6
22
  export const PrefViewerStyles = `
7
23
  :host .pref-viewer-wrapper {
8
24
  display: grid !important;
@@ -67,6 +83,10 @@ export const PrefViewer3DStyles = `
67
83
  display: none;
68
84
  }
69
85
 
86
+ pref-viewer-3d[show-animation-menu="false"] > div.animation-menu {
87
+ display: none;
88
+ }
89
+
70
90
  pref-viewer-3d {
71
91
  grid-column: 1;
72
92
  grid-row: 1;
@@ -181,6 +201,8 @@ export const PrefViewerMenu3DStyles = `
181
201
 
182
202
  pref-viewer-menu-3d>.menu-wrapper>.menu-panel {
183
203
  width: var(--panel-width);
204
+ max-height: 70vh;
205
+ overflow-y: auto;
184
206
  border-radius: var(--panel-radius);
185
207
  background: var(--panel-bg-color);
186
208
  border: 1px solid var(--panel-border-color);
@@ -386,6 +408,80 @@ export const PrefViewerMenu3DStyles = `
386
408
  pref-viewer-menu-3d[data-applying]>.menu-wrapper>.menu-panel>.menu-footer>.menu-apply {
387
409
  opacity: 0.75;
388
410
  }
411
+
412
+ /* Color Slider Styles */
413
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-color-picker {
414
+ display: flex;
415
+ flex-direction: column;
416
+ gap: 8px;
417
+ padding: var(--panel-spacing) 0;
418
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
419
+ }
420
+
421
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-color-picker:last-child {
422
+ border-bottom: none;
423
+ }
424
+
425
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-color-picker>.color-slider-row {
426
+ display: flex;
427
+ align-items: center;
428
+ justify-content: space-between;
429
+ }
430
+
431
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-color-picker>.color-slider-row>.color-picker-label {
432
+ font-size: var(--font-size-medium);
433
+ font-weight: var(--font-weight-medium);
434
+ color: var(--text-color);
435
+ }
436
+
437
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-color-picker>.color-slider-row>.color-preview {
438
+ width: 20px;
439
+ height: 20px;
440
+ border-radius: 50%;
441
+ border: 2px solid rgba(255, 255, 255, 0.3);
442
+ flex-shrink: 0;
443
+ }
444
+
445
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-color-picker>.color-slider {
446
+ -webkit-appearance: none;
447
+ appearance: none;
448
+ width: 100%;
449
+ height: 10px;
450
+ border-radius: 5px;
451
+ cursor: pointer;
452
+ background: linear-gradient(to right,
453
+ hsl(0,100%,50%), hsl(30,100%,50%), hsl(60,100%,50%), hsl(90,100%,50%),
454
+ hsl(120,100%,50%), hsl(150,100%,50%), hsl(180,100%,50%), hsl(210,100%,50%),
455
+ hsl(240,100%,50%), hsl(270,100%,50%), hsl(300,100%,50%), hsl(330,100%,50%),
456
+ hsl(360,100%,50%));
457
+ outline: none;
458
+ }
459
+
460
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-color-picker>.color-slider::-webkit-slider-thumb {
461
+ -webkit-appearance: none;
462
+ width: 16px;
463
+ height: 16px;
464
+ border-radius: 50%;
465
+ background: white;
466
+ border: 2px solid rgba(0, 0, 0, 0.25);
467
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
468
+ cursor: pointer;
469
+ }
470
+
471
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-color-picker>.color-slider::-moz-range-thumb {
472
+ width: 16px;
473
+ height: 16px;
474
+ border-radius: 50%;
475
+ background: white;
476
+ border: 2px solid rgba(0, 0, 0, 0.25);
477
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
478
+ cursor: pointer;
479
+ }
480
+
481
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-color-picker[data-disabled] {
482
+ opacity: 0.5;
483
+ pointer-events: none;
484
+ }
389
485
  `;
390
486
 
391
487
  export const PrefViewer3DAnimationMenuStyles = `