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 = BINARY_HEADER_SIZE as u64 + 4 + triangle_count * BINARY_TRIANGLE_SIZE as u64;
62
63    if expected_size == file_size {
64        return Ok(true);
65    }
66
67    // Fall back to a textual sniff: if it starts with "solid" and contains
68    // "facet" in the first chunk, treat as ASCII.
69    file.seek(SeekFrom::Start(0))?;
70    let mut buf = [0u8; 256];
71    let n = file.read(&mut buf)?;
72    let head = String::from_utf8_lossy(&buf[..n]);
73    let head_trim = head.trim_start();
74    if head_trim.starts_with("solid") && head.contains("facet") {
75        return Ok(false);
76    }
77
78    Ok(true)
79}
80
81fn quantize(v: f32) -> i32 {
82    // 1e-6 quantization grid for vertex deduplication.
83    (v * 1_000_000.0).round() as i32
84}
85
86struct MeshBuilder {
87    vertices: Vec<Point3f>,
88    face_normals: Vec<Vector3f>,
89    faces: Vec<[usize; 3]>,
90    index: HashMap<(i32, i32, i32), usize>,
91}
92
93impl MeshBuilder {
94    fn new() -> Self {
95        Self {
96            vertices: Vec::new(),
97            face_normals: Vec::new(),
98            faces: Vec::new(),
99            index: HashMap::new(),
100        }
101    }
102
103    fn add_vertex(&mut self, v: Point3f) -> usize {
104        let key = (quantize(v.x), quantize(v.y), quantize(v.z));
105        if let Some(&i) = self.index.get(&key) {
106            return i;
107        }
108        let i = self.vertices.len();
109        self.vertices.push(v);
110        self.index.insert(key, i);
111        i
112    }
113
114    fn add_triangle(&mut self, normal: Vector3f, a: Point3f, b: Point3f, c: Point3f) {
115        let ia = self.add_vertex(a);
116        let ib = self.add_vertex(b);
117        let ic = self.add_vertex(c);
118        if ia == ib || ib == ic || ia == ic {
119            return; // degenerate
120        }
121        self.faces.push([ia, ib, ic]);
122        self.face_normals.push(normal);
123    }
124
125    fn into_mesh(self) -> TriangleMesh {
126        let MeshBuilder { vertices, face_normals, faces, .. } = self;
127        let mut mesh = TriangleMesh::from_vertices_and_faces(vertices, faces);
128
129        // Derive per-vertex normals by averaging adjacent face normals.
130        if !mesh.faces.is_empty() && !face_normals.is_empty() {
131            let mut acc = vec![Vector3f::new(0.0, 0.0, 0.0); mesh.vertices.len()];
132            for (face, n) in mesh.faces.iter().zip(face_normals.iter()) {
133                for &vi in face {
134                    acc[vi] += *n;
135                }
136            }
137            for v in &mut acc {
138                let len = v.norm();
139                if len > 0.0 {
140                    *v /= len;
141                }
142            }
143            mesh.set_normals(acc);
144        }
145
146        mesh
147    }
148}
149
150/// Read an STL file (auto-detects ASCII vs. binary).
151pub fn read_stl<P: AsRef<Path>>(path: P) -> Result<TriangleMesh> {
152    let path = path.as_ref();
153    if is_binary_stl(path)? {
154        read_binary_stl(path)
155    } else {
156        read_ascii_stl(path)
157    }
158}
159
160fn read_binary_stl(path: &Path) -> Result<TriangleMesh> {
161    let file = File::open(path)?;
162    let mut reader = BufReader::new(file);
163
164    let mut header = [0u8; BINARY_HEADER_SIZE];
165    reader.read_exact(&mut header)?;
166    let triangle_count = reader.read_u32::<LittleEndian>()?;
167
168    let mut builder = MeshBuilder::new();
169    for i in 0..triangle_count {
170        let nx = reader.read_f32::<LittleEndian>()
171            .map_err(|_| Error::InvalidData(format!("Failed to read normal of triangle {}", i)))?;
172        let ny = reader.read_f32::<LittleEndian>()?;
173        let nz = reader.read_f32::<LittleEndian>()?;
174
175        let mut verts = [Point3f::new(0.0, 0.0, 0.0); 3];
176        for v in &mut verts {
177            let x = reader.read_f32::<LittleEndian>()?;
178            let y = reader.read_f32::<LittleEndian>()?;
179            let z = reader.read_f32::<LittleEndian>()?;
180            *v = Point3f::new(x, y, z);
181        }
182        let _attr = reader.read_u16::<LittleEndian>()?;
183
184        builder.add_triangle(Vector3f::new(nx, ny, nz), verts[0], verts[1], verts[2]);
185    }
186
187    Ok(builder.into_mesh())
188}
189
190fn parse_vec3(parts: &[&str], line_num: usize, what: &str) -> Result<(f32, f32, f32)> {
191    if parts.len() < 3 {
192        return Err(Error::InvalidData(format!(
193            "Invalid {} at line {}: expected 3 numbers",
194            what, line_num
195        )));
196    }
197    let x = parts[0].parse::<f32>().map_err(|_| {
198        Error::InvalidData(format!("Invalid {} x at line {}", what, line_num))
199    })?;
200    let y = parts[1].parse::<f32>().map_err(|_| {
201        Error::InvalidData(format!("Invalid {} y at line {}", what, line_num))
202    })?;
203    let z = parts[2].parse::<f32>().map_err(|_| {
204        Error::InvalidData(format!("Invalid {} z at line {}", what, line_num))
205    })?;
206    Ok((x, y, z))
207}
208
209fn read_ascii_stl(path: &Path) -> Result<TriangleMesh> {
210    let file = File::open(path)?;
211    let reader = BufReader::new(file);
212
213    let mut builder = MeshBuilder::new();
214    let mut current_normal = Vector3f::new(0.0, 0.0, 0.0);
215    let mut current_verts: Vec<Point3f> = Vec::with_capacity(3);
216
217    for (line_num, line) in reader.lines().enumerate() {
218        let line = line?;
219        let trimmed = line.trim();
220        if trimmed.is_empty() {
221            continue;
222        }
223        let parts: Vec<&str> = trimmed.split_whitespace().collect();
224        match parts[0] {
225            "facet" => {
226                // "facet normal nx ny nz"
227                if parts.len() >= 5 && parts[1] == "normal" {
228                    let (nx, ny, nz) = parse_vec3(&parts[2..5], line_num + 1, "normal")?;
229                    current_normal = Vector3f::new(nx, ny, nz);
230                } else {
231                    current_normal = Vector3f::new(0.0, 0.0, 0.0);
232                }
233                current_verts.clear();
234            }
235            "vertex" => {
236                if parts.len() < 4 {
237                    return Err(Error::InvalidData(format!(
238                        "Invalid vertex at line {}: expected 3 coordinates",
239                        line_num + 1
240                    )));
241                }
242                let (x, y, z) = parse_vec3(&parts[1..4], line_num + 1, "vertex")?;
243                current_verts.push(Point3f::new(x, y, z));
244            }
245            "endfacet" => {
246                if current_verts.len() == 3 {
247                    builder.add_triangle(
248                        current_normal,
249                        current_verts[0],
250                        current_verts[1],
251                        current_verts[2],
252                    );
253                }
254                current_verts.clear();
255            }
256            _ => {}
257        }
258    }
259
260    Ok(builder.into_mesh())
261}
262
263/// Write an STL file using the given options.
264pub fn write_stl<P: AsRef<Path>>(
265    mesh: &TriangleMesh,
266    path: P,
267    options: &StlWriteOptions,
268) -> Result<()> {
269    if options.binary {
270        write_binary_stl(mesh, path.as_ref(), options)
271    } else {
272        write_ascii_stl(mesh, path.as_ref(), options)
273    }
274}
275
276fn compute_face_normal(a: Point3f, b: Point3f, c: Point3f) -> Vector3f {
277    let u = b - a;
278    let v = c - a;
279    let n = u.cross(&v);
280    let len = n.norm();
281    if len > 0.0 {
282        n / len
283    } else {
284        Vector3f::new(0.0, 0.0, 0.0)
285    }
286}
287
288fn write_binary_stl(mesh: &TriangleMesh, path: &Path, options: &StlWriteOptions) -> Result<()> {
289    let file = File::create(path)?;
290    let mut writer = BufWriter::new(file);
291
292    let mut header = [0u8; BINARY_HEADER_SIZE];
293    let bytes = options.binary_header.as_bytes();
294    let n = bytes.len().min(BINARY_HEADER_SIZE);
295    header[..n].copy_from_slice(&bytes[..n]);
296    writer.write_all(&header)?;
297    writer.write_u32::<LittleEndian>(mesh.faces.len() as u32)?;
298
299    for face in &mesh.faces {
300        let a = mesh.vertices[face[0]];
301        let b = mesh.vertices[face[1]];
302        let c = mesh.vertices[face[2]];
303        let normal = compute_face_normal(a, b, c);
304        writer.write_f32::<LittleEndian>(normal.x)?;
305        writer.write_f32::<LittleEndian>(normal.y)?;
306        writer.write_f32::<LittleEndian>(normal.z)?;
307        for v in [a, b, c] {
308            writer.write_f32::<LittleEndian>(v.x)?;
309            writer.write_f32::<LittleEndian>(v.y)?;
310            writer.write_f32::<LittleEndian>(v.z)?;
311        }
312        writer.write_u16::<LittleEndian>(0)?;
313    }
314
315    writer.flush()?;
316    Ok(())
317}
318
319fn write_ascii_stl(mesh: &TriangleMesh, path: &Path, options: &StlWriteOptions) -> Result<()> {
320    let file = File::create(path)?;
321    let mut writer = BufWriter::new(file);
322
323    writeln!(writer, "solid {}", options.solid_name)?;
324    for face in &mesh.faces {
325        let a = mesh.vertices[face[0]];
326        let b = mesh.vertices[face[1]];
327        let c = mesh.vertices[face[2]];
328        let n = compute_face_normal(a, b, c);
329        writeln!(writer, "  facet normal {} {} {}", n.x, n.y, n.z)?;
330        writeln!(writer, "    outer loop")?;
331        writeln!(writer, "      vertex {} {} {}", a.x, a.y, a.z)?;
332        writeln!(writer, "      vertex {} {} {}", b.x, b.y, b.z)?;
333        writeln!(writer, "      vertex {} {} {}", c.x, c.y, c.z)?;
334        writeln!(writer, "    endloop")?;
335        writeln!(writer, "  endfacet")?;
336    }
337    writeln!(writer, "endsolid {}", options.solid_name)?;
338    Ok(())
339}
340
341impl crate::registry::MeshReader for StlReader {
342    fn read_mesh(&self, path: &Path) -> Result<TriangleMesh> {
343        read_stl(path)
344    }
345
346    fn can_read(&self, path: &Path) -> bool {
347        is_binary_stl(path).is_ok()
348    }
349
350    fn format_name(&self) -> &'static str {
351        "stl"
352    }
353}
354
355impl crate::registry::MeshWriter for StlWriter {
356    fn write_mesh(&self, mesh: &TriangleMesh, path: &Path) -> Result<()> {
357        write_stl(mesh, path, &StlWriteOptions::default())
358    }
359
360    fn format_name(&self) -> &'static str {
361        "stl"
362    }
363}
364
365impl MeshReader for StlReader {
366    fn read_mesh<P: AsRef<Path>>(path: P) -> Result<TriangleMesh> {
367        read_stl(path)
368    }
369}
370
371impl MeshWriter for StlWriter {
372    fn write_mesh<P: AsRef<Path>>(mesh: &TriangleMesh, path: P) -> Result<()> {
373        write_stl(mesh, path, &StlWriteOptions::default())
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use tempfile::tempdir;
381    use threecrate_core::Point3f;
382
383    fn sample_mesh() -> TriangleMesh {
384        // Simple tetrahedron.
385        let vertices = vec![
386            Point3f::new(0.0, 0.0, 0.0),
387            Point3f::new(1.0, 0.0, 0.0),
388            Point3f::new(0.0, 1.0, 0.0),
389            Point3f::new(0.0, 0.0, 1.0),
390        ];
391        let faces = vec![[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]];
392        TriangleMesh::from_vertices_and_faces(vertices, faces)
393    }
394
395    #[test]
396    fn binary_round_trip() {
397        let mesh = sample_mesh();
398        let dir = tempdir().unwrap();
399        let path = dir.path().join("tet.stl");
400
401        write_stl(&mesh, &path, &StlWriteOptions { binary: true, ..Default::default() }).unwrap();
402        assert!(is_binary_stl(&path).unwrap());
403
404        let read = read_stl(&path).unwrap();
405        assert_eq!(read.vertex_count(), 4);
406        assert_eq!(read.face_count(), 4);
407    }
408
409    #[test]
410    fn ascii_round_trip() {
411        let mesh = sample_mesh();
412        let dir = tempdir().unwrap();
413        let path = dir.path().join("tet_ascii.stl");
414
415        write_stl(
416            &mesh,
417            &path,
418            &StlWriteOptions { binary: false, ..Default::default() },
419        )
420        .unwrap();
421        assert!(!is_binary_stl(&path).unwrap());
422
423        let read = read_stl(&path).unwrap();
424        assert_eq!(read.vertex_count(), 4);
425        assert_eq!(read.face_count(), 4);
426    }
427
428    #[test]
429    fn ascii_fixture_parses() {
430        let dir = tempdir().unwrap();
431        let path = dir.path().join("tri.stl");
432        let content = "\
433solid foo
434  facet normal 0 0 1
435    outer loop
436      vertex 0 0 0
437      vertex 1 0 0
438      vertex 0 1 0
439    endloop
440  endfacet
441endsolid foo
442";
443        std::fs::write(&path, content).unwrap();
444        let mesh = read_stl(&path).unwrap();
445        assert_eq!(mesh.face_count(), 1);
446        assert_eq!(mesh.vertex_count(), 3);
447    }
448
449    #[test]
450    fn auto_dispatch_via_read_mesh() {
451        let mesh = sample_mesh();
452        let dir = tempdir().unwrap();
453        let path = dir.path().join("auto.stl");
454        crate::write_mesh(&mesh, &path).unwrap();
455        let read = crate::read_mesh(&path).unwrap();
456        assert_eq!(read.face_count(), 4);
457    }
458}