@preference-sl/pref-viewer 2.16.0-beta.1 → 2.16.0-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,34 +3,32 @@ import BabylonJSController from "./babylonjs-controller.js";
3
3
  import { DEFAULT_LOCALE, resolveLocale, translate } from "./localization/i18n.js";
4
4
 
5
5
  /**
6
- * PrefViewerMenu3D - Localized floating control surface for Babylon render toggles used by PrefViewer.
6
+ * PrefViewerMenu3D - Localized floating control panel for Babylon.js render settings.
7
7
  *
8
- * Capabilities:
9
- * - Builds an accessible hover/focus-activated panel with switches for AA, SSAO, IBL, and dynamic shadows.
10
- * - Caches translated copy in `#texts` and listens for culture changes via the `culture` attribute or `setCulture()`.
11
- * - Tracks applied vs. draft render settings so pending diffs, button enablement, and status text stay in sync.
12
- * - Supports per-switch availability (enabled/disabled), preventing interaction and pending diffs for unavailable toggles.
13
- * - Emits `pref-viewer-menu-3d-apply` whenever the user confirms toggles, allowing PrefViewer/BabylonJSController to persist.
14
- * - Shows transient status/error messages and per-switch pending states while operations complete.
8
+ * Renders a gear-icon toggle button that appears on viewer hover and opens a panel with:
9
+ * - Boolean switches for every boolean render setting (AA, SSAO, IBL, shadows, highlight, etc.).
10
+ * - A hue slider for the highlight color.
11
+ * - A time-of-day slider with play/pause continuous lighting-cycle animation.
12
+ *
13
+ * Every interaction emits `pref-viewer-3d-render-settings-change-requested` immediately; there is no Apply button or
14
+ * draft/pending state. Switch changes emit the full settings snapshot, while highlight color and
15
+ * lighting changes emit partial payloads to avoid unnecessary scene reloads.
15
16
  *
16
17
  * Public API:
17
- * - `setSettings(settings)`: hydrates switches with the latest applied render snapshot.
18
- * - `setApplying(isApplying, hasError?)`: locks the UI and optionally displays errors while async updates run.
19
- * - `setViewerHover(isHovering)`: opens/closes the panel based on viewer hover state.
20
- * - `setEnabled(isEnabled)`: hides the menu in 2D mode and clears hover data.
21
- * - `setSwitchEnabled(settingKey, isEnabled)`: enables/disables a concrete toggle (e.g. IBL) based on runtime availability.
22
- * - `setCulture(cultureId)`: forces a locale change and re-renders copy from the i18n layer.
18
+ * - `setSettings(settings)`: syncs the UI with the latest render settings snapshot.
19
+ * - `setApplying(isApplying)`: locks interactions while an async apply operation is running.
20
+ * - `setViewerHover(isHovering)`: drives panel visibility from the 3D viewport hover state.
21
+ * - `setEnabled(isEnabled)`: hides the menu when 3D mode is unavailable.
22
+ * - `setElementEnabled(settingKey, isEnabled)`: enables/disables a specific switch or slider at runtime.
23
+ * - `setElementVisible(settingKey, isVisible)`: shows/hides a specific switch or slider at runtime.
24
+ * - `setCulture(cultureId)`: switches the display locale.
23
25
  *
24
- * Lifecycle & Integration:
25
- * - Automatically creates shadow DOM/UI in `connectedCallback`, wiring hover/focus listeners and ARIA labels.
26
- * - Exposes a single `culture` observed attribute so hosts can drive localization declaratively.
26
+ * Observed attributes: `culture`. Runtime render values are synchronized through `setSettings()`.
27
27
  *
28
- * Accessibility:
29
- * - Uses ARIA labels/live regions for the toggle button, dialog panel, pending indicator, and status slot.
28
+ * Events emitted: `pref-viewer-3d-render-settings-change-requested` - `{ detail: { settings: object } }`.
30
29
  */
31
30
  export default class PrefViewerMenu3D extends HTMLElement {
32
31
  #elements = {
33
- applyButton: null,
34
32
  colorPicker: null,
35
33
  colorSlider: null,
36
34
  colorPickerPreview: null,
@@ -43,8 +41,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
43
41
  lightingSlider: null,
44
42
  lightingValue: null,
45
43
  panel: null,
46
- pendingLabel: null,
47
- status: null,
44
+ sliders: {},
45
+ sliderWrappers: {},
48
46
  subtitle: null,
49
47
  switches: {},
50
48
  switchCopyNodes: {},
@@ -54,7 +52,6 @@ export default class PrefViewerMenu3D extends HTMLElement {
54
52
  };
55
53
 
56
54
  #handles = {
57
- onApplyClick: null,
58
55
  onColorSliderInput: null,
59
56
  onLightingSliderInput: null,
60
57
  onLightingPlayClick: null,
@@ -65,25 +62,29 @@ export default class PrefViewerMenu3D extends HTMLElement {
65
62
  };
66
63
 
67
64
  #DEFAULT_RENDER_SETTINGS = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
68
- // Only include boolean settings as switches (exclude illuminationColor which is handled by color picker)
65
+ // Only include boolean settings as switches; scalar settings are handled by sliders.
69
66
  #MENU_SWITCHES = Object.keys(this.#DEFAULT_RENDER_SETTINGS).filter(key =>
70
67
  typeof this.#DEFAULT_RENDER_SETTINGS[key] === "boolean"
71
68
  );
72
- #appliedSettings = { ...this.#DEFAULT_RENDER_SETTINGS };
73
- #draftSettings = { ...this.#DEFAULT_RENDER_SETTINGS };
74
- #switchAvailability = {};
75
- #statusTimeout = null;
69
+ #settings = { ...this.#DEFAULT_RENDER_SETTINGS };
70
+ #elementAvailability = {};
71
+ #elementVisibility = {};
76
72
  #isApplying = false;
77
73
  #isEnabled = true;
78
74
  #isLightingPlaying = false;
79
75
  #lightingAnimationFrame = null;
80
76
  #lightingPlaybackStart = 0;
81
77
  #lightingPlaybackBase = 0.5;
82
- #lightingPlaybackDurationMs = 28000;
78
+ #lightingPlaybackDurationMs = 15000;
83
79
 
84
80
  #culture = DEFAULT_LOCALE;
85
81
  #texts = {};
86
82
 
83
+ /**
84
+ * Creates the menu custom element.
85
+ * Rendering is deferred until connectedCallback() so attributes can be set before the UI is built.
86
+ * @public
87
+ */
87
88
  constructor() {
88
89
  super();
89
90
  }
@@ -94,40 +95,22 @@ export default class PrefViewerMenu3D extends HTMLElement {
94
95
  * @returns {string[]} Array of attribute names to observe.
95
96
  */
96
97
  static get observedAttributes() {
97
- return ["culture", "show-illumination", "illumination-color", "lighting-time-of-day"];
98
+ return ["culture"];
98
99
  }
99
100
 
100
101
  /**
101
- * Reacts to observed attribute mutations, currently handling locale changes.
102
+ * Reacts to observed attribute mutations and mirrors them into the internal settings/UI state.
102
103
  * @public
103
- * @param {string} name - Attribute name that changed.
104
- * @param {*} _old - Previous attribute value (unused).
105
- * @param {*} value - New attribute value.
104
+ * @param {"culture"} name - Attribute name that changed.
105
+ * @param {string|null} _old - Previous attribute value (unused).
106
+ * @param {string|null} value - New attribute value.
106
107
  * @returns {void}
107
108
  */
108
109
  attributeChangedCallback(name, _old, value) {
109
- if (name === "culture") {
110
- this.#setCultureInternal(value || DEFAULT_LOCALE);
111
- } else if (name === "show-illumination" || name === "illumination-color") {
112
- // Update draft settings when attributes change
113
- if (name === "show-illumination") {
114
- this.#draftSettings.illuminationEnabled = value === "true" || value === true;
115
- // Update the corresponding switch if it exists
116
- const switchEl = this.#elements.switches?.illuminationEnabled;
117
- if (switchEl) {
118
- switchEl.checked = this.#draftSettings.illuminationEnabled;
119
- }
120
- } else if (name === "illumination-color") {
121
- this.#draftSettings.illuminationColor = value;
122
- // Update color picker preview if exists
123
- this.#updateColorPickerPreview(value);
124
- }
125
- } else if (name === "lighting-time-of-day") {
126
- const parsed = Number(value);
127
- if (Number.isFinite(parsed)) {
128
- this.#draftSettings.lightingTimeOfDay = this.#clampLightingTime(parsed);
129
- this.#updateLightingSliderUI(this.#draftSettings.lightingTimeOfDay);
130
- }
110
+ switch (name) {
111
+ case "culture":
112
+ this.#setCultureInternal(value || DEFAULT_LOCALE);
113
+ break;
131
114
  }
132
115
  }
133
116
 
@@ -143,27 +126,23 @@ export default class PrefViewerMenu3D extends HTMLElement {
143
126
  this.#createDOMElements();
144
127
  this.#cacheElements();
145
128
  this.#attachEvents();
146
- this.setSettings(this.#appliedSettings);
129
+ this.setSettings(this.#settings);
147
130
  }
148
131
  }
149
132
 
150
133
  /**
151
134
  * Lifecycle hook invoked when the element is removed from the DOM.
152
- * Cleans up listeners and pending timers to avoid leaks.
135
+ * Cleans up listeners and stops lighting playback to avoid leaks.
153
136
  * @public
154
137
  * @returns {void}
155
138
  */
156
139
  disconnectedCallback() {
157
140
  this.#detachEvents();
158
141
  this.#stopLightingPlayback();
159
- if (this.#statusTimeout) {
160
- clearTimeout(this.#statusTimeout);
161
- this.#statusTimeout = null;
162
- }
163
142
  }
164
143
 
165
144
  /**
166
- * Builds the shadow DOM structure for the toggle button, panel, switches, and footer controls.
145
+ * Builds the internal DOM structure for the toggle button, panel, switches, color slider, and lighting controls.
167
146
  * @private
168
147
  * @returns {void}
169
148
  */
@@ -180,34 +159,31 @@ export default class PrefViewerMenu3D extends HTMLElement {
180
159
  <p class="menu-subtitle">${this.#texts.subtitle}</p>
181
160
  </div>
182
161
  <div class="menu-switches">
183
- ${this.#MENU_SWITCHES.map((key) => this.#renderSwitchRow(key, this.#texts)).join("")}
162
+ ${this.#MENU_SWITCHES.map((key) => this.#renderSwitchRow(key, this.#texts.features)).join("")}
184
163
  </div>
185
- <div class="menu-color-picker" data-setting="highlightColor">
164
+ <div class="menu-color-picker menu-slider" data-setting="highlightColor">
186
165
  <div class="color-slider-row">
187
- <span class="color-picker-label">${this.#texts.switches?.highlightColor?.label || "Highlight Color"}</span>
188
- <span class="color-preview" style="background-color: ${this.#draftSettings.highlightColor || '#ff6700'}"></span>
166
+ <span class="color-picker-label">${this.#texts.features?.highlightColor?.label || "Highlight Color"}</span>
167
+ <span class="color-preview" style="background-color: ${this.#settings.highlightColor || '#ff6700'}"></span>
189
168
  </div>
190
- <input type="range" class="color-slider" min="0" max="359" value="${PrefViewerMenu3D.#hexToHue(this.#draftSettings.highlightColor || '#ff6700')}">
169
+ <input type="range" data-setting="highlightColor" class="color-slider" min="0" max="359" value="${PrefViewerMenu3D.#hexToHue(this.#settings.highlightColor || '#ff6700')}">
191
170
  </div>
192
- <div class="menu-lighting-slider" data-setting="lightingTimeOfDay">
171
+ <div class="menu-lighting-slider menu-slider" data-setting="lightingTimeOfDay">
193
172
  <div class="lighting-slider-row">
194
- <span class="lighting-slider-label">${this.#texts.switches?.lightingTimeOfDay?.label || "Time of day"}</span>
195
- <span class="lighting-slider-value">${PrefViewerMenu3D.#formatLightingTime(this.#draftSettings.lightingTimeOfDay ?? 0.5)}</span>
173
+ <span class="lighting-slider-label menu-slider-label">${this.#texts.features?.lightingTimeOfDay?.label || "Time of day"}</span>
174
+ <span class="lighting-slider-value">${PrefViewerMenu3D.#formatLightingTime(this.#settings.lightingTimeOfDay ?? 0.5)}</span>
196
175
  </div>
197
- <input type="range" class="lighting-slider" min="0" max="1000" step="1" value="${Math.round((this.#draftSettings.lightingTimeOfDay ?? 0.5) * 1000)}">
176
+ <input type="range" data-setting="lightingTimeOfDay" class="lighting-slider" min="0" max="1000" step="1" value="${Math.round((this.#settings.lightingTimeOfDay ?? 0.5) * 1000)}">
198
177
  <div class="lighting-slider-markers" aria-hidden="true">
199
- <span class="lighting-slider-start">${this.#texts.switches?.lightingTimeOfDay?.startLabel || "Night"}</span>
200
- <span class="lighting-slider-mid">${this.#texts.switches?.lightingTimeOfDay?.midLabel || "Day"}</span>
201
- <span class="lighting-slider-end">${this.#texts.switches?.lightingTimeOfDay?.endLabel || "Night"}</span>
178
+ <span class="lighting-slider-start">${this.#texts.features?.lightingTimeOfDay?.startLabel || "Night"}</span>
179
+ <span class="lighting-slider-mid">${this.#texts.features?.lightingTimeOfDay?.midLabel || "Day"}</span>
180
+ <span class="lighting-slider-end">${this.#texts.features?.lightingTimeOfDay?.endLabel || "Night"}</span>
202
181
  </div>
203
182
  <div class="lighting-slider-actions">
204
183
  <button type="button" class="lighting-play-button">${this.#texts.actions?.play || "Play"}</button>
205
184
  </div>
206
185
  </div>
207
- <div class="menu-footer">
208
- <span class="menu-pending" aria-live="polite">${this.#texts.pendingLabel}</span>
209
- <span class="menu-status" aria-live="assertive"></span>
210
- </div>
186
+ <div class="menu-footer"></div>
211
187
  </div>
212
188
  `;
213
189
  const style = document.createElement("style");
@@ -216,14 +192,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
216
192
  }
217
193
 
218
194
  /**
219
- * Generates the HTML fragment for a single render-setting switch.
195
+ * Generates the HTML fragment for a single boolean render-setting switch.
220
196
  * @private
221
- * @param {string} key - Render setting identifier (aa, ssao, ibl, etc.).
222
- * @param {object} copy - Locale-aware copy bundle used for labels.
197
+ * @param {string} key - Render setting key from BabylonJSController.DEFAULT_RENDER_SETTINGS.
198
+ * @param {Record<string, {label?: string, helper?: string}>} copy - Locale-aware feature copy used for labels.
223
199
  * @returns {string} HTML string representing the switch row.
224
200
  */
225
201
  #renderSwitchRow(key, copy) {
226
- const switchCopy = copy.switches?.[key] ?? {};
202
+ const switchCopy = copy?.[key] ?? {};
227
203
  const label = switchCopy.label ?? key;
228
204
  const helper = switchCopy.helper ?? "";
229
205
  return `
@@ -243,7 +219,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
243
219
  /**
244
220
  * Pulls the full translation namespace for the menu using the active culture.
245
221
  * @private
246
- * @returns {object} Resolved translation object or an empty fallback.
222
+ * @returns {object} Resolved `prefViewer.menu3d` translation object or an empty fallback.
247
223
  */
248
224
  #getMenuNamespace() {
249
225
  const namespace = translate("prefViewer.menu3d", { locale: this.#culture });
@@ -253,24 +229,18 @@ export default class PrefViewerMenu3D extends HTMLElement {
253
229
  /**
254
230
  * Normalizes locale data into a predictable shape consumed by the UI helpers.
255
231
  * @private
256
- * @returns {object} Structured copy bundle for titles, actions, status, and switches.
232
+ * @returns {{title: string, subtitle: string, actions: {play: string, pause: string}, features: object}} Structured copy bundle for titles, actions, and feature labels.
257
233
  */
258
234
  #getMenuTexts() {
259
235
  const data = this.#getMenuNamespace();
260
236
  return {
261
237
  title: data.title || "",
262
238
  subtitle: data.subtitle || "",
263
- pendingLabel: data.pendingLabel || "",
264
239
  actions: {
265
- apply: data.actions?.apply || "",
266
- applying: data.actions?.applying || "",
267
240
  play: data.actions?.play || "",
268
241
  pause: data.actions?.pause || "",
269
242
  },
270
- status: {
271
- error: data.status?.error || "",
272
- },
273
- switches: data.switches || {},
243
+ features: data.features || {},
274
244
  };
275
245
  }
276
246
 
@@ -291,34 +261,28 @@ export default class PrefViewerMenu3D extends HTMLElement {
291
261
  if (this.#elements.subtitle) {
292
262
  this.#elements.subtitle.textContent = this.#texts.subtitle;
293
263
  }
294
- if (this.#elements.pendingLabel) {
295
- this.#elements.pendingLabel.textContent = this.#texts.pendingLabel;
296
- }
297
- if (this.#elements.applyButton) {
298
- this.#elements.applyButton.textContent = this.#isApplying ? this.#texts.actions.applying : this.#texts.actions.apply;
299
- }
300
264
  if (this.#elements.lightingPlayButton) {
301
265
  this.#elements.lightingPlayButton.textContent = this.#isLightingPlaying ? (this.#texts.actions.pause || "Pause") : (this.#texts.actions.play || "Play");
302
266
  this.#elements.lightingPlayButton.setAttribute("aria-pressed", this.#isLightingPlaying ? "true" : "false");
303
267
  }
304
268
  if (this.#elements.lightingSliderLabel) {
305
- this.#elements.lightingSliderLabel.textContent = this.#texts.switches?.lightingTimeOfDay?.label || "Time of day";
269
+ this.#elements.lightingSliderLabel.textContent = this.#texts.features?.lightingTimeOfDay?.label || "Time of day";
306
270
  }
307
271
  if (this.#elements.lightingSliderStart) {
308
- this.#elements.lightingSliderStart.textContent = this.#texts.switches?.lightingTimeOfDay?.startLabel || "Night";
272
+ this.#elements.lightingSliderStart.textContent = this.#texts.features?.lightingTimeOfDay?.startLabel || "Night";
309
273
  }
310
274
  if (this.#elements.lightingSliderMid) {
311
- this.#elements.lightingSliderMid.textContent = this.#texts.switches?.lightingTimeOfDay?.midLabel || "Day";
275
+ this.#elements.lightingSliderMid.textContent = this.#texts.features?.lightingTimeOfDay?.midLabel || "Day";
312
276
  }
313
277
  if (this.#elements.lightingSliderEnd) {
314
- this.#elements.lightingSliderEnd.textContent = this.#texts.switches?.lightingTimeOfDay?.endLabel || "Night";
278
+ this.#elements.lightingSliderEnd.textContent = this.#texts.features?.lightingTimeOfDay?.endLabel || "Night";
315
279
  }
316
280
  this.#updateLightingPlayButtonUI();
317
281
  Object.entries(this.#elements.switchCopyNodes).forEach(([key, nodes]) => {
318
282
  if (!nodes) {
319
283
  return;
320
284
  }
321
- const switchCopy = this.#texts.switches?.[key] ?? {};
285
+ const switchCopy = this.#texts.features?.[key] ?? {};
322
286
  nodes.title && (nodes.title.textContent = switchCopy.label ?? key);
323
287
  nodes.helper && (nodes.helper.textContent = switchCopy.helper ?? "");
324
288
  });
@@ -342,7 +306,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
342
306
  }
343
307
 
344
308
  /**
345
- * Caches references to frequently used DOM nodes for faster lookups.
309
+ * Caches references to frequently used DOM nodes for event binding and UI synchronization.
346
310
  * @private
347
311
  * @returns {void}
348
312
  */
@@ -351,11 +315,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
351
315
  this.#elements.panel = this.querySelector(".menu-panel");
352
316
  this.#elements.title = this.querySelector(".menu-title");
353
317
  this.#elements.subtitle = this.querySelector(".menu-subtitle");
354
- this.#elements.applyButton = this.querySelector(".menu-apply");
355
- this.#elements.pendingLabel = this.querySelector(".menu-pending");
356
- this.#elements.status = this.querySelector(".menu-status");
357
-
358
- // Cache color picker elements
318
+
319
+ // Cache slider elements
359
320
  this.#elements.colorPicker = this.querySelector(".menu-color-picker");
360
321
  this.#elements.colorSlider = this.querySelector(".color-slider");
361
322
  this.#elements.colorPickerPreview = this.querySelector(".color-preview");
@@ -367,6 +328,11 @@ export default class PrefViewerMenu3D extends HTMLElement {
367
328
  this.#elements.lightingSliderEnd = this.querySelector(".lighting-slider-end");
368
329
  this.#elements.lightingSlider = this.querySelector(".lighting-slider");
369
330
  this.#elements.lightingValue = this.querySelector(".lighting-slider-value");
331
+
332
+ this.#elements.sliders["highlightColor"] = this.querySelector("input[data-setting='highlightColor']");
333
+ this.#elements.sliderWrappers["highlightColor"] = this.querySelector(".menu-slider[data-setting='highlightColor']");
334
+ this.#elements.sliders["lightingTimeOfDay"] = this.querySelector("input[data-setting='lightingTimeOfDay']");
335
+ this.#elements.sliderWrappers["lightingTimeOfDay"] = this.querySelector(".menu-slider[data-setting='lightingTimeOfDay']");
370
336
 
371
337
  this.#MENU_SWITCHES.forEach((key) => {
372
338
  this.#elements.switches[key] = this.querySelector(`input[data-setting="${key}"]`);
@@ -380,7 +346,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
380
346
  }
381
347
 
382
348
  /**
383
- * Attaches all interactive event listeners for hover, focus, switch changes, and apply clicks.
349
+ * Attaches all interactive event listeners for hover, focus, switch, color, and lighting controls.
384
350
  * @private
385
351
  * @returns {void}
386
352
  */
@@ -397,7 +363,6 @@ export default class PrefViewerMenu3D extends HTMLElement {
397
363
  this.#closeMenu();
398
364
  };
399
365
  this.#handles.onSwitchChange = (event) => this.#handleSwitchChange(event);
400
- this.#handles.onApplyClick = () => this.#emitApply();
401
366
  this.#handles.onColorSliderInput = (event) => this.#handleColorSliderInput(event);
402
367
  this.#handles.onLightingSliderInput = (event) => this.#handleLightingSliderInput(event);
403
368
  this.#handles.onLightingPlayClick = () => this.#toggleLightingPlayback();
@@ -407,7 +372,6 @@ export default class PrefViewerMenu3D extends HTMLElement {
407
372
  this.#elements.panel.addEventListener("mouseenter", this.#handles.onPanelEnter);
408
373
  this.addEventListener("mouseleave", this.#handles.onHostLeave);
409
374
  Object.values(this.#elements.switches).forEach((input) => input?.addEventListener("change", this.#handles.onSwitchChange));
410
- this.#elements.applyButton?.addEventListener("click", this.#handles.onApplyClick);
411
375
  this.#elements.colorSlider?.addEventListener("input", this.#handles.onColorSliderInput);
412
376
  this.#elements.lightingSlider?.addEventListener("input", this.#handles.onLightingSliderInput);
413
377
  this.#elements.lightingPlayButton?.addEventListener("click", this.#handles.onLightingPlayClick);
@@ -427,8 +391,6 @@ export default class PrefViewerMenu3D extends HTMLElement {
427
391
  this.#elements.panel?.removeEventListener("mouseenter", this.#handles.onPanelEnter);
428
392
  this.removeEventListener("mouseleave", this.#handles.onHostLeave);
429
393
  Object.values(this.#elements.switches).forEach((input) => input?.removeEventListener("change", this.#handles.onSwitchChange));
430
- this.#elements.applyButton?.removeEventListener("click", this.#handles.onApplyClick);
431
-
432
394
  this.#elements.colorSlider?.removeEventListener("input", this.#handles.onColorSliderInput);
433
395
  this.#elements.lightingSlider?.removeEventListener("input", this.#handles.onLightingSliderInput);
434
396
  this.#elements.lightingPlayButton?.removeEventListener("click", this.#handles.onLightingPlayClick);
@@ -456,7 +418,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
456
418
  }
457
419
 
458
420
  /**
459
- * Handles switch changes by updating the draft settings and recomputing pending indicators.
421
+ * Handles switch changes by updating settings and emitting a full settings snapshot.
460
422
  * @private
461
423
  * @param {Event} event - Change event triggered by a switch input.
462
424
  * @returns {void}
@@ -469,32 +431,28 @@ export default class PrefViewerMenu3D extends HTMLElement {
469
431
  if (!key) {
470
432
  return;
471
433
  }
472
- this.#draftSettings[key] = event.currentTarget.checked;
473
- this.#updatePendingState();
474
-
475
- // For highlight toggle, apply immediately without waiting for user to click Apply
476
- if (key === "highlightEnabled") {
477
- this.#emitApply(true);
478
- }
434
+ this.#settings[key] = event.currentTarget.checked;
435
+ this.#emitApply();
479
436
  }
480
437
 
481
438
  /**
482
- * Handles clicks on the color picker buttons.
439
+ * Handles hue slider input by converting the selected hue to a highlight color.
440
+ * Emits a partial payload so highlight color changes apply without scheduling a render-settings reload.
483
441
  * @private
484
- * @param {Event} event - Click event.
442
+ * @param {Event} event - Input event from the hue slider.
485
443
  * @returns {void}
486
444
  */
487
445
  #handleColorSliderInput(event) {
488
446
  const hue = parseInt(event.target.value, 10);
489
447
  const color = PrefViewerMenu3D.#hueToHex(hue);
490
- this.#draftSettings.highlightColor = color;
448
+ this.#settings.highlightColor = color;
491
449
  this.#updateColorPickerPreview(color);
492
- this.#updatePendingState();
493
- this.#emitApply(true);
450
+ this.#emitApply({ highlightColor: color }, { partial: true });
494
451
  }
495
452
 
496
453
  /**
497
- * Handles lighting cycle slider input by updating the draft state and applying it immediately.
454
+ * Handles lighting cycle slider input by updating the normalized time-of-day value.
455
+ * Emits a partial payload so lighting changes apply immediately without scheduling a render-settings reload.
498
456
  * @private
499
457
  * @param {Event} event - Input event from the time-of-day slider.
500
458
  * @returns {void}
@@ -505,14 +463,13 @@ export default class PrefViewerMenu3D extends HTMLElement {
505
463
  return;
506
464
  }
507
465
  const normalized = this.#clampLightingTime(rawValue / 1000);
508
- this.#draftSettings.lightingTimeOfDay = normalized;
466
+ this.#settings.lightingTimeOfDay = normalized;
509
467
  if (this.#isLightingPlaying) {
510
468
  this.#lightingPlaybackBase = normalized;
511
469
  this.#lightingPlaybackStart = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
512
470
  }
513
471
  this.#updateLightingSliderUI(normalized);
514
- this.#updatePendingState();
515
- this.#emitApply(true);
472
+ this.#emitApply({ lightingTimeOfDay: normalized }, { partial: true });
516
473
  }
517
474
 
518
475
  /**
@@ -530,6 +487,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
530
487
 
531
488
  /**
532
489
  * Starts the continuous day/night playback loop.
490
+ * The playback speed is controlled by `#lightingPlaybackDurationMs`, which represents one full 0..1 cycle.
533
491
  * @private
534
492
  * @returns {void}
535
493
  */
@@ -538,7 +496,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
538
496
  return;
539
497
  }
540
498
  this.#isLightingPlaying = true;
541
- this.#lightingPlaybackBase = this.#draftSettings.lightingTimeOfDay ?? 0.5;
499
+ this.#lightingPlaybackBase = this.#settings.lightingTimeOfDay ?? 0.5;
542
500
  this.#lightingPlaybackStart = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
543
501
  this.#updateLightingPlayButtonUI();
544
502
  const tick = () => {
@@ -548,9 +506,9 @@ export default class PrefViewerMenu3D extends HTMLElement {
548
506
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
549
507
  const elapsed = now - this.#lightingPlaybackStart;
550
508
  const cycle = (this.#lightingPlaybackBase + (elapsed / this.#lightingPlaybackDurationMs)) % 1;
551
- this.#draftSettings.lightingTimeOfDay = cycle;
509
+ this.#settings.lightingTimeOfDay = cycle;
552
510
  this.#updateLightingSliderUI(cycle);
553
- this.#emitApply(true, { persist: false });
511
+ this.#emitApply({ lightingTimeOfDay: cycle, persist: false }, { partial: true });
554
512
  this.#lightingAnimationFrame = requestAnimationFrame(tick);
555
513
  };
556
514
  this.#lightingAnimationFrame = requestAnimationFrame(tick);
@@ -559,6 +517,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
559
517
  /**
560
518
  * Stops the continuous day/night playback loop.
561
519
  * @private
520
+ * @param {boolean} [persist=false] - True to emit a final apply event that should be persisted by the host.
562
521
  * @returns {void}
563
522
  */
564
523
  #stopLightingPlayback(persist = false) {
@@ -568,7 +527,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
568
527
  this.#lightingAnimationFrame = null;
569
528
  }
570
529
  if (persist) {
571
- this.#emitApply(true, { persist: true });
530
+ this.#emitApply({ lightingTimeOfDay: this.#settings.lightingTimeOfDay, persist: true }, { partial: true });
572
531
  }
573
532
  this.#updateLightingPlayButtonUI();
574
533
  }
@@ -587,6 +546,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
587
546
  this.#elements.lightingPlayButton.setAttribute("aria-pressed", this.#isLightingPlaying ? "true" : "false");
588
547
  }
589
548
 
549
+ /**
550
+ * Converts a hue angle into a saturated hex color.
551
+ * @private
552
+ * @param {number} hue - Hue angle in degrees. Values outside 0..359 wrap around.
553
+ * @returns {string} Hex color string in "#rrggbb" format.
554
+ */
590
555
  static #hueToHex(hue) {
591
556
  const h = ((hue % 360) + 360) % 360;
592
557
  const a = Math.min(0.5, 1 - 0.5);
@@ -597,6 +562,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
597
562
  return '#' + [f(0), f(8), f(4)].map((v) => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
598
563
  }
599
564
 
565
+ /**
566
+ * Extracts the hue angle from a hex color string.
567
+ * @private
568
+ * @param {string} hex - Hex color string in "#rrggbb" format.
569
+ * @returns {number} Hue angle in degrees, or the default orange hue when the value is invalid.
570
+ */
600
571
  static #hexToHue(hex) {
601
572
  if (!hex || hex.length < 7) return 24;
602
573
  const r = parseInt(hex.slice(1, 3), 16) / 255;
@@ -609,6 +580,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
609
580
  return Math.round(h * 60);
610
581
  }
611
582
 
583
+ /**
584
+ * Formats a normalized lighting-cycle value as a 24-hour clock label.
585
+ * @private
586
+ * @param {number} value - Normalized time-of-day value between 0 and 1.
587
+ * @returns {string} Time label formatted as "HH:mm".
588
+ */
612
589
  static #formatLightingTime(value) {
613
590
  const normalized = Math.min(1, Math.max(0, Number(value) || 0));
614
591
  const minutes = Math.round(normalized * 24 * 60) % (24 * 60);
@@ -617,6 +594,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
617
594
  return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
618
595
  }
619
596
 
597
+ /**
598
+ * Clamps a lighting-cycle value to the supported normalized range.
599
+ * @private
600
+ * @param {number} value - Value to clamp.
601
+ * @returns {number} Clamped value between 0 and 1, or 0.5 when the input is invalid.
602
+ */
620
603
  #clampLightingTime(value) {
621
604
  if (!Number.isFinite(value)) {
622
605
  return 0.5;
@@ -625,7 +608,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
625
608
  }
626
609
 
627
610
  /**
628
- * Updates the color picker UI (input value, preview, selected button).
611
+ * Updates the color picker UI, including the hue slider and color preview.
629
612
  * @private
630
613
  * @param {string} color - Selected color.
631
614
  * @returns {void}
@@ -669,129 +652,60 @@ export default class PrefViewerMenu3D extends HTMLElement {
669
652
  }
670
653
 
671
654
  /**
672
- * Emits the apply event with the current draft settings when changes are pending.
655
+ * Emits the apply event with the current settings immediately unless the menu is locked.
673
656
  * @private
657
+ * @param {object} [extraSettings={}] - Additional fields merged into the emitted settings payload, or the complete payload when `options.partial` is true.
658
+ * @param {object} [options={}] - Event payload options.
659
+ * @param {boolean} [options.partial=false] - True to emit only `extraSettings` instead of the full settings snapshot.
674
660
  * @returns {void}
675
661
  */
676
- #emitApply(force = false, extraSettings = {}) {
677
- // For illumination/color changes, we apply immediately regardless of pending state
678
- if (!force && this.#isApplying) {
662
+ #emitApply(extraSettings = {}, options = {}) {
663
+ if (this.#isApplying) {
679
664
  return;
680
665
  }
681
-
682
- const detail = { settings: { ...this.#draftSettings, ...extraSettings } };
683
- const customEventOptions = {
666
+ const detail = { settings: options.partial ? { ...extraSettings } : { ...this.#settings, ...extraSettings } };
667
+ const eventOptions = {
684
668
  bubbles: true,
685
669
  cancelable: true,
686
670
  composed: true,
687
- detail: detail,
671
+ detail,
688
672
  };
689
- this.dispatchEvent(new CustomEvent("pref-viewer-menu-3d-apply", customEventOptions));
673
+ this.dispatchEvent(new CustomEvent("pref-viewer-3d-render-settings-change-requested", eventOptions));
690
674
  }
691
675
 
692
676
  /**
693
- * Synchronizes checkbox states with the draft settings snapshot.
677
+ * Synchronizes checkbox states with the current settings snapshot.
694
678
  * @private
695
679
  * @returns {void}
696
680
  */
697
681
  #updateSwitches() {
698
682
  Object.entries(this.#elements.switches).forEach(([key, input]) => {
699
683
  if (input) {
700
- const isEnabled = this.#switchAvailability[key] !== false;
701
- input.checked = Boolean(this.#draftSettings[key]);
684
+ const isEnabled = this.#elementAvailability[key] !== false;
685
+ const isVisible = this.#elementVisibility[key] !== false;
686
+ input.checked = Boolean(this.#settings[key]);
702
687
  input.disabled = !isEnabled;
703
688
  this.#elements.switchWrappers[key]?.toggleAttribute("data-disabled", !isEnabled);
689
+ this.#elements.switchWrappers[key]?.toggleAttribute("data-hidden", !isVisible);
704
690
  }
705
691
  });
706
692
  }
707
693
 
708
694
  /**
709
- * Toggles pending labels, apply button state, and per-switch markers based on outstanding changes.
695
+ * Synchronizes slider states with the current settings snapshot.
710
696
  * @private
711
697
  * @returns {void}
712
698
  */
713
- #updatePendingState() {
714
- const pendingKeys = this.#getPendingKeys();
715
- const pending = pendingKeys.length > 0;
716
- if (this.#elements.pendingLabel) {
717
- this.#elements.pendingLabel.hidden = !pending;
718
- }
719
- if (!this.#isApplying && this.#elements.applyButton) {
720
- this.#elements.applyButton.disabled = !pending;
721
- }
722
- this.#updateSwitchPendingIndicators(pendingKeys);
723
- }
724
-
725
- /**
726
- * Calculates which render keys differ between draft and applied snapshots.
727
- * @private
728
- * @returns {string[]} Keys currently pending application.
729
- */
730
- #getPendingKeys() {
731
- return Object.keys(this.#DEFAULT_RENDER_SETTINGS).filter((key) => {
732
- if (key === "lightingTimeOfDay") {
733
- return false;
734
- }
735
- if (this.#elements.switches[key]?.disabled) {
736
- return false;
737
- }
738
- return this.#draftSettings[key] !== this.#appliedSettings[key];
739
- });
740
- }
741
-
742
- /**
743
- * Convenience helper to check if any pending changes exist.
744
- * @private
745
- * @returns {boolean}
746
- */
747
- #hasPendingChanges() {
748
- return this.#getPendingKeys().length > 0;
749
- }
750
-
751
- /**
752
- * Adds or removes the `data-pending` attribute on switch wrappers to visually flag unsaved toggles.
753
- * @private
754
- * @param {string[]} pendingKeys - Keys currently pending.
755
- * @returns {void}
756
- */
757
- #updateSwitchPendingIndicators(pendingKeys = []) {
758
- Object.entries(this.#elements.switchWrappers).forEach(([key, wrapper]) => {
759
- if (!wrapper) {
760
- return;
699
+ #updateSliders() {
700
+ Object.entries(this.#elements.sliders).forEach(([key, input]) => {
701
+ if (input) {
702
+ const isEnabled = this.#elementAvailability[key] !== false;
703
+ const isVisible = this.#elementVisibility[key] !== false;
704
+ input.disabled = !isEnabled;
705
+ this.#elements.sliderWrappers[key]?.toggleAttribute("data-disabled", !isEnabled);
706
+ this.#elements.sliderWrappers[key]?.toggleAttribute("data-hidden", !isVisible);
761
707
  }
762
- wrapper.toggleAttribute("data-pending", pendingKeys.includes(key));
763
708
  });
764
- this.#elements.lightingSliderWrapper?.toggleAttribute("data-pending", pendingKeys.includes("lightingTimeOfDay"));
765
- }
766
-
767
- /**
768
- * Shows a transient status message in the footer and schedules it to auto-clear.
769
- * @private
770
- * @param {string} message - The status text to display.
771
- * @returns {void}
772
- */
773
- #setStatusMessage(message) {
774
- if (!this.#elements.status) {
775
- return;
776
- }
777
- this.#elements.status.textContent = message;
778
- if (message) {
779
- this.setAttribute("data-status", "visible");
780
- } else {
781
- this.removeAttribute("data-status");
782
- }
783
- if (this.#statusTimeout) {
784
- clearTimeout(this.#statusTimeout);
785
- this.#statusTimeout = null;
786
- }
787
- if (message) {
788
- const schedule = typeof window !== "undefined" ? window.setTimeout : setTimeout;
789
- this.#statusTimeout = schedule(() => {
790
- this.#elements.status.textContent = "";
791
- this.removeAttribute("data-status");
792
- this.#statusTimeout = null;
793
- }, 4000);
794
- }
795
709
  }
796
710
 
797
711
  /**
@@ -801,24 +715,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
801
715
  */
802
716
 
803
717
  /**
804
- * Locks or unlocks the menu while changes are applied and updates the footer status text.
718
+ * Locks or unlocks the menu while changes are applied.
805
719
  * @public
806
720
  * @param {boolean} isApplying - Whether an apply operation is currently running.
807
- * @param {boolean} [hasError=false] - Optional flag to show an error message.
808
721
  * @returns {void}
809
722
  */
810
- setApplying(isApplying, hasError = false) {
723
+ setApplying(isApplying) {
811
724
  this.#isApplying = isApplying;
812
725
  this.toggleAttribute("data-applying", isApplying);
813
- if (this.#elements.applyButton) {
814
- this.#elements.applyButton.textContent = isApplying ? this.#texts.actions.applying : this.#texts.actions.apply;
815
- this.#elements.applyButton.disabled = isApplying || !this.#hasPendingChanges();
816
- }
817
- if (hasError) {
818
- this.#setStatusMessage(this.#texts.status.error);
819
- } else if (!isApplying) {
820
- this.#setStatusMessage("");
821
- }
822
726
  }
823
727
 
824
728
  /**
@@ -852,32 +756,23 @@ export default class PrefViewerMenu3D extends HTMLElement {
852
756
  }
853
757
 
854
758
  /**
855
- * Applies a new set of render settings to the UI, resetting draft state and pending markers.
759
+ * Applies a new render settings snapshot to the UI and clears the applying state.
856
760
  * @public
857
- * @param {object} settings - Render settings snapshot from PrefViewer.
761
+ * @param {object} settings - Render settings snapshot from PrefViewer or BabylonJSController.
858
762
  * @returns {void}
859
763
  */
860
764
  setSettings(settings = {}) {
861
- this.#appliedSettings = { ...this.#DEFAULT_RENDER_SETTINGS, ...settings };
862
- this.#draftSettings = { ...this.#appliedSettings };
765
+ this.#settings = { ...this.#DEFAULT_RENDER_SETTINGS, ...settings };
863
766
  this.#updateSwitches();
864
- this.#updatePendingState();
865
-
866
- // Sync color slider with current highlightColor
867
- if (this.#draftSettings.highlightColor) {
868
- this.#updateColorPickerUI(this.#draftSettings.highlightColor);
869
- }
870
- if (this.#draftSettings.lightingTimeOfDay !== undefined) {
871
- this.#updateLightingSliderUI(this.#draftSettings.lightingTimeOfDay);
767
+ if (this.#settings.highlightColor) {
768
+ this.#updateColorPickerUI(this.#settings.highlightColor);
872
769
  }
873
- // Update illumination color picker if present
874
- if (this.#draftSettings.illuminationColor) {
875
- this.#updateColorPickerUI(this.#draftSettings.illuminationColor);
770
+ if (this.#settings.lightingTimeOfDay !== undefined) {
771
+ this.#updateLightingSliderUI(this.#settings.lightingTimeOfDay);
876
772
  }
773
+ this.#updateSliders();
877
774
  this.#updateLightingPlayButtonUI();
878
-
879
775
  this.setApplying(false);
880
- this.#setStatusMessage("");
881
776
  }
882
777
 
883
778
  /**
@@ -899,22 +794,44 @@ export default class PrefViewerMenu3D extends HTMLElement {
899
794
  }
900
795
 
901
796
  /**
902
- * Enables or disables a specific render-setting switch in the menu.
797
+ * Enables or disables a specific render-setting switch or slider in the menu.
903
798
  * @public
904
- * @param {string} settingKey - Switch key (e.g. "iblEnabled").
799
+ * @param {string} settingKey - Setting key (e.g. "iblEnabled", "highlightColor", or "lightingTimeOfDay").
905
800
  * @param {boolean} [isEnabled=true] - True to allow interaction, false to disable it.
906
801
  * @returns {void}
907
802
  */
908
- setSwitchEnabled(settingKey, isEnabled = true) {
909
- if (!settingKey || !this.#MENU_SWITCHES.includes(settingKey)) {
803
+ setElementEnabled(settingKey, isEnabled = true) {
804
+ if (!settingKey) {
910
805
  return;
911
806
  }
912
- const enabled = Boolean(isEnabled);
913
- this.#switchAvailability[settingKey] = enabled;
914
- if (!enabled) {
915
- this.#draftSettings[settingKey] = this.#appliedSettings[settingKey];
807
+
808
+ if (this.#MENU_SWITCHES.includes(settingKey)) {
809
+ this.#elementAvailability[settingKey] = Boolean(isEnabled);
810
+ this.#updateSwitches();
811
+ } else if (this.#elements.sliders[settingKey]) {
812
+ this.#elementAvailability[settingKey] = Boolean(isEnabled);
813
+ this.#updateSliders();
814
+ }
815
+ }
816
+
817
+ /**
818
+ * Shows or hides a specific render-setting switch or slider in the menu.
819
+ * @public
820
+ * @param {string} settingKey - Setting key (e.g. "iblEnabled", "highlightColor", or "lightingTimeOfDay").
821
+ * @param {boolean} [isVisible=true] - True to show the switch, false to hide it.
822
+ * @returns {void}
823
+ */
824
+ setElementVisible(settingKey, isVisible = true) {
825
+ if (!settingKey) {
826
+ return;
827
+ }
828
+
829
+ if (this.#MENU_SWITCHES.includes(settingKey)) {
830
+ this.#elementVisibility[settingKey] = Boolean(isVisible);
831
+ this.#updateSwitches();
832
+ } else if (this.#elements.sliders[settingKey]) {
833
+ this.#elementVisibility[settingKey] = Boolean(isVisible);
834
+ this.#updateSliders();
916
835
  }
917
- this.#updateSwitches();
918
- this.#updatePendingState();
919
836
  }
920
837
  }