Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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");
    }
}