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/dnahelix.rs

//! **DNA Helix** — a true double-helix data viz where each record is a **rung**
//! (base pair) of a ladder that spirals around a central axis. This is the
//! `Layout3D::Helix` generalized into a first-class [`Facet`] with user-choosable
//! data→geometry roles (see `.nornir/helix-facets-design.md`, Variant A).
//!
//! **The fixed geometric roles ← data fields:**
//!
//! | role | what it does | field |
//! |---|---|---|
//! | **axial position** | where up the ladder a rung sits | `axial` (`z` — progression / time / index) |
//! | **rung radius** `R_i` | fat/thin helix at that step | `radius` (`x` — magnitude) |
//! | **rung lean / base-pair asymmetry** | the signed lean of the pair | `lean` (`y` — a signed value) |
//! | **twist** `θ_i = i·twist` | the helical spin itself | constant `twist` pitch |
//! | **colour** | the 4th dimension | per-rung `color` |
//!
//! The `y` (lean) role has two shippable interpretations, toggled by [`DnaAxis`]:
//! - [`DnaAxis::Tilt`] — strand A sits at `R·(1 + k·y)`, strand B at `R·(1 − k·y)`,
//!   so `y>0` pushes the pair toward strand A and the ladder visibly *leans*.
//! - [`DnaAxis::Pitch`] — `y` is the local vertical rise between rungs, so it
//!   stretches/compresses the helix (rungs stay symmetric).
//!
//! Strands render as glowing tubes (neon orbs along each backbone), rungs as
//! glowing edges. Spins on its axis by default (injected-clock spin, FC-7) and a
//! pointer-drag orbits the camera (the drag takes over and pauses the spin), exactly
//! like [`crate::RabbitLogo3D`] / [`crate::Graph3D`].

use egui::{Color32, Pos2, Sense, Stroke, 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 (matches
/// [`crate::Graph3D`]'s gentle ~0.5 Hz).
const EFFECT_RATE: f32 = 0.5;

/// Which data role `y` (the `lean` field) plays — the `DnaAxis::{Tilt, Pitch}`
/// toggle from the design spec, so both interpretations ship.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum DnaAxis {
    /// `y` = base-pair asymmetry: strand A at `R·(1 + k·y)`, strand B at `R·(1 − k·y)`
    /// — the ladder leans toward the heavier strand (the default).
    Tilt,
    /// `y` = local pitch: the vertical rise between successive rungs is scaled by
    /// `(1 + k·y)`, so `y` stretches/compresses the helix (rungs stay symmetric).
    Pitch,
}

impl DnaAxis {
    pub const ALL: [(DnaAxis, &'static str); 2] = [(DnaAxis::Tilt, "tilt"), (DnaAxis::Pitch, "pitch")];
    pub fn label(self) -> &'static str {
        match self {
            DnaAxis::Tilt => "tilt",
            DnaAxis::Pitch => "pitch",
        }
    }
}

/// One rung (base pair) of the ladder — the record whose fields drive the geometry.
/// `axial`/`radius`/`lean` are in the caller's own units; the facet normalises
/// `axial` and `radius` across the whole ladder and clamps `lean` to `[-1, 1]`.
#[derive(Clone, Debug)]
pub struct DnaRung {
    pub label: String,
    pub color: Color32,
    /// `z` role — where up the ladder (progression / time / index).
    pub axial: f32,
    /// `x` role — the rung's half-width magnitude (fat/thin helix here).
    pub radius: f32,
    /// `y` role — a **signed** value: the base-pair lean (Tilt) or local pitch (Pitch).
    pub lean: f32,
}

impl DnaRung {
    pub fn new(label: impl Into<String>, color: Color32, axial: f32, radius: f32, lean: f32) -> Self {
        Self { label: label.into(), color, axial, radius, lean }
    }
}

/// The DNA double-helix viewer.
pub struct DnaHelix {
    pub title: String,
    /// The ladder — one [`DnaRung`] per base pair.
    pub rungs: Vec<DnaRung>,
    /// Which role the `y`/`lean` field plays (Tilt vs Pitch).
    pub axis: DnaAxis,
    /// Constant helical pitch: the twist added per rung, in radians.
    pub twist: f32,
    /// Asymmetry / pitch gain `k` — how strongly `lean` leans (Tilt) or stretches (Pitch).
    pub k: f32,
    /// Orbit yaw (radians) about Y, and a fixed tilt about X for a 3/4 view.
    pub yaw: f32,
    pub tilt: f32,
    pub spin: bool,
    spin_rate: f32,
    /// Injected animation clock (seconds; FC-7). `Some` → pure spin function.
    clock_s: Option<f64>,
}

impl DnaHelix {
    /// Build the helix from its rungs (base pairs), with the default Tilt lean role,
    /// a gentle twist, and the classic slightly-tilted 3/4 view.
    pub fn new(title: impl Into<String>, rungs: Vec<DnaRung>) -> Self {
        Self {
            title: title.into(),
            rungs,
            axis: DnaAxis::Tilt,
            twist: 0.5,
            k: 0.6,
            yaw: 0.6,
            tilt: 0.28,
            spin: true,
            spin_rate: 0.4,
            clock_s: None,
        }
    }

    pub fn with_axis(mut self, axis: DnaAxis) -> Self {
        self.axis = axis;
        self
    }
    pub fn with_twist(mut self, twist: f32) -> Self {
        self.twist = twist;
        self
    }
    pub fn with_k(mut self, k: f32) -> Self {
        self.k = k;
        self
    }
    /// Pin the animation clock (seconds; FC-7) — the spin 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);
    }
    /// Switch the `y`/`lean` role (Tilt ⇄ Pitch) — the design-spec toggle.
    pub fn set_axis(&mut self, axis: DnaAxis) {
        self.axis = axis;
    }

    /// The min/max of a field across the ladder (for normalisation), never zero-span.
    fn span(&self, f: impl Fn(&DnaRung) -> f32) -> (f32, f32) {
        let mut mn = f32::MAX;
        let mut mx = f32::MIN;
        for r in &self.rungs {
            let v = f(r);
            mn = mn.min(v);
            mx = mx.max(v);
        }
        if !mn.is_finite() || !mx.is_finite() {
            return (0.0, 1.0);
        }
        (mn, mx)
    }

    /// The two backbone anchor points (`strandA`, `strandB`) of rung `i`, in the
    /// normalised `[-1, 1]` cube — the pure geometry the render + tests read.
    /// `vpos` is the precomputed vertical position array (built once per frame so
    /// the Pitch mode can accumulate).
    fn rung_points(&self, i: usize, vpos: &[f32], r_span: (f32, f32)) -> ([f32; 3], [f32; 3]) {
        let rung = &self.rungs[i];
        let yv = vpos[i];
        let theta = i as f32 * self.twist;
        // Rung half-width, normalised to a comfortable base radius.
        let (rmn, rmx) = r_span;
        let rnorm = ((rung.radius - rmn) / (rmx - rmn).max(1e-6)).clamp(0.0, 1.0);
        let base_r = 0.35 + 0.55 * rnorm;
        let lean = rung.lean.clamp(-1.0, 1.0);
        let (ra, rb) = match self.axis {
            // Tilt: the pair leans — strand A grows, strand B shrinks (signed).
            DnaAxis::Tilt => (base_r * (1.0 + self.k * lean), base_r * (1.0 - self.k * lean)),
            // Pitch: the lean rode into the vertical spacing (see `vpos`); rungs symmetric.
            DnaAxis::Pitch => (base_r, base_r),
        };
        let (s, c) = theta.sin_cos();
        // Strand B is the opposite side of the axis (θ + π ⇒ negate).
        let a = [c * ra, yv, s * ra];
        let b = [-c * rb, yv, -s * rb];
        (a, b)
    }

    /// Build the per-rung vertical position array (`[-1, 1]`), honouring the axis
    /// role: Tilt uses the `axial` field directly; Pitch accumulates `(1 + k·lean)`.
    fn vertical_positions(&self) -> Vec<f32> {
        let n = self.rungs.len();
        if n == 0 {
            return Vec::new();
        }
        match self.axis {
            DnaAxis::Tilt => {
                let (mn, mx) = self.span(|r| r.axial);
                self.rungs
                    .iter()
                    .map(|r| {
                        let t = (r.axial - mn) / (mx - mn).max(1e-6);
                        t.clamp(0.0, 1.0) * 2.0 - 1.0
                    })
                    .collect()
            }
            DnaAxis::Pitch => {
                // Cumulative rise: each gap scaled by (1 + k·lean). Then normalise.
                let mut acc = 0.0f32;
                let mut raw = Vec::with_capacity(n);
                for r in &self.rungs {
                    let step = 1.0 + self.k * r.lean.clamp(-1.0, 1.0);
                    acc += step.max(0.05); // never fold backwards
                    raw.push(acc);
                }
                let mn = raw.first().copied().unwrap_or(0.0);
                let mx = raw.last().copied().unwrap_or(1.0);
                raw.iter().map(|v| ((v - mn) / (mx - mn).max(1e-6)) * 2.0 - 1.0).collect()
            }
        }
    }

    /// Rotate a point by yaw (Y) then tilt (X). Returns view-space `(x, y, z)`.
    fn rotate(&self, p: [f32; 3], yaw: f32, tilt: f32) -> [f32; 3] {
        let (sy, cy) = yaw.sin_cos();
        let x1 = p[0] * cy + p[2] * sy;
        let z1 = -p[0] * sy + p[2] * cy;
        let (st, ct) = tilt.sin_cos();
        let y2 = p[1] * ct - z1 * st;
        let z2 = p[1] * st + z1 * ct;
        [x1, y2, z2]
    }

    /// 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
    }
}

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

    fn ui(&mut self, ui: &mut egui::Ui) {
        let th = theme(ui);
        // FC-7 spin: 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 render_yaw = 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 rotates 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 = render_yaw.to_degrees().rem_euclid(360.0);
        resp.widget_info(|| {
            facett_core::a11y::Semantics::image(format!(
                "DNA double-helix — yaw {degs:.0}° · {} rungs · lean={}",
                self.rungs.len(),
                self.axis.label()
            ))
            .value(render_yaw as f64)
            .widget_info()
        });

        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);
        if self.rungs.is_empty() {
            painter.text(rect.center(), egui::Align2::CENTER_CENTER, "no rungs", egui::FontId::proportional(13.0), th.text_dim);
            #[cfg(feature = "testmatrix")]
            facett_core::testmatrix::emit("facett-graph3d::DnaHelix::ui", "ui_render", true, "rungs=0 drew=empty_hint");
            return;
        }

        let center = rect.center();
        // A helix is tall: give it a little more vertical throw than width.
        let scale = rect.size().min_elem() * 0.40;
        let vpos = self.vertical_positions();
        let r_span = self.span(|r| r.radius);
        let phase = self.phase(ui.input(|i| i.time));
        if self.clock_s.is_none() {
            ui.ctx().request_repaint();
        }

        // Project every strand anchor once.
        let n = self.rungs.len();
        let mut a_px: Vec<(Pos2, f32)> = Vec::with_capacity(n);
        let mut b_px: Vec<(Pos2, f32)> = Vec::with_capacity(n);
        for i in 0..n {
            let (a, b) = self.rung_points(i, &vpos, r_span);
            let ra = self.rotate(a, render_yaw, self.tilt);
            let rb = self.rotate(b, render_yaw, self.tilt);
            a_px.push((center + vec2(ra[0], -ra[1]) * scale, ra[2]));
            b_px.push((center + vec2(rb[0], -rb[1]) * scale, rb[2]));
        }

        // Depth-sorted draw: build (depth, kind, ...) then paint far→near so nearer
        // geometry overpaints. We paint rungs + the two backbones as a single stream.
        let depth_bright = |z: f32| (0.5 + (z + 1.0) * 0.25).clamp(0.35, 1.0);

        // 1) Backbone strands as glowing tubes: a line + neon orbs along each anchor.
        //    Draw both strands' segments, faded by mean depth.
        let strand_a_col = neon::neon_color(0.0); // cyan backbone
        let strand_b_col = neon::neon_color(0.55); // magenta backbone
        for i in 1..n {
            for (px, col) in [(&a_px, strand_a_col), (&b_px, strand_b_col)] {
                let (p0, z0) = px[i - 1];
                let (p1, z1) = px[i];
                let z = (z0 + z1) * 0.5;
                let bright = depth_bright(z);
                painter.line_segment([p0, p1], Stroke::new(2.0_f32, col.gamma_multiply(bright)));
                neon::paint_edge_particles(&painter, p0, p1, col, bright, phase);
            }
        }

        // 2) Rungs (base pairs) as glowing edges strandA_i ↔ strandB_i, coloured by
        //    the rung's own data colour.
        for i in 0..n {
            let (pa, za) = a_px[i];
            let (pb, zb) = b_px[i];
            let z = (za + zb) * 0.5;
            let bright = depth_bright(z);
            let col = self.rungs[i].color;
            painter.line_segment([pa, pb], Stroke::new(1.5_f32, col.gamma_multiply(bright * 0.9)));
        }

        // 3) Backbone orbs (the glowing tubes' node beads), depth-ordered.
        let mut beads: Vec<(Pos2, f32, Color32)> = Vec::with_capacity(2 * n);
        for i in 0..n {
            beads.push((a_px[i].0, a_px[i].1, strand_a_col));
            beads.push((b_px[i].0, b_px[i].1, strand_b_col));
        }
        beads.sort_by(|l, r| l.1.partial_cmp(&r.1).unwrap_or(std::cmp::Ordering::Equal));
        for (p, z, col) in &beads {
            let r = 2.5 + (z + 1.0) * 1.8;
            let bright = depth_bright(*z);
            neon::paint_node_orb(&painter, *p, r, *col, 0.5, 1.0);
            painter.circle_filled(*p, r, col.gamma_multiply(bright));
        }

        #[cfg(feature = "testmatrix")]
        {
            let all_finite = a_px.iter().chain(b_px.iter()).all(|(p, z)| p.x.is_finite() && p.y.is_finite() && z.is_finite());
            facett_core::testmatrix::emit(
                "facett-graph3d::DnaHelix::ui",
                "ui_render",
                !self.rungs.is_empty() && all_finite,
                &format!("rungs={} axis={} projection_finite={all_finite}", self.rungs.len(), self.axis.label()),
            );
        }
    }

    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "title": self.title,
            "rungs": self.rungs.len(),
            "axis": self.axis.label(),
            "twist": self.twist,
            "k": self.k,
            "yaw": self.yaw,
            "tilt": self.tilt,
            "spin": self.spin,
            "spin_rate": self.spin_rate,
            "clock_s": self.clock_s,
            // The data→geometry role map (the design-spec table), as data.
            "axis_mapping": {
                "axial": "z (progression / index)",
                "radius": "x (magnitude)",
                "lean": if self.axis == DnaAxis::Tilt { "y (base-pair asymmetry)" } else { "y (local pitch)" },
            },
        })
    }

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

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

    fn sample(n: usize) -> DnaHelix {
        let rungs: Vec<DnaRung> = (0..n)
            .map(|i| {
                let t = i as f32 / n.max(1) as f32;
                DnaRung::new(
                    format!("bp{i}"),
                    Color32::from_rgb(0, 200, 220),
                    i as f32,                              // axial → z
                    0.5 + 0.5 * (t * std::f32::consts::TAU).sin(), // radius → x
                    (t * std::f32::consts::TAU).cos(),     // lean → y (signed)
                )
            })
            .collect();
        DnaHelix::new("dna", rungs)
    }

    #[test]
    fn tilt_makes_the_pair_asymmetric_pitch_keeps_it_symmetric() {
        // A rung with a strong positive lean: in Tilt, strand A is farther from the
        // axis than strand B; in Pitch, both strands sit at the same radius.
        let mut h = DnaHelix::new(
            "dna",
            vec![
                DnaRung::new("a", Color32::WHITE, 0.0, 1.0, 0.0),
                DnaRung::new("b", Color32::WHITE, 1.0, 1.0, 0.8),
                DnaRung::new("c", Color32::WHITE, 2.0, 1.0, 0.0),
            ],
        )
        .with_k(0.6);

        let vpos = h.vertical_positions();
        let rspan = h.span(|r| r.radius);
        let (a, b) = h.rung_points(1, &vpos, rspan);
        let ra = (a[0] * a[0] + a[2] * a[2]).sqrt();
        let rb = (b[0] * b[0] + b[2] * b[2]).sqrt();
        assert!(ra > rb + 0.1, "Tilt: y>0 pushes strand A out (ra={ra} rb={rb})");

        h.set_axis(DnaAxis::Pitch);
        let vpos = h.vertical_positions();
        let (a, b) = h.rung_points(1, &vpos, rspan);
        let ra = (a[0] * a[0] + a[2] * a[2]).sqrt();
        let rb = (b[0] * b[0] + b[2] * b[2]).sqrt();
        assert!((ra - rb).abs() < 1e-4, "Pitch: the pair is symmetric (ra={ra} rb={rb})");
    }

    #[test]
    fn vertical_positions_span_the_cube_and_ascend() {
        let h = sample(30);
        let vp = h.vertical_positions();
        assert_eq!(vp.len(), 30);
        assert!((vp[0] - -1.0).abs() < 1e-5, "first rung at the bottom");
        assert!((vp[29] - 1.0).abs() < 1e-5, "last rung at the top");
        assert!(vp.windows(2).all(|w| w[1] >= w[0] - 1e-4), "the ladder ascends");
    }

    #[test]
    fn state_json_exposes_the_axis_mapping_and_counts() {
        let h = sample(24).with_axis(DnaAxis::Pitch);
        let j = h.state_json();
        assert_eq!(j["rungs"], 24);
        assert_eq!(j["axis"], "pitch");
        assert_eq!(j["axis_mapping"]["axial"], "z (progression / index)");
        assert_eq!(j["axis_mapping"]["radius"], "x (magnitude)");
        assert!(j["axis_mapping"]["lean"].as_str().unwrap().contains("pitch"));
    }

    #[test]
    fn headless_render_draws_the_ladder() {
        use facett_core::harness;
        let mut h = sample(20).with_clock(1.0);
        let r = harness::headless_render(&mut h);
        assert!(r.drew(), "the double-helix tessellated to vertices ({} verts)", r.vertices);
        assert_eq!(r.state["rungs"], 20);
        assert_eq!(r.state["clock_s"], 1.0);
    }

    #[test]
    fn injected_clock_makes_the_spin_reproducible() {
        use facett_core::harness;
        let mut a = sample(16).with_clock(2.5);
        let mut b = sample(16).with_clock(2.5);
        let ra = harness::headless_render(&mut a);
        let rb = harness::headless_render(&mut b);
        assert_eq!(ra.vertices, rb.vertices, "same clock → same drawn geometry");
    }

    #[test]
    fn empty_helix_draws_the_hint_without_panicking() {
        use facett_core::harness;
        let mut h = DnaHelix::new("dna", Vec::new());
        let r = harness::headless_render(&mut h);
        assert_eq!(r.state["rungs"], 0);
        let _ = r.drew();
    }
}