csx 0.0.2

Pure Rust continuous-structure geometry and material modeling, compatible with CSXCAD and openEMS
Documentation
//! STL mesh reader (ASCII and binary).
//!
//! The format is detected automatically: data whose length matches the
//! triangle count of the binary header is binary, otherwise text starting
//! with `solid` is ASCII.
//!
//! ```
//! use csx::Vec3;
//! use csx::io::stl;
//!
//! // A tetrahedron, written as binary STL and read back.
//! let v = [Vec3::ZERO, Vec3::unit(0), Vec3::unit(1), Vec3::unit(2)];
//! let tris = [[v[0], v[2], v[1]], [v[0], v[1], v[3]], [v[0], v[3], v[2]], [v[1], v[2], v[3]]];
//! let data = stl::write_binary(&tris);
//! let poly = stl::parse(&data)?;
//! assert_eq!(poly.vertices().len(), 4);
//! assert!(poly.is_closed());
//! assert!(poly.contains(Vec3::new(0.1, 0.1, 0.1)));
//! # Ok::<(), csx::Error>(())
//! ```

use crate::error::{Error, Result};
use crate::math::Vec3;
use crate::primitives::Polyhedron;

/// Parses STL data (binary or ASCII) into a polyhedron, merging coincident
/// vertices.
///
/// # Errors
///
/// [`Error::Format`] for truncated or malformed data, [`Error::Invalid`] if
/// the triangles do not form a valid polyhedron (non-finite coordinates).
pub fn parse(data: &[u8]) -> Result<Polyhedron> {
    Polyhedron::from_triangles(&parse_triangles(data)?)
}

/// Parses STL data into a list of triangles (no vertex merging).
///
/// # Errors
///
/// [`Error::Format`] for truncated binary data, non-UTF-8 or malformed
/// ASCII data, or facets with fewer than three vertices.
pub fn parse_triangles(data: &[u8]) -> Result<Vec<[Vec3; 3]>> {
    if is_binary(data) {
        parse_binary(data)
    } else {
        parse_ascii(data)
    }
}

fn is_binary(data: &[u8]) -> bool {
    if data.len() < 84 {
        return false;
    }
    let n = u32::from_le_bytes([data[80], data[81], data[82], data[83]]) as usize;
    let expected = n.checked_mul(50).and_then(|x| x.checked_add(84));
    if expected == Some(data.len()) {
        return true;
    }
    // Binary files whose header happens to start with "solid" still match
    // the size check above; everything else must look like ASCII.
    !data.trim_ascii_start().starts_with(b"solid")
}

fn parse_binary(data: &[u8]) -> Result<Vec<[Vec3; 3]>> {
    if data.len() < 84 {
        return Err(Error::Format("STL: truncated binary header".into()));
    }
    let n = u32::from_le_bytes([data[80], data[81], data[82], data[83]]) as usize;
    let body = &data[84..];
    if body.len() / 50 < n {
        return Err(Error::Format(format!(
            "STL: expected {n} triangles, file is truncated"
        )));
    }
    let f = |b: &[u8], o: usize| f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]) as f64;
    Ok(body
        .as_chunks::<50>()
        .0
        .iter()
        .take(n)
        .map(|rec| {
            core::array::from_fn(|k| {
                let o = 12 + 12 * k;
                Vec3::new(f(rec, o), f(rec, o + 4), f(rec, o + 8))
            })
        })
        .collect())
}

fn parse_ascii(data: &[u8]) -> Result<Vec<[Vec3; 3]>> {
    let text =
        core::str::from_utf8(data).map_err(|_| Error::Format("STL: invalid ASCII data".into()))?;
    let mut tris = Vec::new();
    let mut cur: Vec<Vec3> = Vec::with_capacity(3);
    let mut tok = text.split_ascii_whitespace();
    while let Some(t) = tok.next() {
        match t {
            "vertex" => {
                let mut c = [0.0; 3];
                for v in &mut c {
                    *v = tok
                        .next()
                        .and_then(|s| s.parse().ok())
                        .ok_or_else(|| Error::Format("STL: bad vertex".into()))?;
                }
                cur.push(Vec3(c));
            }
            "endloop" => {
                if cur.len() < 3 {
                    return Err(Error::Format(
                        "STL: facet with fewer than 3 vertices".into(),
                    ));
                }
                for k in 1..cur.len() - 1 {
                    tris.push([cur[0], cur[k], cur[k + 1]]);
                }
                cur.clear();
            }
            _ => {}
        }
    }
    Ok(tris)
}

/// Serialises triangles as binary STL (normals computed from the vertex
/// order, coordinates rounded to `f32`).
#[must_use]
pub fn write_binary(tris: &[[Vec3; 3]]) -> Vec<u8> {
    let mut out = vec![0u8; 80];
    out.extend_from_slice(&(tris.len() as u32).to_le_bytes());
    for t in tris {
        let n = (t[1] - t[0]).cross(t[2] - t[0]).normalized();
        for v in core::iter::once(n).chain(t.iter().copied()) {
            for c in v.0 {
                out.extend_from_slice(&(c as f32).to_le_bytes());
            }
        }
        out.extend_from_slice(&[0, 0]);
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    const TETRA: &str = "solid t
facet normal 0 0 -1
 outer loop
  vertex 0 0 0
  vertex 0 1 0
  vertex 1 0 0
 endloop
endfacet
facet normal 0 -1 0
 outer loop
  vertex 0 0 0
  vertex 1 0 0
  vertex 0 0 1
 endloop
endfacet
facet normal -1 0 0
 outer loop
  vertex 0 0 0
  vertex 0 0 1
  vertex 0 1 0
 endloop
endfacet
facet normal 1 1 1
 outer loop
  vertex 1 0 0
  vertex 0 1 0
  vertex 0 0 1
 endloop
endfacet
endsolid t
";

    #[test]
    fn ascii_and_binary() {
        let p = parse(TETRA.as_bytes()).unwrap();
        assert_eq!(p.vertices().len(), 4);
        assert!(p.is_closed());
        assert!(p.contains(Vec3::new(0.1, 0.1, 0.1)));
        assert!(!p.contains(Vec3::new(0.5, 0.5, 0.5)));
        let bin = write_binary(&parse_triangles(TETRA.as_bytes()).unwrap());
        let q = parse(&bin).unwrap();
        assert_eq!(q.faces().len(), 4);
        assert!(q.contains(Vec3::new(0.1, 0.1, 0.1)));
        assert!(parse(&bin[..100]).is_err());
    }
}