point-formats 0.3.1

Dependency-light LiDAR/point-cloud/mesh format conversion crate with explicit adapters for heavyweight formats.
Documentation
use crate::error::{Error, Result};
use crate::format::Format;
use crate::io::{
    fmt_f64, read_f32_le, read_u16_le, read_u32_le, write_f32_le, write_u16_le, write_u32_le,
    StlOptions,
};
use crate::types::{Face, Geometry, Mesh, Vec3, Vertex};
use std::io::{Read, Write};

pub fn read<R: Read>(reader: &mut R) -> Result<Geometry> {
    let mut bytes = Vec::new();
    reader.read_to_end(&mut bytes)?;
    if looks_like_binary_stl(&bytes) {
        read_binary(&bytes)
    } else {
        read_ascii(&String::from_utf8_lossy(&bytes))
    }
}

pub fn write<W: Write>(writer: &mut W, geometry: &Geometry, options: &StlOptions) -> Result<()> {
    let mesh = match geometry {
        Geometry::Mesh(mesh) => mesh,
        Geometry::PointCloud(_) => {
            return Err(Error::LossyConversionBlocked {
                from: "point cloud",
                to: Format::Stl,
                reason: "STL stores triangles only; point clouds must be meshed before STL export"
                    .to_string(),
            })
        }
    };
    validate_mesh(mesh)?;
    if options.binary {
        write_binary(writer, mesh)
    } else {
        write_ascii(writer, mesh)
    }
}

fn looks_like_binary_stl(bytes: &[u8]) -> bool {
    if bytes.len() < 84 {
        return false;
    }
    let mut count_bytes = [0_u8; 4];
    count_bytes.copy_from_slice(&bytes[80..84]);
    let count = u32::from_le_bytes(count_bytes) as usize;
    84usize
        .checked_add(count.saturating_mul(50))
        .map(|expected| expected == bytes.len())
        .unwrap_or(false)
}

fn read_binary(bytes: &[u8]) -> Result<Geometry> {
    let mut cursor = std::io::Cursor::new(bytes);
    let mut header = [0_u8; 80];
    cursor.read_exact(&mut header)?;
    let count = read_u32_le(&mut cursor)? as usize;
    let mut vertices = Vec::with_capacity(count * 3);
    let mut faces = Vec::with_capacity(count);

    for tri in 0..count {
        let normal = Vec3::new(
            read_f32_le(&mut cursor)? as f64,
            read_f32_le(&mut cursor)? as f64,
            read_f32_le(&mut cursor)? as f64,
        );
        let base = vertices.len();
        for _ in 0..3 {
            vertices.push(Vertex {
                position: Vec3::new(
                    read_f32_le(&mut cursor)? as f64,
                    read_f32_le(&mut cursor)? as f64,
                    read_f32_le(&mut cursor)? as f64,
                ),
                normal: Some(normal),
                color: None,
            });
        }
        let _attribute_byte_count = read_u16_le(&mut cursor)?;
        faces.push(Face::new(base, base + 1, base + 2));
        if tri == count - 1 {
            break;
        }
    }

    let mut mesh = Mesh::new(vertices, faces);
    mesh.metadata.source_format = Some(Format::Stl);
    mesh.metadata.point_count_hint = Some(mesh.vertices.len());
    mesh.metadata.comments.push(
        String::from_utf8_lossy(&header)
            .trim_matches(char::from(0))
            .trim()
            .to_string(),
    );
    Ok(Geometry::Mesh(mesh))
}

fn read_ascii(text: &str) -> Result<Geometry> {
    let mut vertices = Vec::new();
    let mut faces = Vec::new();
    let mut current_normal = Vec3::ZERO;
    let mut current_vertices = Vec::new();

    for (line_idx, line) in text.lines().enumerate() {
        let line_no = line_idx + 1;
        let parts: Vec<&str> = line.split_whitespace().collect();
        match parts.as_slice() {
            ["facet", "normal", nx, ny, nz] => {
                current_normal = Vec3::new(
                    parse_ascii_f64(line_no, nx)?,
                    parse_ascii_f64(line_no, ny)?,
                    parse_ascii_f64(line_no, nz)?,
                );
            }
            ["vertex", x, y, z] => {
                current_vertices.push(Vertex {
                    position: Vec3::new(
                        parse_ascii_f64(line_no, x)?,
                        parse_ascii_f64(line_no, y)?,
                        parse_ascii_f64(line_no, z)?,
                    ),
                    normal: Some(current_normal),
                    color: None,
                });
            }
            ["endfacet"] => {
                if current_vertices.len() < 3 {
                    return Err(Error::parse(
                        Format::Stl,
                        line_no,
                        "facet has fewer than three vertices",
                    ));
                }
                let base = vertices.len();
                vertices.append(&mut current_vertices);
                faces.push(Face::new(base, base + 1, base + 2));
            }
            _ => {}
        }
    }

    let mut mesh = Mesh::new(vertices, faces);
    mesh.metadata.source_format = Some(Format::Stl);
    mesh.metadata.point_count_hint = Some(mesh.vertices.len());
    Ok(Geometry::Mesh(mesh))
}

fn write_binary<W: Write>(writer: &mut W, mesh: &Mesh) -> Result<()> {
    let mut header = [0_u8; 80];
    let label = b"binary STL generated by point-formats";
    header[..label.len()].copy_from_slice(label);
    writer.write_all(&header)?;
    let face_count = u32::try_from(mesh.faces.len())
        .map_err(|_| Error::invalid("STL binary cannot store more than u32::MAX triangles"))?;
    write_u32_le(writer, face_count)?;
    for face in &mesh.faces {
        let normal = face_normal(mesh, face);
        write_f32_le(writer, normal.x as f32)?;
        write_f32_le(writer, normal.y as f32)?;
        write_f32_le(writer, normal.z as f32)?;
        for &idx in &face.indices {
            let position = mesh.vertices[idx].position;
            write_f32_le(writer, position.x as f32)?;
            write_f32_le(writer, position.y as f32)?;
            write_f32_le(writer, position.z as f32)?;
        }
        write_u16_le(writer, 0)?;
    }
    Ok(())
}

fn write_ascii<W: Write>(writer: &mut W, mesh: &Mesh) -> Result<()> {
    writeln!(writer, "solid point_formats")?;
    for face in &mesh.faces {
        let normal = face_normal(mesh, face);
        writeln!(
            writer,
            "  facet normal {} {} {}",
            fmt_f64(normal.x, 9),
            fmt_f64(normal.y, 9),
            fmt_f64(normal.z, 9)
        )?;
        writeln!(writer, "    outer loop")?;
        for &idx in &face.indices {
            let position = mesh.vertices[idx].position;
            writeln!(
                writer,
                "      vertex {} {} {}",
                fmt_f64(position.x, 9),
                fmt_f64(position.y, 9),
                fmt_f64(position.z, 9)
            )?;
        }
        writeln!(writer, "    endloop")?;
        writeln!(writer, "  endfacet")?;
    }
    writeln!(writer, "endsolid point_formats")?;
    Ok(())
}

fn face_normal(mesh: &Mesh, face: &Face) -> Vec3 {
    let a = mesh.vertices[face.indices[0]].position;
    let b = mesh.vertices[face.indices[1]].position;
    let c = mesh.vertices[face.indices[2]].position;
    b.sub(a).cross(c.sub(a)).normalized().unwrap_or(Vec3::ZERO)
}

fn validate_mesh(mesh: &Mesh) -> Result<()> {
    for (face_idx, face) in mesh.faces.iter().enumerate() {
        for &idx in &face.indices {
            if idx >= mesh.vertices.len() {
                return Err(Error::invalid(format!(
                    "face {face_idx} references vertex {idx}, but mesh has only {} vertices",
                    mesh.vertices.len()
                )));
            }
        }
    }
    Ok(())
}

fn parse_ascii_f64(line: usize, value: &str) -> Result<f64> {
    value
        .parse::<f64>()
        .map_err(|_| Error::parse(Format::Stl, line, format!("invalid number '{value}'")))
}