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

nordisk/facett

facett-helix/src/tubeview.rs

//! **TubeView** — the [`Facet`] that draws a [`TubeField`] as a **real 3D tube/pipe**:
//! an orbiting, lit, translucent cylinder with data smeared on its walls and a glowing
//! data core running down the centre axis. It builds a [`facett_map3d::Mesh3`] via
//! [`crate::mesh::tube_mesh`], projects it with a [`facett_map3d::OrbitCamera`],
//! depth-sorts (painter's algorithm), lambert-shades each face, and paints it — the
//! translucent wall lets the opaque core show through, so the axis data reads *inside*
//! the pipe.
//!
//! FC contract, same as the rest of facett:
//! - **Serializable state** ([`state_json`](Facet::state_json)): the field resolution +
//!   ranges, mesh vertex/triangle counts, camera pose — the picture asserted as DATA.
//! - **[`TubeMsg`] + [`update`](TubeView::update)**: the robot-/CLI-addressable surface
//!   (orbit, spin, tick the clock).
//! - **Injected clock** ([`with_clock`](TubeView::with_clock)): the spin is a pure
//!   function of it (determinism).

use egui::{Color32, Pos2, Sense, vec2};
use facett_core::{Facet, FacetCaps, theme};
use facett_map3d::{OrbitCamera, V3, ViewPreset};
use serde::{Deserialize, Serialize};

use crate::mesh::tube_mesh;
use crate::tube::TubeField;

/// Spin rate (radians / second) for the auto-orbit.
const SPIN_RATE: f32 = 0.5;
/// Wall opacity — translucent so the core data axis shows through the pipe.
const WALL_ALPHA: f32 = 0.5;

/// A robot-/CLI-addressable control message. Applied by [`TubeView::update`].
#[derive(Clone, Debug, PartialEq)]
pub enum TubeMsg {
    /// Advance the injected clock by `dt` seconds (drives the spin).
    Tick(f64),
    /// Pin the injected clock to `seconds`.
    SetClock(f64),
    /// Set the orbit azimuth (radians); also stops the auto-spin.
    SetAzimuth(f32),
    /// Start / stop the auto-orbit.
    ToggleSpin(bool),
    /// Snap to a canonical camera preset.
    Preset(ViewPreset),
}

/// The 3D tube [`Facet`] over a [`TubeField`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TubeView {
    pub title: String,
    pub field: TubeField,
    /// Outer wall radius (design units).
    pub radius: f32,
    /// Axial length (design units); the tube spans `y ∈ [-height/2, +height/2]`.
    pub height: f32,
    /// Orbit azimuth (radians).
    azimuth: f32,
    /// Orbit elevation (radians).
    elevation: f32,
    /// Auto-orbit the camera.
    spin: bool,
    /// Injected clock (seconds). `Some` ⇒ the spin is a pure function of it.
    clock_s: Option<f64>,
}

/// One projected, shaded, depth-keyed triangle ready to paint. Public so the render
/// path is unit-testable headless (project → cull → shade, without egui/GPU).
#[derive(Clone, Copy, Debug)]
pub struct Face {
    pub pts: [Pos2; 3],
    pub color: Color32,
    /// Mean camera-space depth (larger = farther; painter's algorithm sorts on it).
    pub depth: f32,
}

impl TubeView {
    /// Build a view over a [`TubeField`].
    pub fn new(title: impl Into<String>, field: TubeField) -> Self {
        let (az, el) = ViewPreset::Isometric.angles();
        Self { title: title.into(), field, radius: 0.7, height: 2.0, azimuth: az, elevation: el, spin: true, clock_s: None }
    }

    /// A self-contained demo tube (the [`TubeField::demo`] helical fixture).
    pub fn demo() -> Self {
        Self::new("tube", TubeField::demo())
    }

    /// Pin the animation clock (seconds) — makes the spin deterministic.
    #[must_use]
    pub fn with_clock(mut self, seconds: f64) -> Self {
        self.clock_s = Some(seconds);
        self
    }

    /// Apply a [`TubeMsg`]. Returns `true` if it changed observable state.
    pub fn update(&mut self, msg: TubeMsg) -> bool {
        match msg {
            TubeMsg::Tick(dt) => {
                self.clock_s = Some(self.clock_s.unwrap_or(0.0) + dt);
                true
            }
            TubeMsg::SetClock(s) => {
                let changed = self.clock_s != Some(s);
                self.clock_s = Some(s);
                changed
            }
            TubeMsg::SetAzimuth(a) => {
                let changed = self.azimuth != a || self.spin;
                self.azimuth = a;
                self.spin = false;
                changed
            }
            TubeMsg::ToggleSpin(on) => {
                let changed = self.spin != on;
                self.spin = on;
                changed
            }
            TubeMsg::Preset(p) => {
                let (az, el) = p.angles();
                let changed = self.azimuth != az || self.elevation != el;
                self.azimuth = az;
                self.elevation = el;
                changed
            }
        }
    }

    #[must_use]
    pub fn azimuth(&self) -> f32 {
        self.azimuth
    }

    /// The effective azimuth for an ambient clock value — auto-spin advances it.
    fn azimuth_for(&self, time_s: f64) -> f32 {
        if self.spin { self.azimuth + time_s as f32 * SPIN_RATE } else { self.azimuth }
    }

    /// Build the orbit camera for a given clock (pure).
    fn camera_at(&self, time_s: f64) -> OrbitCamera {
        let mut cam = OrbitCamera { target: V3::new(0.0, 0.0, 0.0), ..OrbitCamera::default() };
        cam.goal_azimuth = self.azimuth_for(time_s);
        cam.goal_elevation = self.elevation;
        cam.goal_distance = 3.2;
        cam.goal_target = V3::new(0.0, 0.0, 0.0);
        cam.settle();
        cam
    }

    /// **The pure render core** — project the tube mesh into a `w×h` pane and return the
    /// shaded, depth-sorted [`Face`]s (far → near). Wall faces are translucent so the
    /// opaque core axis shows through. No egui, no GPU — headless-testable.
    #[must_use]
    pub fn faces(&self, w: f32, h: f32) -> Vec<Face> {
        let clock = self.clock_s.unwrap_or(0.0);
        let cam = self.camera_at(clock);
        let mesh = tube_mesh(&self.field, self.radius, self.height);
        let center = (w * 0.5, h * 0.5);
        let half_h = h * 0.5;
        // Wall is the first `wall_tri` triangles (built first in `tube_mesh`); the rest
        // are the core. Derive the split from the field resolution.
        let wall_tris = if self.field.is_valid() { 2 * self.field.theta_bins * (self.field.z_slices - 1) } else { 0 };
        // Lambert light (design frame, y-up).
        let light = V3::new(0.4, 0.9, 0.5).normalized();

        let mut out = Vec::with_capacity(mesh.triangle_count());
        for tri in 0..mesh.triangle_count() {
            let i0 = mesh.indices[tri * 3] as usize;
            let i1 = mesh.indices[tri * 3 + 1] as usize;
            let i2 = mesh.indices[tri * 3 + 2] as usize;
            let p0 = mesh.positions[i0];
            let p1 = mesh.positions[i1];
            let p2 = mesh.positions[i2];
            let a = cam.project(V3::new(p0[0], p0[1], p0[2]), center, half_h);
            let b = cam.project(V3::new(p1[0], p1[1], p1[2]), center, half_h);
            let c = cam.project(V3::new(p2[0], p2[1], p2[2]), center, half_h);
            if !(a.visible && b.visible && c.visible) {
                continue;
            }
            // Geometric normal for lambert shading.
            let e1 = V3::new(p1[0] - p0[0], p1[1] - p0[1], p1[2] - p0[2]);
            let e2 = V3::new(p2[0] - p0[0], p2[1] - p0[1], p2[2] - p0[2]);
            let n = e1.cross(e2).normalized();
            let lambert = n.dot(light).abs(); // two-sided (we see both wall faces)
            let shade = (0.35 + 0.65 * lambert).clamp(0.0, 1.0);
            let src = mesh.colors[tri];
            let lit = Color32::from_rgb(
                (src.r() as f32 * shade) as u8,
                (src.g() as f32 * shade) as u8,
                (src.b() as f32 * shade) as u8,
            );
            let color = if tri < wall_tris { lit.gamma_multiply(WALL_ALPHA) } else { lit };
            out.push(Face {
                pts: [Pos2::new(a.x, a.y), Pos2::new(b.x, b.y), Pos2::new(c.x, c.y)],
                color,
                depth: (a.depth + b.depth + c.depth) / 3.0,
            });
        }
        // Painter's algorithm: far first.
        out.sort_by(|x, y| y.depth.partial_cmp(&x.depth).unwrap_or(std::cmp::Ordering::Equal));
        out
    }

    /// **The pure view** (FC-9): paint the tube and return the produced [`TubeMsg`]s.
    pub fn render(&self, ui: &mut egui::Ui) -> Vec<TubeMsg> {
        let mut msgs = Vec::new();
        let th = theme(ui);
        ui.horizontal_wrapped(|ui| {
            let mut spin = self.spin;
            if ui.checkbox(&mut spin, "spin").changed() {
                msgs.push(TubeMsg::ToggleSpin(spin));
            }
            ui.separator();
            for p in [ViewPreset::Isometric, ViewPreset::Front, ViewPreset::Top, ViewPreset::Oblique] {
                if ui.button(p.name()).clicked() {
                    msgs.push(TubeMsg::Preset(p));
                }
            }
        });
        ui.separator();

        let ambient = ui.input(|i| i.time);
        if self.clock_s.is_none() && self.spin {
            ui.ctx().request_repaint();
        }
        let (rect, _resp) = ui.allocate_exact_size(ui.available_size().max(vec2(320.0, 280.0)), Sense::hover());
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, egui::CornerRadius::ZERO, th.bg);

        // Build faces at this pane size + clock, then translate into `rect`.
        let clock_view = self.clock_s.unwrap_or(ambient);
        let view = if self.clock_s.is_some() { self.clone() } else { self.clone().with_clock(clock_view) };
        let faces = view.faces(rect.width(), rect.height());
        let offset = rect.min.to_vec2();
        for f in &faces {
            let pts = [f.pts[0] + offset, f.pts[1] + offset, f.pts[2] + offset];
            painter.add(egui::Shape::convex_polygon(pts.to_vec(), f.color, egui::Stroke::NONE));
        }

        #[cfg(feature = "testmatrix")]
        facett_core::testmatrix::emit(
            "facett-helix::TubeView::ui",
            "ui_render",
            !faces.is_empty(),
            &format!("faces={} theta={} z={}", faces.len(), self.field.theta_bins, self.field.z_slices),
        );

        msgs
    }
}

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

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

    fn ui(&mut self, ui: &mut egui::Ui) {
        for m in self.render(ui) {
            self.update(m);
        }
    }

    fn state_json(&self) -> serde_json::Value {
        let mesh = tube_mesh(&self.field, self.radius, self.height);
        let (wlo, whi) = self.field.wall_range();
        let (clo, chi) = self.field.core_range();
        serde_json::json!({
            "title": self.title,
            "theta_bins": self.field.theta_bins,
            "z_slices": self.field.z_slices,
            "wall_len": self.field.wall.len(),
            "core_len": self.field.core.len(),
            "valid": self.field.is_valid(),
            "radius": self.radius,
            "height": self.height,
            "vertex_count": mesh.vertex_count(),
            "triangle_count": mesh.triangle_count(),
            "wall_min": wlo,
            "wall_max": whi,
            "core_min": clo,
            "core_max": chi,
            "azimuth": self.azimuth,
            "elevation": self.elevation,
            "spin": self.spin,
            "clock_s": self.clock_s,
        })
    }
}

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

    #[test]
    fn demo_state_is_data() {
        let v = TubeView::demo();
        let st = v.state_json();
        assert_eq!(st["theta_bins"], 48);
        assert_eq!(st["z_slices"], 40);
        assert_eq!(st["valid"], true);
        assert!(st["vertex_count"].as_u64().unwrap() > 100);
        assert!(st["triangle_count"].as_u64().unwrap() > 100);
        assert!(st["wall_max"].as_f64().unwrap() > st["wall_min"].as_f64().unwrap());
    }

    #[test]
    fn spin_and_clock_are_addressable() {
        let mut v = TubeView::demo();
        assert!(v.update(TubeMsg::SetClock(1.0)));
        assert!(v.update(TubeMsg::ToggleSpin(false)));
        assert_eq!(v.state_json()["spin"], false);
        // SetAzimuth stops the spin and moves the camera.
        let mut v2 = TubeView::demo();
        assert!(v2.update(TubeMsg::SetAzimuth(1.23)));
        assert!((v2.azimuth() - 1.23).abs() < 1e-6);
        assert_eq!(v2.state_json()["spin"], false);
    }

    #[test]
    fn injected_clock_makes_the_spin_deterministic() {
        let a = TubeView::demo().with_clock(2.0).state_json();
        let b = TubeView::demo().with_clock(2.0).state_json();
        assert_eq!(a, b, "same clock ⇒ byte-identical state (FC-7)");
        // A different clock spins the azimuth-derived faces to a different layout.
        let fa = TubeView::demo().with_clock(0.0).faces(400.0, 400.0);
        let fb = TubeView::demo().with_clock(2.0).faces(400.0, 400.0);
        let ca = fa[0].pts[0];
        let cb = fb[0].pts[0];
        assert!((ca.x - cb.x).abs() + (ca.y - cb.y).abs() > 0.5, "the spin moved the geometry");
    }

    #[test]
    fn faces_render_non_blank_with_many_colours() {
        // A "non-blank render" proof at the projection level: the tube projects many
        // visible, on-pane triangles spanning many distinct shades (wall smear + core).
        let v = TubeView::demo().with_clock(0.0);
        let faces = v.faces(500.0, 500.0);
        assert!(faces.len() > 200, "the tube projects many visible faces (got {})", faces.len());
        // Every face lands inside the pane (no infinity smear).
        let on_pane = faces
            .iter()
            .filter(|f| f.pts.iter().all(|p| p.x >= -50.0 && p.x <= 550.0 && p.y >= -50.0 && p.y <= 550.0))
            .count();
        assert!(on_pane as f32 / faces.len() as f32 > 0.9, "most faces are on-pane");
        // Distinct colour buckets (5-bit per channel) — a lit, data-coloured tube.
        let mut shades = std::collections::HashSet::new();
        for f in &faces {
            let k = ((f.color.r() as u32 >> 3) << 10) | ((f.color.g() as u32 >> 3) << 5) | (f.color.b() as u32 >> 3);
            shades.insert(k);
        }
        assert!(shades.len() >= 8, "the tube shows many shades (got {})", shades.len());
    }

    #[test]
    fn model_round_trips_through_serde() {
        let mut v = TubeView::demo();
        v.update(TubeMsg::SetClock(1.5));
        v.update(TubeMsg::Preset(ViewPreset::Front));
        let json = serde_json::to_string(&v).unwrap();
        let back: TubeView = serde_json::from_str(&json).unwrap();
        assert_eq!(back, v, "the observable Model round-trips through serde");
    }
}