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