Skip to main content

ogeom_io/
stl.rs

1//! STL: a triangle soup, in ASCII or binary.
2//!
3//! The format has no notion of a shared vertex (every triangle names its three
4//! corners in full) and no notion of topology at all. Writing to it therefore
5//! *loses* everything the kernel knows: which triangles belong to which face,
6//! which edges were exact, what the surfaces actually were. That is the format's
7//! nature and not a shortcoming of this writer.
8//!
9//! # Reading gives a mesh, not a shape
10//!
11//! [`read`] returns a [`Triangulation`], not a `Shape`, and that is deliberate.
12//! Recovering a B-rep from a triangle soup means deciding which triangles are
13//! coplanar enough to be one face, which chains of edges are one curve, and
14//! what surface each face was cut from: that is surface reconstruction, a
15//! research problem, not a file format concern. A function returning a `Shape`
16//! here would have to guess, and the guess would be wrong in ways nothing
17//! downstream could detect.
18//!
19//! # The normals are written and ignored
20//!
21//! Every STL triangle carries a facet normal. Most writers get it right, some
22//! write zeros, and some write it inconsistent with the winding. [`read`]
23//! therefore recomputes normals from the winding and discards what the file
24//! said, which is what every robust reader does. [`write()`] emits the true
25//! normal, because a reader that trusts it should not be punished for it.
26
27use std::fmt::Write as _;
28
29use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
30use ogeom_math::{Point, Vector};
31use ogeom_topo::Triangulation;
32
33/// Which encoding to write.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Encoding {
36    /// Human-readable. Roughly six times the size and exact only to the
37    /// precision printed.
38    Ascii,
39    /// Compact and exact, but `f32`; see [`write()`].
40    Binary,
41}
42
43/// The header a binary STL carries, and the name an ASCII one does.
44const HEADER: &str = "ogeom";
45
46/// Write a triangulation as STL.
47///
48/// # Precision
49///
50/// STL stores coordinates as `f32` in binary and as printed decimals in ASCII.
51/// The kernel works in `f64`. A round trip therefore loses precision (about
52/// seven significant digits in binary), and a model far from the origin loses
53/// it where it matters most: a part at 1e6 units has a binary STL resolution of
54/// about 0.06 units. This writes what the format can hold; it does not pretend
55/// the result round-trips exactly, and [`read`] will not return what was
56/// written.
57///
58/// # Errors
59///
60/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the mesh has no
61/// triangles, or holds a triangle index that is not a vertex.
62pub fn write(mesh: &Triangulation, encoding: Encoding) -> OgeomResult<Vec<u8>> {
63    if mesh.triangles.is_empty() {
64        ogeom_bail!(Construction, "an STL with no triangles describes nothing");
65    }
66    for triangle in &mesh.triangles {
67        for index in triangle {
68            if *index as usize >= mesh.positions.len() {
69                ogeom_bail!(
70                    Construction,
71                    "a triangle names vertex {index}, and the mesh has {}",
72                    mesh.positions.len()
73                );
74            }
75        }
76    }
77    Ok(match encoding {
78        Encoding::Ascii => write_ascii(mesh).into_bytes(),
79        Encoding::Binary => write_binary(mesh),
80    })
81}
82
83/// Read STL, in either encoding.
84///
85/// The encoding is detected rather than asked for: a file that starts with
86/// `solid` is *usually* ASCII, but binary files written by several well-known
87/// programs start with it too, because their 80-byte header happens to. The
88/// reliable test is whether the file's length matches what its triangle count
89/// claims, so that is the test used.
90///
91/// # Errors
92///
93/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the bytes are
94/// not STL of either kind, or are truncated part-way through a triangle.
95pub fn read(bytes: &[u8], tol: Tolerances) -> OgeomResult<Triangulation> {
96    if looks_binary(bytes) {
97        read_binary(bytes, tol)
98    } else {
99        read_ascii(bytes, tol)
100    }
101}
102
103/// Whether the bytes are a binary STL.
104///
105/// Decided by arithmetic, not by the leading word. A binary file is 84 bytes of
106/// header and count plus exactly 50 per triangle; nothing else lands on that
107/// length by accident, and plenty of binary files begin with `solid`.
108fn looks_binary(bytes: &[u8]) -> bool {
109    const HEADER_BYTES: usize = 84;
110    const PER_TRIANGLE: usize = 50;
111    if bytes.len() < HEADER_BYTES {
112        return false;
113    }
114    let Ok(count) = <[u8; 4]>::try_from(&bytes[80..84]) else {
115        return false;
116    };
117    let claimed = u32::from_le_bytes(count) as usize;
118    bytes.len() == HEADER_BYTES + claimed * PER_TRIANGLE
119}
120
121/// Render the ASCII form.
122fn write_ascii(mesh: &Triangulation) -> String {
123    let mut out = String::with_capacity(mesh.triangles.len() * 260);
124    let _ = writeln!(out, "solid {HEADER}");
125    for triangle in &mesh.triangles {
126        let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
127        let n = facet_normal(a, b, c);
128        let _ = writeln!(out, "  facet normal {:e} {:e} {:e}", n.x, n.y, n.z);
129        let _ = writeln!(out, "    outer loop");
130        for p in [a, b, c] {
131            let _ = writeln!(out, "      vertex {:e} {:e} {:e}", p.x, p.y, p.z);
132        }
133        let _ = writeln!(out, "    endloop");
134        let _ = writeln!(out, "  endfacet");
135    }
136    let _ = writeln!(out, "endsolid {HEADER}");
137    out
138}
139
140/// Render the binary form.
141fn write_binary(mesh: &Triangulation) -> Vec<u8> {
142    let mut out = Vec::with_capacity(84 + mesh.triangles.len() * 50);
143    let mut header = [b' '; 80];
144    let name = HEADER.as_bytes();
145    header[..name.len()].copy_from_slice(name);
146    out.extend_from_slice(&header);
147
148    #[allow(clippy::cast_possible_truncation)]
149    out.extend_from_slice(&(mesh.triangles.len() as u32).to_le_bytes());
150
151    for triangle in &mesh.triangles {
152        let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
153        let n = facet_normal(a, b, c);
154        for v in [n, a.to_vector(), b.to_vector(), c.to_vector()] {
155            #[allow(clippy::cast_possible_truncation)]
156            for component in [v.x as f32, v.y as f32, v.z as f32] {
157                out.extend_from_slice(&component.to_le_bytes());
158            }
159        }
160        // The attribute-count field. Some tools smuggle colour through it;
161        // writing anything but zero makes the file unreadable to the ones that
162        // do not expect it.
163        out.extend_from_slice(&0_u16.to_le_bytes());
164    }
165    out
166}
167
168/// Parse the binary form.
169fn read_binary(bytes: &[u8], tol: Tolerances) -> OgeomResult<Triangulation> {
170    let Ok(count) = <[u8; 4]>::try_from(&bytes[80..84]) else {
171        ogeom_bail!(Construction, "the binary STL header is truncated");
172    };
173    let count = u32::from_le_bytes(count) as usize;
174
175    let mut mesh = Triangulation::new();
176    for i in 0..count {
177        let at = 84 + i * 50;
178        // The facet normal is read past rather than used; see the module docs.
179        let corners: Vec<Point> = (0..3)
180            .map(|k| {
181                let base = at + 12 + k * 12;
182                Point::new(
183                    f64::from(read_f32(bytes, base)),
184                    f64::from(read_f32(bytes, base + 4)),
185                    f64::from(read_f32(bytes, base + 8)),
186                )
187            })
188            .collect();
189        push(&mut mesh, corners[0], corners[1], corners[2]);
190    }
191    Ok(mesh.welded(tol))
192}
193
194/// One little-endian `f32`, or zero past the end.
195///
196/// A truncated file is caught by [`looks_binary`] before this runs, so the
197/// fallback is unreachable in practice; returning zero rather than panicking
198/// keeps a malformed file a bad *mesh* instead of a crash.
199fn read_f32(bytes: &[u8], at: usize) -> f32 {
200    <[u8; 4]>::try_from(bytes.get(at..at + 4).unwrap_or(&[0; 4]))
201        .map(f32::from_le_bytes)
202        .unwrap_or(0.0)
203}
204
205/// Parse the ASCII form.
206///
207/// Deliberately lenient about layout: indentation, blank lines, and the solid
208/// name vary between writers, and the keywords are what carry the meaning.
209/// Deliberately strict about a facet having exactly three vertices, because a
210/// facet with four is a quad some writer emitted and silently dropping one
211/// corner would put a hole in the mesh.
212fn read_ascii(bytes: &[u8], tol: Tolerances) -> OgeomResult<Triangulation> {
213    let Ok(text) = std::str::from_utf8(bytes) else {
214        ogeom_bail!(
215            Construction,
216            "these bytes are neither binary STL nor valid UTF-8, so they are \
217             not ASCII STL either"
218        );
219    };
220
221    let mut mesh = Triangulation::new();
222    let mut corners: Vec<Point> = Vec::with_capacity(3);
223    let mut in_facet = false;
224    let mut facets = 0_usize;
225
226    for (line_number, line) in text.lines().enumerate() {
227        let mut words = line.split_whitespace();
228        let Some(keyword) = words.next() else {
229            continue;
230        };
231        match keyword {
232            "facet" => {
233                in_facet = true;
234                corners.clear();
235            }
236            "vertex" => {
237                let values: Vec<f64> = words.filter_map(|w| w.parse().ok()).collect();
238                if values.len() != 3 {
239                    ogeom_bail!(
240                        Construction,
241                        "line {}: a vertex needs three numbers, got {:?}",
242                        line_number + 1,
243                        line.trim()
244                    );
245                }
246                corners.push(Point::new(values[0], values[1], values[2]));
247            }
248            "endfacet" => {
249                if corners.len() != 3 {
250                    ogeom_bail!(
251                        Construction,
252                        "line {}: a facet has {} vertices; STL facets are \
253                         triangles, and dropping a corner would put a hole in \
254                         the mesh",
255                        line_number + 1,
256                        corners.len()
257                    );
258                }
259                push(&mut mesh, corners[0], corners[1], corners[2]);
260                in_facet = false;
261                facets += 1;
262            }
263            _ => {}
264        }
265    }
266
267    if in_facet {
268        ogeom_bail!(Construction, "the file ends part-way through a facet");
269    }
270    if facets == 0 {
271        ogeom_bail!(
272            Construction,
273            "no facets found; these bytes are not STL of either kind"
274        );
275    }
276    Ok(mesh.welded(tol))
277}
278
279/// Append one triangle, with its vertices unshared.
280///
281/// Welding happens afterwards, over the whole mesh at once. Sharing as we go
282/// would need a lookup per vertex against everything read so far, and get the
283/// same answer more slowly.
284fn push(mesh: &mut Triangulation, a: Point, b: Point, c: Point) {
285    #[allow(clippy::cast_possible_truncation)]
286    let base = mesh.positions.len() as u32;
287    let normal = facet_normal(a, b, c);
288    for p in [a, b, c] {
289        mesh.positions.push(p);
290        mesh.normals.push(normal);
291        mesh.parameters.push((0.0, 0.0));
292    }
293    mesh.triangles.push([base, base + 1, base + 2]);
294}
295
296/// The unit normal a triangle's winding implies, or zero if it has no area.
297fn facet_normal(a: Point, b: Point, c: Point) -> Vector {
298    let n = (b - a).cross(c - a);
299    let length = n.magnitude();
300    if length <= f64::MIN_POSITIVE {
301        Vector::ZERO
302    } else {
303        n / length
304    }
305}
306
307#[cfg(test)]
308#[allow(clippy::unwrap_used, clippy::expect_used)]
309mod tests {
310    use super::*;
311    use approx::assert_relative_eq;
312
313    const T: Tolerances = Tolerances::millimetres();
314
315    /// A unit tetrahedron, wound outward.
316    fn tetrahedron() -> Triangulation {
317        let mut mesh = Triangulation::new();
318        mesh.positions = vec![
319            Point::ORIGIN,
320            Point::new(1.0, 0.0, 0.0),
321            Point::new(0.0, 1.0, 0.0),
322            Point::new(0.0, 0.0, 1.0),
323        ];
324        mesh.normals = vec![Vector::Z; 4];
325        mesh.parameters = vec![(0.0, 0.0); 4];
326        mesh.triangles = vec![[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]];
327        mesh
328    }
329
330    #[test]
331    fn a_mesh_survives_a_round_trip_through_either_encoding() {
332        let original = tetrahedron();
333        for encoding in [Encoding::Ascii, Encoding::Binary] {
334            let bytes = write(&original, encoding).unwrap();
335            let back = read(&bytes, T).unwrap();
336
337            assert_eq!(
338                back.triangle_count(),
339                original.triangle_count(),
340                "{encoding:?}"
341            );
342            assert_eq!(
343                back.vertex_count(),
344                original.vertex_count(),
345                "{encoding:?}: welding should recover the shared corners"
346            );
347            assert!(back.is_closed(), "{encoding:?}: the tetrahedron came apart");
348            assert_relative_eq!(back.volume(), original.volume(), epsilon = 1e-6);
349        }
350    }
351
352    #[test]
353    fn the_encoding_is_detected_by_length_not_by_the_leading_word() {
354        // Several well-known programs write binary files whose 80-byte header
355        // begins with "solid". Sniffing the first word sends those down the
356        // ASCII path and produces an empty mesh with no error.
357        let mut bytes = write(&tetrahedron(), Encoding::Binary).unwrap();
358        bytes[..5].copy_from_slice(b"solid");
359
360        assert!(looks_binary(&bytes), "length is what decides");
361        let back = read(&bytes, T).unwrap();
362        assert_eq!(back.triangle_count(), 4);
363    }
364
365    #[test]
366    fn the_written_normal_agrees_with_the_winding() {
367        // A reader that trusts the facet normal should not be punished for it,
368        // even though this one does not.
369        let text = String::from_utf8(write(&tetrahedron(), Encoding::Ascii).unwrap()).unwrap();
370        let normals: Vec<Vec<f64>> = text
371            .lines()
372            .filter(|l| l.trim_start().starts_with("facet normal"))
373            .map(|l| {
374                l.split_whitespace()
375                    .filter_map(|w| w.parse().ok())
376                    .collect()
377            })
378            .collect();
379        assert_eq!(normals.len(), 4);
380
381        let mesh = tetrahedron();
382        for (written, triangle) in normals.iter().zip(&mesh.triangles) {
383            let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
384            let expected = facet_normal(a, b, c);
385            assert_relative_eq!(written[0], expected.x, epsilon = 1e-9);
386            assert_relative_eq!(written[1], expected.y, epsilon = 1e-9);
387            assert_relative_eq!(written[2], expected.z, epsilon = 1e-9);
388        }
389    }
390
391    #[test]
392    fn a_read_mesh_takes_its_normals_from_the_winding_not_the_file() {
393        // Files with zeroed or inverted facet normals are common. Trusting them
394        // makes a mesh that renders inside out and whose volume comes out
395        // negative, with nothing in the geometry to say why.
396        let lying = "solid liar
397  facet normal 0 0 -1
398    outer loop
399      vertex 0 0 0
400      vertex 1 0 0
401      vertex 0 1 0
402    endloop
403  endfacet
404endsolid liar
405";
406        let mesh = read(lying.as_bytes(), T).unwrap();
407        assert_eq!(mesh.triangle_count(), 1);
408        for normal in &mesh.normals {
409            assert_relative_eq!(normal.z, 1.0, epsilon = 1e-12);
410        }
411    }
412
413    #[test]
414    fn a_facet_that_is_not_a_triangle_is_refused_rather_than_trimmed() {
415        // Some writers emit quads. Keeping the first three corners looks like it
416        // works and leaves a hole where the fourth was.
417        let quad = "solid q
418  facet normal 0 0 1
419    outer loop
420      vertex 0 0 0
421      vertex 1 0 0
422      vertex 1 1 0
423      vertex 0 1 0
424    endloop
425  endfacet
426endsolid q
427";
428        let refused = read(quad.as_bytes(), T);
429        assert!(refused.is_err());
430        assert!(format!("{}", refused.unwrap_err()).contains("triangles"));
431    }
432
433    #[test]
434    fn a_truncated_or_empty_file_is_refused() {
435        assert!(read(b"", T).is_err());
436        assert!(read(b"solid x\nendsolid x\n", T).is_err(), "no facets");
437        assert!(
438            read(
439                b"solid x\n  facet normal 0 0 1\n    outer loop\n      vertex 0 0 0\n",
440                T
441            )
442            .is_err(),
443            "ends inside a facet"
444        );
445    }
446
447    #[test]
448    fn a_mesh_with_nothing_in_it_is_not_written() {
449        assert!(write(&Triangulation::new(), Encoding::Ascii).is_err());
450
451        let mut broken = tetrahedron();
452        broken.triangles.push([0, 1, 99]);
453        assert!(write(&broken, Encoding::Binary).is_err());
454    }
455
456    #[test]
457    fn binary_is_the_length_the_format_says_it_is() {
458        let mesh = tetrahedron();
459        let bytes = write(&mesh, Encoding::Binary).unwrap();
460        assert_eq!(bytes.len(), 84 + 4 * 50);
461        assert_eq!(&bytes[..5], b"ogeom");
462        assert_eq!(u32::from_le_bytes(bytes[80..84].try_into().unwrap()), 4);
463    }
464
465    #[test]
466    fn a_binary_round_trip_loses_precision_and_the_docs_say_so() {
467        // Not a defect to fix; the format stores f32. The test exists so that
468        // if someone later "fixes" a failing comparison by loosening a
469        // tolerance, they meet this instead and learn where the loss comes from.
470        let mut mesh = tetrahedron();
471        mesh.positions[1] = Point::new(1.000_000_1, 0.0, 0.0);
472        let back = read(&write(&mesh, Encoding::Binary).unwrap(), T).unwrap();
473
474        let moved = back
475            .positions
476            .iter()
477            .any(|p| (p.x - 1.000_000_1).abs() > 1e-9 && (p.x - 1.0).abs() < 1e-3);
478        assert!(moved, "f32 should have rounded the seventh digit away");
479    }
480}