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