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