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}