univert 0.4.0

Universal file converter (library and CLI)
Documentation
use std::fs;
use std::io::{BufRead, BufReader, BufWriter, Write};

use crate::{Error, Job, Result};

pub fn stl_to_obj(job: &Job) -> Result<()> {
  let mut file = fs::File::open(job.input)?;
  let stl = stl_io::read_stl(&mut file)
    .map_err(|e| Error::other(format!("stl parse: {e}")))?;

  let out = fs::File::create(job.output)?;
  let mut w = BufWriter::new(out);

  writeln!(w, "o Solid Object")?;
  writeln!(w)?;

  for v in &stl.vertices {
    writeln!(w, "v {} {} {}", v[0], v[1], v[2])?;
  }
  writeln!(w)?;

  for t in &stl.faces {
    // OBJ indices are 1-based.
    writeln!(
      w,
      "f {} {} {}",
      t.vertices[0] + 1,
      t.vertices[1] + 1,
      t.vertices[2] + 1,
    )?;
  }

  w.flush()?;
  Ok(())
}

pub fn stl_to_png(job: &Job) -> Result<()> {
  // Renders via openscad with a generated wrapper that imports the STL.
  crate::converters::scad::stl_to_png(job)
}

pub fn obj_to_stl(job: &Job) -> Result<()> {
  let file = fs::File::open(job.input)?;
  let reader = BufReader::new(file);

  let mut vertices: Vec<[f32; 3]> = Vec::new();
  let mut faces: Vec<Vec<usize>> = Vec::new();

  for line in reader.lines() {
    let line = line?;
    let trimmed = line.trim();
    if trimmed.is_empty() || trimmed.starts_with('#') {
      continue;
    }
    let mut parts = trimmed.split_whitespace();
    match parts.next() {
      Some("v") => {
        let nums: Vec<f32> = parts
          .take(3)
          .map(|s| s.parse::<f32>())
          .collect::<std::result::Result<_, _>>()
          .map_err(|e| Error::other(format!("obj: bad vertex: {e}")))?;
        if nums.len() != 3 {
          return Err(Error::other("obj: vertex needs 3 coordinates"));
        }
        vertices.push([nums[0], nums[1], nums[2]]);
      }
      Some("f") => {
        let indices: Vec<usize> = parts
          .map(|tok| {
            // Strip texture / normal indices after `/`.
            let idx_str = tok.split('/').next().unwrap_or(tok);
            idx_str.parse::<i64>().map(|i| {
              // OBJ supports negative (1-based from end) indices.
              if i > 0 {
                (i - 1) as usize
              } else if i < 0 {
                ((vertices.len() as i64) + i) as usize
              } else {
                0
              }
            })
          })
          .collect::<std::result::Result<_, _>>()
          .map_err(|e| Error::other(format!("obj: bad face: {e}")))?;
        if indices.len() >= 3 {
          faces.push(indices);
        }
      }
      _ => {}
    }
  }

  // Triangulate faces (fan triangulation).
  let mut triangles: Vec<stl_io::Triangle> = Vec::new();
  for face in &faces {
    for i in 1..face.len() - 1 {
      let v0 = vertices[face[0]];
      let v1 = vertices[face[i]];
      let v2 = vertices[face[i + 1]];
      triangles.push(stl_io::Triangle {
        normal: face_normal(v0, v1, v2),
        vertices: [
          stl_io::Vertex::new(v0),
          stl_io::Vertex::new(v1),
          stl_io::Vertex::new(v2),
        ],
      });
    }
  }

  let out = fs::File::create(job.output)?;
  let mut w = BufWriter::new(out);
  stl_io::write_stl(&mut w, triangles.iter())
    .map_err(|e| Error::other(format!("stl write: {e}")))?;
  w.flush()?;
  Ok(())
}

fn face_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> stl_io::Normal {
  let ux = b[0] - a[0];
  let uy = b[1] - a[1];
  let uz = b[2] - a[2];
  let vx = c[0] - a[0];
  let vy = c[1] - a[1];
  let vz = c[2] - a[2];
  let nx = uy * vz - uz * vy;
  let ny = uz * vx - ux * vz;
  let nz = ux * vy - uy * vx;
  let len = (nx * nx + ny * ny + nz * nz).sqrt();
  if len > 0.0 {
    stl_io::Normal::new([nx / len, ny / len, nz / len])
  } else {
    stl_io::Normal::new([0.0, 0.0, 0.0])
  }
}