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