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 Ok(read_with_quantum(bytes, tol)?.mesh)
97}
98
99#[derive(Debug, Clone)]
101pub struct StlMesh {
102 pub mesh: Triangulation,
104 pub quantum: f64,
110}
111
112pub 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
128fn 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
136fn 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
147fn 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
168fn 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
187fn 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 out.extend_from_slice(&0_u16.to_le_bytes());
211 }
212 out
213}
214
215fn 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 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
240fn 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
251fn 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 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
343fn 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
360fn 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 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 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 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 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 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 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 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 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}