Skip to main content

fast_mesh_cli/io/
mod.rs

1//! Mesh file formats, selected by file extension: STL (binary and ASCII), OBJ,
2//! PLY (ASCII and binary little-endian) and binary glTF (`.glb`), as input and
3//! output.
4//!
5//! Parsers take the whole file as bytes and return an error, never a panic, on
6//! anything malformed or truncated. A count in a file is never trusted: it is
7//! checked against the bytes actually there before anything is allocated from
8//! it, so a small file cannot ask for a huge allocation.
9
10mod glb;
11mod json;
12mod obj;
13mod ply;
14mod stl;
15
16use std::ffi::OsString;
17use std::fmt;
18use std::fs::{self, File, OpenOptions};
19use std::io::{BufWriter, Write};
20use std::path::{Path, PathBuf};
21
22/// An indexed triangle mesh in file coordinates.
23#[derive(Debug, Clone, PartialEq)]
24pub struct MeshData {
25    /// `[x, y, z, ...]`.
26    pub positions: Vec<f64>,
27    /// Three vertex ids per triangle.
28    pub indices: Vec<u32>,
29}
30
31/// A file could not be read, parsed or written; the message says why.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Error(String);
34
35impl Error {
36    /// An error with this message.
37    pub fn new(message: impl Into<String>) -> Self {
38        Error(message.into())
39    }
40}
41
42impl fmt::Display for Error {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.write_str(&self.0)
45    }
46}
47
48impl std::error::Error for Error {}
49
50impl From<std::io::Error> for Error {
51    fn from(e: std::io::Error) -> Self {
52        Error(e.to_string())
53    }
54}
55
56/// `Result` with [`Error`] as the error type.
57pub type Result<T> = std::result::Result<T, Error>;
58
59/// A file format.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum Format {
62    /// `.stl`: binary or ASCII.
63    Stl,
64    /// `.obj`: Wavefront, always text.
65    Obj,
66    /// `.ply`: ASCII or binary little-endian.
67    Ply,
68    /// `.glb`: binary glTF 2.0, always binary.
69    Glb,
70}
71
72impl Format {
73    /// The format a path's extension names, in any case.
74    ///
75    /// # Errors
76    /// When the extension is missing or is not a supported format.
77    pub fn from_path(path: &Path) -> Result<Format> {
78        let extension = path
79            .extension()
80            .map(|e| e.to_string_lossy().to_ascii_lowercase());
81        match extension.as_deref() {
82            Some("stl") => Ok(Format::Stl),
83            Some("obj") => Ok(Format::Obj),
84            Some("ply") => Ok(Format::Ply),
85            Some("glb") => Ok(Format::Glb),
86            Some("gltf") => Err(Error::new(format!(
87                "{}: JSON glTF (.gltf) is not supported, use binary glTF (.glb)",
88                path.display()
89            ))),
90            _ => Err(Error::new(format!(
91                "{}: unknown file format (expected .stl, .obj, .ply or .glb)",
92                path.display()
93            ))),
94        }
95    }
96
97    /// Whether the format has both a binary and an ASCII form.
98    pub fn has_ascii_form(self) -> bool {
99        matches!(self, Format::Stl | Format::Ply)
100    }
101}
102
103/// Parses a whole file. The result has at least one triangle, finite
104/// positions and every index below the vertex count.
105///
106/// # Errors
107/// When the bytes are not a valid file of `format`, or memory runs out.
108pub fn parse(format: Format, bytes: &[u8]) -> Result<MeshData> {
109    match format {
110        Format::Stl => stl::parse(bytes),
111        Format::Obj => obj::parse(bytes),
112        Format::Ply => ply::parse(bytes),
113        Format::Glb => glb::parse(bytes),
114    }
115}
116
117/// Writes `data` as a whole file, dropping vertices no triangle uses. `ascii`
118/// picks the text form of a format that has both ([`Format::has_ascii_form`])
119/// and is ignored by the others. STL, binary PLY and GLB hold 32-bit floats;
120/// OBJ and ASCII PLY keep the full double precision.
121///
122/// # Errors
123/// When `data` has no triangles or an index past its vertices, when a
124/// coordinate or count does not fit the format, or when writing fails.
125pub fn write_mesh<W: Write>(
126    data: &MeshData,
127    format: Format,
128    ascii: bool,
129    out: &mut W,
130) -> Result<()> {
131    let data = compacted(data)?;
132    match format {
133        Format::Stl => stl::write(&data, ascii, out),
134        Format::Obj => obj::write(&data, out),
135        Format::Ply => ply::write(&data, ascii, out),
136        Format::Glb => glb::write(&data, out),
137    }
138}
139
140/// `data` with only the vertices its triangles use, in their original order.
141fn compacted(data: &MeshData) -> Result<MeshData> {
142    if data.indices.is_empty() {
143        return Err(Error::new("the mesh has no triangles"));
144    }
145    let vertices = data.positions.len() / 3;
146    const UNUSED: u32 = u32::MAX;
147    let mut map = try_vec(vertices, UNUSED)?;
148    for &i in &data.indices {
149        let Some(slot) = map.get_mut(i as usize) else {
150            return Err(Error::new(format!(
151                "vertex {i} is past the {vertices} vertices"
152            )));
153        };
154        *slot = 0;
155    }
156    let mut positions = with_capacity(data.positions.len())?;
157    let mut next = 0u32;
158    for (v, slot) in map.iter_mut().enumerate() {
159        if *slot != UNUSED {
160            *slot = next;
161            next += 1;
162            positions.extend_from_slice(&data.positions[3 * v..3 * v + 3]);
163        }
164    }
165    let mut indices = with_capacity(data.indices.len())?;
166    indices.extend(data.indices.iter().map(|&i| map[i as usize]));
167    Ok(MeshData { positions, indices })
168}
169
170/// Writes a file so that it appears whole or not at all: into a temporary file
171/// beside it, then renamed over `path` (through a symbolic link, to its
172/// target). A failure removes the temporary file and leaves `path` as it was.
173///
174/// # Errors
175/// When `write` fails or the file system refuses.
176pub fn atomic_write(
177    path: &Path,
178    write: impl FnOnce(&mut BufWriter<File>) -> Result<()>,
179) -> Result<()> {
180    let target = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
181    let describe = |e: std::io::Error| Error::new(format!("{}: {e}", path.display()));
182    let name = target
183        .file_name()
184        .ok_or_else(|| Error::new(format!("{}: not a file name", path.display())))?;
185    let dir = match target.parent() {
186        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
187        _ => PathBuf::from("."),
188    };
189    let mut temporary = OsString::from(".");
190    temporary.push(name);
191    temporary.push(format!(".{}.tmp", std::process::id()));
192    let temporary = dir.join(temporary);
193    let file = OpenOptions::new()
194        .write(true)
195        .create_new(true)
196        .open(&temporary)
197        .map_err(describe)?;
198    let finish = || -> Result<()> {
199        let mut out = BufWriter::with_capacity(1 << 20, file);
200        write(&mut out)?;
201        let file = out.into_inner().map_err(|e| describe(e.into_error()))?;
202        file.sync_all().map_err(describe)?;
203        drop(file);
204        fs::rename(&temporary, &target).map_err(describe)
205    };
206    let result = finish();
207    if result.is_err() {
208        let _ = fs::remove_file(&temporary);
209    }
210    result
211}
212
213fn out_of_memory() -> Error {
214    Error::new("out of memory")
215}
216
217/// An empty vector with room for `n`, or an error instead of an abort.
218fn with_capacity<T>(n: usize) -> Result<Vec<T>> {
219    let mut v = Vec::new();
220    v.try_reserve_exact(n).map_err(|_| out_of_memory())?;
221    Ok(v)
222}
223
224/// `n` copies of `value`, or an error instead of an abort.
225fn try_vec<T: Clone>(n: usize, value: T) -> Result<Vec<T>> {
226    let mut v = with_capacity(n)?;
227    v.resize(n, value);
228    Ok(v)
229}
230
231/// Appends, or an error instead of an abort.
232fn push_all<T: Copy>(v: &mut Vec<T>, values: &[T]) -> Result<()> {
233    v.try_reserve(values.len()).map_err(|_| out_of_memory())?;
234    v.extend_from_slice(values);
235    Ok(())
236}
237
238/// The next whitespace-separated token from `*at`, moving past it.
239fn next_token<'a>(bytes: &'a [u8], at: &mut usize) -> Option<&'a [u8]> {
240    let start = *at + bytes[*at..].iter().position(|b| !b.is_ascii_whitespace())?;
241    let len = bytes[start..]
242        .iter()
243        .position(|b| b.is_ascii_whitespace())
244        .unwrap_or(bytes.len() - start);
245    *at = start + len;
246    Some(&bytes[start..start + len])
247}
248
249/// A token as a finite number.
250fn finite_number(token: &[u8]) -> Option<f64> {
251    let value: f64 = std::str::from_utf8(token).ok()?.parse().ok()?;
252    value.is_finite().then_some(value)
253}
254
255/// Some of a token, safe to put in a message.
256fn shown(token: &[u8]) -> String {
257    let text = String::from_utf8_lossy(&token[..token.len().min(40)]);
258    let cut = if token.len() > 40 { "..." } else { "" };
259    format!("\"{}{cut}\"", text.escape_debug())
260}
261
262/// A coordinate as the 32-bit float a binary format holds.
263fn to_f32(value: f64, format: &str) -> Result<f32> {
264    let single = value as f32;
265    if single.is_finite() {
266        Ok(single)
267    } else {
268        Err(Error::new(format!(
269            "{format}: coordinate {value:e} does not fit its 32-bit floats"
270        )))
271    }
272}
273
274/// Refuses a mesh a parser must not return.
275fn check_mesh(data: MeshData, format: &str) -> Result<MeshData> {
276    if data.indices.is_empty() {
277        return Err(Error::new(format!("{format}: the file has no faces")));
278    }
279    let vertices = data.positions.len() / 3;
280    if vertices > u32::MAX as usize {
281        return Err(Error::new(format!(
282            "{format}: {vertices} vertices is too many for 32-bit indices"
283        )));
284    }
285    if let Some(i) = data.indices.iter().find(|&&i| i as usize >= vertices) {
286        return Err(Error::new(format!(
287            "{format}: face index {i} is past the {vertices} vertices"
288        )));
289    }
290    if !data.positions.iter().all(|x| x.is_finite()) {
291        return Err(Error::new(format!(
292            "{format}: a vertex coordinate is not a finite number"
293        )));
294    }
295    Ok(data)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    /// `name` in a new directory of its own; `remove` deletes it at the end.
303    fn temp_path(name: &str) -> PathBuf {
304        let dir = std::env::temp_dir().join(format!("fast-mesh-cli-{}-{name}", std::process::id()));
305        fs::create_dir_all(&dir).unwrap();
306        dir.join(name)
307    }
308
309    fn remove(path: &Path) {
310        let _ = fs::remove_dir_all(path.parent().unwrap());
311    }
312
313    #[test]
314    fn atomic_write_keeps_the_old_file_when_the_writer_fails_midway() {
315        let path = temp_path("midway.stl");
316        fs::write(&path, b"old").unwrap();
317        let result = atomic_write(&path, |out| {
318            out.write_all(&[7u8; 4 << 20])?;
319            Err(Error::new("disk went away"))
320        });
321        assert_eq!(result.unwrap_err().to_string(), "disk went away");
322        assert_eq!(fs::read(&path).unwrap(), b"old");
323        let names: Vec<_> = fs::read_dir(path.parent().unwrap())
324            .unwrap()
325            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
326            .filter(|n| n.contains("midway"))
327            .collect();
328        assert_eq!(names, ["midway.stl"], "no temporary file left");
329        remove(&path);
330    }
331
332    #[test]
333    fn atomic_write_replaces() {
334        let path = temp_path("replace.stl");
335        fs::write(&path, b"old").unwrap();
336        atomic_write(&path, |out| Ok(out.write_all(b"new")?)).unwrap();
337        assert_eq!(fs::read(&path).unwrap(), b"new");
338        remove(&path);
339    }
340
341    #[test]
342    fn format_from_path() {
343        assert_eq!(
344            Format::from_path(Path::new("a/b.STL")).unwrap(),
345            Format::Stl
346        );
347        assert_eq!(Format::from_path(Path::new("b.Glb")).unwrap(), Format::Glb);
348        assert!(Format::from_path(Path::new("b")).is_err());
349        let message = Format::from_path(Path::new("b.gltf")).unwrap_err();
350        assert!(message.to_string().contains(".glb"));
351    }
352
353    #[test]
354    fn compaction_keeps_vertex_order() {
355        let data = MeshData {
356            positions: vec![
357                9.0, 9.0, 9.0, 0.0, 0.0, 0.0, 8.0, 8.0, 8.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0,
358            ],
359            indices: vec![4, 1, 3],
360        };
361        let out = compacted(&data).unwrap();
362        assert_eq!(out.positions, [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]);
363        assert_eq!(out.indices, [2, 0, 1]);
364    }
365
366    #[test]
367    fn tokens() {
368        let mut at = 0;
369        let bytes = b"  ab\tc\n\n";
370        assert_eq!(next_token(bytes, &mut at), Some(&b"ab"[..]));
371        assert_eq!(next_token(bytes, &mut at), Some(&b"c"[..]));
372        assert_eq!(next_token(bytes, &mut at), None);
373        assert_eq!(next_token(b"", &mut 0), None);
374    }
375}