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    Ok(read_with_quantum(bytes, tol)?.mesh)
97}
98
99/// A mesh read from STL, with how finely its encoding could place a vertex.
100#[derive(Debug, Clone)]
101pub struct StlMesh {
102    /// The triangles, welded.
103    pub mesh: Triangulation,
104    /// How far a vertex may stand from where its writer meant it for the
105    /// encoding alone: half a step of the coordinate grid on each axis, over
106    /// the three axes at once. A binary file's grid is single precision at
107    /// its largest coordinate; an ASCII file's is the finest digit any of its
108    /// coordinates prints, at that same magnitude.
109    pub quantum: f64,
110}
111
112/// Read STL, as [`read`], keeping how finely the encoding placed the
113/// vertices: what a surface fitted to them must allow them before it is the
114/// surface that is wrong.
115///
116/// # Errors
117///
118/// As [`read`].
119pub fn read_with_quantum(bytes: &[u8], tol: Tolerances) -> OgeomResult<StlMesh> {
120    let (mesh, quantum) = if looks_binary(bytes) {
121        read_binary(bytes, tol)?
122    } else {
123        read_ascii(bytes, tol)?
124    };
125    Ok(StlMesh { mesh, quantum })
126}
127
128/// The largest coordinate magnitude of any vertex.
129fn largest(mesh: &Triangulation) -> f64 {
130    mesh.positions
131        .iter()
132        .flat_map(|p| [p.x.abs(), p.y.abs(), p.z.abs()])
133        .fold(0.0_f64, f64::max)
134}
135
136/// The significant digits a printed number carries: its mantissa's digits
137/// from the first nonzero one.
138fn significant_digits(word: &str) -> usize {
139    let mantissa = word.split(['e', 'E']).next().unwrap_or("");
140    mantissa
141        .chars()
142        .filter(char::is_ascii_digit)
143        .skip_while(|&c| c == '0')
144        .count()
145}
146
147/// Whether the bytes are a binary STL.
148///
149/// Decided by arithmetic, not by the leading word. A binary file is 84 bytes of
150/// header and count plus exactly 50 per triangle; nothing else lands on that
151/// length by accident, and plenty of binary files begin with `solid`.
152fn looks_binary(bytes: &[u8]) -> bool {
153    const HEADER_BYTES: usize = 84;
154    const PER_TRIANGLE: usize = 50;
155    if bytes.len() < HEADER_BYTES {
156        return false;
157    }
158    let Ok(count) = <[u8; 4]>::try_from(&bytes[80..84]) else {
159        return false;
160    };
161    let claimed = u32::from_le_bytes(count) as usize;
162    claimed
163        .checked_mul(PER_TRIANGLE)
164        .and_then(|body| body.checked_add(HEADER_BYTES))
165        == Some(bytes.len())
166}
167
168/// Render the ASCII form.
169fn write_ascii(mesh: &Triangulation) -> String {
170    let mut out = String::with_capacity(mesh.triangles.len() * 260);
171    let _ = writeln!(out, "solid {HEADER}");
172    for triangle in &mesh.triangles {
173        let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
174        let n = facet_normal(a, b, c);
175        let _ = writeln!(out, "  facet normal {:e} {:e} {:e}", n.x, n.y, n.z);
176        let _ = writeln!(out, "    outer loop");
177        for p in [a, b, c] {
178            let _ = writeln!(out, "      vertex {:e} {:e} {:e}", p.x, p.y, p.z);
179        }
180        let _ = writeln!(out, "    endloop");
181        let _ = writeln!(out, "  endfacet");
182    }
183    let _ = writeln!(out, "endsolid {HEADER}");
184    out
185}
186
187/// Render the binary form.
188fn write_binary(mesh: &Triangulation) -> Vec<u8> {
189    let mut out = Vec::with_capacity(84 + mesh.triangles.len() * 50);
190    let mut header = [b' '; 80];
191    let name = HEADER.as_bytes();
192    header[..name.len()].copy_from_slice(name);
193    out.extend_from_slice(&header);
194
195    #[allow(clippy::cast_possible_truncation)]
196    out.extend_from_slice(&(mesh.triangles.len() as u32).to_le_bytes());
197
198    for triangle in &mesh.triangles {
199        let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
200        let n = facet_normal(a, b, c);
201        for v in [n, a.to_vector(), b.to_vector(), c.to_vector()] {
202            #[allow(clippy::cast_possible_truncation)]
203            for component in [v.x as f32, v.y as f32, v.z as f32] {
204                out.extend_from_slice(&component.to_le_bytes());
205            }
206        }
207        // The attribute-count field. Some tools smuggle colour through it;
208        // writing anything but zero makes the file unreadable to the ones that
209        // do not expect it.
210        out.extend_from_slice(&0_u16.to_le_bytes());
211    }
212    out
213}
214
215/// Parse the binary form.
216fn read_binary(bytes: &[u8], tol: Tolerances) -> OgeomResult<(Triangulation, f64)> {
217    let Ok(count) = <[u8; 4]>::try_from(&bytes[80..84]) else {
218        ogeom_bail!(Construction, "the binary STL header is truncated");
219    };
220    let count = u32::from_le_bytes(count) as usize;
221
222    let mut mesh = Triangulation::new();
223    for i in 0..count {
224        let at = 84 + i * 50;
225        // The facet normal is read past rather than used; see the module docs.
226        let corners: [Point; 3] = std::array::from_fn(|k| {
227            let base = at + 12 + k * 12;
228            Point::new(
229                f64::from(read_f32(bytes, base)),
230                f64::from(read_f32(bytes, base + 4)),
231                f64::from(read_f32(bytes, base + 8)),
232            )
233        });
234        push(&mut mesh, corners[0], corners[1], corners[2]);
235    }
236    let quantum = ogeom_algo::single_precision_quantum(&mesh);
237    Ok((mesh.welded(tol), quantum))
238}
239
240/// One little-endian `f32`, or zero past the end.
241///
242/// A truncated file is caught by [`looks_binary`] before this runs, so the
243/// fallback is unreachable in practice; returning zero rather than panicking
244/// keeps a malformed file a bad *mesh* instead of a crash.
245fn read_f32(bytes: &[u8], at: usize) -> f32 {
246    <[u8; 4]>::try_from(bytes.get(at..at + 4).unwrap_or(&[0; 4]))
247        .map(f32::from_le_bytes)
248        .unwrap_or(0.0)
249}
250
251/// Parse the ASCII form.
252///
253/// Deliberately lenient about layout: indentation, blank lines, and the solid
254/// name vary between writers, and the keywords are what carry the meaning.
255/// Deliberately strict about a facet having exactly three vertices, because a
256/// facet with four is a quad some writer emitted and silently dropping one
257/// corner would put a hole in the mesh.
258fn read_ascii(bytes: &[u8], tol: Tolerances) -> OgeomResult<(Triangulation, f64)> {
259    let Ok(text) = std::str::from_utf8(bytes) else {
260        ogeom_bail!(
261            Construction,
262            "these bytes are neither binary STL nor valid UTF-8, so they are \
263             not ASCII STL either"
264        );
265    };
266
267    let mut mesh = Triangulation::new();
268    let mut corners: Vec<Point> = Vec::with_capacity(3);
269    let mut in_facet = false;
270    let mut facets = 0_usize;
271    let mut digits = 0_usize;
272
273    for (line_number, line) in text.lines().enumerate() {
274        let mut words = line.split_whitespace();
275        let Some(keyword) = words.next() else {
276            continue;
277        };
278        match keyword {
279            "facet" => {
280                in_facet = true;
281                corners.clear();
282            }
283            "vertex" => {
284                let words: Vec<&str> = words.collect();
285                digits = words
286                    .iter()
287                    .map(|w| significant_digits(w))
288                    .fold(digits, usize::max);
289                let values: Vec<f64> = words.iter().filter_map(|w| w.parse().ok()).collect();
290                if values.len() != 3 {
291                    ogeom_bail!(
292                        Construction,
293                        "line {}: a vertex needs three numbers, got {:?}",
294                        line_number + 1,
295                        line.trim()
296                    );
297                }
298                corners.push(Point::new(values[0], values[1], values[2]));
299            }
300            "endfacet" => {
301                if corners.len() != 3 {
302                    ogeom_bail!(
303                        Construction,
304                        "line {}: a facet has {} vertices; STL facets are \
305                         triangles, and dropping a corner would put a hole in \
306                         the mesh",
307                        line_number + 1,
308                        corners.len()
309                    );
310                }
311                push(&mut mesh, corners[0], corners[1], corners[2]);
312                in_facet = false;
313                facets += 1;
314            }
315            _ => {}
316        }
317    }
318
319    if in_facet {
320        ogeom_bail!(Construction, "the file ends part-way through a facet");
321    }
322    if facets == 0 {
323        ogeom_bail!(
324            Construction,
325            "no facets found; these bytes are not STL of either kind"
326        );
327    }
328    // The finest digit printed, at the magnitude of the largest coordinate:
329    // a writer printing a fixed count of significant digits puts its last
330    // one there, and one printing fixed decimals prints more there than
331    // anywhere.
332    let magnitude = largest(&mesh);
333    let quantum = if magnitude > 0.0 && digits > 0 {
334        let digits = f64::from(u32::try_from(digits).unwrap_or(u32::MAX));
335        let place = magnitude.log10().floor() - digits + 1.0;
336        10.0_f64.powf(place) * 3.0_f64.sqrt() / 2.0
337    } else {
338        ogeom_algo::single_precision_quantum(&mesh)
339    };
340    Ok((mesh.welded(tol), quantum))
341}
342
343/// Append one triangle, with its vertices unshared.
344///
345/// Welding happens afterwards, over the whole mesh at once. Sharing while reading
346/// would need a lookup per vertex against everything read so far, and get the
347/// same answer more slowly.
348fn push(mesh: &mut Triangulation, a: Point, b: Point, c: Point) {
349    #[allow(clippy::cast_possible_truncation)]
350    let base = mesh.positions.len() as u32;
351    let normal = facet_normal(a, b, c);
352    for p in [a, b, c] {
353        mesh.positions.push(p);
354        mesh.normals.push(normal);
355        mesh.parameters.push((0.0, 0.0));
356    }
357    mesh.triangles.push([base, base + 1, base + 2]);
358}
359
360/// The unit normal a triangle's winding implies, or zero if it has no area.
361fn facet_normal(a: Point, b: Point, c: Point) -> Vector {
362    let n = (b - a).cross(c - a);
363    let length = n.magnitude();
364    if length <= f64::MIN_POSITIVE {
365        Vector::ZERO
366    } else {
367        n / length
368    }
369}
370
371#[cfg(test)]
372#[allow(clippy::unwrap_used, clippy::expect_used)]
373mod tests {
374    use super::*;
375    use approx::assert_relative_eq;
376
377    const T: Tolerances = Tolerances::millimetres();
378
379    /// A unit tetrahedron, wound outward.
380    fn tetrahedron() -> Triangulation {
381        let mut mesh = Triangulation::new();
382        mesh.positions = vec![
383            Point::ORIGIN,
384            Point::new(1.0, 0.0, 0.0),
385            Point::new(0.0, 1.0, 0.0),
386            Point::new(0.0, 0.0, 1.0),
387        ];
388        mesh.normals = vec![Vector::Z; 4];
389        mesh.parameters = vec![(0.0, 0.0); 4];
390        mesh.triangles = vec![[0, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]];
391        mesh
392    }
393
394    #[test]
395    fn a_mesh_survives_a_round_trip_through_either_encoding() {
396        let original = tetrahedron();
397        for encoding in [Encoding::Ascii, Encoding::Binary] {
398            let bytes = write(&original, encoding).unwrap();
399            let back = read(&bytes, T).unwrap();
400
401            assert_eq!(
402                back.triangle_count(),
403                original.triangle_count(),
404                "{encoding:?}"
405            );
406            assert_eq!(
407                back.vertex_count(),
408                original.vertex_count(),
409                "{encoding:?}: welding should recover the shared corners"
410            );
411            assert!(back.is_closed(), "{encoding:?}: the tetrahedron came apart");
412            assert_relative_eq!(back.volume(), original.volume(), epsilon = 1e-6);
413        }
414    }
415
416    #[test]
417    fn the_encoding_is_detected_by_length_not_by_the_leading_word() {
418        // Several well-known programs write binary files whose 80-byte header
419        // begins with "solid". Sniffing the first word sends those down the
420        // ASCII path and produces an empty mesh with no error.
421        let mut bytes = write(&tetrahedron(), Encoding::Binary).unwrap();
422        bytes[..5].copy_from_slice(b"solid");
423
424        assert!(looks_binary(&bytes), "length is what decides");
425        let back = read(&bytes, T).unwrap();
426        assert_eq!(back.triangle_count(), 4);
427    }
428
429    #[test]
430    fn the_written_normal_agrees_with_the_winding() {
431        // A reader that trusts the facet normal should not be punished for it,
432        // even though this one does not.
433        let text = String::from_utf8(write(&tetrahedron(), Encoding::Ascii).unwrap()).unwrap();
434        let normals: Vec<Vec<f64>> = text
435            .lines()
436            .filter(|l| l.trim_start().starts_with("facet normal"))
437            .map(|l| {
438                l.split_whitespace()
439                    .filter_map(|w| w.parse().ok())
440                    .collect()
441            })
442            .collect();
443        assert_eq!(normals.len(), 4);
444
445        let mesh = tetrahedron();
446        for (written, triangle) in normals.iter().zip(&mesh.triangles) {
447            let [a, b, c] = triangle.map(|i| mesh.positions[i as usize]);
448            let expected = facet_normal(a, b, c);
449            assert_relative_eq!(written[0], expected.x, epsilon = 1e-9);
450            assert_relative_eq!(written[1], expected.y, epsilon = 1e-9);
451            assert_relative_eq!(written[2], expected.z, epsilon = 1e-9);
452        }
453    }
454
455    #[test]
456    fn a_read_mesh_takes_its_normals_from_the_winding_not_the_file() {
457        // Files with zeroed or inverted facet normals are common. Trusting them
458        // makes a mesh that renders inside out and whose volume comes out
459        // negative, with nothing in the geometry to say why.
460        let lying = "solid liar
461  facet normal 0 0 -1
462    outer loop
463      vertex 0 0 0
464      vertex 1 0 0
465      vertex 0 1 0
466    endloop
467  endfacet
468endsolid liar
469";
470        let mesh = read(lying.as_bytes(), T).unwrap();
471        assert_eq!(mesh.triangle_count(), 1);
472        for normal in &mesh.normals {
473            assert_relative_eq!(normal.z, 1.0, epsilon = 1e-12);
474        }
475    }
476
477    #[test]
478    fn a_facet_that_is_not_a_triangle_is_refused_rather_than_trimmed() {
479        // Some writers emit quads. Keeping the first three corners looks like it
480        // works and leaves a hole where the fourth was.
481        let quad = "solid q
482  facet normal 0 0 1
483    outer loop
484      vertex 0 0 0
485      vertex 1 0 0
486      vertex 1 1 0
487      vertex 0 1 0
488    endloop
489  endfacet
490endsolid q
491";
492        let refused = read(quad.as_bytes(), T);
493        assert!(refused.is_err());
494        assert!(format!("{}", refused.unwrap_err()).contains("triangles"));
495    }
496
497    #[test]
498    fn a_truncated_or_empty_file_is_refused() {
499        assert!(read(b"", T).is_err());
500        assert!(read(b"solid x\nendsolid x\n", T).is_err(), "no facets");
501        assert!(
502            read(
503                b"solid x\n  facet normal 0 0 1\n    outer loop\n      vertex 0 0 0\n",
504                T
505            )
506            .is_err(),
507            "ends inside a facet"
508        );
509    }
510
511    #[test]
512    fn a_mesh_with_nothing_in_it_is_not_written() {
513        assert!(write(&Triangulation::new(), Encoding::Ascii).is_err());
514
515        let mut broken = tetrahedron();
516        broken.triangles.push([0, 1, 99]);
517        assert!(write(&broken, Encoding::Binary).is_err());
518    }
519
520    #[test]
521    fn binary_is_the_length_the_format_says_it_is() {
522        let mesh = tetrahedron();
523        let bytes = write(&mesh, Encoding::Binary).unwrap();
524        assert_eq!(bytes.len(), 84 + 4 * 50);
525        assert_eq!(&bytes[..5], b"ogeom");
526        assert_eq!(u32::from_le_bytes(bytes[80..84].try_into().unwrap()), 4);
527    }
528
529    #[test]
530    fn a_binary_round_trip_loses_precision_and_the_docs_say_so() {
531        // Not a defect to fix; the format stores f32. The test exists so that
532        // if someone later "fixes" a failing comparison by loosening a
533        // tolerance, they meet this instead and learn where the loss comes from.
534        let mut mesh = tetrahedron();
535        mesh.positions[1] = Point::new(1.000_000_1, 0.0, 0.0);
536        let back = read(&write(&mesh, Encoding::Binary).unwrap(), T).unwrap();
537
538        let moved = back
539            .positions
540            .iter()
541            .any(|p| (p.x - 1.000_000_1).abs() > 1e-9 && (p.x - 1.0).abs() < 1e-3);
542        assert!(moved, "f32 should have rounded the seventh digit away");
543    }
544
545    #[test]
546    fn a_binary_file_s_quantum_is_single_precision_at_its_largest_coordinate() {
547        let mut mesh = tetrahedron();
548        for p in &mut mesh.positions {
549            *p = Point::new(p.x + 100.0, p.y, p.z);
550        }
551        let read = read_with_quantum(&write(&mesh, Encoding::Binary).unwrap(), T).unwrap();
552        // Single-precision values between 64 and 128 are 2^-17 apart; half
553        // a step on each of three axes.
554        let want = 2.0_f64.powi(-17) * 3.0_f64.sqrt() / 2.0;
555        assert!(
556            (read.quantum - want).abs() < want * 1e-9,
557            "{}",
558            read.quantum
559        );
560    }
561
562    #[test]
563    fn an_ascii_file_s_quantum_is_its_finest_digit_at_its_largest_coordinate() {
564        // Six significant digits at magnitudes up to 250: the last digit is
565        // the thousandth, for coordinates both large and small.
566        let text = "solid t\nfacet normal 0 0 0\nouter loop\n\
567                    vertex 0 0 0\nvertex 250.125 0 0\nvertex 0 1.25000 0\n\
568                    endloop\nendfacet\nendsolid t\n";
569        let read = read_with_quantum(text.as_bytes(), T).unwrap();
570        let want = 1e-3 * 3.0_f64.sqrt() / 2.0;
571        assert!(
572            (read.quantum - want).abs() < want * 1e-9,
573            "{}",
574            read.quantum
575        );
576    }
577}