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