Skip to main content

neco_stl/
lib.rs

1//! STL file parser and writer with vertex deduplication.
2
3use std::collections::HashMap;
4use std::fs::File;
5use std::io::{BufWriter, Write};
6use std::path::Path;
7
8/// Triangle surface mesh parsed from STL.
9#[derive(Debug, Clone)]
10pub struct TriSurface {
11    pub nodes: Vec<[f64; 3]>,
12    pub triangles: Vec<[usize; 3]>,
13}
14
15fn detect_ascii(data: &[u8]) -> bool {
16    if !data.starts_with(b"solid") {
17        return false;
18    }
19    if data.len() > 5 && !data[5].is_ascii_whitespace() {
20        return false;
21    }
22    if data.len() >= 84 {
23        let num_triangles = u32::from_le_bytes([data[80], data[81], data[82], data[83]]) as usize;
24        let expected = 84 + 50 * num_triangles;
25        if expected == data.len() {
26            return false;
27        }
28    }
29    true
30}
31
32fn parse_stl_binary(data: &[u8]) -> Result<Vec<[[f32; 3]; 3]>, String> {
33    if data.len() < 84 {
34        return Err(
35            "binary STL is too short (84 bytes required for header + triangle count)".into(),
36        );
37    }
38    let num_triangles = u32::from_le_bytes([data[80], data[81], data[82], data[83]]) as usize;
39    let expected = 84 + 50 * num_triangles;
40    if data.len() < expected {
41        return Err(format!(
42            "binary STL data is truncated (expected {expected} bytes, got {})",
43            data.len()
44        ));
45    }
46
47    let mut triangles = Vec::with_capacity(num_triangles);
48    for i in 0..num_triangles {
49        let base = 84 + 50 * i;
50        let mut verts = [[0.0_f32; 3]; 3];
51        for (v, vert) in verts.iter_mut().enumerate() {
52            let vbase = base + 12 + 12 * v;
53            for (c, coord) in vert.iter_mut().enumerate() {
54                let offset = vbase + 4 * c;
55                *coord = f32::from_le_bytes([
56                    data[offset],
57                    data[offset + 1],
58                    data[offset + 2],
59                    data[offset + 3],
60                ]);
61            }
62        }
63        triangles.push(verts);
64    }
65    Ok(triangles)
66}
67
68fn parse_stl_ascii(data: &[u8]) -> Result<Vec<[[f32; 3]; 3]>, String> {
69    let text = std::str::from_utf8(data)
70        .map_err(|e| format!("failed to decode ASCII STL as UTF-8: {e}"))?;
71
72    let mut triangles = Vec::new();
73    let mut current_verts = Vec::new();
74    let mut in_facet = false;
75
76    for line in text.lines() {
77        let trimmed = line.trim();
78        if trimmed.starts_with("facet ") {
79            in_facet = true;
80            current_verts.clear();
81        } else if trimmed == "endfacet" {
82            if !in_facet {
83                return Err("encountered endfacet outside facet block".into());
84            }
85            if current_verts.len() != 3 {
86                return Err(format!(
87                    "facet must contain exactly 3 vertices, got {}",
88                    current_verts.len()
89                ));
90            }
91            triangles.push([current_verts[0], current_verts[1], current_verts[2]]);
92            in_facet = false;
93        } else if trimmed.starts_with("vertex ") && in_facet {
94            let parts: Vec<&str> = trimmed.split_whitespace().collect();
95            if parts.len() != 4 {
96                return Err(format!("invalid vertex line: {trimmed}"));
97            }
98            let x = parts[1]
99                .parse()
100                .map_err(|_| format!("failed to parse vertex coordinate: {}", parts[1]))?;
101            let y = parts[2]
102                .parse()
103                .map_err(|_| format!("failed to parse vertex coordinate: {}", parts[2]))?;
104            let z = parts[3]
105                .parse()
106                .map_err(|_| format!("failed to parse vertex coordinate: {}", parts[3]))?;
107            current_verts.push([x, y, z]);
108        }
109    }
110
111    Ok(triangles)
112}
113
114/// Parse STL bytes into a triangle surface mesh.
115pub fn parse_stl(data: &[u8]) -> Result<TriSurface, String> {
116    let raw_triangles = if detect_ascii(data) {
117        parse_stl_ascii(data)?
118    } else {
119        parse_stl_binary(data)?
120    };
121
122    let mut nodes = Vec::new();
123    let mut triangles = Vec::new();
124    let mut vertex_map: HashMap<[u64; 3], usize> = HashMap::new();
125
126    let quantize = |v: f32| -> u64 { ((v as f64) * 1e6).round().to_bits() };
127
128    for tri in &raw_triangles {
129        let mut indices = [0usize; 3];
130        for (i, v) in tri.iter().enumerate() {
131            let key = [quantize(v[0]), quantize(v[1]), quantize(v[2])];
132            let idx = if let Some(&existing) = vertex_map.get(&key) {
133                existing
134            } else {
135                let idx = nodes.len();
136                nodes.push([v[0] as f64, v[1] as f64, v[2] as f64]);
137                vertex_map.insert(key, idx);
138                idx
139            };
140            indices[i] = idx;
141        }
142        if indices[0] != indices[1] && indices[1] != indices[2] && indices[2] != indices[0] {
143            triangles.push(indices);
144        }
145    }
146
147    if triangles.is_empty() {
148        return Err("STL file contains no valid triangles".into());
149    }
150
151    Ok(TriSurface { nodes, triangles })
152}
153
154fn write_f32_triple(writer: &mut dyn Write, v: [f64; 3]) -> std::io::Result<()> {
155    for &c in &v {
156        writer.write_all(&(c as f32).to_le_bytes())?;
157    }
158    Ok(())
159}
160
161fn checked_triangle_count(len: usize) -> std::io::Result<u32> {
162    u32::try_from(len).map_err(|_| {
163        std::io::Error::new(
164            std::io::ErrorKind::InvalidInput,
165            "triangle count exceeds u32::MAX for STL binary format",
166        )
167    })
168}
169
170/// Write a binary STL file.
171pub fn write_stl_binary(
172    nodes: &[[f64; 3]],
173    triangles: &[[usize; 3]],
174    path: &Path,
175) -> std::io::Result<()> {
176    let file = File::create(path)?;
177    let mut writer = BufWriter::new(file);
178    writer.write_all(&[0u8; 80])?;
179    let n_tris = checked_triangle_count(triangles.len())?;
180    writer.write_all(&n_tris.to_le_bytes())?;
181
182    for tri in triangles {
183        let v0 = nodes[tri[0]];
184        let v1 = nodes[tri[1]];
185        let v2 = nodes[tri[2]];
186        let normal = triangle_normal(v0, v1, v2);
187
188        write_f32_triple(&mut writer, normal)?;
189        write_f32_triple(&mut writer, v0)?;
190        write_f32_triple(&mut writer, v1)?;
191        write_f32_triple(&mut writer, v2)?;
192        writer.write_all(&0u16.to_le_bytes())?;
193    }
194
195    writer.flush()
196}
197
198/// Write an ASCII STL file.
199pub fn write_stl_ascii(
200    nodes: &[[f64; 3]],
201    triangles: &[[usize; 3]],
202    path: &Path,
203) -> std::io::Result<()> {
204    let file = File::create(path)?;
205    let mut writer = BufWriter::new(file);
206    writeln!(writer, "solid mesh")?;
207
208    for tri in triangles {
209        let v0 = nodes[tri[0]];
210        let v1 = nodes[tri[1]];
211        let v2 = nodes[tri[2]];
212        let normal = triangle_normal(v0, v1, v2);
213
214        writeln!(
215            writer,
216            "  facet normal {} {} {}",
217            normal[0], normal[1], normal[2]
218        )?;
219        writeln!(writer, "    outer loop")?;
220        writeln!(writer, "      vertex {} {} {}", v0[0], v0[1], v0[2])?;
221        writeln!(writer, "      vertex {} {} {}", v1[0], v1[1], v1[2])?;
222        writeln!(writer, "      vertex {} {} {}", v2[0], v2[1], v2[2])?;
223        writeln!(writer, "    endloop")?;
224        writeln!(writer, "  endfacet")?;
225    }
226
227    writeln!(writer, "endsolid mesh")?;
228    writer.flush()
229}
230
231#[cfg(test)]
232mod write_tests {
233    use super::*;
234
235    #[test]
236    fn binary_writer_rejects_triangle_count_above_u32() {
237        let error = checked_triangle_count(usize::MAX).expect_err("usize::MAX exceeds u32");
238        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
239    }
240}
241
242fn triangle_normal(v0: [f64; 3], v1: [f64; 3], v2: [f64; 3]) -> [f64; 3] {
243    let e1 = [v1[0] - v0[0], v1[1] - v0[1], v1[2] - v0[2]];
244    let e2 = [v2[0] - v0[0], v2[1] - v0[1], v2[2] - v0[2]];
245    let cx = e1[1] * e2[2] - e1[2] * e2[1];
246    let cy = e1[2] * e2[0] - e1[0] * e2[2];
247    let cz = e1[0] * e2[1] - e1[1] * e2[0];
248    let len = (cx * cx + cy * cy + cz * cz).sqrt();
249    if len < 1e-15 {
250        [0.0, 0.0, 0.0]
251    } else {
252        [cx / len, cy / len, cz / len]
253    }
254}
255
256impl TriSurface {
257    /// Compute per-face normals.
258    pub fn face_normals(&self) -> Vec<[f64; 3]> {
259        self.triangles
260            .iter()
261            .map(|tri| triangle_normal(self.nodes[tri[0]], self.nodes[tri[1]], self.nodes[tri[2]]))
262            .collect()
263    }
264
265    /// Extract feature edges using the angle between adjacent face normals.
266    pub fn feature_edges(&self, angle_threshold_deg: f64) -> Vec<[usize; 2]> {
267        let normals = self.face_normals();
268        let cos_threshold = angle_threshold_deg.to_radians().cos();
269        let mut edge_faces: HashMap<(usize, usize), Vec<usize>> = HashMap::new();
270
271        for (fi, tri) in self.triangles.iter().enumerate() {
272            for &(a, b) in &[(tri[0], tri[1]), (tri[1], tri[2]), (tri[2], tri[0])] {
273                let key = if a < b { (a, b) } else { (b, a) };
274                edge_faces.entry(key).or_default().push(fi);
275            }
276        }
277
278        let mut result = Vec::new();
279        for (&(a, b), faces) in &edge_faces {
280            let is_feature = if faces.len() == 1 {
281                true
282            } else if faces.len() == 2 {
283                let n0 = normals[faces[0]];
284                let n1 = normals[faces[1]];
285                let dot = n0[0] * n1[0] + n0[1] * n1[1] + n0[2] * n1[2];
286                dot < cos_threshold
287            } else {
288                true
289            };
290            if is_feature {
291                result.push([a, b]);
292            }
293        }
294        result
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    fn make_binary_stl(triangles: &[[[f32; 3]; 3]]) -> Vec<u8> {
303        let mut buf = Vec::new();
304        buf.extend_from_slice(&[0u8; 80]);
305        let count = u32::try_from(triangles.len()).expect("triangle count exceeds u32");
306        buf.extend_from_slice(&count.to_le_bytes());
307        for tri in triangles {
308            buf.extend_from_slice(&[0u8; 12]);
309            for v in tri {
310                for &c in v {
311                    buf.extend_from_slice(&c.to_le_bytes());
312                }
313            }
314            buf.extend_from_slice(&[0u8; 2]);
315        }
316        buf
317    }
318
319    #[test]
320    fn parse_ascii_stl() {
321        let ascii = b"solid test
322facet normal 0 0 1
323  outer loop
324    vertex 0 0 0
325    vertex 1 0 0
326    vertex 0 1 0
327  endloop
328endfacet
329facet normal 0 0 1
330  outer loop
331    vertex 1 0 0
332    vertex 1 1 0
333    vertex 0 1 0
334  endloop
335endfacet
336endsolid test";
337
338        let surface = parse_stl(ascii).unwrap();
339        assert_eq!(surface.nodes.len(), 4);
340        assert_eq!(surface.triangles.len(), 2);
341    }
342
343    #[test]
344    fn degenerate_triangle_filtered() {
345        let ascii = b"solid test
346facet normal 0 0 1
347  outer loop
348    vertex 0 0 0
349    vertex 0 0 0
350    vertex 0 1 0
351  endloop
352endfacet
353facet normal 0 0 1
354  outer loop
355    vertex 0 0 0
356    vertex 1 0 0
357    vertex 0 1 0
358  endloop
359endfacet
360endsolid test";
361
362        let surface = parse_stl(ascii).unwrap();
363        assert_eq!(surface.triangles.len(), 1);
364    }
365
366    #[test]
367    fn parse_binary_stl_single_triangle() {
368        let tri = [[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
369        let data = make_binary_stl(&[[tri[0], tri[1], tri[2]]]);
370        let surface = parse_stl(&data).unwrap();
371        assert_eq!(surface.nodes.len(), 3);
372        assert_eq!(surface.triangles.len(), 1);
373    }
374
375    #[test]
376    fn parse_binary_stl_multiple_triangles() {
377        let tris = [
378            [[0.0f32, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
379            [[1.0f32, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]],
380        ];
381        let data = make_binary_stl(&tris);
382        let surface = parse_stl(&data).unwrap();
383        assert_eq!(surface.nodes.len(), 4);
384        assert_eq!(surface.triangles.len(), 2);
385    }
386
387    #[test]
388    fn binary_stl_truncated_error() {
389        let data = vec![0u8; 50];
390        let result = parse_stl(&data);
391        assert!(result.is_err());
392        assert!(result.unwrap_err().contains("too short"));
393    }
394
395    #[test]
396    fn binary_stl_data_shortage_error() {
397        let mut data = vec![0u8; 84];
398        data[80] = 1;
399        let result = parse_stl(&data);
400        assert!(result.is_err());
401        assert!(result.unwrap_err().contains("truncated"));
402    }
403
404    #[test]
405    fn binary_stl_zero_triangles_error() {
406        let data = make_binary_stl(&[]);
407        let result = parse_stl(&data);
408        assert!(result.is_err());
409        assert!(result.unwrap_err().contains("no valid triangles"));
410    }
411
412    #[test]
413    fn detect_ascii_vs_binary() {
414        let ascii = b"solid test\nfacet normal 0 0 1\nendsolid test";
415        assert!(detect_ascii(ascii));
416
417        let binary = vec![0u8; 84];
418        assert!(!detect_ascii(&binary));
419
420        let mut tricky = vec![0u8; 84];
421        tricky[..5].copy_from_slice(b"solid");
422        tricky[5] = b' ';
423        assert!(!detect_ascii(&tricky));
424    }
425
426    #[test]
427    fn test_face_normals_basic() {
428        let surface = TriSurface {
429            nodes: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
430            triangles: vec![[0, 1, 2]],
431        };
432        let normals = surface.face_normals();
433        assert_eq!(normals.len(), 1);
434        assert!(normals[0][0].abs() < 1e-10);
435        assert!(normals[0][1].abs() < 1e-10);
436        assert!((normals[0][2] - 1.0).abs() < 1e-10);
437    }
438
439    #[test]
440    fn test_face_normals_degenerate() {
441        let surface = TriSurface {
442            nodes: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]],
443            triangles: vec![[0, 1, 2]],
444        };
445        let normals = surface.face_normals();
446        assert_eq!(normals.len(), 1);
447        assert!(normals[0][0].abs() < 1e-10);
448        assert!(normals[0][1].abs() < 1e-10);
449        assert!(normals[0][2].abs() < 1e-10);
450    }
451
452    #[test]
453    fn test_feature_edges_cube() {
454        let nodes = vec![
455            [0.0, 0.0, 0.0],
456            [1.0, 0.0, 0.0],
457            [1.0, 1.0, 0.0],
458            [0.0, 1.0, 0.0],
459            [0.0, 0.0, 1.0],
460            [1.0, 0.0, 1.0],
461            [1.0, 1.0, 1.0],
462            [0.0, 1.0, 1.0],
463        ];
464        let triangles = vec![
465            [0, 2, 1],
466            [0, 3, 2],
467            [4, 5, 6],
468            [4, 6, 7],
469            [0, 1, 5],
470            [0, 5, 4],
471            [3, 6, 2],
472            [3, 7, 6],
473            [0, 4, 7],
474            [0, 7, 3],
475            [1, 2, 6],
476            [1, 6, 5],
477        ];
478        let surface = TriSurface { nodes, triangles };
479        let edges = surface.feature_edges(30.0);
480        assert_eq!(edges.len(), 12);
481    }
482
483    #[test]
484    fn write_stl_files() {
485        let dir = std::env::temp_dir();
486        let suffix = format!(
487            "{}-{}",
488            std::process::id(),
489            std::time::SystemTime::now()
490                .duration_since(std::time::UNIX_EPOCH)
491                .unwrap()
492                .as_nanos()
493        );
494        let binary_path = dir.join(format!("neco-stl-{suffix}.bin.stl"));
495        let ascii_path = dir.join(format!("neco-stl-{suffix}.ascii.stl"));
496
497        let nodes = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
498        let triangles = [[0usize, 1, 2]];
499        write_stl_binary(&nodes, &triangles, &binary_path).unwrap();
500        write_stl_ascii(&nodes, &triangles, &ascii_path).unwrap();
501
502        let binary = std::fs::read(&binary_path).unwrap();
503        let ascii = std::fs::read_to_string(&ascii_path).unwrap();
504        assert_eq!(binary.len(), 134);
505        assert!(ascii.starts_with("solid mesh"));
506        assert!(ascii.contains("facet normal"));
507
508        let _ = std::fs::remove_file(binary_path);
509        let _ = std::fs::remove_file(ascii_path);
510    }
511}