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

nordisk/facett

facett-model3d/src/mesh.rs

//! **The mesh model + glTF loader.** A [`Mesh3d`] is a small multi-material
//! superset of [`facett_core::rabbit::RabbitMesh`]: instead of one flat triangle
//! soup it carries named [`MeshGroup`]s, so a sub-mesh (e.g. the `"eyes"`) can be
//! addressed and tinted independently while the rest of the model is shaded
//! normally. The same `(positions, normals, indices)` triple per group feeds
//! facett's existing CPU painter unchanged.
//!
//! [`load_glb`] reads an embedded `.glb`/glTF buffer via the pure-Rust `gltf`
//! crate (no GPU, wasm-safe), bakes each node's transform into world space, keys
//! every group by name (node → mesh → material, first non-empty), and normalises
//! the whole model into the `[-1, 1]` design box so the projection scale matches
//! `rabbit_mesh`. No RNG and a stable group order (glTF order) → byte-stable
//! geometry, so headless snapshots stay deterministic.

/// One named, single-material sub-mesh: a `(positions, normals, indices)` triple
/// plus the glTF base colour. `name` is how a host selects the status-tinted group
/// (e.g. `"eyes"`).
#[derive(Clone, Debug)]
pub struct MeshGroup {
    pub name: String,
    pub positions: Vec<[f32; 3]>,
    pub normals: Vec<[f32; 3]>,
    pub indices: Vec<u32>,
    /// Straight RGBA base colour from the glTF material (`pbr.base_color_factor`).
    pub base_color: [f32; 4],
}

impl MeshGroup {
    pub fn triangle_count(&self) -> usize {
        self.indices.len() / 3
    }
}

/// A loaded model: an ordered list of named groups. Built by [`load_glb`] (or
/// directly by the procedural [`crate::skull`] generator).
#[derive(Clone, Debug, Default)]
pub struct Mesh3d {
    pub groups: Vec<MeshGroup>,
}

impl Mesh3d {
    pub fn vertex_count(&self) -> usize {
        self.groups.iter().map(|g| g.positions.len()).sum()
    }
    pub fn triangle_count(&self) -> usize {
        self.groups.iter().map(|g| g.triangle_count()).sum()
    }
    /// Does a group with this (case-insensitive) name exist?
    pub fn has_group(&self, name: &str) -> bool {
        self.groups.iter().any(|g| g.name.eq_ignore_ascii_case(name))
    }
    pub fn group_names(&self) -> Vec<&str> {
        self.groups.iter().map(|g| g.name.as_str()).collect()
    }

    /// **Repair inconsistent winding against the authored normals.** For every
    /// triangle whose geometric normal contradicts the average of its own vertex
    /// normals, swap two indices so the two agree. Returns how many were flipped.
    ///
    /// The authored normals are the exporter's statement of which way each surface
    /// faces; the index order is what a back-face cull actually tests. When they
    /// disagree the renderer culls precisely the faces it should draw and draws the
    /// ones it should cull — the model renders **inside out**. The bundled
    /// procedural skull shipped with **1156 of its 1288** triangles in that state.
    ///
    /// A no-op on a sound mesh, and a no-op by construction on a mesh with no
    /// authored normals (those are derived *from* the winding, so they always agree).
    pub fn repair_winding(&mut self) -> usize {
        let mut fixed = 0usize;
        for g in &mut self.groups {
            for t in g.indices.as_chunks_mut::<3>().0 {
                let (i0, i1, i2) = (t[0] as usize, t[1] as usize, t[2] as usize);
                let (Some(p), Some(q), Some(r)) =
                    (g.positions.get(i0), g.positions.get(i1), g.positions.get(i2))
                else {
                    continue;
                };
                let u = [q[0] - p[0], q[1] - p[1], q[2] - p[2]];
                let v = [r[0] - p[0], r[1] - p[1], r[2] - p[2]];
                let fnm =
                    [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
                let mut vn = [0.0f32; 3];
                for i in [i0, i1, i2] {
                    if let Some(n) = g.normals.get(i) {
                        for c in 0..3 {
                            vn[c] += n[c];
                        }
                    }
                }
                if fnm[0] * vn[0] + fnm[1] * vn[1] + fnm[2] * vn[2] < 0.0 {
                    t.swap(1, 2);
                    fixed += 1;
                }
            }
        }
        fixed
    }

    /// Is every group a **closed** shell (each edge shared by two triangles)? A
    /// closed shell can be back-face culled; an **open** one must be drawn
    /// two-sided, or culling its back faces leaves you looking straight through the
    /// model to the background.
    ///
    /// The bundled CC0 Khronos *Avocado* is the case that made this matter: it is a
    /// **half** avocado, an open shell with a flat 41-edge cut rim at `z ≈ 0.24`.
    /// Back-face culled, the entire inside of the far half vanished and the pane
    /// showed background straight through the fruit — the reported clipping.
    pub fn is_watertight(&self) -> bool {
        self.groups.iter().all(|g| facett_core::render::cpu::is_watertight(&g.positions, &g.indices))
    }

    /// Total boundary (unpaired) edges across all groups — `0` ⇒ watertight. The
    /// observable behind [`is_watertight`](Self::is_watertight), published in
    /// `state_json` so a headless test asserts it as DATA.
    pub fn boundary_edges(&self) -> usize {
        self.groups
            .iter()
            .map(|g| facett_core::render::cpu::boundary_edge_count(&g.positions, &g.indices))
            .sum()
    }

    /// Triangles whose geometric normal contradicts their authored vertex normal —
    /// the "inside-out" oracle. A well-formed mesh reports `0`; anything else is
    /// culled exactly where it should be drawn.
    pub fn inside_out_triangles(&self) -> usize {
        self.groups
            .iter()
            .map(|g| facett_core::render::cpu::inside_out_triangles(&g.positions, &g.normals, &g.indices))
            .sum()
    }

    /// The **bounding-sphere radius** about the design-box origin — the
    /// pose-invariant half-extent the viewer fits the pane to
    /// ([`facett_core::render::cpu::fit_scale`]). Because a sphere is
    /// rotation-invariant, a scale derived from this can never let the model leave
    /// the widget rect at *any* yaw/tilt, which the old hard-coded `0.42 · min_elem`
    /// could only manage by luck. `0` for an empty mesh.
    pub fn bounding_radius(&self) -> f32 {
        facett_core::render::cpu::bounding_radius(self.groups.iter().flat_map(|g| g.positions.iter()))
    }

    /// **Normalise into the `[-1, 1]` design box** (y up): centre on the AABB
    /// midpoint and uniform-scale by the largest half-extent, so any model fills
    /// the same projection scale `rabbit_mesh` uses. A no-op-safe on empty meshes.
    pub fn normalise_unit_box(&mut self) {
        let mut min = [f32::MAX; 3];
        let mut max = [f32::MIN; 3];
        for g in &self.groups {
            for p in &g.positions {
                for c in 0..3 {
                    min[c] = min[c].min(p[c]);
                    max[c] = max[c].max(p[c]);
                }
            }
        }
        if min[0] > max[0] {
            return; // no vertices
        }
        let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5, (min[2] + max[2]) * 0.5];
        let half = (0..3).map(|c| (max[c] - min[c]) * 0.5).fold(0.0_f32, f32::max).max(1e-6);
        let s = 1.0 / half;
        for g in &mut self.groups {
            for p in &mut g.positions {
                for c in 0..3 {
                    p[c] = (p[c] - center[c]) * s;
                }
            }
        }
    }
}

/// Errors from [`load_glb`].
#[derive(Debug)]
pub enum LoadError {
    Gltf(gltf::Error),
    /// The document parsed but produced no triangles.
    Empty,
}

impl std::fmt::Display for LoadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            LoadError::Gltf(e) => write!(f, "glTF parse error: {e}"),
            LoadError::Empty => write!(f, "glTF contained no triangle geometry"),
        }
    }
}
impl std::error::Error for LoadError {}
impl From<gltf::Error> for LoadError {
    fn from(e: gltf::Error) -> Self {
        LoadError::Gltf(e)
    }
}

/// Multiply a column-major glTF 4×4 by a point (w=1).
fn apply_mat(m: &[[f32; 4]; 4], p: [f32; 3]) -> [f32; 3] {
    [
        m[0][0] * p[0] + m[1][0] * p[1] + m[2][0] * p[2] + m[3][0],
        m[0][1] * p[0] + m[1][1] * p[1] + m[2][1] * p[2] + m[3][1],
        m[0][2] * p[0] + m[1][2] * p[1] + m[2][2] * p[2] + m[3][2],
    ]
}
/// Multiply by the upper-3×3 (for normals; close enough without inverse-transpose
/// for the rigid/uniform transforms low-poly exports use) and renormalise.
fn apply_mat3(m: &[[f32; 4]; 4], n: [f32; 3]) -> [f32; 3] {
    let v = [
        m[0][0] * n[0] + m[1][0] * n[1] + m[2][0] * n[2],
        m[0][1] * n[0] + m[1][1] * n[1] + m[2][1] * n[2],
        m[0][2] * n[0] + m[1][2] * n[1] + m[2][2] * n[2],
    ];
    let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt().max(1e-6);
    [v[0] / len, v[1] / len, v[2] / len]
}

/// Flat per-triangle normals (used when a primitive ships no NORMAL attribute).
fn flat_normals(positions: &[[f32; 3]], indices: &[u32]) -> Vec<[f32; 3]> {
    let mut acc = vec![[0.0f32; 3]; positions.len()];
    for t in indices.as_chunks::<3>().0 {
        let (a, b, c) = (positions[t[0] as usize], positions[t[1] as usize], positions[t[2] as usize]);
        let u = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
        let v = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
        let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
        for &i in t {
            for k in 0..3 {
                acc[i as usize][k] += n[k];
            }
        }
    }
    for n in &mut acc {
        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt().max(1e-6);
        for v in n {
            *v /= len;
        }
    }
    acc
}

/// **Load an embedded glTF/`.glb` buffer into a [`Mesh3d`].** Walks every node
/// (baking its local transform), turns each mesh primitive into a named
/// [`MeshGroup`], computes flat normals when absent, then normalises into the
/// unit box. Group names key on node → mesh → material name (first non-empty).
pub fn load_glb(bytes: &[u8]) -> Result<Mesh3d, LoadError> {
    let (doc, buffers, _images) = gltf::import_slice(bytes)?;
    let mut mesh = Mesh3d::default();

    for node in doc.nodes() {
        let Some(node_mesh) = node.mesh() else { continue };
        let m = node.transform().matrix();
        for prim in node_mesh.primitives() {
            if prim.mode() != gltf::mesh::Mode::Triangles {
                continue;
            }
            let reader = prim.reader(|b| buffers.get(b.index()).map(|d| &d.0[..]));
            let Some(pos_iter) = reader.read_positions() else { continue };
            let positions: Vec<[f32; 3]> = pos_iter.map(|p| apply_mat(&m, p)).collect();
            if positions.is_empty() {
                continue;
            }
            let indices: Vec<u32> = match reader.read_indices() {
                Some(idx) => idx.into_u32().collect(),
                None => (0..positions.len() as u32).collect(),
            };
            let normals: Vec<[f32; 3]> = match reader.read_normals() {
                Some(nv) => nv.map(|n| apply_mat3(&m, n)).collect(),
                None => flat_normals(&positions, &indices),
            };
            let name = node
                .name()
                .filter(|s| !s.is_empty())
                .or_else(|| node_mesh.name().filter(|s| !s.is_empty()))
                .or_else(|| prim.material().name().filter(|s| !s.is_empty()))
                .unwrap_or("mesh")
                .to_string();
            let base_color = prim.material().pbr_metallic_roughness().base_color_factor();
            mesh.groups.push(MeshGroup { name, positions, normals, indices, base_color });
        }
    }

    if mesh.triangle_count() == 0 {
        return Err(LoadError::Empty);
    }
    mesh.normalise_unit_box();
    // **Winding repair.** A back-face cull tests the index order; the lighting reads
    // the authored normals. When an exporter leaves the two disagreeing, the viewer
    // culls exactly the faces it should draw — the model renders inside out. The
    // bundled procedural skull arrived that way (1156 of 1288 triangles).
    mesh.repair_winding();
    Ok(mesh)
}