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

//! **TubeField** — the sampled data a [`TubeView`](crate::TubeView) draws as a real
//! 3D tube/pipe. Two independent scalar fields share one cylindrical frame:
//!
//! - [`wall`](TubeField::wall) — data **smeared over the cylinder WALLS**: a
//!   `theta_bins × z_slices` grid, one value per `(θ, z)` cell, wrapped around the
//!   pipe's outer surface (θ around the circle, z along the axis).
//! - [`core`](TubeField::core) — data along the **CENTER AXIS** (the midpoint of the
//!   circle): one value per `z` slice, running straight down the middle of the pipe.
//!
//! The field is **dependency-free** (serde + std only) — the same portability seam
//! [`model`](crate::model) + [`layout`](crate::layout) are: it round-trips through
//! serde and carries no egui / GPU state. [`crate::mesh::tube_mesh`] turns it into a
//! [`facett_map3d::Mesh3`] the CPU/GPU painter draws.

use serde::{Deserialize, Serialize};

/// A cylindrical scalar field: values on the tube **walls** (`theta_bins × z_slices`)
/// plus values along its **core** axis (`z_slices`). Row-major wall layout: the value
/// at angular bin `theta` and axial slice `z` lives at `wall[z * theta_bins + theta]`.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TubeField {
    /// Number of angular bins around the circle (columns of the wall grid).
    pub theta_bins: usize,
    /// Number of slices along the axis (rows of the wall grid / length of `core`).
    pub z_slices: usize,
    /// Wall data, row-major `[z * theta_bins + theta]`, `theta_bins * z_slices` values.
    pub wall: Vec<f32>,
    /// Core-axis data, one value per `z` slice (`z_slices` values).
    pub core: Vec<f32>,
}

impl TubeField {
    /// A zeroed field with the given resolution (`theta_bins ≥ 3`, `z_slices ≥ 2`).
    #[must_use]
    pub fn new(theta_bins: usize, z_slices: usize) -> Self {
        let theta_bins = theta_bins.max(3);
        let z_slices = z_slices.max(2);
        Self { theta_bins, z_slices, wall: vec![0.0; theta_bins * z_slices], core: vec![0.0; z_slices] }
    }

    /// Build a field by sampling `wall(θ, z)` and `core(z)` closures over the grid,
    /// where `θ ∈ [0,1)` is the fractional angle and `z ∈ [0,1]` the fractional axial
    /// position. Deterministic — two builds are byte-identical.
    pub fn from_fn(
        theta_bins: usize,
        z_slices: usize,
        mut wall: impl FnMut(f32, f32) -> f32,
        mut core: impl FnMut(f32) -> f32,
    ) -> Self {
        let mut f = Self::new(theta_bins, z_slices);
        let (tb, zs) = (f.theta_bins, f.z_slices);
        for z in 0..zs {
            let zf = z as f32 / (zs - 1) as f32;
            f.core[z] = core(zf);
            for t in 0..tb {
                let tf = t as f32 / tb as f32;
                f.wall[z * tb + t] = wall(tf, zf);
            }
        }
        f
    }

    /// `true` iff the vectors match the declared resolution — the shape invariant a
    /// deserialized field must satisfy before it can be meshed.
    #[must_use]
    pub fn is_valid(&self) -> bool {
        self.theta_bins >= 3
            && self.z_slices >= 2
            && self.wall.len() == self.theta_bins * self.z_slices
            && self.core.len() == self.z_slices
    }

    /// The wall value at angular bin `theta` (wrapped) and axial slice `z` (clamped).
    #[must_use]
    pub fn wall_at(&self, theta: usize, z: usize) -> f32 {
        if self.theta_bins == 0 || self.z_slices == 0 {
            return 0.0;
        }
        let t = theta % self.theta_bins;
        let z = z.min(self.z_slices - 1);
        self.wall[z * self.theta_bins + t]
    }

    /// The core value at axial slice `z` (clamped).
    #[must_use]
    pub fn core_at(&self, z: usize) -> f32 {
        if self.z_slices == 0 {
            return 0.0;
        }
        self.core[z.min(self.z_slices - 1)]
    }

    /// Set the wall cell `(theta, z)` (wrapped / clamped). No-op on an empty field.
    pub fn set_wall(&mut self, theta: usize, z: usize, v: f32) {
        if self.theta_bins == 0 || self.z_slices == 0 {
            return;
        }
        let t = theta % self.theta_bins;
        let z = z.min(self.z_slices - 1);
        self.wall[z * self.theta_bins + t] = v;
    }

    /// Set the core value at slice `z` (clamped). No-op on an empty field.
    pub fn set_core(&mut self, z: usize, v: f32) {
        if self.z_slices == 0 {
            return;
        }
        let z = z.min(self.z_slices - 1);
        self.core[z] = v;
    }

    /// `(min, max)` of the wall field (both `0.0` when empty).
    #[must_use]
    pub fn wall_range(&self) -> (f32, f32) {
        range_of(&self.wall)
    }

    /// `(min, max)` of the core field (both `0.0` when empty).
    #[must_use]
    pub fn core_range(&self) -> (f32, f32) {
        range_of(&self.core)
    }

    /// A self-contained **demo field**: a helical hot-band smeared over the wall
    /// (θ-phase advancing with z) plus a smooth swelling pulse down the core — a
    /// non-trivial fixture that exercises every colour of the ramp with no host.
    #[must_use]
    pub fn demo() -> Self {
        use std::f32::consts::TAU;
        Self::from_fn(
            48,
            40,
            |t, z| {
                // A diagonal band winding around the tube: bright where the angular
                // phase tracks the axial position (the helical hot streak).
                let phase = (t - z).rem_euclid(1.0);
                let band = (phase * TAU).cos() * 0.5 + 0.5;
                // Modulated by a gentle axial envelope so the ends cool off.
                (band * (0.35 + 0.65 * (z * std::f32::consts::PI).sin())).clamp(0.0, 1.0)
            },
            |z| {
                // A swelling pulse down the core: two humps along the axis.
                ((z * TAU).sin() * 0.5 + 0.5).clamp(0.0, 1.0)
            },
        )
    }
}

/// `(min, max)` of a slice, `(0.0, 0.0)` when empty.
fn range_of(v: &[f32]) -> (f32, f32) {
    let mut lo = f32::INFINITY;
    let mut hi = f32::NEG_INFINITY;
    for &x in v {
        lo = lo.min(x);
        hi = hi.max(x);
    }
    if lo.is_finite() { (lo, hi) } else { (0.0, 0.0) }
}

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

    #[test]
    fn new_zeros_with_clamped_resolution() {
        let f = TubeField::new(2, 1); // below the floors
        assert_eq!(f.theta_bins, 3);
        assert_eq!(f.z_slices, 2);
        assert_eq!(f.wall.len(), 6);
        assert_eq!(f.core.len(), 2);
        assert!(f.is_valid());
        assert_eq!(f.wall_range(), (0.0, 0.0));
    }

    #[test]
    fn from_fn_samples_the_grid_row_major() {
        let f = TubeField::from_fn(4, 3, |t, z| t + 10.0 * z, |z| z);
        assert!(f.is_valid());
        // wall[z*theta_bins + theta] holds wall(theta_frac, z_frac).
        assert!((f.wall_at(0, 0) - 0.0).abs() < 1e-6);
        assert!((f.wall_at(1, 0) - 0.25).abs() < 1e-6); // theta 1/4
        assert!((f.wall_at(0, 2) - 10.0).abs() < 1e-6); // z at 1.0
        assert!((f.core_at(2) - 1.0).abs() < 1e-6);
    }

    #[test]
    fn set_and_read_back_with_wrap() {
        let mut f = TubeField::new(6, 5);
        f.set_wall(6, 0, 3.0); // theta 6 wraps to 0
        assert!((f.wall_at(0, 0) - 3.0).abs() < 1e-6);
        f.set_core(99, 2.0); // z clamps to last slice
        assert!((f.core_at(4) - 2.0).abs() < 1e-6);
    }

    #[test]
    fn roundtrips_through_serde() {
        let f = TubeField::demo();
        assert!(f.is_valid());
        let json = serde_json::to_string(&f).unwrap();
        let back: TubeField = serde_json::from_str(&json).unwrap();
        assert_eq!(back, f, "the TubeField round-trips through serde");
        // The demo spans a real range on both fields (not a flat zero fixture).
        let (wlo, whi) = f.wall_range();
        assert!(whi - wlo > 0.3, "demo wall field spans a range ({wlo}..{whi})");
        let (clo, chi) = f.core_range();
        assert!(chi - clo > 0.3, "demo core field spans a range ({clo}..{chi})");
    }
}