gmac_rs 0.2.0

Blazingly fast geometry manipulation and creation library
Documentation
use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Write};

use crate::error::Result;

#[cfg(feature = "rayon")]
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};

/// Writes a triangle mesh to a Wavefront OBJ file.
///
/// The function first writes all vertex positions, then defines each triangle
/// face by referencing the 1-based indices of its vertices. If the `rayon`
/// feature is enabled, the face data will be formatted in parallel.
///
/// # Arguments
/// * `nodes` - A slice of 3D vertex positions.
/// * `cells` - A slice of triangles, each defined by 0-based indices into the `nodes` array.
/// * `filename` - Optional file path. Defaults to `"mesh.obj"` if `None`.
///
/// # Returns
/// Returns `Ok(())` on success, or an `std::io::Error` on failure.
pub fn write_obj(
    nodes: &[[f64; 3]],
    cells: &[[usize; 3]],
    filename: Option<&str>,
) -> Result<()> {
    let file = File::create(filename.unwrap_or("mesh.obj"))?;
    let mut writer = BufWriter::new(file);

    // Write Header
    writeln!(writer, "# OBJ file generated by Rust mesh library")?;
    writeln!(writer, "# {} vertices, {} faces", nodes.len(), cells.len())?;

    // Write all vertex positions
    for node in nodes {
        writeln!(writer, "v {} {} {}", node[0], node[1], node[2])?;
    }

    // Compute all face strings in parallel (if enabled)
    let process_cell = |cell: &[usize; 3]| -> String {
        // OBJ format is 1-based, so we must add 1 to each 0-based index.
        format!("f {} {} {}", cell[0] + 1, cell[1] + 1, cell[2] + 1)
    };

    #[cfg(feature = "rayon")]
    let face_strings: Vec<String> = cells.par_iter().map(process_cell).collect();

    #[cfg(not(feature = "rayon"))]
    let face_strings: Vec<String> = cells.iter().map(process_cell).collect();

    // Write all face definitions
    for face_str in face_strings {
        writeln!(writer, "{}", face_str)?;
    }

    Ok(())
}

/// Reads a Wavefront OBJ file and extracts vertex and face data.
///
/// This function parses lines beginning with 'v ' for vertices and 'f ' for faces.
/// It correctly handles the 1-based indexing of the OBJ format for faces and can
/// parse complex face definitions (e.g., `f v/vt/vn` or `f v//vn`).
///
/// # Arguments
/// * `filename` - The path to the `.obj` file.
///
/// # Returns
/// A `Result` containing a tuple with:
/// - `Vec<[f64; 3]>`: The vector of vertex positions (`nodes`).
/// - `Vec<[usize; 3]>`: The vector of triangle indices (`cells`).
pub fn read_obj(filename: &str) -> Result<(Vec<[f64; 3]>, Vec<[usize; 3]>)> {
    let file = File::open(filename)?;
    let reader = BufReader::new(file);

    let mut nodes = Vec::new();
    let mut cells = Vec::new();

    for line in reader.lines() {
        let line = line?;
        let parts: Vec<&str> = line.split_whitespace().collect();
        if parts.is_empty() {
            continue;
        }

        match parts[0] {
            // Vertex position line: "v x y z"
            "v" => {
                if parts.len() >= 4 {
                    let x = parts[1].parse::<f64>().unwrap_or(0.0);
                    let y = parts[2].parse::<f64>().unwrap_or(0.0);
                    let z = parts[3].parse::<f64>().unwrap_or(0.0);
                    nodes.push([x, y, z]);
                }
            }
            // Face definition line: "f v1 v2 v3"
            "f" => {
                if parts.len() >= 4 {
                    let mut face_indices = Vec::new();
                    for part in &parts[1..] {
                        // Handle complex definitions like "f v/vt/vn" by splitting on '/'
                        // and taking the first part (the vertex index).
                        let index_str = part.split('/').next().unwrap_or("");
                        if let Ok(index) = index_str.parse::<usize>() {
                            // OBJ is 1-based, so subtract 1 for 0-based index.
                            face_indices.push(index - 1);
                        }
                    }

                    // Triangulate if the face is a quad.
                    if face_indices.len() == 3 {
                        cells.push([face_indices[0], face_indices[1], face_indices[2]]);
                    } else if face_indices.len() == 4 {
                        cells.push([face_indices[0], face_indices[1], face_indices[2]]);
                        cells.push([face_indices[0], face_indices[2], face_indices[3]]);
                    }
                }
            }
            // Ignore other lines like comments, normals, texture coords, etc.
            _ => {}
        }
    }

    Ok((nodes, cells))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs::{remove_file, read_to_string};
    use std::io::Write;

    // A helper function to create a temporary file for testing.
    fn create_temp_file(content: &str) -> String {
        let filename = format!(
            "temp_{}.obj",
            std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_nanos()
        );
        let mut file = File::create(&filename).unwrap();
        writeln!(file, "{}", content).unwrap();
        filename
    }

    #[test]
    fn test_write_obj_simple() {
        let nodes = vec![[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]];
        let cells = vec![[0, 1, 2]];
        let filename = "test_write_simple.obj";

        let result = write_obj(&nodes, &cells, Some(filename));
        assert!(result.is_ok());

        let content = read_to_string(filename).unwrap();
        assert!(content.contains("v 1 2 3"));
        assert!(content.contains("v 4 5 6"));
        assert!(content.contains("v 7 8 9"));
        // OBJ is 1-based, so indices are incremented.
        assert!(content.contains("f 1 2 3"));

        // Clean up the test file.
        remove_file(filename).unwrap();
    }

    #[test]
    fn test_read_obj_simple() {
        let obj_content = "
# Test comment
v 1.0 0.0 0.0
v 0.0 1.0 0.0
v 0.0 0.0 1.0

f 1 2 3
";
        let filename = create_temp_file(obj_content);
        let (nodes, cells) = read_obj(&filename).unwrap();

        assert_eq!(nodes.len(), 3);
        assert_eq!(cells.len(), 1);
        assert_eq!(nodes[0], [1.0, 0.0, 0.0]);
        // Remember that read_obj converts from 1-based to 0-based indices.
        assert_eq!(cells[0], [0, 1, 2]);

        remove_file(filename).unwrap();
    }

    #[test]
    fn test_read_obj_complex_faces() {
        let obj_content = "
v 1.0 0.0 0.0
v 0.0 1.0 0.0
v 0.0 0.0 1.0
vt 0.0 0.0
vn 0.0 1.0 0.0

f 1/1/1 2/1/1 3/1/1
";
        let filename = create_temp_file(obj_content);
        let (nodes, cells) = read_obj(&filename).unwrap();

        assert_eq!(nodes.len(), 3);
        assert_eq!(cells.len(), 1);
        assert_eq!(cells[0], [0, 1, 2], "Should correctly parse v/vt/vn format");

        remove_file(filename).unwrap();
    }

    #[test]
    fn test_read_obj_quad_triangulation() {
        let obj_content = "
v 0.0 0.0 0.0
v 1.0 0.0 0.0
v 1.0 1.0 0.0
v 0.0 1.0 0.0

# A single quad face
f 1 2 3 4
";
        let filename = create_temp_file(obj_content);
        let (_, cells) = read_obj(&filename).unwrap();

        assert_eq!(
            cells.len(),
            2,
            "A quad should be triangulated into two faces"
        );
        assert_eq!(cells[0], [0, 1, 2]);
        assert_eq!(cells[1], [0, 2, 3]);

        remove_file(filename).unwrap();
    }
}