crater/serde/
stl.rs

1//! A module for serializing [`MeshCollection`] to the STL format.
2//!
3//! # Examples
4//!
5//! ## Generate `.stl` text as a `&str`:
6//! ```rust
7//! use crater::primitives::prelude::*;
8//!
9//! let triangle = Triangle::new([
10//!    [0.0, 0.0, 0.0],
11//!    [1.0, 0.0, 0.0],
12//!    [0.0, 1.0, 0.0],
13//! ]);
14//! let mesh: MeshCollection = TriangleMesh::new(vec![triangle]).into();
15//! let stl_data = mesh.to_stl();
16//! assert_eq!(stl_data, "solid\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid\n");
17//! ```
18//!
19//! ## Write an `.stl` file:
20//! ```rust
21//! use crater::primitives::prelude::*;
22//! let triangle = Triangle::new([
23//!    [0.0, 0.0, 0.0],
24//!    [1.0, 0.0, 0.0],
25//!    [0.0, 1.0, 0.0],
26//! ]);
27//! let mesh: MeshCollection = TriangleMesh::new(vec![triangle]).into();
28//! mesh.write_stl("test.stl").expect("Failed to write STL file");
29//! ```
30//!
31//! ## Read `.stl` string into a [`TriangleMesh`]:
32//! ```rust
33//! use crater::primitives::prelude::*;
34//! let stl_content = r#"
35//! solid two_triangles
36//!   facet normal 0 0 1
37//!     outer loop
38//!       vertex 0 0 0
39//!       vertex 1 0 0
40//!       vertex 1 1 0
41//!     endloop
42//!   endfacet
43//!   facet normal 0 1 0
44//!     outer loop
45//!       vertex 0 1 0
46//!       vertex 1 1 0
47//!       vertex 0 1 1
48//!     endloop
49//!   endfacet
50//! endsolid two_triangles
51//! "#;
52//! let mesh = MeshCollection::from_stl(stl_content).expect("Failed to read STL file");
53//!
54//! assert_eq!(mesh.num_triangles(), 2);
55//! assert_eq!(mesh.num_vertices(), 6);
56//! ```
57//!
58//! ## Read from an `.stl` file:
59//! ```rust
60//! use crater::primitives::prelude::*;
61//! let stl_content = r#"
62//! solid two_triangles
63//!   facet normal 0 0 1
64//!     outer loop
65//!       vertex 0 0 0
66//!       vertex 1 0 0
67//!       vertex 1 1 0
68//!     endloop
69//!   endfacet
70//!   facet normal 0 1 0
71//!     outer loop
72//!       vertex 0 1 0
73//!       vertex 1 1 0
74//!       vertex 0 1 1
75//!     endloop
76//!   endfacet
77//! endsolid two_triangles
78//! "#;
79//!
80//! let path =std::path::Path::new("test.stl");
81//! std::fs::write(path, stl_content).expect("Failed to write STL file");
82//! let mesh = MeshCollection::from_stl(stl_content).expect("Failed to read STL file");
83//! ```
84
85use crate::primitives::mesh::{MeshCollection, Normal, Triangle, TriangleMesh};
86use anyhow::{Error, Ok, Result};
87use regex::Regex;
88use std::io::Write;
89
90const STL_REGEX: &str = r"(?x)
91    facet\snormal\s+
92        (-?\d*\.?\d+(?:[eE]-?\d+)?)\s+
93        (-?\d*\.?\d+(?:[eE]-?\d+)?)\s+
94        (-?\d*\.?\d+(?:[eE]-?\d+)?)[\s\S]*?
95    vertex\s+
96        (-?\d*\.?\d+(?:[eE]-?\d+)?)\s+
97        (-?\d*\.?\d+(?:[eE]-?\d+)?)\s+
98        (-?\d*\.?\d+(?:[eE]-?\d+)?)[\s\S]*?
99    vertex\s+
100        (-?\d*\.?\d+(?:[eE]-?\d+)?)\s+
101        (-?\d*\.?\d+(?:[eE]-?\d+)?)\s+
102        (-?\d*\.?\d+(?:[eE]-?\d+)?)[\s\S]*?
103    vertex\s+
104        (-?\d*\.?\d+(?:[eE]-?\d+)?)\s+
105        (-?\d*\.?\d+(?:[eE]-?\d+)?)\s+
106        (-?\d*\.?\d+(?:[eE]-?\d+)?).*
107";
108
109/// Represents a facet (triangle) in the STL format.
110pub struct Facet<'a> {
111    /// Normal vector of the facet.
112    normal: &'a Normal,
113    /// Vertices of the triangle.
114    vertices: &'a Triangle,
115}
116
117impl Facet<'_> {
118    /// Converts the facet to its STL string representation.
119    fn to_stl(&self) -> String {
120        format!(
121            "facet normal {} {} {}\nouter loop\nvertex {} {} {}\nvertex {} {} {}\nvertex {} {} {}\nendloop\nendfacet\n",
122            self.normal[0],
123            self.normal[1],
124            self.normal[2],
125            self.vertices.p0()[0],
126            self.vertices.p0()[1],
127            self.vertices.p0()[2],
128            self.vertices.p1()[0],
129            self.vertices.p1()[1],
130            self.vertices.p1()[2],
131            self.vertices.p2()[0],
132            self.vertices.p2()[1],
133            self.vertices.p2()[2],
134        )
135    }
136}
137
138impl MeshCollection {
139    /// Writes the STL representation of the mesh collection to a file.
140    pub fn write_stl(&self, path: &str) -> Result<(), Error> {
141        let path_buf = std::path::PathBuf::from(path);
142        std::fs::create_dir_all(path_buf.parent().expect("Failed to get parent directory"))
143            .expect("Failed to create directory");
144        let mut file = std::fs::File::create(path)?;
145        write!(file, "{}", self.to_stl())?;
146        Ok(())
147    }
148
149    pub fn to_stl(&self) -> String {
150        let mut stl = String::new();
151        for mesh in self.meshes.iter() {
152            stl.push_str(&format!(
153                "solid\n{}endsolid\n",
154                mesh.triangles
155                    .iter()
156                    .zip(mesh.normals().iter())
157                    .map(|(tri, norm)| Facet {
158                        normal: norm,
159                        vertices: tri,
160                    })
161                    .map(|facet| facet.to_stl())
162                    .collect::<String>()
163            ));
164        }
165        stl
166    }
167
168    /// Reads an STL file and returns a `TriangleMesh`.
169    pub fn from_stl(stl_str: &str) -> Result<Self> {
170        // Define the regex pattern
171        let re = Regex::new(STL_REGEX).unwrap();
172        // Find all matches, create new triangles, and build into mesh
173        let triangle_norms = re
174            .captures_iter(stl_str)
175            .map(|caps| {
176                let normal = [
177                    caps[1].parse::<f32>()?,
178                    caps[2].parse::<f32>()?,
179                    caps[3].parse::<f32>()?,
180                ];
181                let vertices = [
182                    [
183                        caps[4].parse::<f32>()?,
184                        caps[5].parse::<f32>()?,
185                        caps[6].parse::<f32>()?,
186                    ],
187                    [
188                        caps[7].parse::<f32>()?,
189                        caps[8].parse::<f32>()?,
190                        caps[9].parse::<f32>()?,
191                    ],
192                    [
193                        caps[10].parse::<f32>()?,
194                        caps[11].parse::<f32>()?,
195                        caps[12].parse::<f32>()?,
196                    ],
197                ];
198                Ok((Triangle::new(vertices), normal))
199            })
200            .filter_map(|tri_norm: Result<(Triangle, Normal), anyhow::Error>| tri_norm.ok())
201            .unzip();
202        Ok(TriangleMesh {
203            triangles: triangle_norms.0,
204            normals: triangle_norms.1,
205        }
206        .into())
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::{csg::prelude::*, primitives::bounding::BoundingBox, primitives::prelude::*};
214    use backend_macro::with_backend;
215    use rstest::{fixture, rstest};
216    use std::fs;
217    use std::path::Path;
218
219    /// Resolution for the marching cubes algorithm.
220    const RESOLUTION: usize = 40;
221
222    #[fixture]
223    fn bounds() -> BoundingBox<3> {
224        BoundingBox::new([-10.0, -10.0, -10.0], [10.0, 10.0, 10.0])
225    }
226
227    #[with_backend]
228    #[rstest]
229    #[case("sphere")]
230    #[case("torus")]
231    /// Test that the STL file is written correctly from a triangular mesh
232    fn stl_render(#[case] filename: &str, bounds: BoundingBox<3>) {
233        let field = match filename {
234            "sphere" => Field3D::<Backend>::sphere(1.0, device()),
235            "torus" => Field3D::<Backend>::torus(1.0, 2.0, device()),
236            _ => panic!("Invalid filename"),
237        };
238
239        // Generate the mesh using marching cubes algorithm
240        let params = MarchingCubesParams {
241            region: field.into_isosurface(0.0).region(),
242            bounds,
243            resolution: (RESOLUTION, RESOLUTION, RESOLUTION),
244            algebra: Algebra::<Backend>::default(),
245        };
246        let mesh: MeshCollection = marching_cubes::<Backend>(&params, &device()).into();
247
248        // Generate STL data and write to file
249        let stl_data = mesh.to_stl();
250        let mut path_buf = std::path::PathBuf::from("./target/test_renderings/");
251        path_buf.push(filename);
252        path_buf.set_extension("stl");
253        std::fs::create_dir_all(path_buf.parent().expect("Failed to get parent directory"))
254            .expect("Failed to create directory");
255        std::fs::write(path_buf, stl_data).expect("Failed to write STL file");
256    }
257
258    #[test]
259    /// Test that the Facet's STL string representation is correct
260    fn test_facet_mesh_to_stl() {
261        let normal = [0.0, 0.0, 1.0];
262        let triangle = Triangle::new([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
263        let facet = Facet {
264            normal: &normal,
265            vertices: &triangle,
266        };
267
268        let expected_stl = "facet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\n";
269        assert_eq!(facet.to_stl(), expected_stl);
270
271        // The same should happen if we let TriangleMesh compute the normal
272        let mesh: MeshCollection = TriangleMesh::new(vec![triangle]).into();
273        let expected_stl = "solid\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid\n";
274        assert_eq!(mesh.to_stl(), expected_stl);
275    }
276
277    #[test]
278    /// Test that the STL file is written correctly
279    fn test_write_stl() {
280        let vertices = Triangle::new([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]);
281        let mesh: MeshCollection = TriangleMesh::new(vec![vertices]).into();
282
283        let filename = "./target/test_mesh.stl";
284        mesh.write_stl(filename).expect("Failed to write STL file");
285
286        assert!(Path::new(filename).exists(), "STL file was not created");
287
288        let expected_stl = "solid\nfacet normal 0 0 1\nouter loop\nvertex 0 0 0\nvertex 1 0 0\nvertex 0 1 0\nendloop\nendfacet\nendsolid\n";
289        let written_stl = fs::read_to_string(filename).expect("Failed to read STL file");
290        assert_eq!(written_stl, expected_stl);
291    }
292
293    #[test]
294    /// Test that an empty TriangleMesh produces the correct STL format
295    fn test_empty_triangle_mesh_stl() {
296        let mesh: MeshCollection = TriangleMesh::new(vec![]).into();
297
298        let expected_stl = "solid\nendsolid\n";
299        assert_eq!(mesh.to_stl(), expected_stl);
300    }
301
302    #[test]
303    /// Test reading stl file
304    fn test_read_stl() {
305        // Example ASCII STL content
306        let stl_content = r#"
307    solid cube
308      facet normal 0 0 1
309        outer loop
310          vertex 0 0 0
311          vertex 1 0 0
312          vertex 1 1 0
313        endloop
314      endfacet
315      facet normal 0 1 0
316        outer loop
317          vertex 0 1 0
318          vertex 1 1 0
319          vertex 0 1 1
320        endloop
321      endfacet
322    endsolid cube
323    "#;
324
325        let mesh = MeshCollection::from_stl(stl_content).expect("Failed to read STL file");
326        assert_eq!(mesh.num_triangles(), 2);
327        assert_eq!(mesh.num_vertices(), 6);
328
329        // Do a round trip sanity check
330        let stl_data = mesh.to_stl();
331        let mesh_round_trip = MeshCollection::from_stl(&stl_data).expect("Failed to read STL file");
332        assert_eq!(mesh, mesh_round_trip);
333    }
334}