@preference-sl/pref-viewer 2.14.0 → 2.16.0-beta.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.
@@ -8,6 +8,8 @@ export const translations = {
8
8
  actions: {
9
9
  apply: "Apply",
10
10
  applying: "Applying...",
11
+ play: "Play",
12
+ pause: "Pause",
11
13
  },
12
14
  status: {
13
15
  error: "Couldn't apply the changes.",
@@ -37,6 +39,13 @@ export const translations = {
37
39
  label: "Highlight Color",
38
40
  helper: "Color for hover highlight on animations.",
39
41
  },
42
+ lightingTimeOfDay: {
43
+ label: "Time of day",
44
+ helper: "Slide from night to day and back to night with a smooth sun and moon cycle.",
45
+ startLabel: "Night",
46
+ midLabel: "Day",
47
+ endLabel: "Night",
48
+ },
40
49
  },
41
50
  },
42
51
  downloadDialog: {
@@ -66,6 +75,8 @@ export const translations = {
66
75
  actions: {
67
76
  apply: "Aplicar",
68
77
  applying: "Aplicando...",
78
+ play: "Reproducir",
79
+ pause: "Pausar",
69
80
  },
70
81
  status: {
71
82
  error: "No se pudo aplicar los cambios.",
@@ -95,6 +106,13 @@ export const translations = {
95
106
  label: "Color de resaltado",
96
107
  helper: "Color del resaltado al pasar sobre animaciones.",
97
108
  },
109
+ lightingTimeOfDay: {
110
+ label: "Hora del día",
111
+ helper: "Desliza de noche a día y vuelve a noche con un ciclo suave de sol y luna.",
112
+ startLabel: "Noche",
113
+ midLabel: "Día",
114
+ endLabel: "Noche",
115
+ },
98
116
  },
99
117
  },
100
118
  downloadDialog: {
@@ -229,6 +229,9 @@ export default class PrefViewer3D extends HTMLElement {
229
229
  outerFloor: new MaterialData("outerFloor", undefined, undefined, ["outerFloor"]),
230
230
  },
231
231
  ibl: new IBLData(),
232
+ lighting: {
233
+ timeOfDay: 0.5,
234
+ },
232
235
  },
233
236
  };
234
237
  }
@@ -426,6 +429,38 @@ export default class PrefViewer3D extends HTMLElement {
426
429
  return needUpdate;
427
430
  }
428
431
 
432
+ /**
433
+ * Resolves incoming lighting settings (time-of-day slider) and marks the option as pending when changed.
434
+ * Supports both nested `options.lighting.timeOfDay` and flat `options.lightingTimeOfDay` payloads so the menu
435
+ * and external API can share the same controller path.
436
+ * @private
437
+ * @param {object} options - Options payload containing lighting state.
438
+ * @returns {boolean} True when lighting differs from the cached state, otherwise false.
439
+ */
440
+ #checkNeedToUpdateLighting(options) {
441
+ if (!options) {
442
+ return false;
443
+ }
444
+
445
+ const lightingState = this.#data.options.lighting;
446
+ const incomingTime = typeof options.lightingTimeOfDay === "number"
447
+ ? options.lightingTimeOfDay
448
+ : typeof options.lighting?.timeOfDay === "number"
449
+ ? options.lighting.timeOfDay
450
+ : undefined;
451
+
452
+ if (!Number.isFinite(incomingTime)) {
453
+ return false;
454
+ }
455
+
456
+ const normalizedTime = Math.min(1, Math.max(0, incomingTime));
457
+ const controllerTime = this.#babylonJSController?.getRenderSettings?.()?.lightingTimeOfDay;
458
+ const currentTime = Number.isFinite(controllerTime) ? controllerTime : lightingState.timeOfDay;
459
+ const needUpdate = normalizedTime !== currentTime;
460
+ lightingState.timeOfDay = normalizedTime;
461
+ return needUpdate;
462
+ }
463
+
429
464
  /**
430
465
  * Dispatches a "prefviewer3d-loading" event and updates loading state attributes.
431
466
  * Used internally when a loading operation starts.
@@ -608,14 +643,29 @@ export default class PrefViewer3D extends HTMLElement {
608
643
  this.#checkNeedToUpdateContainers(config);
609
644
 
610
645
  // Options
646
+ let needUpdateLighting = false;
647
+ const iblOptions = config.scene?.ibl
648
+ ? { ...(config.options ?? {}), ibl: { ...(config.options?.ibl ?? {}), ...config.scene.ibl } }
649
+ : config.options;
650
+
611
651
  if (config.options) {
612
652
  this.#checkNeedToUpdateCamera(config.options);
613
653
  this.#checkNeedToUpdateMaterials(config.options);
614
- await this.#checkNeedToUpdateIBL(config.options);
654
+ needUpdateLighting = this.#checkNeedToUpdateLighting(config.options);
655
+ }
656
+
657
+ if (iblOptions) {
658
+ await this.#checkNeedToUpdateIBL(iblOptions);
615
659
  }
616
660
 
617
661
  const loadDetail = await this.#babylonJSController.load(forceReload);
618
662
 
663
+ if (needUpdateLighting && typeof this.#babylonJSController.setIlluminationConfig === "function") {
664
+ this.#babylonJSController.setIlluminationConfig({
665
+ lightingTimeOfDay: this.#data.options.lighting.timeOfDay,
666
+ });
667
+ }
668
+
619
669
  // Apply initial render settings if provided in options
620
670
  if (config.options?.render) {
621
671
  await this.applyRenderSettings(config.options.render);
@@ -649,6 +699,13 @@ export default class PrefViewer3D extends HTMLElement {
649
699
  if (needUpdateIBL) {
650
700
  someSetted = someSetted || (await this.#babylonJSController.setIBLOptions());
651
701
  }
702
+ const needUpdateLighting = this.#checkNeedToUpdateLighting(options);
703
+ if (needUpdateLighting && typeof this.#babylonJSController.setIlluminationConfig === "function") {
704
+ this.#babylonJSController.setIlluminationConfig({
705
+ lightingTimeOfDay: this.#data.options.lighting.timeOfDay,
706
+ });
707
+ someSetted = true;
708
+ }
652
709
 
653
710
  // Apply render settings if provided
654
711
  if (options.render) {
@@ -910,7 +967,17 @@ export default class PrefViewer3D extends HTMLElement {
910
967
  }
911
968
 
912
969
  // Exclude highlight settings from the reload — they are handled above without requiring a scene reload
913
- const { highlightEnabled: _he, highlightColor: _hc, ...reloadSettings } = settings;
970
+ if (settings.lightingTimeOfDay !== undefined && typeof this.#babylonJSController.setIlluminationConfig === "function") {
971
+ const lightingTimeOfDay = Number(settings.lightingTimeOfDay);
972
+ if (Number.isFinite(lightingTimeOfDay)) {
973
+ this.#babylonJSController.setIlluminationConfig({
974
+ lightingTimeOfDay,
975
+ persist: settings.persist !== false,
976
+ });
977
+ }
978
+ }
979
+
980
+ const { highlightEnabled: _he, highlightColor: _hc, lightingTimeOfDay: _lt, lighting: _lighting, persist: _persist, ...reloadSettings } = settings;
914
981
  const scheduled = this.#babylonJSController.scheduleRenderSettingsReload(reloadSettings);
915
982
  if (!scheduled.changed) {
916
983
  return { changed: false, success: true };
@@ -34,6 +34,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
34
34
  colorPicker: null,
35
35
  colorSlider: null,
36
36
  colorPickerPreview: null,
37
+ lightingPlayButton: null,
38
+ lightingSliderWrapper: null,
39
+ lightingSliderLabel: null,
40
+ lightingSliderStart: null,
41
+ lightingSliderMid: null,
42
+ lightingSliderEnd: null,
43
+ lightingSlider: null,
44
+ lightingValue: null,
37
45
  panel: null,
38
46
  pendingLabel: null,
39
47
  status: null,
@@ -48,6 +56,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
48
56
  #handles = {
49
57
  onApplyClick: null,
50
58
  onColorSliderInput: null,
59
+ onLightingSliderInput: null,
60
+ onLightingPlayClick: null,
51
61
  onHostLeave: null,
52
62
  onPanelEnter: null,
53
63
  onSwitchChange: null,
@@ -65,6 +75,11 @@ export default class PrefViewerMenu3D extends HTMLElement {
65
75
  #statusTimeout = null;
66
76
  #isApplying = false;
67
77
  #isEnabled = true;
78
+ #isLightingPlaying = false;
79
+ #lightingAnimationFrame = null;
80
+ #lightingPlaybackStart = 0;
81
+ #lightingPlaybackBase = 0.5;
82
+ #lightingPlaybackDurationMs = 28000;
68
83
 
69
84
  #culture = DEFAULT_LOCALE;
70
85
  #texts = {};
@@ -79,7 +94,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
79
94
  * @returns {string[]} Array of attribute names to observe.
80
95
  */
81
96
  static get observedAttributes() {
82
- return ["culture", "show-illumination", "illumination-color"];
97
+ return ["culture", "show-illumination", "illumination-color", "lighting-time-of-day"];
83
98
  }
84
99
 
85
100
  /**
@@ -107,6 +122,12 @@ export default class PrefViewerMenu3D extends HTMLElement {
107
122
  // Update color picker preview if exists
108
123
  this.#updateColorPickerPreview(value);
109
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
131
  }
111
132
  }
112
133
 
@@ -134,6 +155,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
134
155
  */
135
156
  disconnectedCallback() {
136
157
  this.#detachEvents();
158
+ this.#stopLightingPlayback();
137
159
  if (this.#statusTimeout) {
138
160
  clearTimeout(this.#statusTimeout);
139
161
  this.#statusTimeout = null;
@@ -167,6 +189,21 @@ export default class PrefViewerMenu3D extends HTMLElement {
167
189
  </div>
168
190
  <input type="range" class="color-slider" min="0" max="359" value="${PrefViewerMenu3D.#hexToHue(this.#draftSettings.highlightColor || '#ff6700')}">
169
191
  </div>
192
+ <div class="menu-lighting-slider" data-setting="lightingTimeOfDay">
193
+ <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>
196
+ </div>
197
+ <input type="range" class="lighting-slider" min="0" max="1000" step="1" value="${Math.round((this.#draftSettings.lightingTimeOfDay ?? 0.5) * 1000)}">
198
+ <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>
202
+ </div>
203
+ <div class="lighting-slider-actions">
204
+ <button type="button" class="lighting-play-button">${this.#texts.actions?.play || "Play"}</button>
205
+ </div>
206
+ </div>
170
207
  <div class="menu-footer">
171
208
  <span class="menu-pending" aria-live="polite">${this.#texts.pendingLabel}</span>
172
209
  <span class="menu-status" aria-live="assertive"></span>
@@ -227,6 +264,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
227
264
  actions: {
228
265
  apply: data.actions?.apply || "",
229
266
  applying: data.actions?.applying || "",
267
+ play: data.actions?.play || "",
268
+ pause: data.actions?.pause || "",
230
269
  },
231
270
  status: {
232
271
  error: data.status?.error || "",
@@ -258,6 +297,23 @@ export default class PrefViewerMenu3D extends HTMLElement {
258
297
  if (this.#elements.applyButton) {
259
298
  this.#elements.applyButton.textContent = this.#isApplying ? this.#texts.actions.applying : this.#texts.actions.apply;
260
299
  }
300
+ if (this.#elements.lightingPlayButton) {
301
+ this.#elements.lightingPlayButton.textContent = this.#isLightingPlaying ? (this.#texts.actions.pause || "Pause") : (this.#texts.actions.play || "Play");
302
+ this.#elements.lightingPlayButton.setAttribute("aria-pressed", this.#isLightingPlaying ? "true" : "false");
303
+ }
304
+ if (this.#elements.lightingSliderLabel) {
305
+ this.#elements.lightingSliderLabel.textContent = this.#texts.switches?.lightingTimeOfDay?.label || "Time of day";
306
+ }
307
+ if (this.#elements.lightingSliderStart) {
308
+ this.#elements.lightingSliderStart.textContent = this.#texts.switches?.lightingTimeOfDay?.startLabel || "Night";
309
+ }
310
+ if (this.#elements.lightingSliderMid) {
311
+ this.#elements.lightingSliderMid.textContent = this.#texts.switches?.lightingTimeOfDay?.midLabel || "Day";
312
+ }
313
+ if (this.#elements.lightingSliderEnd) {
314
+ this.#elements.lightingSliderEnd.textContent = this.#texts.switches?.lightingTimeOfDay?.endLabel || "Night";
315
+ }
316
+ this.#updateLightingPlayButtonUI();
261
317
  Object.entries(this.#elements.switchCopyNodes).forEach(([key, nodes]) => {
262
318
  if (!nodes) {
263
319
  return;
@@ -303,6 +359,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
303
359
  this.#elements.colorPicker = this.querySelector(".menu-color-picker");
304
360
  this.#elements.colorSlider = this.querySelector(".color-slider");
305
361
  this.#elements.colorPickerPreview = this.querySelector(".color-preview");
362
+ this.#elements.lightingPlayButton = this.querySelector(".lighting-play-button");
363
+ this.#elements.lightingSliderWrapper = this.querySelector(".menu-lighting-slider");
364
+ this.#elements.lightingSliderLabel = this.querySelector(".lighting-slider-label");
365
+ this.#elements.lightingSliderStart = this.querySelector(".lighting-slider-start");
366
+ this.#elements.lightingSliderMid = this.querySelector(".lighting-slider-mid");
367
+ this.#elements.lightingSliderEnd = this.querySelector(".lighting-slider-end");
368
+ this.#elements.lightingSlider = this.querySelector(".lighting-slider");
369
+ this.#elements.lightingValue = this.querySelector(".lighting-slider-value");
306
370
 
307
371
  this.#MENU_SWITCHES.forEach((key) => {
308
372
  this.#elements.switches[key] = this.querySelector(`input[data-setting="${key}"]`);
@@ -335,6 +399,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
335
399
  this.#handles.onSwitchChange = (event) => this.#handleSwitchChange(event);
336
400
  this.#handles.onApplyClick = () => this.#emitApply();
337
401
  this.#handles.onColorSliderInput = (event) => this.#handleColorSliderInput(event);
402
+ this.#handles.onLightingSliderInput = (event) => this.#handleLightingSliderInput(event);
403
+ this.#handles.onLightingPlayClick = () => this.#toggleLightingPlayback();
338
404
 
339
405
  this.#elements.toggleButton.addEventListener("mouseenter", this.#handles.onToggleEnter);
340
406
  this.#elements.toggleButton.addEventListener("focus", this.#handles.onToggleEnter);
@@ -343,6 +409,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
343
409
  Object.values(this.#elements.switches).forEach((input) => input?.addEventListener("change", this.#handles.onSwitchChange));
344
410
  this.#elements.applyButton?.addEventListener("click", this.#handles.onApplyClick);
345
411
  this.#elements.colorSlider?.addEventListener("input", this.#handles.onColorSliderInput);
412
+ this.#elements.lightingSlider?.addEventListener("input", this.#handles.onLightingSliderInput);
413
+ this.#elements.lightingPlayButton?.addEventListener("click", this.#handles.onLightingPlayClick);
346
414
  }
347
415
 
348
416
  /**
@@ -362,6 +430,8 @@ export default class PrefViewerMenu3D extends HTMLElement {
362
430
  this.#elements.applyButton?.removeEventListener("click", this.#handles.onApplyClick);
363
431
 
364
432
  this.#elements.colorSlider?.removeEventListener("input", this.#handles.onColorSliderInput);
433
+ this.#elements.lightingSlider?.removeEventListener("input", this.#handles.onLightingSliderInput);
434
+ this.#elements.lightingPlayButton?.removeEventListener("click", this.#handles.onLightingPlayClick);
365
435
  }
366
436
 
367
437
  /**
@@ -423,6 +493,100 @@ export default class PrefViewerMenu3D extends HTMLElement {
423
493
  this.#emitApply(true);
424
494
  }
425
495
 
496
+ /**
497
+ * Handles lighting cycle slider input by updating the draft state and applying it immediately.
498
+ * @private
499
+ * @param {Event} event - Input event from the time-of-day slider.
500
+ * @returns {void}
501
+ */
502
+ #handleLightingSliderInput(event) {
503
+ const rawValue = parseInt(event.target.value, 10);
504
+ if (!Number.isFinite(rawValue)) {
505
+ return;
506
+ }
507
+ const normalized = this.#clampLightingTime(rawValue / 1000);
508
+ this.#draftSettings.lightingTimeOfDay = normalized;
509
+ if (this.#isLightingPlaying) {
510
+ this.#lightingPlaybackBase = normalized;
511
+ this.#lightingPlaybackStart = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
512
+ }
513
+ this.#updateLightingSliderUI(normalized);
514
+ this.#updatePendingState();
515
+ this.#emitApply(true);
516
+ }
517
+
518
+ /**
519
+ * Toggles the continuous lighting playback loop.
520
+ * @private
521
+ * @returns {void}
522
+ */
523
+ #toggleLightingPlayback() {
524
+ if (this.#isLightingPlaying) {
525
+ this.#stopLightingPlayback(true);
526
+ return;
527
+ }
528
+ this.#startLightingPlayback();
529
+ }
530
+
531
+ /**
532
+ * Starts the continuous day/night playback loop.
533
+ * @private
534
+ * @returns {void}
535
+ */
536
+ #startLightingPlayback() {
537
+ if (this.#isLightingPlaying) {
538
+ return;
539
+ }
540
+ this.#isLightingPlaying = true;
541
+ this.#lightingPlaybackBase = this.#draftSettings.lightingTimeOfDay ?? 0.5;
542
+ this.#lightingPlaybackStart = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
543
+ this.#updateLightingPlayButtonUI();
544
+ const tick = () => {
545
+ if (!this.#isLightingPlaying) {
546
+ return;
547
+ }
548
+ const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
549
+ const elapsed = now - this.#lightingPlaybackStart;
550
+ const cycle = (this.#lightingPlaybackBase + (elapsed / this.#lightingPlaybackDurationMs)) % 1;
551
+ this.#draftSettings.lightingTimeOfDay = cycle;
552
+ this.#updateLightingSliderUI(cycle);
553
+ this.#emitApply(true, { persist: false });
554
+ this.#lightingAnimationFrame = requestAnimationFrame(tick);
555
+ };
556
+ this.#lightingAnimationFrame = requestAnimationFrame(tick);
557
+ }
558
+
559
+ /**
560
+ * Stops the continuous day/night playback loop.
561
+ * @private
562
+ * @returns {void}
563
+ */
564
+ #stopLightingPlayback(persist = false) {
565
+ this.#isLightingPlaying = false;
566
+ if (this.#lightingAnimationFrame !== null) {
567
+ cancelAnimationFrame(this.#lightingAnimationFrame);
568
+ this.#lightingAnimationFrame = null;
569
+ }
570
+ if (persist) {
571
+ this.#emitApply(true, { persist: true });
572
+ }
573
+ this.#updateLightingPlayButtonUI();
574
+ }
575
+
576
+ /**
577
+ * Refreshes the play button label and pressed state.
578
+ * @private
579
+ * @returns {void}
580
+ */
581
+ #updateLightingPlayButtonUI() {
582
+ if (!this.#elements.lightingPlayButton) {
583
+ return;
584
+ }
585
+ this.#elements.lightingSliderWrapper?.toggleAttribute("data-playing", this.#isLightingPlaying);
586
+ this.#elements.lightingPlayButton.textContent = this.#isLightingPlaying ? (this.#texts.actions.pause || "Pause") : (this.#texts.actions.play || "Play");
587
+ this.#elements.lightingPlayButton.setAttribute("aria-pressed", this.#isLightingPlaying ? "true" : "false");
588
+ }
589
+
426
590
  static #hueToHex(hue) {
427
591
  const h = ((hue % 360) + 360) % 360;
428
592
  const a = Math.min(0.5, 1 - 0.5);
@@ -445,6 +609,21 @@ export default class PrefViewerMenu3D extends HTMLElement {
445
609
  return Math.round(h * 60);
446
610
  }
447
611
 
612
+ static #formatLightingTime(value) {
613
+ const normalized = Math.min(1, Math.max(0, Number(value) || 0));
614
+ const minutes = Math.round(normalized * 24 * 60) % (24 * 60);
615
+ const hours = Math.floor(minutes / 60);
616
+ const mins = minutes % 60;
617
+ return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
618
+ }
619
+
620
+ #clampLightingTime(value) {
621
+ if (!Number.isFinite(value)) {
622
+ return 0.5;
623
+ }
624
+ return Math.min(1, Math.max(0, value));
625
+ }
626
+
448
627
  /**
449
628
  * Updates the color picker UI (input value, preview, selected button).
450
629
  * @private
@@ -474,18 +653,33 @@ export default class PrefViewerMenu3D extends HTMLElement {
474
653
  }
475
654
  }
476
655
 
656
+ /**
657
+ * Updates the lighting slider value and formatted time label.
658
+ * @private
659
+ * @param {number} value - Normalized 0..1 lighting cycle value.
660
+ * @returns {void}
661
+ */
662
+ #updateLightingSliderUI(value) {
663
+ if (this.#elements.lightingSlider) {
664
+ this.#elements.lightingSlider.value = Math.round(this.#clampLightingTime(value) * 1000);
665
+ }
666
+ if (this.#elements.lightingValue) {
667
+ this.#elements.lightingValue.textContent = PrefViewerMenu3D.#formatLightingTime(value);
668
+ }
669
+ }
670
+
477
671
  /**
478
672
  * Emits the apply event with the current draft settings when changes are pending.
479
673
  * @private
480
674
  * @returns {void}
481
675
  */
482
- #emitApply(force = false) {
676
+ #emitApply(force = false, extraSettings = {}) {
483
677
  // For illumination/color changes, we apply immediately regardless of pending state
484
678
  if (!force && this.#isApplying) {
485
679
  return;
486
680
  }
487
681
 
488
- const detail = { settings: { ...this.#draftSettings } };
682
+ const detail = { settings: { ...this.#draftSettings, ...extraSettings } };
489
683
  const customEventOptions = {
490
684
  bubbles: true,
491
685
  cancelable: true,
@@ -535,6 +729,9 @@ export default class PrefViewerMenu3D extends HTMLElement {
535
729
  */
536
730
  #getPendingKeys() {
537
731
  return Object.keys(this.#DEFAULT_RENDER_SETTINGS).filter((key) => {
732
+ if (key === "lightingTimeOfDay") {
733
+ return false;
734
+ }
538
735
  if (this.#elements.switches[key]?.disabled) {
539
736
  return false;
540
737
  }
@@ -564,6 +761,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
564
761
  }
565
762
  wrapper.toggleAttribute("data-pending", pendingKeys.includes(key));
566
763
  });
764
+ this.#elements.lightingSliderWrapper?.toggleAttribute("data-pending", pendingKeys.includes("lightingTimeOfDay"));
567
765
  }
568
766
 
569
767
  /**
@@ -649,6 +847,7 @@ export default class PrefViewerMenu3D extends HTMLElement {
649
847
  if (!this.#isEnabled) {
650
848
  this.removeAttribute("data-viewer-hover");
651
849
  this.#closeMenu();
850
+ this.#stopLightingPlayback();
652
851
  }
653
852
  }
654
853
 
@@ -668,10 +867,14 @@ export default class PrefViewerMenu3D extends HTMLElement {
668
867
  if (this.#draftSettings.highlightColor) {
669
868
  this.#updateColorPickerUI(this.#draftSettings.highlightColor);
670
869
  }
870
+ if (this.#draftSettings.lightingTimeOfDay !== undefined) {
871
+ this.#updateLightingSliderUI(this.#draftSettings.lightingTimeOfDay);
872
+ }
671
873
  // Update illumination color picker if present
672
874
  if (this.#draftSettings.illuminationColor) {
673
875
  this.#updateColorPickerUI(this.#draftSettings.illuminationColor);
674
876
  }
877
+ this.#updateLightingPlayButtonUI();
675
878
 
676
879
  this.setApplying(false);
677
880
  this.#setStatusMessage("");
@@ -486,7 +486,11 @@ export default class PrefViewer extends HTMLElement {
486
486
  if (!this.#component3D) {
487
487
  return;
488
488
  }
489
- this.#menu3D?.setApplying(true);
489
+ const { highlightEnabled: _he, highlightColor: _hc, lightingTimeOfDay: _lt, lighting: _lighting, persist: _persist, ...reloadSettings } = settings ?? {};
490
+ const hasReloadSettings = Object.keys(reloadSettings).length > 0;
491
+ if (hasReloadSettings) {
492
+ this.#menu3D?.setApplying(true);
493
+ }
490
494
  try {
491
495
  const result = await this.#component3D.applyRenderSettings(settings);
492
496
  if (!result?.changed) {
package/src/styles.js CHANGED
@@ -482,6 +482,123 @@ export const PrefViewerMenu3DStyles = `
482
482
  opacity: 0.5;
483
483
  pointer-events: none;
484
484
  }
485
+
486
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider {
487
+ display: flex;
488
+ flex-direction: column;
489
+ gap: 8px;
490
+ padding: var(--panel-spacing) 0;
491
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
492
+ }
493
+
494
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider[data-pending] .lighting-slider-label::after {
495
+ content: "•";
496
+ margin-left: 8px;
497
+ color: var(--accent-color);
498
+ font-size: 1.1em;
499
+ }
500
+
501
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider>.lighting-slider-row {
502
+ display: flex;
503
+ align-items: center;
504
+ justify-content: space-between;
505
+ gap: 12px;
506
+ }
507
+
508
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider>.lighting-slider-row>.lighting-slider-label {
509
+ font-size: var(--font-size-medium);
510
+ font-weight: var(--font-weight-medium);
511
+ color: var(--text-color);
512
+ }
513
+
514
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider>.lighting-slider-row>.lighting-slider-value {
515
+ font-size: var(--font-size-small);
516
+ color: var(--muted-color);
517
+ font-variant-numeric: tabular-nums;
518
+ letter-spacing: 0.06em;
519
+ }
520
+
521
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider>.lighting-slider {
522
+ -webkit-appearance: none;
523
+ appearance: none;
524
+ width: 100%;
525
+ height: 10px;
526
+ border-radius: 999px;
527
+ cursor: pointer;
528
+ background:
529
+ linear-gradient(to right,
530
+ rgba(255, 214, 120, 0.95) 0%,
531
+ rgba(255, 179, 71, 0.98) 20%,
532
+ rgba(255, 244, 214, 0.95) 50%,
533
+ rgba(118, 165, 255, 0.92) 80%,
534
+ rgba(56, 66, 115, 0.95) 100%);
535
+ outline: none;
536
+ }
537
+
538
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider>.lighting-slider::-webkit-slider-thumb {
539
+ -webkit-appearance: none;
540
+ width: 18px;
541
+ height: 18px;
542
+ border-radius: 50%;
543
+ background: #ffffff;
544
+ border: 2px solid rgba(0, 0, 0, 0.24);
545
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
546
+ cursor: pointer;
547
+ }
548
+
549
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider>.lighting-slider::-moz-range-thumb {
550
+ width: 18px;
551
+ height: 18px;
552
+ border-radius: 50%;
553
+ background: #ffffff;
554
+ border: 2px solid rgba(0, 0, 0, 0.24);
555
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
556
+ cursor: pointer;
557
+ }
558
+
559
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider>.lighting-slider-markers {
560
+ display: flex;
561
+ justify-content: space-between;
562
+ font-size: var(--font-size-small);
563
+ color: var(--muted-color);
564
+ letter-spacing: 0.02em;
565
+ }
566
+
567
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider>.lighting-slider-actions {
568
+ display: flex;
569
+ justify-content: flex-end;
570
+ }
571
+
572
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider>.lighting-slider-actions>.lighting-play-button {
573
+ appearance: none;
574
+ border: 1px solid rgba(255, 255, 255, 0.18);
575
+ border-radius: 999px;
576
+ background: rgba(255, 255, 255, 0.08);
577
+ color: var(--text-color);
578
+ padding: 6px 12px;
579
+ font-size: var(--font-size-small);
580
+ font-weight: var(--font-weight-medium);
581
+ cursor: pointer;
582
+ transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
583
+ }
584
+
585
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider>.lighting-slider-actions>.lighting-play-button:hover,
586
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider>.lighting-slider-actions>.lighting-play-button:focus-visible {
587
+ background: rgba(255, 255, 255, 0.14);
588
+ border-color: rgba(255, 255, 255, 0.28);
589
+ transform: translateY(-1px);
590
+ outline: none;
591
+ }
592
+
593
+ pref-viewer-menu-3d>.menu-wrapper>.menu-panel>.menu-lighting-slider[data-playing] .lighting-slider {
594
+ background:
595
+ linear-gradient(to right,
596
+ rgba(255, 220, 132, 0.96) 0%,
597
+ rgba(255, 187, 88, 0.98) 18%,
598
+ rgba(255, 247, 226, 0.96) 50%,
599
+ rgba(112, 154, 255, 0.92) 82%,
600
+ rgba(45, 55, 102, 0.98) 100%);
601
+ }
485
602
  `;
486
603
 
487
604
  export const PrefViewer3DAnimationMenuStyles = `