Skip to main content

geop_ops_rasterize/
stl.rs

1//! STL export: a [`RasterizedModel`]'s faces as a triangle mesh file.
2//!
3//! STL is a bag of triangles, each with a normal and its three corners
4//! listed counter-clockwise seen from outside. The triangles here are the
5//! ones the viewer draws (see [`crate::rasterize_model_tagged`]), so an
6//! exported mesh is exactly what is on screen. Their winding comes from the
7//! `(u, v)` triangulation, which says nothing about outside; the surface
8//! normals do — every face's surface normal points out of its solid (see
9//! `geop_core_topology::validation::face_orientation`) — so each triangle
10//! is wound to agree with them.
11//!
12//! Faces are sampled one by one, so two faces sharing an edge each sample
13//! it on their own: the mesh is as watertight as those samplings agree.
14
15use std::io::{self, Write};
16
17use geop_core_math::{primitives::TriangleFace, scalars::Scalar, vector::Vector3};
18use geop_core_topology::FaceId;
19
20use crate::RasterizedModel;
21
22/// One STL facet: its outward normal, and its corners counter-clockwise
23/// seen from outside.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub struct StlTriangle {
26    pub normal: [f32; 3],
27    pub corners: [[f32; 3]; 3],
28}
29
30/// Which of STL's two encodings to write.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum StlFormat {
33    /// Compact, and what most tools expect.
34    Binary,
35    /// Plain text, one line per normal and corner — readable and diffable.
36    Ascii,
37}
38
39fn f32s<S: Scalar>(v: &Vector3<S>) -> [f32; 3] {
40    [
41        v[0].to_f64() as f32,
42        v[1].to_f64() as f32,
43        v[2].to_f64() as f32,
44    ]
45}
46
47/// `t` as an STL facet, wound to face the way its surface does: outward.
48/// A triangle without surface normals (at a pole) keeps its winding.
49fn outward<S: Scalar>(t: &TriangleFace<S>) -> StlTriangle {
50    let flat = f32s(&t.normal);
51    let surface = t.vertex_normals.map(|ns| {
52        ns.iter().map(f32s).fold([0.0; 3], |acc, n| {
53            [acc[0] + n[0], acc[1] + n[1], acc[2] + n[2]]
54        })
55    });
56    let agrees = surface.is_none_or(|s| flat[0] * s[0] + flat[1] * s[1] + flat[2] * s[2] >= 0.0);
57    let [a, b, c] = [f32s(&t.a), f32s(&t.b), f32s(&t.c)];
58    if agrees {
59        StlTriangle {
60            normal: flat,
61            corners: [a, b, c],
62        }
63    } else {
64        StlTriangle {
65            normal: flat.map(|x| -x),
66            corners: [a, c, b],
67        }
68    }
69}
70
71/// The triangles of `faces` of `raster`, in the order given, wound outward.
72pub fn stl_triangles<S: Scalar>(raster: &RasterizedModel<S>, faces: &[FaceId]) -> Vec<StlTriangle> {
73    faces
74        .iter()
75        .filter_map(|face| raster.faces.get(face))
76        .flatten()
77        .map(outward)
78        .collect()
79}
80
81/// Write `triangles` as an STL file named `name` (the solid name an ASCII
82/// file carries, and the header of a binary one).
83pub fn write_stl(
84    triangles: &[StlTriangle],
85    name: &str,
86    format: StlFormat,
87    out: &mut impl Write,
88) -> io::Result<()> {
89    match format {
90        StlFormat::Binary => {
91            // An 80-byte header, which must not start with "solid" — some
92            // readers take that for an ASCII file.
93            let mut header = [b' '; 80];
94            let text = format!("geop {name}");
95            let len = text.len().min(80);
96            header[..len].copy_from_slice(&text.as_bytes()[..len]);
97            out.write_all(&header)?;
98            let count = u32::try_from(triangles.len())
99                .map_err(|_| io::Error::other("too many triangles for a binary STL file"))?;
100            out.write_all(&count.to_le_bytes())?;
101            for t in triangles {
102                for v in std::iter::once(&t.normal).chain(&t.corners) {
103                    for x in v {
104                        out.write_all(&x.to_le_bytes())?;
105                    }
106                }
107                // Attribute byte count, unused.
108                out.write_all(&0u16.to_le_bytes())?;
109            }
110        }
111        StlFormat::Ascii => {
112            // A name is a single word in ASCII STL.
113            let name: String = name
114                .chars()
115                .map(|c| if c.is_whitespace() { '_' } else { c })
116                .collect();
117            writeln!(out, "solid {name}")?;
118            for t in triangles {
119                let [nx, ny, nz] = t.normal;
120                writeln!(out, "  facet normal {nx:e} {ny:e} {nz:e}")?;
121                writeln!(out, "    outer loop")?;
122                for [x, y, z] in t.corners {
123                    writeln!(out, "      vertex {x:e} {y:e} {z:e}")?;
124                }
125                writeln!(out, "    endloop")?;
126                writeln!(out, "  endfacet")?;
127            }
128            writeln!(out, "endsolid {name}")?;
129        }
130    }
131    Ok(())
132}
133
134#[cfg(test)]
135mod tests {
136    use geop_core_math::{scalars::scal_in_f64::ScalInF64, vector::Vector3};
137    use geop_core_part::Part;
138    use geop_ops_extrude_revolve::{cube_solid, sphere::sphere_solid};
139
140    use super::*;
141    use crate::rasterize_model_tagged;
142
143    type S = ScalInF64;
144
145    fn triangles_of(part: &Part<S>) -> Vec<StlTriangle> {
146        let raster = rasterize_model_tagged(part.topology(), 8).unwrap();
147        let mut faces: Vec<FaceId> = raster.faces.keys().copied().collect();
148        faces.sort_by_key(|f| f.0);
149        stl_triangles(&raster, &faces)
150    }
151
152    fn unit_cube() -> Vec<StlTriangle> {
153        let mut part = Part::<S>::new();
154        cube_solid(
155            &mut part,
156            "c",
157            Vector3::from_array([S::ZERO; 3]),
158            Vector3::from_array([S::ONE; 3]),
159        )
160        .unwrap();
161        triangles_of(&part)
162    }
163
164    /// Every facet of a closed convex solid around `centre` faces away from
165    /// it, by its winding and by its normal.
166    fn assert_outward(triangles: &[StlTriangle], centre: [f32; 3]) {
167        assert!(!triangles.is_empty());
168        let sub = |p: [f32; 3], q: [f32; 3]| [p[0] - q[0], p[1] - q[1], p[2] - q[2]];
169        let dot = |p: [f32; 3], q: [f32; 3]| p[0] * q[0] + p[1] * q[1] + p[2] * q[2];
170        for t in triangles {
171            let [a, b, c] = t.corners;
172            let (u, v) = (sub(b, a), sub(c, a));
173            let cross = [
174                u[1] * v[2] - u[2] * v[1],
175                u[2] * v[0] - u[0] * v[2],
176                u[0] * v[1] - u[1] * v[0],
177            ];
178            let out = sub(a, centre);
179            assert!(dot(cross, out) > 0.0, "{t:?} winds inward");
180            assert!(dot(t.normal, out) > 0.0, "{t:?} has an inward normal");
181        }
182    }
183
184    #[test]
185    fn cube_facets_face_outward() {
186        assert_outward(&unit_cube(), [0.5; 3]);
187    }
188
189    /// Curved faces: the winding has to come from the surface normals.
190    #[test]
191    fn sphere_facets_face_outward() {
192        let mut part = Part::<S>::new();
193        sphere_solid(&mut part, "s", Vector3::from_array([S::ZERO; 3]), S::ONE).unwrap();
194        assert_outward(&triangles_of(&part), [0.0; 3]);
195    }
196
197    #[test]
198    fn binary_layout() {
199        let triangles = unit_cube();
200        let mut bytes = Vec::new();
201        write_stl(&triangles, "cube", StlFormat::Binary, &mut bytes).unwrap();
202        assert_eq!(bytes.len(), 84 + 50 * triangles.len());
203        assert!(!bytes.starts_with(b"solid"));
204        assert_eq!(
205            u32::from_le_bytes(bytes[80..84].try_into().unwrap()) as usize,
206            triangles.len()
207        );
208    }
209
210    #[test]
211    fn ascii_layout() {
212        let triangles = unit_cube();
213        let mut bytes = Vec::new();
214        write_stl(&triangles, "my cube", StlFormat::Ascii, &mut bytes).unwrap();
215        let text = String::from_utf8(bytes).unwrap();
216        assert!(text.starts_with("solid my_cube\n"));
217        assert!(text.trim_end().ends_with("endsolid my_cube"));
218        assert_eq!(text.matches("facet normal").count(), triangles.len());
219        assert_eq!(text.matches("vertex").count(), 3 * triangles.len());
220    }
221}