facett-helix/src/mesh.rs
//! **Tube mesh** — turn a [`TubeField`](crate::tube::TubeField) into a
//! [`facett_map3d::Mesh3`] (positions + per-vertex normals + per-triangle colours +
//! indices) the CPU painter / wgpu path projects, culls, sorts, and lights.
//!
//! The mesh has two parts sharing one axis (Y-up, the same normalised design frame
//! [`facett_map3d`] uses):
//!
//! - the **cylinder WALL** — a `theta_bins × z_slices` grid of vertices at
//! `radius` from the axis, each quad coloured by the [`wall`](crate::tube::TubeField::wall)
//! value smeared over that patch (outward radial normals so it lights around the pipe);
//! - the **CORE axis tube** — a thin cylinder down the centre (x=z=0), each ring
//! coloured by the [`core`](crate::tube::TubeField::core) value at that slice, so the
//! data running along the midpoint of the circle is visible through the pipe.
//!
//! Pure data, deterministic, no RNG — two builds are byte-identical.
use egui::Color32;
use facett_map3d::Mesh3;
use crate::tube::TubeField;
/// The design-frame radius of the thin **core** tube, as a fraction of the outer
/// wall radius — slender so it reads as an axis line, not a second pipe.
const CORE_RADIUS_FRAC: f32 = 0.12;
/// Angular segments of the core tube (a hexagonal prism reads as a line yet still
/// catches light from any orbit angle).
const CORE_SEGMENTS: usize = 8;
/// A perceptual **turbo-ish ramp** `t ∈ [0,1] → colour`: deep blue → cyan → green →
/// yellow → red. Values are clamped, so out-of-range data still maps to an endpoint.
#[must_use]
pub fn ramp(t: f32) -> Color32 {
let t = t.clamp(0.0, 1.0);
// Five control stops; piecewise-linear between them.
const STOPS: [(f32, f32, f32); 5] = [
(40.0, 60.0, 180.0), // deep blue
(30.0, 180.0, 220.0), // cyan
(70.0, 210.0, 90.0), // green
(240.0, 200.0, 60.0), // yellow
(225.0, 70.0, 55.0), // red
];
let x = t * (STOPS.len() - 1) as f32;
let i = (x.floor() as usize).min(STOPS.len() - 2);
let f = x - i as f32;
let lerp = |a: f32, b: f32| a + (b - a) * f;
let (a, b) = (STOPS[i], STOPS[i + 1]);
Color32::from_rgb(lerp(a.0, b.0) as u8, lerp(a.1, b.1) as u8, lerp(a.2, b.2) as u8)
}
/// Normalise a value into `[0,1]` against `(lo, hi)`; `0.5` for a degenerate range.
#[inline]
fn norm(v: f32, lo: f32, hi: f32) -> f32 {
if (hi - lo).abs() < f32::EPSILON { 0.5 } else { (v - lo) / (hi - lo) }
}
/// Build the tube [`Mesh3`] for `field`: an outer wall of `radius` and a core tube,
/// spanning `y ∈ [-height/2, +height/2]` (axis along +Y). The wall is coloured by the
/// wall field, the core by the core field — each normalised against its own range so
/// both always use the full ramp. Returns an empty mesh for an invalid field.
#[must_use]
pub fn tube_mesh(field: &TubeField, radius: f32, height: f32) -> Mesh3 {
let mut mesh = Mesh3::default();
if !field.is_valid() {
return mesh;
}
let (tb, zs) = (field.theta_bins, field.z_slices);
let (wlo, whi) = field.wall_range();
let (clo, chi) = field.core_range();
let y_of = |z: usize| -height * 0.5 + height * z as f32 / (zs - 1) as f32;
// ── cylinder WALL ────────────────────────────────────────────────────────
// A grid of tb×zs vertices; θ wraps by connecting column i to (i+1) % tb.
let wall_base = mesh.positions.len() as u32;
for z in 0..zs {
let y = y_of(z);
for t in 0..tb {
let ang = std::f32::consts::TAU * t as f32 / tb as f32;
let (s, c) = ang.sin_cos();
mesh.positions.push([radius * c, y, radius * s]);
mesh.normals.push([c, 0.0, s]); // outward radial
}
}
let widx = |z: usize, t: usize| wall_base + (z * tb + (t % tb)) as u32;
for z in 0..zs - 1 {
for t in 0..tb {
// Colour the patch by the mean of its four corner wall values.
let m = 0.25
* (field.wall_at(t, z) + field.wall_at(t + 1, z) + field.wall_at(t, z + 1) + field.wall_at(t + 1, z + 1));
let col = ramp(norm(m, wlo, whi));
let (a, b, c, d) = (widx(z, t), widx(z, t + 1), widx(z + 1, t + 1), widx(z + 1, t));
push_quad(&mut mesh, a, b, c, d, col);
}
}
// ── CORE axis tube ───────────────────────────────────────────────────────
let core_r = radius * CORE_RADIUS_FRAC;
let core_base = mesh.positions.len() as u32;
for z in 0..zs {
let y = y_of(z);
for t in 0..CORE_SEGMENTS {
let ang = std::f32::consts::TAU * t as f32 / CORE_SEGMENTS as f32;
let (s, c) = ang.sin_cos();
mesh.positions.push([core_r * c, y, core_r * s]);
mesh.normals.push([c, 0.0, s]);
}
}
let cidx = |z: usize, t: usize| core_base + (z * CORE_SEGMENTS + (t % CORE_SEGMENTS)) as u32;
for z in 0..zs - 1 {
// The whole ring at slice z takes the core value (mean of the two z rings).
let m = 0.5 * (field.core_at(z) + field.core_at(z + 1));
let col = ramp(norm(m, clo, chi));
for t in 0..CORE_SEGMENTS {
let (a, b, c, d) = (cidx(z, t), cidx(z, t + 1), cidx(z + 1, t + 1), cidx(z + 1, t));
push_quad(&mut mesh, a, b, c, d, col);
}
}
mesh
}
/// Push a quad `a-b-c-d` (CCW) as two triangles sharing `col`.
fn push_quad(mesh: &mut Mesh3, a: u32, b: u32, c: u32, d: u32, col: Color32) {
mesh.indices.extend_from_slice(&[a, b, c]);
mesh.colors.push(col);
mesh.indices.extend_from_slice(&[a, c, d]);
mesh.colors.push(col);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ramp_spans_blue_to_red() {
let lo = ramp(0.0);
let hi = ramp(1.0);
assert!(lo.b() > lo.r(), "the low end is blue-dominant");
assert!(hi.r() > hi.b(), "the high end is red-dominant");
// Monotone-ish: the endpoints are clearly distinct.
assert_ne!(lo, hi);
}
#[test]
fn mesh_vertex_and_triangle_counts_match_the_grid() {
let field = TubeField::new(12, 8);
let mesh = tube_mesh(&field, 1.0, 2.0);
// Wall: 12*8 verts + core: 8*8 verts.
assert_eq!(mesh.vertex_count(), 12 * 8 + CORE_SEGMENTS * 8);
// Wall quads: 12*(8-1) → *2 tris; core quads: 8*(8-1) → *2 tris.
let wall_tris = 2 * 12 * 7;
let core_tris = 2 * CORE_SEGMENTS * 7;
assert_eq!(mesh.triangle_count(), wall_tris + core_tris);
// Parallel invariants: one normal per vertex, one colour per triangle.
assert_eq!(mesh.normals.len(), mesh.positions.len());
assert_eq!(mesh.colors.len(), mesh.triangle_count());
assert_eq!(mesh.indices.len(), mesh.triangle_count() * 3);
}
#[test]
fn invalid_field_yields_an_empty_mesh() {
let mut field = TubeField::new(6, 4);
field.wall.pop(); // break the shape invariant
assert!(!field.is_valid());
let mesh = tube_mesh(&field, 1.0, 2.0);
assert_eq!(mesh.vertex_count(), 0);
assert_eq!(mesh.triangle_count(), 0);
}
#[test]
fn wall_colours_track_the_field_and_use_the_full_ramp() {
// A wall that is 0 on one half and 1 on the other must produce both a
// blue-ish and a red-ish triangle (the ramp endpoints), proving the smear.
let field = TubeField::from_fn(16, 6, |t, _z| if t < 0.5 { 0.0 } else { 1.0 }, |_z| 0.0);
let mesh = tube_mesh(&field, 1.0, 2.0);
let mut blue = false;
let mut red = false;
for c in &mesh.colors {
let (r, b) = (c.r() as i32, c.b() as i32);
if b > r + 40 {
blue = true;
}
if r > b + 40 {
red = true;
}
}
assert!(blue && red, "the wall data smears both ramp extremes onto the pipe");
}
}