Skip to main content

threecrate_io/
stl.rs

1//! STL (STereoLithography) file format support
2//!
3//! Supports both ASCII and binary STL variants for reading and writing
4//! triangle meshes. STL stores each triangle as three independent vertices
5//! plus a face normal; on read, identical vertices are deduplicated so the
6//! resulting [`TriangleMesh`] has a shared vertex buffer.
7
8use crate::{MeshReader, MeshWriter};
9use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
10use std::collections::HashMap;
11use std::fs::File;
12use std::io::{BufRead, BufReader, BufWriter, Read, Seek, SeekFrom, Write};
13use std::path::Path;
14use threecrate_core::{Error, Point3f, Result, TriangleMesh, Vector3f};
15
16const BINARY_HEADER_SIZE: usize = 80;
17const BINARY_TRIANGLE_SIZE: usize = 50;
18
19/// STL reader (registry handle).
20pub struct StlReader;
21
22/// STL writer (registry handle).
23pub struct StlWriter;
24
25/// Options for writing STL files.
26#[derive(Debug, Clone)]
27pub struct StlWriteOptions {
28    /// If true, write a binary STL file; otherwise write ASCII.
29    pub binary: bool,
30    /// Solid name used in ASCII output (ignored for binary).
31    pub solid_name: String,
32    /// Header text for binary output (truncated/padded to 80 bytes).
33    pub binary_header: String,
34}
35
36impl Default for StlWriteOptions {
37    fn default() -> Self {
38        Self {
39            binary: true,
40            solid_name: "threecrate".to_string(),
41            binary_header: "Binary STL generated by ThreeCrate".to_string(),
42        }
43    }
44}
45
46/// Detect whether a file is binary or ASCII STL.
47///
48/// ASCII files begin with the literal token `solid`, but some binary writers
49/// also begin their 80-byte header with `solid`. The reliable check is to
50/// compare the file size to what the binary triangle count would imply.
51fn is_binary_stl(path: &Path) -> Result<bool> {
52    let mut file = File::open(path)?;
53    let file_size = file.metadata()?.len();
54
55    if file_size < (BINARY_HEADER_SIZE + 4) as u64 {
56        return Ok(false);
57    }
58
59    file.seek(SeekFrom::Start(BINARY_HEADER_SIZE as u64))?;
60    let triangle_count = file.read_u32::<LittleEndian>()? as u64;
61    let expected_size =
62        BINARY_HEADER_SIZE as u64 + 4 + triangle_count * BINARY_TRIANGLE_SIZE as u64;
63
64    if expected_size == file_size {
65        return Ok(true);
66    }
67
68    // Fall back to a textual sniff: if it starts with "solid" and contains
69    // "facet" in the first chunk, treat as ASCII.
70    file.seek(SeekFrom::Start(0))?;
71    let mut buf = [0u8; 256];
72    let n = file.read(&mut buf)?;
73    let head = String::from_utf8_lossy(&buf[..n]);
74    let head_trim = head.trim_start();
75    if head_trim.starts_with("solid") && head.contains("facet") {
76        return Ok(false);
77    }
78
79    Ok(true)
80}
81
82fn quantize(v: f32) -> i64 {
83    // 1e-6 grid for vertex deduplication. i64 so coordinates past ~2147 units
84    // (an i32 limit) don't saturate and merge distinct vertices; NaN and inf get
85    // their own keys instead of merging with 0.
86    if v.is_finite() {
87        (v as f64 * 1_000_000.0).round() as i64
88    } else {
89        i64::MIN + v.to_bits() as i64
90    }
91}
92
93struct MeshBuilder {
94    vertices: Vec<Point3f>,
95    face_normals: Vec<Vector3f>,
96    faces: Vec<[usize; 3]>,
97    index: HashMap<(i64, i64, i64), usize>,
98}
99
100impl MeshBuilder {
101    fn new() -> Self {
102        Self {
103            vertices: Vec::new(),
104            face_normals: Vec::new(),
105            faces: Vec::new(),
106            index: HashMap::new(),
107        }
108    }
109
110    fn add_vertex(&mut self, v: Point3f) -> usize {
111        let key = (quantize(v.x), quantize(v.y), quantize(v.z));
112        if let Some(&i) = self.index.get(&key) {
113            return i;
114        }
115        let i = self.vertices.len();
116        self.vertices.push(v);
117        self.index.insert(key, i);
118        i
119    }
120
121    fn add_triangle(&mut self, normal: Vector3f, a: Point3f, b: Point3f, c: Point3f) {
122        let ia = self.add_vertex(a);
123        let ib = self.add_vertex(b);
124        let ic = self.add_vertex(c);
125        if ia == ib || ib == ic || ia == ic {
126            return; // degenerate
127        }
128        self.faces.push([ia, ib, ic]);
129        self.face_normals.push(normal);
130    }
131
132    fn into_mesh(self) -> TriangleMesh {
133        let MeshBuilder {
134            vertices,
135            face_normals,
136            faces,
137            ..
138        } = self;
139        let mut mesh = TriangleMesh::from_vertices_and_faces(vertices, faces);
140
141        // Derive per-vertex normals by averaging adjacent face normals.
142        if !mesh.faces.is_empty() && !face_normals.is_empty() {
143            let mut acc = vec![Vector3f::new(0.0, 0.0, 0.0); mesh.vertices.len()];
144            for (face, n) in mesh.faces.iter().zip(face_normals.iter()) {
145                for &vi in face {
146                    acc[vi] += *n;
147                }
148            }
149            for v in &mut acc {
150                let len = v.norm();
151                if len > 0.0 {
152                    *v /= len;
153                }
154            }
155            mesh.set_normals(acc);
156        }
157
158        mesh
159    }
160}
161
162/// Read an STL file (auto-detects ASCII vs. binary).
163pub fn read_stl<P: AsRef<Path>>(path: P) -> Result<TriangleMesh> {
164    let path = path.as_ref();
165    if is_binary_stl(path)? {
166        read_binary_stl(path)
167    } else {
168        read_ascii_stl(path)
169    }
170}
171
172fn read_binary_stl(path: &Path) -> Result<TriangleMesh> {
173    let file = File::open(path)?;
174    let mut reader = BufReader::new(file);
175
176    let mut header = [0u8; BINARY_HEADER_SIZE];
177    reader.read_exact(&mut header)?;
178    let triangle_count = reader.read_u32::<LittleEndian>()?;
179
180    let mut builder = MeshBuilder::new();
181    for i in 0..triangle_count {
182        let nx = reader
183            .read_f32::<LittleEndian>()
184            .map_err(|_| Error::InvalidData(format!("Failed to read normal of triangle {}", i)))?;
185        let ny = reader.read_f32::<LittleEndian>()?;
186        let nz = reader.read_f32::<LittleEndian>()?;
187
188        let mut verts = [Point3f::new(0.0, 0.0, 0.0); 3];
189        for v in &mut verts {
190            let x = reader.read_f32::<LittleEndian>()?;
191            let y = reader.read_f32::<LittleEndian>()?;
192            let z = reader.read_f32::<LittleEndian>()?;
193            *v = Point3f::new(x, y, z);
194        }
195        let _attr = reader.read_u16::<LittleEndian>()?;
196
197        builder.add_triangle(Vector3f::new(nx, ny, nz), verts[0], verts[1], verts[2]);
198    }
199
200    Ok(builder.into_mesh())
201}
202
203fn parse_vec3(parts: &[&str], line_num: usize, what: &str) -> Result<(f32, f32, f32)> {
204    if parts.len() < 3 {
205        return Err(Error::InvalidData(format!(
206            "Invalid {} at line {}: expected 3 numbers",
207            what, line_num
208        )));
209    }
210    let x = parts[0]
211        .parse::<f32>()
212        .map_err(|_| Error::InvalidData(format!("Invalid {} x at line {}", what, line_num)))?;
213    let y = parts[1]
214        .parse::<f32>()
215        .map_err(|_| Error::InvalidData(format!("Invalid {} y at line {}", what, line_num)))?;
216    let z = parts[2]
217        .parse::<f32>()
218        .map_err(|_| Error::InvalidData(format!("Invalid {} z at line {}", what, line_num)))?;
219    Ok((x, y, z))
220}
221
222fn read_ascii_stl(path: &Path) -> Result<TriangleMesh> {
223    let file = File::open(path)?;
224    let reader = BufReader::new(file);
225
226    let mut builder = MeshBuilder::new();
227    let mut current_normal = Vector3f::new(0.0, 0.0, 0.0);
228    let mut current_verts: Vec<Point3f> = Vec::with_capacity(3);
229
230    for (line_num, line) in reader.lines().enumerate() {
231        let line = line?;
232        let trimmed = line.trim();
233        if trimmed.is_empty() {
234            continue;
235        }
236        let parts: Vec<&str> = trimmed.split_whitespace().collect();
237        match parts[0] {
238            "facet" => {
239                // "facet normal nx ny nz"
240                if parts.len() >= 5 && parts[1] == "normal" {
241                    let (nx, ny, nz) = parse_vec3(&parts[2..5], line_num + 1, "normal")?;
242                    current_normal = Vector3f::new(nx, ny, nz);
243                } else {
244                    current_normal = Vector3f::new(0.0, 0.0, 0.0);
245                }
246                current_verts.clear();
247            }
248            "vertex" => {
249                if parts.len() < 4 {
250                    return Err(Error::InvalidData(format!(
251                        "Invalid vertex at line {}: expected 3 coordinates",
252                        line_num + 1
253                    )));
254                }
255                let (x, y, z) = parse_vec3(&parts[1..4], line_num + 1, "vertex")?;
256                current_verts.push(Point3f::new(x, y, z));
257            }
258            "endfacet" => {
259                if current_verts.len() == 3 {
260                    builder.add_triangle(
261                        current_normal,
262                        current_verts[0],
263                        current_verts[1],
264                        current_verts[2],
265                    );
266                }
267                current_verts.clear();
268            }
269            _ => {}
270        }
271    }
272
273    Ok(builder.into_mesh())
274}
275
276/// Write an STL file using the given options.
277pub fn write_stl<P: AsRef<Path>>(
278    mesh: &TriangleMesh,
279    path: P,
280    options: &StlWriteOptions,
281) -> Result<()> {
282    if options.binary {
283        write_binary_stl(mesh, path.as_ref(), options)
284    } else {
285        write_ascii_stl(mesh, path.as_ref(), options)
286    }
287}
288
289fn compute_face_normal(a: Point3f, b: Point3f, c: Point3f) -> Vector3f {
290    let u = b - a;
291    let v = c - a;
292    let n = u.cross(&v);
293    let len = n.norm();
294    if len > 0.0 {
295        n / len
296    } else {
297        Vector3f::new(0.0, 0.0, 0.0)
298    }
299}
300
301fn write_binary_stl(mesh: &TriangleMesh, path: &Path, options: &StlWriteOptions) -> Result<()> {
302    let file = File::create(path)?;
303    let mut writer = BufWriter::new(file);
304
305    let mut header = [0u8; BINARY_HEADER_SIZE];
306    let bytes = options.binary_header.as_bytes();
307    let n = bytes.len().min(BINARY_HEADER_SIZE);
308    header[..n].copy_from_slice(&bytes[..n]);
309    writer.write_all(&header)?;
310    writer.write_u32::<LittleEndian>(mesh.faces.len() as u32)?;
311
312    for face in &mesh.faces {
313        let a = mesh.vertices[face[0]];
314        let b = mesh.vertices[face[1]];
315        let c = mesh.vertices[face[2]];
316        let normal = compute_face_normal(a, b, c);
317        writer.write_f32::<LittleEndian>(normal.x)?;
318        writer.write_f32::<LittleEndian>(normal.y)?;
319        writer.write_f32::<LittleEndian>(normal.z)?;
320        for v in [a, b, c] {
321            writer.write_f32::<LittleEndian>(v.x)?;
322            writer.write_f32::<LittleEndian>(v.y)?;
323            writer.write_f32::<LittleEndian>(v.z)?;
324        }
325        writer.write_u16::<LittleEndian>(0)?;
326    }
327
328    writer.flush()?;
329    Ok(())
330}
331
332fn write_ascii_stl(mesh: &TriangleMesh, path: &Path, options: &StlWriteOptions) -> Result<()> {
333    let file = File::create(path)?;
334    let mut writer = BufWriter::new(file);
335
336    writeln!(writer, "solid {}", options.solid_name)?;
337    for face in &mesh.faces {
338        let a = mesh.vertices[face[0]];
339        let b = mesh.vertices[face[1]];
340        let c = mesh.vertices[face[2]];
341        let n = compute_face_normal(a, b, c);
342        writeln!(writer, "  facet normal {} {} {}", n.x, n.y, n.z)?;
343        writeln!(writer, "    outer loop")?;
344        writeln!(writer, "      vertex {} {} {}", a.x, a.y, a.z)?;
345        writeln!(writer, "      vertex {} {} {}", b.x, b.y, b.z)?;
346        writeln!(writer, "      vertex {} {} {}", c.x, c.y, c.z)?;
347        writeln!(writer, "    endloop")?;
348        writeln!(writer, "  endfacet")?;
349    }
350    writeln!(writer, "endsolid {}", options.solid_name)?;
351    Ok(())
352}
353
354impl crate::registry::MeshReader for StlReader {
355    fn read_mesh(&self, path: &Path) -> Result<TriangleMesh> {
356        read_stl(path)
357    }
358
359    fn can_read(&self, path: &Path) -> bool {
360        is_binary_stl(path).is_ok()
361    }
362
363    fn format_name(&self) -> &'static str {
364        "stl"
365    }
366}
367
368impl crate::registry::MeshWriter for StlWriter {
369    fn write_mesh(&self, mesh: &TriangleMesh, path: &Path) -> Result<()> {
370        write_stl(mesh, path, &StlWriteOptions::default())
371    }
372
373    fn format_name(&self) -> &'static str {
374        "stl"
375    }
376}
377
378impl MeshReader for StlReader {
379    fn read_mesh<P: AsRef<Path>>(path: P) -> Result<TriangleMesh> {
380        read_stl(path)
381    }
382}
383
384impl MeshWriter for StlWriter {
385    fn write_mesh<P: AsRef<Path>>(mesh: &TriangleMesh, path: P) -> Result<()> {
386        write_stl(mesh, path, &StlWriteOptions::default())
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use tempfile::tempdir;
394    use threecrate_core::Point3f;
395
396    fn sample_mesh() -> TriangleMesh {
397        // Simple tetrahedron.
398        let vertices = vec![
399            Point3f::new(0.0, 0.0, 0.0),
400            Point3f::new(1.0, 0.0, 0.0),
401            Point3f::new(0.0, 1.0, 0.0),
402            Point3f::new(0.0, 0.0, 1.0),
403        ];
404        let faces = vec![[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]];
405        TriangleMesh::from_vertices_and_faces(vertices, faces)
406    }
407
408    #[test]
409    fn far_from_origin_vertices_stay_distinct() {
410        // Millimetre CAD parts easily pass 2147 units; the old i32 key merged
411        // 3000 and 4000 here and dropped the triangle as degenerate.
412        let dir = tempdir().unwrap();
413        let path = dir.path().join("far.stl");
414        std::fs::write(
415            &path,
416            "solid far
417             facet normal 0 0 1
418outer loop
419vertex 3000 0 0
420vertex 4000 0 0
421             vertex 3000 1000 0
422endloop
423endfacet
424             facet normal 0 0 1
425outer loop
426vertex 0 0 0
427vertex 1 0 0
428             vertex 0 1 0
429endloop
430endfacet
431endsolid far
432",
433        )
434        .unwrap();
435        let mesh = read_stl(&path).unwrap();
436        assert_eq!(mesh.faces.len(), 2);
437        assert_eq!(mesh.vertices.len(), 6);
438    }
439
440    #[test]
441    fn binary_round_trip() {
442        let mesh = sample_mesh();
443        let dir = tempdir().unwrap();
444        let path = dir.path().join("tet.stl");
445
446        write_stl(
447            &mesh,
448            &path,
449            &StlWriteOptions {
450                binary: true,
451                ..Default::default()
452            },
453        )
454        .unwrap();
455        assert!(is_binary_stl(&path).unwrap());
456
457        let read = read_stl(&path).unwrap();
458        assert_eq!(read.vertex_count(), 4);
459        assert_eq!(read.face_count(), 4);
460    }
461
462    #[test]
463    fn ascii_round_trip() {
464        let mesh = sample_mesh();
465        let dir = tempdir().unwrap();
466        let path = dir.path().join("tet_ascii.stl");
467
468        write_stl(
469            &mesh,
470            &path,
471            &StlWriteOptions {
472                binary: false,
473                ..Default::default()
474            },
475        )
476        .unwrap();
477        assert!(!is_binary_stl(&path).unwrap());
478
479        let read = read_stl(&path).unwrap();
480        assert_eq!(read.vertex_count(), 4);
481        assert_eq!(read.face_count(), 4);
482    }
483
484    #[test]
485    fn ascii_fixture_parses() {
486        let dir = tempdir().unwrap();
487        let path = dir.path().join("tri.stl");
488        let content = "\
489solid foo
490  facet normal 0 0 1
491    outer loop
492      vertex 0 0 0
493      vertex 1 0 0
494      vertex 0 1 0
495    endloop
496  endfacet
497endsolid foo
498";
499        std::fs::write(&path, content).unwrap();
500        let mesh = read_stl(&path).unwrap();
501        assert_eq!(mesh.face_count(), 1);
502        assert_eq!(mesh.vertex_count(), 3);
503    }
504
505    #[test]
506    fn auto_dispatch_via_read_mesh() {
507        let mesh = sample_mesh();
508        let dir = tempdir().unwrap();
509        let path = dir.path().join("auto.stl");
510        crate::write_mesh(&mesh, &path).unwrap();
511        let read = crate::read_mesh(&path).unwrap();
512        assert_eq!(read.face_count(), 4);
513    }
514}