facett-demo/src/demo_photoreal.rs
//! **Photoreal day-cycle demo** — a self-contained toggle that drives the
//! `facett-map3d-flight` [`DayCycle`](facett_map3d_flight::DayCycle) time-of-day +
//! weather model and shows the resulting physically-derived sun/sky as live swatches
//! and numbers. It is the interactive proof of the "animate the sun" realism win:
//! drag the hour and watch the sun rise in the east, redden through golden hour, set
//! in the west and darken into night; flip **overcast** and the sky greys + flattens.
//!
//! Native-only (it uses the `facett-map3d-flight` engine, which is a native-only
//! dependency of the demo — same gate as `osm_fly`). The state is a **headless
//! oracle** ([`PhotorealDemo::state_json`]) so the self-test can drive the model
//! without a window; the egui panel is a thin view over it. This module is
//! deliberately self-registered with a **one-line** `pub mod` in `lib.rs` so it does
//! not collide with sibling agents editing `DemoState`.
use facett_map3d_flight::daycycle::{DayCycle, Weather, golden_hour_factor};
use facett_map3d_flight::{Atmosphere, SunSky};
/// Live photoreal day-cycle demo state (headless heart + egui view).
pub struct PhotorealDemo {
/// The demo toggle — is the photoreal day-cycle panel active.
pub enabled: bool,
/// Time of day, hours `0..24` (12 = solar noon).
pub hour: f32,
/// Auto-advance the clock when shown (a slow animated day).
pub playing: bool,
/// The day-cycle model (latitude / season / weather / exposure).
pub cycle: DayCycle,
/// Camera altitude the sky is evaluated at, metres.
pub altitude_m: f32,
/// The reused atmosphere scattering model (transmittance LUT).
atmos: Atmosphere,
}
impl Default for PhotorealDemo {
fn default() -> Self {
// Sweden midsummer, mid-afternoon so the opening frame already shows a warm,
// clearly-lit scene (not noon-flat, not night).
Self {
enabled: false,
hour: 15.5,
playing: false,
cycle: DayCycle::default(),
altitude_m: 1500.0,
atmos: Atmosphere::earth(),
}
}
}
impl PhotorealDemo {
/// Fresh demo (Sweden, midsummer, clear).
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// The current physically-derived sun/sky for the selected time-of-day + weather.
#[must_use]
pub fn sun_sky(&self) -> (SunSky, [f32; 3]) {
self.cycle.sun_sky(self.hour, &self.atmos, self.altitude_m)
}
/// The sun elevation, degrees (negative below the horizon).
#[must_use]
pub fn elevation_deg(&self) -> f32 {
self.cycle.solar_angles(self.hour).elevation_rad.to_degrees()
}
/// The golden-hour weight `0..1` at the current time.
#[must_use]
pub fn golden_hour(&self) -> f32 {
golden_hour_factor(self.cycle.solar_angles(self.hour).elevation_rad)
}
/// Toggle the overcast weather (clear ↔ full overcast).
pub fn set_overcast(&mut self, on: bool) {
self.cycle.weather = if on { Weather::overcast() } else { Weather::clear() };
}
/// Whether the sky is currently overcast (cloudiness above half).
#[must_use]
pub fn is_overcast(&self) -> bool {
self.cycle.weather.cloudiness > 0.5
}
/// Advance the clock by `dt_hours` (wrapping at 24) when [`Self::playing`].
pub fn tick(&mut self, dt_hours: f32) {
if self.playing {
self.hour = (self.hour + dt_hours).rem_euclid(24.0);
}
}
/// **Headless oracle** — the whole demo state as JSON, so the self-test can drive
/// the model and assert the sky responds to time/weather without a window.
#[must_use]
pub fn state_json(&self) -> String {
let (sky, horizon) = self.sun_sky();
format!(
"{{\"enabled\":{},\"hour\":{:.3},\"latitude_deg\":{:.2},\"day_of_year\":{},\"cloudiness\":{:.3},\"altitude_m\":{:.1},\"elevation_deg\":{:.3},\"golden_hour\":{:.3},\"sun_intensity\":{:.4},\"ambient\":{:.4},\"sun_color\":[{:.4},{:.4},{:.4}],\"sky_color\":[{:.4},{:.4},{:.4}],\"horizon\":[{:.4},{:.4},{:.4}]}}",
self.enabled,
self.hour,
self.cycle.latitude_deg,
self.cycle.day_of_year,
self.cycle.weather.cloudiness,
self.altitude_m,
self.elevation_deg(),
self.golden_hour(),
sky.sun_intensity,
sky.ambient,
sky.sun_color[0], sky.sun_color[1], sky.sun_color[2],
sky.sky_color[0], sky.sky_color[1], sky.sky_color[2],
horizon[0], horizon[1], horizon[2],
)
}
/// The egui control panel: the toggle, time-of-day slider, latitude/season, the
/// overcast switch, and live sun/sky swatches + readout.
pub fn ui(&mut self, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
ui.checkbox(&mut self.enabled, "Photoreal day-cycle");
ui.checkbox(&mut self.playing, "▶ animate");
});
ui.add(egui::Slider::new(&mut self.hour, 0.0..=24.0).text("hour").fixed_decimals(1));
ui.add(egui::Slider::new(&mut self.cycle.latitude_deg, -66.0..=66.0).text("latitude°"));
let mut doy = self.cycle.day_of_year as f32;
if ui.add(egui::Slider::new(&mut doy, 0.0..=365.0).text("day of year")).changed() {
self.cycle.day_of_year = doy.round() as u32;
}
let mut overcast = self.is_overcast();
if ui.checkbox(&mut overcast, "overcast").changed() {
self.set_overcast(overcast);
}
let (sky, horizon) = self.sun_sky();
ui.separator();
ui.horizontal(|ui| {
swatch(ui, "sun", sky.sun_color);
swatch(ui, "sky", sky.sky_color);
swatch(ui, "horizon", horizon);
});
ui.label(format!(
"elevation {:.1}° golden-hour {:.2} sun-intensity {:.2} ambient {:.2}",
self.elevation_deg(),
self.golden_hour(),
sky.sun_intensity,
sky.ambient,
));
if self.golden_hour() > 0.4 {
ui.colored_label(egui::Color32::from_rgb(230, 170, 90), "☀ golden hour");
} else if self.elevation_deg() < 0.0 {
ui.colored_label(egui::Color32::from_rgb(120, 140, 200), "🌙 night");
}
}
}
/// A labelled colour swatch (linear RGB → approx sRGB for display).
fn swatch(ui: &mut egui::Ui, label: &str, linear: [f32; 3]) {
let srgb = |v: f32| (v.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0).round() as u8;
let color = egui::Color32::from_rgb(srgb(linear[0]), srgb(linear[1]), srgb(linear[2]));
ui.vertical(|ui| {
let (rect, _) = ui.allocate_exact_size(egui::vec2(44.0, 22.0), egui::Sense::hover());
ui.painter().rect_filled(rect, 3.0, color);
ui.small(label);
});
}
#[cfg(test)]
mod tests {
use super::*;
/// **INJECT-ASSERT (headless oracle — the demo actually animates the sun):**
/// dragging the clock across the day makes the state_json report a rising then
/// setting sun, golden hour at dusk, and darkness at night. RED if the toggle is
/// wired to a dead model.
#[test]
fn demo_state_tracks_the_day() {
let mut d = PhotorealDemo::new();
d.hour = 12.0;
let noon_elev = d.elevation_deg();
assert!(noon_elev > 40.0, "midsummer noon high, got {noon_elev}");
assert!(d.sun_sky().0.sun_intensity > 1.0, "strong midday sun");
assert!(d.state_json().contains("\"sun_intensity\""), "oracle emits sun_intensity");
// Dusk → golden hour, dimmer + redder sun.
d.hour = 20.5;
assert!(d.golden_hour() > 0.4, "20:30 is golden hour ({})", d.golden_hour());
let dusk = d.sun_sky().0;
assert!(dusk.sun_intensity < 2.0, "dusk sun dimmer than noon");
assert!(dusk.sun_color[0] > dusk.sun_color[2], "dusk sun is warm (R>B)");
// Midnight → below the horizon, near-zero direct sun.
d.hour = 0.0;
assert!(d.elevation_deg() < 6.0, "midnight sun low/below");
assert!(d.sun_sky().0.sun_intensity < d.cycle.sun_sky(12.0, &d.atmos, d.altitude_m).0.sun_intensity, "night darker than noon");
}
/// **INJECT-ASSERT (the overcast toggle does something):** flipping overcast dims
/// the sun and lifts the ambient fill vs clear at the same time.
#[test]
fn overcast_toggle_flattens_the_light() {
let mut d = PhotorealDemo::new();
d.hour = 12.0;
d.set_overcast(false);
let clear = d.sun_sky().0;
d.set_overcast(true);
assert!(d.is_overcast(), "overcast flag set");
let cloudy = d.sun_sky().0;
assert!(cloudy.sun_intensity < clear.sun_intensity, "overcast dims the sun");
assert!(cloudy.ambient > clear.ambient, "overcast lifts ambient");
}
/// INJECT-ASSERT (animate steps the clock only while playing).
#[test]
fn tick_advances_only_when_playing() {
let mut d = PhotorealDemo::new();
d.hour = 10.0;
d.playing = false;
d.tick(1.0);
assert_eq!(d.hour, 10.0, "paused clock frozen");
d.playing = true;
d.tick(1.0);
assert!((d.hour - 11.0).abs() < 1e-6, "playing clock advances");
d.hour = 23.5;
d.tick(1.0);
assert!(d.hour < 1.0, "clock wraps at 24h");
}
}