hronn 0.7.0

An experimental CNC toolpath generator
Documentation
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (c) 2023 lacklustr@protonmail.com https://github.com/eadf
// This file is part of the hronn crate.

#[cfg(test)]
mod tests;

use crate::HronnError;
use crate::HronnError::ParseFloatError;
use std::io::BufWriter;
use std::io::Read;
use std::{
    fs::File,
    io::{BufRead, BufReader, Write},
    path,
};
use vector_traits::num_traits::AsPrimitive;
use vector_traits::prelude::HasXYZ;

#[derive(Debug)]
pub struct Obj<MESH: HasXYZ> {
    pub name: String,
    pub vertices: Vec<MESH>,
    // the indices inside faces and line will be "real" indices, starting at zero.
    // 1 will be added to their value when the obj file is saved.
    // indices will *only* store triangles
    pub indices: Vec<u32>,
    pub lines: Vec<Vec<u32>>,
    pub comments: Option<Vec<String>>, // New field for optional comments
}

impl<MESH: HasXYZ> Default for Obj<MESH> {
    fn default() -> Self {
        Self {
            name: String::new(),
            vertices: vec![],
            indices: vec![],
            lines: vec![],
            comments: None,
        }
    }
}

impl<MESH: HasXYZ> Obj<MESH> {
    // method to set the name used inside the .obj file
    pub fn with_name(mut self, name: impl Into<String>) -> Self {
        self.name = name.into();
        self
    }

    // method to set comments
    pub fn with_comments<I, S>(mut self, comments: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        self.comments = Some(comments.into_iter().map(Into::into).collect());
        self
    }

    pub fn with_vertices(mut self, vertices: Vec<MESH>) -> Self {
        self.vertices = vertices;
        self
    }

    pub fn with_indices(mut self, indices: Vec<u32>) -> Self {
        self.indices = indices;
        self
    }

    pub fn add_vertex(&mut self, p0: MESH) -> u32 {
        let pos = self.vertices.len() as u32;
        self.vertices.push(p0);
        pos
    }

    /// inserts a new point in to self.lines. This point will be used to describe a line between
    /// this point and the previously inserted point in self.line
    pub fn continue_line(&mut self, point: MESH) {
        if self.lines.is_empty() {
            self.start_new_line(point);
        } else {
            let index = self.add_vertex(point);
            // we know last_mut() is Some
            self.lines.last_mut().unwrap().push(index);
        }
    }

    /// Works in conjunction with ´continue_line()´.
    /// Closes the current line by connecting back to its starting vertex
    /// Does nothing if no line is in progress or if the current line is empty
    pub fn close_line(&mut self) {
        if let Some(current_line) = self.lines.last_mut() {
            if !current_line.is_empty() {
                if let Some(&first_index) = current_line.first() {
                    current_line.push(first_index);
                }
            }
        }
    }

    pub fn start_new_line(&mut self, point: MESH) {
        self.lines.push(Vec::new());
        let index = self.add_vertex(point);
        // we know last_mut() is Some
        self.lines.last_mut().unwrap().push(index);
    }

    pub fn add_triangle(&mut self, p0: u32, p1: u32, p2: u32) {
        self.indices.push(p0);
        self.indices.push(p1);
        self.indices.push(p2);
    }

    pub fn add_triangle_as_vertices(&mut self, p0: MESH, p1: MESH, p2: MESH) {
        let index = self.vertices.len() as u32;
        self.vertices.push(p0);
        self.vertices.push(p1);
        self.vertices.push(p2);
        self.indices.push(index);
        self.indices.push(index + 1);
        self.indices.push(index + 2);
    }

    /// Writes the mesh to an OBJ file at the given path.
    ///
    /// # Notes
    /// - Vertex coordinates are **always written as `f32`** (converted from `f64` if necessary).
    /// - Faces/lines use **1-based indexing** (OBJ standard).
    /// - The file is **overwritten** if it exists.
    ///
    /// # Errors
    /// - Returns `HronnError::InternalError` if vertex indices in faces/lines are invalid.
    /// - Propagates filesystem errors (e.g., permission denied).
    ///   Writes the OBJ data to a file (uses `write_to_buffer` internally).
    pub fn write_to_file(&self, filename: impl AsRef<path::Path>) -> Result<(), HronnError> {
        let mut writer = BufWriter::new(File::create(filename)?);
        self.write_to_buffer(&mut writer)
    }

    #[deprecated(since = "0.5.2", note = "Use `write_to_file` instead")]
    pub fn write_obj(&self, filename: impl AsRef<path::Path>) -> Result<(), HronnError> {
        self.write_to_file(filename)
    }

    /// Writes the OBJ data to any `impl Write` (e.g., `Vec<u8>`, `BufWriter`, etc.).
    /// This is useful for testing or in-memory operations.
    pub fn write_to_buffer(&self, writer: &mut impl Write) -> Result<(), HronnError> {
        if self.vertices.is_empty() {
            return Ok(());
        }

        // Write comments before the object name
        if let Some(comments) = &self.comments {
            for comment in comments {
                writeln!(writer, "# {comment}")?;
            }
        }

        let max_index = (self.vertices.len() - 1) as u32;
        // Write object name
        writeln!(writer, "o {}", self.name)?;

        // Write vertices
        for vertex in &self.vertices {
            write!(writer, "v ")?;
            write!(
                writer,
                " {}",
                ryu::Buffer::new().format::<f32>(vertex.x().as_())
            )?;
            write!(
                writer,
                " {}",
                ryu::Buffer::new().format::<f32>(vertex.y().as_())
            )?;
            writeln!(
                writer,
                " {}",
                ryu::Buffer::new().format::<f32>(vertex.z().as_())
            )?;
        }

        // Write faces
        for face in self.indices.chunks(3) {
            write!(writer, "f ")?;
            for element in face {
                if element > &max_index {
                    return Err(HronnError::InternalError(format!(
                        "the vertex index was too high {element} > {max_index}"
                    )));
                }
                // Remember, .obj uses 1-based indexing, so we add 1 to each index
                write!(writer, "{} ", element + 1)?;
            }
            writeln!(writer)?;
        }

        // Write lines using proper 'l' format
        for line in &self.lines {
            if line.is_empty() {
                continue;
            }

            write!(writer, "l")?;
            for &index in line {
                if index > max_index {
                    return Err(HronnError::InternalError(format!(
                        "Line vertex index {index} exceeds maximum index {max_index}",
                    )));
                }
                write!(writer, " {}", index + 1)?; // 1-based indexing
            }
            writeln!(writer)?;
        }
        writer.flush()?;

        Ok(())
    }

    pub fn new_from_file(
        filename: impl AsRef<path::Path> + std::fmt::Debug,
    ) -> Result<Obj<MESH>, HronnError> {
        let file = File::open(filename.as_ref())?;
        Self::new_from_reader(file, Some(filename))
    }

    pub fn new_from_reader<R: Read, P: AsRef<path::Path> + std::fmt::Debug>(
        reader: R,
        filename: Option<P>,
    ) -> Result<Obj<MESH>, HronnError> {
        let reader = BufReader::new(reader);

        let mut name = String::new();
        let mut vertices: Vec<MESH> = Vec::new();
        let mut faces = Vec::new();
        let mut lines = Vec::new();
        let mut triangulation_warnings = 0;
        let mut line_conversion_warnings = 0;

        for line in reader.lines() {
            let line = line?;
            let mut parts = line.split_whitespace();
            match parts.next() {
                Some("o") => {
                    name = parts.next().unwrap_or("").to_string();
                }
                Some("v") => {
                    let x: MESH::Scalar = parts
                        .next()
                        .ok_or(ParseFloatError)?
                        .parse()
                        .map_err(|_| ParseFloatError)?;
                    let y: MESH::Scalar = parts
                        .next()
                        .ok_or(ParseFloatError)?
                        .parse()
                        .map_err(|_| ParseFloatError)?;
                    let z: MESH::Scalar = parts
                        .next()
                        .ok_or(ParseFloatError)?
                        .parse()
                        .map_err(|_| ParseFloatError)?;
                    vertices.push(MESH::new_3d(x, y, z));
                }
                Some("f") => {
                    let face_indices: Vec<u32> = parts
                        .map(|part| part.split('/').next().unwrap_or("0"))
                        .filter_map(|s| s.parse().ok())
                        .collect();

                    match face_indices.len() {
                        2 => {
                            // Convert 2-point "face" to a line
                            line_conversion_warnings += 1;
                            lines.push(vec![face_indices[0] - 1, face_indices[1] - 1]);
                        }
                        3 => {
                            faces.push(face_indices[0] - 1);
                            faces.push(face_indices[1] - 1);
                            faces.push(face_indices[2] - 1);
                        }
                        n if n > 3 => {
                            triangulation_warnings += 1;
                            // Triangulate using fan method (first vertex + adjacent pairs)
                            for i in 1..face_indices.len() - 1 {
                                faces.push(face_indices[0] - 1);
                                faces.push(face_indices[i] - 1);
                                faces.push(face_indices[i + 1] - 1);
                            }
                        }
                        _ => {
                            // Ignore degenerate faces with fewer than 2 vertices
                            continue;
                        }
                    }
                }
                Some("l") => {
                    // Proper line element
                    let line_indices: Vec<u32> = parts
                        .filter_map(|s| s.split('/').next().and_then(|s| s.parse().ok()))
                        .map(|v: usize| (v - 1) as u32)
                        .collect();
                    if !line_indices.is_empty() {
                        lines.push(line_indices);
                    }
                }
                _ => {}
            }
        }

        // Optional: log or warn about triangulation and line conversion if needed
        if triangulation_warnings > 0 {
            if let Some(ref fname) = filename {
                eprintln!(
                    "Warning: Triangulated {triangulation_warnings} polygonal faces in file {fname:?}",
                );
            }
        }
        if line_conversion_warnings > 0 {
            if let Some(ref fname) = filename {
                eprintln!(
                    "Warning: Converted {line_conversion_warnings} 2-point faces to lines in file {fname:?}",
                );
            }
        }

        Ok(Obj {
            name,
            vertices,
            indices: faces,
            lines,
            comments: None,
        })
    }
}