Skip to main content

geop_core_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`]), 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 meet along their edges' shared samples, and at each other's
13//! points along them (see [`crate::rasterize`]): a solid's mesh is closed, every
14//! side of a triangle the side of one other, the other way round.
15
16use std::io::{self, Write};
17
18use geop_core_math::{primitives::TriangleFace, vector::Vector3};
19use geop_core_topology::FaceId;
20
21use crate::RasterizedModel;
22
23/// One STL facet: its outward normal, and its corners counter-clockwise
24/// seen from outside.
25#[derive(Clone, Copy, Debug, PartialEq)]
26pub struct StlTriangle {
27    pub normal: [f32; 3],
28    pub corners: [[f32; 3]; 3],
29}
30
31/// Which of STL's two encodings to write.
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum StlFormat {
34    /// Compact, and what most tools expect.
35    Binary,
36    /// Plain text, one line per normal and corner — readable and diffable.
37    Ascii,
38}
39
40fn f32s(v: &Vector3) -> [f32; 3] {
41    [
42        v[0].to_f64() as f32,
43        v[1].to_f64() as f32,
44        v[2].to_f64() as f32,
45    ]
46}
47
48/// `t` as an STL facet, wound to face the way its surface does: outward.
49/// A triangle without surface normals (at a pole) keeps its winding.
50pub fn outward(t: &TriangleFace) -> StlTriangle {
51    let flat = f32s(&t.normal);
52    let surface = t.vertex_normals.map(|ns| {
53        ns.iter().map(f32s).fold([0.0; 3], |acc, n| {
54            [acc[0] + n[0], acc[1] + n[1], acc[2] + n[2]]
55        })
56    });
57    let agrees = surface.is_none_or(|s| flat[0] * s[0] + flat[1] * s[1] + flat[2] * s[2] >= 0.0);
58    let [a, b, c] = [f32s(&t.a), f32s(&t.b), f32s(&t.c)];
59    if agrees {
60        StlTriangle {
61            normal: flat,
62            corners: [a, b, c],
63        }
64    } else {
65        StlTriangle {
66            normal: flat.map(|x| -x),
67            corners: [a, c, b],
68        }
69    }
70}
71
72/// The triangles of `faces` of `raster`, in the order given, wound outward.
73pub fn stl_triangles(raster: &RasterizedModel, faces: &[FaceId]) -> Vec<StlTriangle> {
74    faces
75        .iter()
76        .filter_map(|face| raster.faces.get(face))
77        .flatten()
78        .map(outward)
79        .collect()
80}
81
82/// The volume `triangles`, wound outward, enclose: the sum of the signed
83/// tetrahedra they span with a corner of the mesh, negative if they are
84/// wound in. Exact for a closed mesh, up to its `f32` corners.
85///
86/// The apex is a corner of the mesh, not the origin: in `f32`, a corner
87/// far from the origin is rounded by more than the mesh's own size there,
88/// and the tetrahedra spanned from the origin weigh that rounding by their
89/// distance: a plate at `(10000, -5000, 2000)` summed from the origin came
90/// out 5.5 over for a bore of 142, back when faces met in two polygons of
91/// an edge a sliver apart.
92pub fn enclosed_volume(triangles: &[StlTriangle]) -> f64 {
93    let Some(first) = triangles.first() else {
94        return 0.0;
95    };
96    let apex = first.corners[0].map(f64::from);
97    triangles
98        .iter()
99        .map(|t| {
100            let [a, b, c] = t
101                .corners
102                .map(|p| [0, 1, 2].map(|k| f64::from(p[k]) - apex[k]));
103            (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0])
104                + a[2] * (b[0] * c[1] - b[1] * c[0]))
105                / 6.0
106        })
107        .sum()
108}
109
110/// Write `triangles` as an STL file named `name` (the solid name an ASCII
111/// file carries, and the header of a binary one).
112pub fn write_stl(
113    triangles: &[StlTriangle],
114    name: &str,
115    format: StlFormat,
116    out: &mut impl Write,
117) -> io::Result<()> {
118    match format {
119        StlFormat::Binary => {
120            // An 80-byte header, which must not start with "solid" — some
121            // readers take that for an ASCII file.
122            let mut header = [b' '; 80];
123            let text = format!("geop {name}");
124            let len = text.len().min(80);
125            header[..len].copy_from_slice(&text.as_bytes()[..len]);
126            out.write_all(&header)?;
127            let count = u32::try_from(triangles.len())
128                .map_err(|_| io::Error::other("too many triangles for a binary STL file"))?;
129            out.write_all(&count.to_le_bytes())?;
130            for t in triangles {
131                for v in std::iter::once(&t.normal).chain(&t.corners) {
132                    for x in v {
133                        out.write_all(&x.to_le_bytes())?;
134                    }
135                }
136                // Attribute byte count, unused.
137                out.write_all(&0u16.to_le_bytes())?;
138            }
139        }
140        StlFormat::Ascii => {
141            // A name is a single word in ASCII STL.
142            let name: String = name
143                .chars()
144                .map(|c| if c.is_whitespace() { '_' } else { c })
145                .collect();
146            writeln!(out, "solid {name}")?;
147            for t in triangles {
148                let [nx, ny, nz] = t.normal;
149                writeln!(out, "  facet normal {nx:e} {ny:e} {nz:e}")?;
150                writeln!(out, "    outer loop")?;
151                for [x, y, z] in t.corners {
152                    writeln!(out, "      vertex {x:e} {y:e} {z:e}")?;
153                }
154                writeln!(out, "    endloop")?;
155                writeln!(out, "  endfacet")?;
156            }
157            writeln!(out, "endsolid {name}")?;
158        }
159    }
160    Ok(())
161}