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

nordisk/facett

facett-graph3d/src/timehelix.rs

//! **Time Helix / Time Vortex** — a rotatable cylinder viewed **down its axis**: you
//! fly *into* a tunnel of stacked layers (see `.nornir/helix-facets-design.md`,
//! Variant B — the Doctor-Who "time vortex" one).
//!
//! - **`Z` = depth into the cylinder = layer index** (time / version). Discrete
//!   layers `z = 0, 1, 2, …` recede toward a bright vanishing point.
//! - Each layer = a **circular cross-section disc**; points are placed within the
//!   disc by `(x, y)` — **polar by default** (`angle = x`, `radius = y`) so the
//!   circle fills, with a cartesian option.
//! - **4th+ dims → colour** (then size/glow); `Z` always stays depth.
//!
//! ### The Doctor-Who render treatment (from the spec)
//! 1. **Camera down the axis** toward a bright vanishing point — a cyan-white core
//!    gradient fading to deep indigo.
//! 2. **Perspective shrink + exponential depth fade** ("tona ner som en
//!    flygsimulator"): `alpha_layer = exp(-depth·k)`, and the colour lerps toward
//!    deep blue with depth (atmospheric fog) — near = big/bright/saturated, far =
//!    small/dim/blue → dissolves to black.
//! 3. **Slow axial swirl** via the injected clock (deeper layers swirl more → the
//!    spiral) — a pointer-drag orbits and pauses the swirl.
//! 4. **Additive glow** points ([`neon::paint_node_orb`]) + faint per-layer bluish
//!    glow rings (no hard circle outline).
//! 5. **Energy filaments** = optional inter-layer edges as glowing streaks
//!    ([`neon::paint_edge_particles`]) — version evolution wires.

use egui::{Color32, Pos2, Sense, vec2};
use serde::{Deserialize, Serialize};

use facett_core::{Facet, FacetCaps, theme};

use crate::neon;

/// Cycles-per-second the injected clock drives the neon breathing at.
const EFFECT_RATE: f32 = 0.5;

/// The deep-indigo fog colour distant layers dissolve toward (atmospheric depth).
const FOG: [f32; 3] = [0.05, 0.06, 0.28];

/// How the disc's `(x, y)` place a point — polar (angle=x, radius=y) or cartesian.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum DiscPlacement {
    /// `angle = x·2π`, `radius = y` — the circle fills naturally (the default).
    Polar,
    /// `x, y` map straight to the disc's cartesian axes.
    Cartesian,
}

impl DiscPlacement {
    pub fn label(self) -> &'static str {
        match self {
            DiscPlacement::Polar => "polar",
            DiscPlacement::Cartesian => "cartesian",
        }
    }
}

/// One point on a layer's disc — the record whose fields drive the placement.
/// `angle`/`radius` are expected in `[0, 1]` (fraction of the disc); `layer` is the
/// depth index into the tunnel.
#[derive(Clone, Debug)]
pub struct TimePoint {
    pub label: String,
    pub color: Color32,
    /// `x` role — angle around the disc (polar) or the cartesian x (fraction `[0,1]`).
    pub angle: f32,
    /// `y` role — radius from the disc centre (polar) or cartesian y (fraction `[0,1]`).
    pub radius: f32,
    /// `z` role — the layer (depth into the tunnel: time / version index).
    pub layer: usize,
}

impl TimePoint {
    pub fn new(label: impl Into<String>, color: Color32, angle: f32, radius: f32, layer: usize) -> Self {
        Self { label: label.into(), color, angle, radius, layer }
    }
}

/// The time-vortex viewer.
pub struct TimeHelix {
    pub title: String,
    /// The points, across all layers.
    pub points: Vec<TimePoint>,
    /// Optional inter-layer energy filaments (index → index) — version-evolution wires.
    pub edges: Vec<(usize, usize)>,
    /// How the disc `(x, y)` place points.
    pub placement: DiscPlacement,
    /// The exponential depth-fade constant `k` in `alpha = exp(-depth·k)`.
    pub depth_fade_k: f32,
    /// How much each layer of depth adds to the swirl angle (radians) — the spiral.
    pub swirl_depth: f32,
    /// Orbit yaw (radians) about the depth axis — the swirl offset the camera adds.
    pub yaw: f32,
    pub spin: bool,
    spin_rate: f32,
    /// Injected animation clock (seconds; FC-7). `Some` → pure swirl function.
    clock_s: Option<f64>,
}

impl TimeHelix {
    /// Build the vortex from its points, with the default polar placement + a gentle
    /// depth fade and swirl.
    pub fn new(title: impl Into<String>, points: Vec<TimePoint>) -> Self {
        Self {
            title: title.into(),
            points,
            edges: Vec::new(),
            placement: DiscPlacement::Polar,
            depth_fade_k: 1.6,
            swirl_depth: 0.35,
            yaw: 0.0,
            spin: true,
            spin_rate: 0.25,
            clock_s: None,
        }
    }

    /// Attach inter-layer energy filaments (drawn as glowing streaks).
    pub fn with_edges(mut self, edges: Vec<(usize, usize)>) -> Self {
        self.edges = edges;
        self
    }
    pub fn with_placement(mut self, p: DiscPlacement) -> Self {
        self.placement = p;
        self
    }
    pub fn with_depth_fade(mut self, k: f32) -> Self {
        self.depth_fade_k = k;
        self
    }
    /// Pin the animation clock (seconds; FC-7) — the swirl becomes a pure function.
    pub fn set_clock(&mut self, seconds: f64) {
        self.clock_s = Some(seconds);
    }
    /// Builder form of [`set_clock`](Self::set_clock).
    pub fn with_clock(mut self, seconds: f64) -> Self {
        self.clock_s = Some(seconds);
        self
    }
    /// Advance the injected clock by `dt` seconds (deterministic stepping).
    pub fn tick(&mut self, dt: f64) {
        self.clock_s = Some(self.clock_s.unwrap_or(0.0) + dt);
    }
    pub fn set_placement(&mut self, p: DiscPlacement) {
        self.placement = p;
    }

    /// The number of layers = max `layer` + 1 (0 when empty).
    pub fn layers(&self) -> usize {
        self.points.iter().map(|p| p.layer).max().map(|m| m + 1).unwrap_or(0)
    }

    /// The neon effect phase (cycles): pure in the injected clock, else ambient.
    fn phase(&self, ambient_time_s: f64) -> f32 {
        self.clock_s.unwrap_or(ambient_time_s) as f32 * EFFECT_RATE
    }

    /// Normalised depth of a layer in `[0, 1]` (0 = nearest disc, 1 = farthest).
    fn depth_norm(&self, layer: usize) -> f32 {
        let last = self.layers().saturating_sub(1).max(1) as f32;
        (layer as f32 / last).clamp(0.0, 1.0)
    }

    /// Perspective shrink for a normalised depth — near layers are big, far layers
    /// recede toward the vanishing point.
    fn persp(depth: f32) -> f32 {
        1.0 / (1.0 + depth * 2.4)
    }

    /// Project point `i` to screen: disc `(x,y)` → swirl-rotated → perspective-shrunk
    /// about `center`. Returns `(screen, depth_norm)`.
    fn project(&self, i: usize, center: Pos2, base: f32, swirl_base: f32) -> (Pos2, f32) {
        let p = &self.points[i];
        let depth = self.depth_norm(p.layer);
        // Disc coords in [-1, 1].
        let (dx, dy) = match self.placement {
            DiscPlacement::Polar => {
                let a = p.angle.clamp(0.0, 1.0) * std::f32::consts::TAU;
                let r = p.radius.clamp(0.0, 1.0);
                (a.cos() * r, a.sin() * r)
            }
            DiscPlacement::Cartesian => (p.angle.clamp(0.0, 1.0) * 2.0 - 1.0, p.radius.clamp(0.0, 1.0) * 2.0 - 1.0),
        };
        // Swirl: the whole disc rotates by the camera yaw + a per-depth twist (deeper
        // layers rotate more → the vortex spiral).
        let swirl = swirl_base + depth * self.swirl_depth * self.layers().max(1) as f32;
        let (s, c) = swirl.sin_cos();
        let rx = dx * c - dy * s;
        let ry = dx * s + dy * c;
        let shrink = base * Self::persp(depth);
        (center + vec2(rx, ry) * shrink, depth)
    }

    /// The depth-faded, fog-lerped colour of a point at normalised `depth`.
    fn faded(color: Color32, depth: f32) -> (Color32, f32) {
        // Colour lerps toward the deep-blue fog with depth (atmospheric fog).
        let lerp = |a: f32, b: f32, t: f32| a + (b - a) * t;
        let r = lerp(color.r() as f32 / 255.0, FOG[0], depth);
        let g = lerp(color.g() as f32 / 255.0, FOG[1], depth);
        let b = lerp(color.b() as f32 / 255.0, FOG[2], depth);
        (Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8), 0.0)
    }
}

impl Facet for TimeHelix {
    fn title(&self) -> &str {
        &self.title
    }

    fn ui(&mut self, ui: &mut egui::Ui) {
        let th = theme(ui);
        // FC-7 swirl: live (ambient dt) vs injected-clock (pure function).
        if self.spin && self.clock_s.is_none() {
            let dt = ui.input(|i| i.stable_dt).min(0.1);
            self.yaw += dt * self.spin_rate;
            ui.ctx().request_repaint();
        }
        let swirl_base = if self.spin {
            self.yaw + self.clock_s.map(|c| c as f32 * self.spin_rate).unwrap_or(0.0)
        } else {
            self.yaw
        };

        let (rect, resp) = ui.allocate_exact_size(ui.available_size(), Sense::click_and_drag());
        // Pointer-drag orbit takeover: the drag swirls and pauses the auto-spin.
        if resp.dragged() {
            let dx = resp.drag_delta().x;
            if dx != 0.0 {
                self.yaw += dx * 0.01;
                self.spin = false;
            }
        }
        let degs = swirl_base.to_degrees().rem_euclid(360.0);
        let layers = self.layers();
        resp.widget_info(|| {
            facett_core::a11y::Semantics::image(format!(
                "time vortex — swirl {degs:.0}° · {} points · {layers} layers",
                self.points.len()
            ))
            .value(swirl_base as f64)
            .widget_info()
        });

        let painter = ui.painter_at(rect);
        // The vortex sits on a deep, near-black ground (the tunnel darkness).
        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
        let center = rect.center();
        let base = rect.size().min_elem() * 0.46;

        // 1) The bright vanishing point: a cyan-white core fading to deep indigo — the
        //    down-the-axis "fly into the tunnel" gradient.
        let core = neon::neon_color(0.0); // cyan
        for step in 0..6 {
            let t = step as f32 / 5.0;
            let r = base * (0.04 + t * 0.5);
            let alpha = (1.0 - t) * 0.10;
            painter.circle_filled(center, r, core.gamma_multiply(alpha));
        }

        if self.points.is_empty() {
            painter.text(center, egui::Align2::CENTER_CENTER, "no layers", egui::FontId::proportional(13.0), th.text_dim);
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit("facett-graph3d::TimeHelix::ui", "ui_render", true, "points=0 drew=empty_hint");
            return;
        }

        let phase = self.phase(ui.input(|i| i.time));
        if self.clock_s.is_none() {
            ui.ctx().request_repaint();
        }

        // Project every point once (screen + normalised depth).
        let proj: Vec<(Pos2, f32)> = (0..self.points.len()).map(|i| self.project(i, center, base, swirl_base)).collect();

        // 2) Faint per-layer bluish glow rings (no hard outline) — one weak additive
        //    ring at each layer's disc edge, receding with perspective.
        let ring_col = neon::neon_color(0.12); // azure
        for layer in 0..layers {
            let depth = self.depth_norm(layer);
            let ring_r = base * Self::persp(depth);
            let alpha = (-depth * self.depth_fade_k).exp() * 0.12;
            if alpha > 0.004 {
                painter.circle_stroke(center, ring_r, egui::Stroke::new(1.5_f32, ring_col.gamma_multiply(alpha)));
            }
        }

        // 3) Energy filaments = inter-layer edges as glowing streaks (version wires),
        //    faded by their mean depth.
        for &(a, b) in &self.edges {
            if a < proj.len() && b < proj.len() {
                let (pa, da) = proj[a];
                let (pb, db) = proj[b];
                let depth = (da + db) * 0.5;
                let alpha = (-depth * self.depth_fade_k).exp();
                if alpha > 0.01 {
                    let (col, _) = Self::faded(self.points[b].color, depth);
                    painter.line_segment([pa, pb], egui::Stroke::new(1.0_f32, col.gamma_multiply(alpha * 0.6)));
                    neon::paint_edge_particles(&painter, pa, pb, col, alpha, phase);
                }
            }
        }

        // 4) Additive glow points, painter's-algorithm far→near so nearer discs
        //    overpaint (bigger/brighter/saturated near; small/dim/blue far).
        let mut order: Vec<usize> = (0..self.points.len()).collect();
        order.sort_by(|&i, &j| proj[j].1.partial_cmp(&proj[i].1).unwrap_or(std::cmp::Ordering::Equal));
        for i in order {
            let (p, depth) = proj[i];
            let alpha = (-depth * self.depth_fade_k).exp();
            if alpha <= 0.01 {
                continue;
            }
            let (col, _) = Self::faded(self.points[i].color, depth);
            let r = (2.0 + 5.0 * Self::persp(depth)).max(1.0);
            neon::paint_node_orb(&painter, p, r, col, 0.6, alpha);
            painter.circle_filled(p, r, col.gamma_multiply(alpha));
        }

        #[cfg(feature = "testmatrix")]
        {
            let all_finite = proj.iter().all(|(p, d)| p.x.is_finite() && p.y.is_finite() && d.is_finite());
            facett_core::testmatrix::emit(
                "facett-graph3d::TimeHelix::ui",
                "ui_render",
                !self.points.is_empty() && all_finite,
                &format!("points={} layers={layers} projection_finite={all_finite}", self.points.len()),
            );
        }
    }

    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "title": self.title,
            "points": self.points.len(),
            "layers": self.layers(),
            "edges": self.edges.len(),
            "placement": self.placement.label(),
            "depth_fade_k": self.depth_fade_k,
            "swirl_depth": self.swirl_depth,
            "yaw": self.yaw,
            "spin": self.spin,
            "spin_rate": self.spin_rate,
            "clock_s": self.clock_s,
            // The data→geometry role map (the design-spec invariant), as data.
            "axis_mapping": {
                "angle": "x (disc angle)",
                "radius": "y (disc radius)",
                "depth": "z (layer / version / time)",
                "extra_dims": "colour",
            },
        })
    }

    fn caps(&self) -> FacetCaps {
        FacetCaps::NONE.themeable().resizable()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// A `layers`-deep vortex with `per` points per disc, evenly around the circle.
    fn sample(layers: usize, per: usize) -> TimeHelix {
        let mut pts = Vec::new();
        let mut edges = Vec::new();
        for l in 0..layers {
            for s in 0..per {
                let idx = pts.len();
                pts.push(TimePoint::new(
                    format!("l{l}s{s}"),
                    Color32::from_rgb(0, 200, 220),
                    s as f32 / per as f32,          // angle → x
                    0.4 + 0.5 * (s as f32 / per as f32), // radius → y
                    l,
                ));
                if l > 0 {
                    edges.push((idx - per, idx)); // wire s to its prior-layer self
                }
            }
        }
        TimeHelix::new("timehelix", pts).with_edges(edges)
    }

    #[test]
    fn layers_count_is_max_layer_plus_one() {
        assert_eq!(sample(14, 8).layers(), 14);
        assert_eq!(TimeHelix::new("t", Vec::new()).layers(), 0);
    }

    #[test]
    fn deeper_layers_shrink_and_fade() {
        let h = sample(10, 6);
        let center = Pos2::new(400.0, 300.0);
        // The nearest disc (layer 0) projects farther from centre than a far disc
        // (layer 9) for the same disc coords → perspective shrink.
        let near = h.project(0, center, 200.0, 0.0).0.distance(center); // layer 0, s=0
        let far_i = h.points.iter().position(|p| p.layer == 9).unwrap();
        let far = h.project(far_i, center, 200.0, 0.0).0.distance(center);
        assert!(near > far, "near disc is bigger than the far disc (near={near} far={far})");
        // Exponential depth fade: alpha shrinks with depth.
        let a_near = (-h.depth_norm(0) * h.depth_fade_k).exp();
        let a_far = (-h.depth_norm(9) * h.depth_fade_k).exp();
        assert!(a_near > a_far, "the far layer fades (a_near={a_near} a_far={a_far})");
        assert!((a_near - 1.0).abs() < 1e-6, "layer 0 is unfaded");
    }

    #[test]
    fn colour_lerps_toward_fog_with_depth() {
        let bright = Color32::from_rgb(0, 220, 255);
        let (near, _) = TimeHelix::faded(bright, 0.0);
        let (far, _) = TimeHelix::faded(bright, 1.0);
        assert_eq!(near, bright, "no fog at depth 0");
        // Far dissolves toward the deep-indigo fog: it loses its bright green channel.
        assert!(far.g() < bright.g(), "the far colour desaturates toward fog");
    }

    #[test]
    fn state_json_exposes_layers_fade_and_mapping() {
        let j = sample(12, 8).state_json();
        assert_eq!(j["points"], 96);
        assert_eq!(j["layers"], 12);
        assert_eq!(j["placement"], "polar");
        assert!(j["depth_fade_k"].as_f64().unwrap() > 0.0);
        assert_eq!(j["axis_mapping"]["depth"], "z (layer / version / time)");
        assert_eq!(j["axis_mapping"]["extra_dims"], "colour");
    }

    #[test]
    fn headless_render_draws_the_vortex() {
        use facett_core::harness;
        let mut h = sample(12, 8).with_clock(1.0);
        let r = harness::headless_render(&mut h);
        assert!(r.drew(), "the vortex tessellated to vertices ({} verts)", r.vertices);
        assert_eq!(r.state["layers"], 12);
        assert_eq!(r.state["clock_s"], 1.0);
    }

    #[test]
    fn empty_vortex_draws_the_core_without_panicking() {
        use facett_core::harness;
        let mut h = TimeHelix::new("timehelix", Vec::new());
        let r = harness::headless_render(&mut h);
        assert_eq!(r.state["points"], 0);
        // The vanishing-point core still paints, so the vortex is never blank.
        assert!(r.drew(), "the core gradient draws even with no points");
    }
}