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