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::parse_f64;
use crate::types::{Color, Face, Geometry, Mesh, Point, PointCloud, Vec3, Vertex};
use std::io::{BufRead, Write};

pub fn read<R: BufRead>(reader: &mut R) -> Result<Geometry> {
    let mut vertices: Vec<Vertex> = Vec::new();
    let mut normals: Vec<Vec3> = Vec::new();
    let mut faces: Vec<Face> = Vec::new();
    let mut comments = Vec::new();

    let mut line = String::new();
    let mut line_no = 0;

    loop {
        line.clear();
        let bytes_read = reader.read_line(&mut line)?;
        if bytes_read == 0 {
            break;
        }
        line_no += 1;
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        if trimmed.starts_with('#') {
            comments.push(trimmed.trim_start_matches('#').trim().to_string());
            continue;
        }
        let mut parts_buf = [""; 16];
        let mut count = 0;
        for part in trimmed.split_whitespace() {
            if count < 16 {
                parts_buf[count] = part;
                count += 1;
            } else {
                break;
            }
        }
        let parts = &parts_buf[..count];
        match parts.first().copied() {
            Some("v") => vertices.push(parse_vertex(line_no, &parts[1..])?),
            Some("vn") => normals.push(parse_normal(line_no, &parts[1..])?),
            Some("f") => parse_face(line_no, &parts[1..], vertices.len(), &mut faces)?,
            // Texture coordinates, groups, objects, materials and smoothing do not
            // affect geometry conversion and are intentionally ignored.
            Some("vt" | "g" | "o" | "s" | "usemtl" | "mtllib") => {}
            Some(_) | None => {}
        }
    }

    if faces.is_empty() {
        let points = vertices
            .into_iter()
            .map(|vertex| {
                let mut point = Point::new(vertex.position.x, vertex.position.y, vertex.position.z);
                point.normal = vertex.normal;
                point.color = vertex.color;
                point
            })
            .collect();
        let mut cloud = PointCloud::new(points);
        cloud.metadata.source_format = Some(Format::Obj);
        cloud.metadata.comments = comments;
        cloud.metadata.point_count_hint = Some(cloud.points.len());
        Ok(Geometry::PointCloud(cloud))
    } else {
        let mut mesh = Mesh::new(vertices, faces);
        mesh.metadata.source_format = Some(Format::Obj);
        mesh.metadata.comments = comments;
        mesh.metadata.point_count_hint = Some(mesh.vertices.len());
        // OBJ normals are indexed independently. This minimal native codec stores
        // vertex normals only when they appear inline via extensions, not through f v//n.
        if !normals.is_empty() {
            mesh.metadata.warnings.push(
                "OBJ normal indices were present; per-face/per-corner normals are not represented in the normalized mesh model".to_string(),
            );
        }
        Ok(Geometry::Mesh(mesh))
    }
}

pub fn write<W: Write>(writer: &mut W, geometry: &Geometry) -> Result<()> {
    writeln!(writer, "# OBJ generated by point-formats")?;
    match geometry {
        Geometry::PointCloud(cloud) => {
            writeln!(writer, "# point cloud exported as vertex-only OBJ; point attributes beyond optional color are non-standard in OBJ")?;
            for point in &cloud.points {
                write_vertex(writer, point.position, point.color)?;
            }
        }
        Geometry::Mesh(mesh) => {
            for vertex in &mesh.vertices {
                write_vertex(writer, vertex.position, vertex.color)?;
            }
            for vertex in &mesh.vertices {
                if let Some(normal) = vertex.normal {
                    write!(writer, "vn ")?;
                    crate::io::write_fmt_f64(writer, normal.x, 9)?;
                    write!(writer, " ")?;
                    crate::io::write_fmt_f64(writer, normal.y, 9)?;
                    write!(writer, " ")?;
                    crate::io::write_fmt_f64(writer, normal.z, 9)?;
                    writeln!(writer)?;
                }
            }
            for face in &mesh.faces {
                writeln!(
                    writer,
                    "f {} {} {}",
                    face.indices[0] + 1,
                    face.indices[1] + 1,
                    face.indices[2] + 1
                )?;
            }
        }
    }
    Ok(())
}

fn parse_vertex(line_no: usize, parts: &[&str]) -> Result<Vertex> {
    if parts.len() < 3 {
        return Err(Error::parse(Format::Obj, line_no, "vertex requires x y z"));
    }
    let position = Vec3::new(
        parse_f64(Format::Obj, line_no, "x", parts[0])?,
        parse_f64(Format::Obj, line_no, "y", parts[1])?,
        parse_f64(Format::Obj, line_no, "z", parts[2])?,
    );
    let mut vertex = Vertex::new(position);
    if parts.len() >= 6 {
        let r = parse_f64(Format::Obj, line_no, "red", parts[3])?;
        let g = parse_f64(Format::Obj, line_no, "green", parts[4])?;
        let b = parse_f64(Format::Obj, line_no, "blue", parts[5])?;
        vertex.color =
            if (0.0..=1.0).contains(&r) && (0.0..=1.0).contains(&g) && (0.0..=1.0).contains(&b) {
                Color::from_unit_rgb(r, g, b)
            } else {
                Some(Color::new(
                    clamp_to_u16(r),
                    clamp_to_u16(g),
                    clamp_to_u16(b),
                ))
            };
    }
    Ok(vertex)
}

fn parse_normal(line_no: usize, parts: &[&str]) -> Result<Vec3> {
    if parts.len() < 3 {
        return Err(Error::parse(Format::Obj, line_no, "normal requires x y z"));
    }
    Ok(Vec3::new(
        parse_f64(Format::Obj, line_no, "nx", parts[0])?,
        parse_f64(Format::Obj, line_no, "ny", parts[1])?,
        parse_f64(Format::Obj, line_no, "nz", parts[2])?,
    ))
}

fn parse_face(
    line_no: usize,
    parts: &[&str],
    vertex_count: usize,
    faces: &mut Vec<Face>,
) -> Result<()> {
    if parts.len() < 3 {
        return Err(Error::parse(
            Format::Obj,
            line_no,
            "face requires at least three vertices",
        ));
    }
    let indices: Vec<usize> = parts
        .iter()
        .map(|token| parse_face_index(line_no, token, vertex_count))
        .collect::<Result<_>>()?;
    for i in 1..(indices.len() - 1) {
        faces.push(Face::new(indices[0], indices[i], indices[i + 1]));
    }
    Ok(())
}

fn parse_face_index(line_no: usize, token: &str, vertex_count: usize) -> Result<usize> {
    let first = token.split('/').next().unwrap_or(token);
    let idx = first.parse::<isize>().map_err(|_| {
        Error::parse(
            Format::Obj,
            line_no,
            format!("invalid face index '{token}'"),
        )
    })?;
    if idx == 0 {
        return Err(Error::parse(
            Format::Obj,
            line_no,
            "OBJ indices are 1-based; 0 is invalid",
        ));
    }
    let zero_based = if idx > 0 {
        (idx - 1) as usize
    } else {
        vertex_count.checked_sub((-idx) as usize).ok_or_else(|| {
            Error::parse(
                Format::Obj,
                line_no,
                format!("negative index '{idx}' is out of range"),
            )
        })?
    };
    if zero_based >= vertex_count {
        return Err(Error::parse(
            Format::Obj,
            line_no,
            format!("face index {idx} refers to missing vertex"),
        ));
    }
    Ok(zero_based)
}

fn write_vertex<W: Write>(writer: &mut W, position: Vec3, color: Option<Color>) -> Result<()> {
    write!(writer, "v ")?;
    crate::io::write_fmt_f64(writer, position.x, 9)?;
    write!(writer, " ")?;
    crate::io::write_fmt_f64(writer, position.y, 9)?;
    write!(writer, " ")?;
    crate::io::write_fmt_f64(writer, position.z, 9)?;
    if let Some(color) = color {
        let rgb = color.to_unit_rgb();
        write!(writer, " ")?;
        crate::io::write_fmt_f64(writer, rgb[0], 9)?;
        write!(writer, " ")?;
        crate::io::write_fmt_f64(writer, rgb[1], 9)?;
        write!(writer, " ")?;
        crate::io::write_fmt_f64(writer, rgb[2], 9)?;
    }
    writeln!(writer)?;
    Ok(())
}

fn clamp_to_u16(value: f64) -> u16 {
    if value <= 0.0 || !value.is_finite() {
        0
    } else if value >= u16::MAX as f64 {
        u16::MAX
    } else {
        value.round() as u16
    }
}