@preference-sl/pref-viewer 2.16.0-beta.8 → 2.16.0

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-menu-3d-apply` 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
+ * - `setSwitchEnabled(settingKey, isEnabled)`: enables/disables a specific toggle at runtime.
23
+ * - `setCulture(cultureId)`: switches the display locale.
23
24
  *
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.
25
+ * Observed attributes: `culture`, `show-illumination`, `illumination-color`, `lighting-time-of-day`.
26
+ * The illumination attributes are mirrored for compatibility with hosts that still set them declaratively.
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-menu-3d-apply` - `{ 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,6 @@ export default class PrefViewerMenu3D extends HTMLElement {
43
41
  lightingSlider: null,
44
42
  lightingValue: null,
45
43
  panel: null,
46
- pendingLabel: null,
47
- status: null,
48
44
  subtitle: null,
49
45
  switches: {},
50
46
  switchCopyNodes: {},
@@ -54,7 +50,6 @@ export default class PrefViewerMenu3D extends HTMLElement {
54
50
  };
55
51
 
56
52
  #handles = {
57
- onApplyClick: null,
58
53
  onColorSliderInput: null,
59
54
  onLightingSliderInput: null,
60
55
  onLightingPlayClick: null,
@@ -65,25 +60,28 @@ export default class PrefViewerMenu3D extends HTMLElement {
65
60
  };
66
61
 
67
62
  #DEFAULT_RENDER_SETTINGS = { ...BabylonJSController.DEFAULT_RENDER_SETTINGS };
68
- // Only include boolean settings as switches (exclude illuminationColor which is handled by color picker)
63
+ // Only include boolean settings as switches; scalar settings are handled by sliders.
69
64
  #MENU_SWITCHES = Object.keys(this.#DEFAULT_RENDER_SETTINGS).filter(key =>
70
65
  typeof this.#DEFAULT_RENDER_SETTINGS[key] === "boolean"
71
66
  );
72
- #appliedSettings = { ...this.#DEFAULT_RENDER_SETTINGS };
73
- #draftSettings = { ...this.#DEFAULT_RENDER_SETTINGS };
67
+ #settings = { ...this.#DEFAULT_RENDER_SETTINGS };
74
68
  #switchAvailability = {};
75
- #statusTimeout = null;
76
69
  #isApplying = false;
77
70
  #isEnabled = true;
78
71
  #isLightingPlaying = false;
79
72
  #lightingAnimationFrame = null;
80
73
  #lightingPlaybackStart = 0;
81
74
  #lightingPlaybackBase = 0.5;
82
- #lightingPlaybackDurationMs = 28000;
75
+ #lightingPlaybackDurationMs = 15000;
83
76
 
84
77
  #culture = DEFAULT_LOCALE;
85
78
  #texts = {};
86
79
 
80
+ /**
81
+ * Creates the menu custom element.
82
+ * Rendering is deferred until connectedCallback() so attributes can be set before the UI is built.
83
+ * @public
84
+ */
87
85
  constructor() {
88
86
  super();
89
87
  }
@@ -98,35 +96,30 @@ export default class PrefViewerMenu3D extends HTMLElement {
98
96
  }
99
97
 
100
98
  /**
101
- * Reacts to observed attribute mutations, currently handling locale changes.
99
+ * Reacts to observed attribute mutations and mirrors them into the internal settings/UI state.
102
100
  * @public
103
- * @param {string} name - Attribute name that changed.
104
- * @param {*} _old - Previous attribute value (unused).
105
- * @param {*} value - New attribute value.
101
+ * @param {"culture"|"show-illumination"|"illumination-color"|"lighting-time-of-day"} name - Attribute name that changed.
102
+ * @param {string|null} _old - Previous attribute value (unused).
103
+ * @param {string|null} value - New attribute value.
106
104
  * @returns {void}
107
105
  */
108
106
  attributeChangedCallback(name, _old, value) {
109
107
  if (name === "culture") {
110
108
  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);
109
+ } else if (name === "show-illumination") {
110
+ this.#settings.illuminationEnabled = value === "true" || value === true;
111
+ const switchEl = this.#elements.switches?.illuminationEnabled;
112
+ if (switchEl) {
113
+ switchEl.checked = this.#settings.illuminationEnabled;
124
114
  }
115
+ } else if (name === "illumination-color") {
116
+ this.#settings.illuminationColor = value;
117
+ this.#updateColorPickerPreview(value);
125
118
  } else if (name === "lighting-time-of-day") {
126
119
  const parsed = Number(value);
127
120
  if (Number.isFinite(parsed)) {
128
- this.#draftSettings.lightingTimeOfDay = this.#clampLightingTime(parsed);
129
- this.#updateLightingSliderUI(this.#draftSettings.lightingTimeOfDay);
121
+ this.#settings.lightingTimeOfDay = this.#clampLightingTime(parsed);
122
+ this.#updateLightingSliderUI(this.#settings.lightingTimeOfDay);
130
123
  }
131
124
  }
132
125
  }
@@ -143,27 +136,23 @@ export default class PrefViewerMenu3D extends HTMLElement {
143
136
  this.#createDOMElements();
144
137
  this.#cacheElements();
145
138
  this.#attachEvents();
146
- this.setSettings(this.#appliedSettings);
139
+ this.setSettings(this.#settings);
147
140
  }
148
141
  }
149
142
 
150
143
  /**
151
144
  * Lifecycle hook invoked when the element is removed from the DOM.
152
- * Cleans up listeners and pending timers to avoid leaks.
145
+ * Cleans up listeners and stops lighting playback to avoid leaks.
153
146
  * @public
154
147
  * @returns {void}
155
148
  */
156
149
  disconnectedCallback() {
157
150
  this.#detachEvents();
158
151
  this.#stopLightingPlayback();
159
- if (this.#statusTimeout) {
160
- clearTimeout(this.#statusTimeout);
161
- this.#statusTimeout = null;
162
- }
163
152
  }
164
153
 
165
154
  /**
166
- * Builds the shadow DOM structure for the toggle button, panel, switches, and footer controls.
155
+ * Builds the internal DOM structure for the toggle button, panel, switches, color slider, and lighting controls.
167
156
  * @private
168
157
  * @returns {void}
169
158
  */
@@ -180,34 +169,31 @@ export default class PrefViewerMenu3D extends HTMLElement {
180
169
  <p class="menu-subtitle">${this.#texts.subtitle}</p>
181
170
  </div>
182
171
  <div class="menu-switches">
183
- ${this.#MENU_SWITCHES.map((key) => this.#renderSwitchRow(key, this.#texts)).join("")}
172
+ ${this.#MENU_SWITCHES.map((key) => this.#renderSwitchRow(key, this.#texts.features)).join("")}
184
173
  </div>
185
174
  <div class="menu-color-picker" data-setting="highlightColor">
186
175
  <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>
176
+ <span class="color-picker-label">${this.#texts.features?.highlightColor?.label || "Highlight Color"}</span>
177
+ <span class="color-preview" style="background-color: ${this.#settings.highlightColor || '#ff6700'}"></span>
189
178
  </div>
190
- <input type="range" class="color-slider" min="0" max="359" value="${PrefViewerMenu3D.#hexToHue(this.#draftSettings.highlightColor || '#ff6700')}">
179
+ <input type="range" class="color-slider" min="0" max="359" value="${PrefViewerMenu3D.#hexToHue(this.#settings.highlightColor || '#ff6700')}">
191
180
  </div>
192
181
  <div class="menu-lighting-slider" data-setting="lightingTimeOfDay">
193
182
  <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>
183
+ <span class="lighting-slider-label">${this.#texts.features?.lightingTimeOfDay?.label || "Time of day"}</span>
184
+ <span class="lighting-slider-value">${PrefViewerMenu3D.#formatLightingTime(this.#settings.lightingTimeOfDay ?? 0.5)}</span>
196
185
  </div>
197
- <input type="range" class="lighting-slider" min="0" max="1000" step="1" value="${Math.round((this.#draftSettings.lightingTimeOfDay ?? 0.5) * 1000)}">
186
+ <input type="range" class="lighting-slider" min="0" max="1000" step="1" value="${Math.round((this.#settings.lightingTimeOfDay ?? 0.5) * 1000)}">
198
187
  <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>
188
+ <span class="lighting-slider-start">${this.#texts.features?.lightingTimeOfDay?.startLabel || "Night"}</span>
189
+ <span class="lighting-slider-mid">${this.#texts.features?.lightingTimeOfDay?.midLabel || "Day"}</span>
190
+ <span class="lighting-slider-end">${this.#texts.features?.lightingTimeOfDay?.endLabel || "Night"}</span>
202
191
  </div>
203
192
  <div class="lighting-slider-actions">
204
193
  <button type="button" class="lighting-play-button">${this.#texts.actions?.play || "Play"}</button>
205
194
  </div>
206
195
  </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>
196
+ <div class="menu-footer"></div>
211
197
  </div>
212
198
  `;
213
199
  const style = document.createElement("style");
@@ -216,14 +202,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
216
202
  }
217
203
 
218
204
  /**
219
- * Generates the HTML fragment for a single render-setting switch.
205
+ * Generates the HTML fragment for a single boolean render-setting switch.
220
206
  * @private
221
- * @param {string} key - Render setting identifier (aa, ssao, ibl, etc.).
222
- * @param {object} copy - Locale-aware copy bundle used for labels.
207
+ * @param {string} key - Render setting key from BabylonJSController.DEFAULT_RENDER_SETTINGS.
208
+ * @param {Record<string, {label?: string, helper?: string}>} copy - Locale-aware feature copy used for labels.
223
209
  * @returns {string} HTML string representing the switch row.
224
210
  */
225
211
  #renderSwitchRow(key, copy) {
226
- const switchCopy = copy.switches?.[key] ?? {};
212
+ const switchCopy = copy?.[key] ?? {};
227
213
  const label = switchCopy.label ?? key;
228
214
  const helper = switchCopy.helper ?? "";
229
215
  return `
@@ -243,7 +229,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
243
229
  /**
244
230
  * Pulls the full translation namespace for the menu using the active culture.
245
231
  * @private
246
- * @returns {object} Resolved translation object or an empty fallback.
232
+ * @returns {object} Resolved `prefViewer.menu3d` translation object or an empty fallback.
247
233
  */
248
234
  #getMenuNamespace() {
249
235
  const namespace = translate("prefViewer.menu3d", { locale: this.#culture });
@@ -253,24 +239,18 @@ export default class PrefViewerMenu3D extends HTMLElement {
253
239
  /**
254
240
  * Normalizes locale data into a predictable shape consumed by the UI helpers.
255
241
  * @private
256
- * @returns {object} Structured copy bundle for titles, actions, status, and switches.
242
+ * @returns {{title: string, subtitle: string, actions: {play: string, pause: string}, features: object}} Structured copy bundle for titles, actions, and feature labels.
257
243
  */
258
244
  #getMenuTexts() {
259
245
  const data = this.#getMenuNamespace();
260
246
  return {
261
247
  title: data.title || "",
262
248
  subtitle: data.subtitle || "",
263
- pendingLabel: data.pendingLabel || "",
264
249
  actions: {
265
- apply: data.actions?.apply || "",
266
- applying: data.actions?.applying || "",
267
250
  play: data.actions?.play || "",
268
251
  pause: data.actions?.pause || "",
269
252
  },
270
- status: {
271
- error: data.status?.error || "",
272
- },
273
- switches: data.switches || {},
253
+ features: data.features || {},
274
254
  };
275
255
  }
276
256
 
@@ -291,34 +271,28 @@ export default class PrefViewerMenu3D extends HTMLElement {
291
271
  if (this.#elements.subtitle) {
292
272
  this.#elements.subtitle.textContent = this.#texts.subtitle;
293
273
  }
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
274
  if (this.#elements.lightingPlayButton) {
301
275
  this.#elements.lightingPlayButton.textContent = this.#isLightingPlaying ? (this.#texts.actions.pause || "Pause") : (this.#texts.actions.play || "Play");
302
276
  this.#elements.lightingPlayButton.setAttribute("aria-pressed", this.#isLightingPlaying ? "true" : "false");
303
277
  }
304
278
  if (this.#elements.lightingSliderLabel) {
305
- this.#elements.lightingSliderLabel.textContent = this.#texts.switches?.lightingTimeOfDay?.label || "Time of day";
279
+ this.#elements.lightingSliderLabel.textContent = this.#texts.features?.lightingTimeOfDay?.label || "Time of day";
306
280
  }
307
281
  if (this.#elements.lightingSliderStart) {
308
- this.#elements.lightingSliderStart.textContent = this.#texts.switches?.lightingTimeOfDay?.startLabel || "Night";
282
+ this.#elements.lightingSliderStart.textContent = this.#texts.features?.lightingTimeOfDay?.startLabel || "Night";
309
283
  }
310
284
  if (this.#elements.lightingSliderMid) {
311
- this.#elements.lightingSliderMid.textContent = this.#texts.switches?.lightingTimeOfDay?.midLabel || "Day";
285
+ this.#elements.lightingSliderMid.textContent = this.#texts.features?.lightingTimeOfDay?.midLabel || "Day";
312
286
  }
313
287
  if (this.#elements.lightingSliderEnd) {
314
- this.#elements.lightingSliderEnd.textContent = this.#texts.switches?.lightingTimeOfDay?.endLabel || "Night";
288
+ this.#elements.lightingSliderEnd.textContent = this.#texts.features?.lightingTimeOfDay?.endLabel || "Night";
315
289
  }
316
290
  this.#updateLightingPlayButtonUI();
317
291
  Object.entries(this.#elements.switchCopyNodes).forEach(([key, nodes]) => {
318
292
  if (!nodes) {
319
293
  return;
320
294
  }
321
- const switchCopy = this.#texts.switches?.[key] ?? {};
295
+ const switchCopy = this.#texts.features?.[key] ?? {};
322
296
  nodes.title && (nodes.title.textContent = switchCopy.label ?? key);
323
297
  nodes.helper && (nodes.helper.textContent = switchCopy.helper ?? "");
324
298
  });
@@ -342,7 +316,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
342
316
  }
343
317
 
344
318
  /**
345
- * Caches references to frequently used DOM nodes for faster lookups.
319
+ * Caches references to frequently used DOM nodes for event binding and UI synchronization.
346
320
  * @private
347
321
  * @returns {void}
348
322
  */
@@ -351,10 +325,6 @@ export default class PrefViewerMenu3D extends HTMLElement {
351
325
  this.#elements.panel = this.querySelector(".menu-panel");
352
326
  this.#elements.title = this.querySelector(".menu-title");
353
327
  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
328
  // Cache color picker elements
359
329
  this.#elements.colorPicker = this.querySelector(".menu-color-picker");
360
330
  this.#elements.colorSlider = this.querySelector(".color-slider");
@@ -380,7 +350,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
380
350
  }
381
351
 
382
352
  /**
383
- * Attaches all interactive event listeners for hover, focus, switch changes, and apply clicks.
353
+ * Attaches all interactive event listeners for hover, focus, switch, color, and lighting controls.
384
354
  * @private
385
355
  * @returns {void}
386
356
  */
@@ -397,7 +367,6 @@ export default class PrefViewerMenu3D extends HTMLElement {
397
367
  this.#closeMenu();
398
368
  };
399
369
  this.#handles.onSwitchChange = (event) => this.#handleSwitchChange(event);
400
- this.#handles.onApplyClick = () => this.#emitApply();
401
370
  this.#handles.onColorSliderInput = (event) => this.#handleColorSliderInput(event);
402
371
  this.#handles.onLightingSliderInput = (event) => this.#handleLightingSliderInput(event);
403
372
  this.#handles.onLightingPlayClick = () => this.#toggleLightingPlayback();
@@ -407,7 +376,6 @@ export default class PrefViewerMenu3D extends HTMLElement {
407
376
  this.#elements.panel.addEventListener("mouseenter", this.#handles.onPanelEnter);
408
377
  this.addEventListener("mouseleave", this.#handles.onHostLeave);
409
378
  Object.values(this.#elements.switches).forEach((input) => input?.addEventListener("change", this.#handles.onSwitchChange));
410
- this.#elements.applyButton?.addEventListener("click", this.#handles.onApplyClick);
411
379
  this.#elements.colorSlider?.addEventListener("input", this.#handles.onColorSliderInput);
412
380
  this.#elements.lightingSlider?.addEventListener("input", this.#handles.onLightingSliderInput);
413
381
  this.#elements.lightingPlayButton?.addEventListener("click", this.#handles.onLightingPlayClick);
@@ -427,8 +395,6 @@ export default class PrefViewerMenu3D extends HTMLElement {
427
395
  this.#elements.panel?.removeEventListener("mouseenter", this.#handles.onPanelEnter);
428
396
  this.removeEventListener("mouseleave", this.#handles.onHostLeave);
429
397
  Object.values(this.#elements.switches).forEach((input) => input?.removeEventListener("change", this.#handles.onSwitchChange));
430
- this.#elements.applyButton?.removeEventListener("click", this.#handles.onApplyClick);
431
-
432
398
  this.#elements.colorSlider?.removeEventListener("input", this.#handles.onColorSliderInput);
433
399
  this.#elements.lightingSlider?.removeEventListener("input", this.#handles.onLightingSliderInput);
434
400
  this.#elements.lightingPlayButton?.removeEventListener("click", this.#handles.onLightingPlayClick);
@@ -456,7 +422,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
456
422
  }
457
423
 
458
424
  /**
459
- * Handles switch changes by updating the draft settings and recomputing pending indicators.
425
+ * Handles switch changes by updating settings and emitting a full settings snapshot.
460
426
  * @private
461
427
  * @param {Event} event - Change event triggered by a switch input.
462
428
  * @returns {void}
@@ -469,32 +435,28 @@ export default class PrefViewerMenu3D extends HTMLElement {
469
435
  if (!key) {
470
436
  return;
471
437
  }
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
- }
438
+ this.#settings[key] = event.currentTarget.checked;
439
+ this.#emitApply();
479
440
  }
480
441
 
481
442
  /**
482
- * Handles clicks on the color picker buttons.
443
+ * Handles hue slider input by converting the selected hue to a highlight color.
444
+ * Emits a partial payload so highlight color changes apply without scheduling a render-settings reload.
483
445
  * @private
484
- * @param {Event} event - Click event.
446
+ * @param {Event} event - Input event from the hue slider.
485
447
  * @returns {void}
486
448
  */
487
449
  #handleColorSliderInput(event) {
488
450
  const hue = parseInt(event.target.value, 10);
489
451
  const color = PrefViewerMenu3D.#hueToHex(hue);
490
- this.#draftSettings.highlightColor = color;
452
+ this.#settings.highlightColor = color;
491
453
  this.#updateColorPickerPreview(color);
492
- this.#updatePendingState();
493
- this.#emitApply(true);
454
+ this.#emitApply({ highlightColor: color }, { partial: true });
494
455
  }
495
456
 
496
457
  /**
497
- * Handles lighting cycle slider input by updating the draft state and applying it immediately.
458
+ * Handles lighting cycle slider input by updating the normalized time-of-day value.
459
+ * Emits a partial payload so lighting changes apply immediately without scheduling a render-settings reload.
498
460
  * @private
499
461
  * @param {Event} event - Input event from the time-of-day slider.
500
462
  * @returns {void}
@@ -505,14 +467,13 @@ export default class PrefViewerMenu3D extends HTMLElement {
505
467
  return;
506
468
  }
507
469
  const normalized = this.#clampLightingTime(rawValue / 1000);
508
- this.#draftSettings.lightingTimeOfDay = normalized;
470
+ this.#settings.lightingTimeOfDay = normalized;
509
471
  if (this.#isLightingPlaying) {
510
472
  this.#lightingPlaybackBase = normalized;
511
473
  this.#lightingPlaybackStart = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
512
474
  }
513
475
  this.#updateLightingSliderUI(normalized);
514
- this.#updatePendingState();
515
- this.#emitApply(true);
476
+ this.#emitApply({ lightingTimeOfDay: normalized }, { partial: true });
516
477
  }
517
478
 
518
479
  /**
@@ -530,6 +491,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
530
491
 
531
492
  /**
532
493
  * Starts the continuous day/night playback loop.
494
+ * The playback speed is controlled by `#lightingPlaybackDurationMs`, which represents one full 0..1 cycle.
533
495
  * @private
534
496
  * @returns {void}
535
497
  */
@@ -538,7 +500,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
538
500
  return;
539
501
  }
540
502
  this.#isLightingPlaying = true;
541
- this.#lightingPlaybackBase = this.#draftSettings.lightingTimeOfDay ?? 0.5;
503
+ this.#lightingPlaybackBase = this.#settings.lightingTimeOfDay ?? 0.5;
542
504
  this.#lightingPlaybackStart = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
543
505
  this.#updateLightingPlayButtonUI();
544
506
  const tick = () => {
@@ -548,9 +510,9 @@ export default class PrefViewerMenu3D extends HTMLElement {
548
510
  const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
549
511
  const elapsed = now - this.#lightingPlaybackStart;
550
512
  const cycle = (this.#lightingPlaybackBase + (elapsed / this.#lightingPlaybackDurationMs)) % 1;
551
- this.#draftSettings.lightingTimeOfDay = cycle;
513
+ this.#settings.lightingTimeOfDay = cycle;
552
514
  this.#updateLightingSliderUI(cycle);
553
- this.#emitApply(true, { persist: false });
515
+ this.#emitApply({ lightingTimeOfDay: cycle, persist: false }, { partial: true });
554
516
  this.#lightingAnimationFrame = requestAnimationFrame(tick);
555
517
  };
556
518
  this.#lightingAnimationFrame = requestAnimationFrame(tick);
@@ -559,6 +521,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
559
521
  /**
560
522
  * Stops the continuous day/night playback loop.
561
523
  * @private
524
+ * @param {boolean} [persist=false] - True to emit a final apply event that should be persisted by the host.
562
525
  * @returns {void}
563
526
  */
564
527
  #stopLightingPlayback(persist = false) {
@@ -568,7 +531,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
568
531
  this.#lightingAnimationFrame = null;
569
532
  }
570
533
  if (persist) {
571
- this.#emitApply(true, { persist: true });
534
+ this.#emitApply({ lightingTimeOfDay: this.#settings.lightingTimeOfDay, persist: true }, { partial: true });
572
535
  }
573
536
  this.#updateLightingPlayButtonUI();
574
537
  }
@@ -587,6 +550,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
587
550
  this.#elements.lightingPlayButton.setAttribute("aria-pressed", this.#isLightingPlaying ? "true" : "false");
588
551
  }
589
552
 
553
+ /**
554
+ * Converts a hue angle into a saturated hex color.
555
+ * @private
556
+ * @param {number} hue - Hue angle in degrees. Values outside 0..359 wrap around.
557
+ * @returns {string} Hex color string in "#rrggbb" format.
558
+ */
590
559
  static #hueToHex(hue) {
591
560
  const h = ((hue % 360) + 360) % 360;
592
561
  const a = Math.min(0.5, 1 - 0.5);
@@ -597,6 +566,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
597
566
  return '#' + [f(0), f(8), f(4)].map((v) => Math.round(v * 255).toString(16).padStart(2, '0')).join('');
598
567
  }
599
568
 
569
+ /**
570
+ * Extracts the hue angle from a hex color string.
571
+ * @private
572
+ * @param {string} hex - Hex color string in "#rrggbb" format.
573
+ * @returns {number} Hue angle in degrees, or the default orange hue when the value is invalid.
574
+ */
600
575
  static #hexToHue(hex) {
601
576
  if (!hex || hex.length < 7) return 24;
602
577
  const r = parseInt(hex.slice(1, 3), 16) / 255;
@@ -609,6 +584,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
609
584
  return Math.round(h * 60);
610
585
  }
611
586
 
587
+ /**
588
+ * Formats a normalized lighting-cycle value as a 24-hour clock label.
589
+ * @private
590
+ * @param {number} value - Normalized time-of-day value between 0 and 1.
591
+ * @returns {string} Time label formatted as "HH:mm".
592
+ */
612
593
  static #formatLightingTime(value) {
613
594
  const normalized = Math.min(1, Math.max(0, Number(value) || 0));
614
595
  const minutes = Math.round(normalized * 24 * 60) % (24 * 60);
@@ -617,6 +598,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
617
598
  return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
618
599
  }
619
600
 
601
+ /**
602
+ * Clamps a lighting-cycle value to the supported normalized range.
603
+ * @private
604
+ * @param {number} value - Value to clamp.
605
+ * @returns {number} Clamped value between 0 and 1, or 0.5 when the input is invalid.
606
+ */
620
607
  #clampLightingTime(value) {
621
608
  if (!Number.isFinite(value)) {
622
609
  return 0.5;
@@ -625,7 +612,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
625
612
  }
626
613
 
627
614
  /**
628
- * Updates the color picker UI (input value, preview, selected button).
615
+ * Updates the color picker UI, including the hue slider and color preview.
629
616
  * @private
630
617
  * @param {string} color - Selected color.
631
618
  * @returns {void}
@@ -669,28 +656,28 @@ export default class PrefViewerMenu3D extends HTMLElement {
669
656
  }
670
657
 
671
658
  /**
672
- * Emits the apply event with the current draft settings when changes are pending.
659
+ * Emits the apply event with the current settings immediately unless the menu is locked.
673
660
  * @private
661
+ * @param {object} [extraSettings={}] - Additional fields merged into the emitted settings payload, or the complete payload when `options.partial` is true.
662
+ * @param {object} [options={}] - Event payload options.
663
+ * @param {boolean} [options.partial=false] - True to emit only `extraSettings` instead of the full settings snapshot.
674
664
  * @returns {void}
675
665
  */
676
- #emitApply(force = false, extraSettings = {}) {
677
- // For illumination/color changes, we apply immediately regardless of pending state
678
- if (!force && this.#isApplying) {
666
+ #emitApply(extraSettings = {}, options = {}) {
667
+ if (this.#isApplying) {
679
668
  return;
680
669
  }
681
-
682
- const detail = { settings: { ...this.#draftSettings, ...extraSettings } };
683
- const customEventOptions = {
670
+ const detail = { settings: options.partial ? { ...extraSettings } : { ...this.#settings, ...extraSettings } };
671
+ this.dispatchEvent(new CustomEvent("pref-viewer-menu-3d-apply", {
684
672
  bubbles: true,
685
673
  cancelable: true,
686
674
  composed: true,
687
- detail: detail,
688
- };
689
- this.dispatchEvent(new CustomEvent("pref-viewer-menu-3d-apply", customEventOptions));
675
+ detail,
676
+ }));
690
677
  }
691
678
 
692
679
  /**
693
- * Synchronizes checkbox states with the draft settings snapshot.
680
+ * Synchronizes checkbox states with the current settings snapshot.
694
681
  * @private
695
682
  * @returns {void}
696
683
  */
@@ -698,102 +685,13 @@ export default class PrefViewerMenu3D extends HTMLElement {
698
685
  Object.entries(this.#elements.switches).forEach(([key, input]) => {
699
686
  if (input) {
700
687
  const isEnabled = this.#switchAvailability[key] !== false;
701
- input.checked = Boolean(this.#draftSettings[key]);
688
+ input.checked = Boolean(this.#settings[key]);
702
689
  input.disabled = !isEnabled;
703
690
  this.#elements.switchWrappers[key]?.toggleAttribute("data-disabled", !isEnabled);
704
691
  }
705
692
  });
706
693
  }
707
694
 
708
- /**
709
- * Toggles pending labels, apply button state, and per-switch markers based on outstanding changes.
710
- * @private
711
- * @returns {void}
712
- */
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;
761
- }
762
- wrapper.toggleAttribute("data-pending", pendingKeys.includes(key));
763
- });
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
- }
796
-
797
695
  /**
798
696
  * ---------------------------
799
697
  * Public methods
@@ -801,24 +699,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
801
699
  */
802
700
 
803
701
  /**
804
- * Locks or unlocks the menu while changes are applied and updates the footer status text.
702
+ * Locks or unlocks the menu while changes are applied.
805
703
  * @public
806
704
  * @param {boolean} isApplying - Whether an apply operation is currently running.
807
- * @param {boolean} [hasError=false] - Optional flag to show an error message.
808
705
  * @returns {void}
809
706
  */
810
- setApplying(isApplying, hasError = false) {
707
+ setApplying(isApplying) {
811
708
  this.#isApplying = isApplying;
812
709
  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
710
  }
823
711
 
824
712
  /**
@@ -852,32 +740,22 @@ export default class PrefViewerMenu3D extends HTMLElement {
852
740
  }
853
741
 
854
742
  /**
855
- * Applies a new set of render settings to the UI, resetting draft state and pending markers.
743
+ * Applies a new render settings snapshot to the UI and clears the applying state.
856
744
  * @public
857
- * @param {object} settings - Render settings snapshot from PrefViewer.
745
+ * @param {object} settings - Render settings snapshot from PrefViewer or BabylonJSController.
858
746
  * @returns {void}
859
747
  */
860
748
  setSettings(settings = {}) {
861
- this.#appliedSettings = { ...this.#DEFAULT_RENDER_SETTINGS, ...settings };
862
- this.#draftSettings = { ...this.#appliedSettings };
749
+ this.#settings = { ...this.#DEFAULT_RENDER_SETTINGS, ...settings };
863
750
  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);
751
+ if (this.#settings.highlightColor) {
752
+ this.#updateColorPickerUI(this.#settings.highlightColor);
872
753
  }
873
- // Update illumination color picker if present
874
- if (this.#draftSettings.illuminationColor) {
875
- this.#updateColorPickerUI(this.#draftSettings.illuminationColor);
754
+ if (this.#settings.lightingTimeOfDay !== undefined) {
755
+ this.#updateLightingSliderUI(this.#settings.lightingTimeOfDay);
876
756
  }
877
757
  this.#updateLightingPlayButtonUI();
878
-
879
758
  this.setApplying(false);
880
- this.#setStatusMessage("");
881
759
  }
882
760
 
883
761
  /**
@@ -909,12 +787,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
909
787
  if (!settingKey || !this.#MENU_SWITCHES.includes(settingKey)) {
910
788
  return;
911
789
  }
912
- const enabled = Boolean(isEnabled);
913
- this.#switchAvailability[settingKey] = enabled;
914
- if (!enabled) {
915
- this.#draftSettings[settingKey] = this.#appliedSettings[settingKey];
916
- }
790
+ this.#switchAvailability[settingKey] = Boolean(isEnabled);
917
791
  this.#updateSwitches();
918
- this.#updatePendingState();
919
792
  }
920
793
  }