1use std::fmt::Write as _;
28
29use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
30use ogeom_math::{Point, Vector};
31use ogeom_topo::Triangulation;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Encoding {
36 Ascii,
39 Binary,
41}
42
43const HEADER: &str = "ogeom";
45
46pub 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
83pub 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
103fn 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
121fn 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
140fn 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 out.extend_from_slice(&0_u16.to_le_bytes());
164 }
165 out
166}
167
168fn 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 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
194fn 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
205fn 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
279fn 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
296fn 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 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 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 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 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 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 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}