Skip to main content

geop_ops_step/
stl.rs

1//! [`ImportStl`]: a mesh of triangles, from an STL file, added to the part
2//! as a B-rep — a solid where it closes, a sheet where it does not.
3
4use geop_core_editor::{
5    edit_form::{Edit, EditForm, FormState},
6    editable::{Context, OperationTier},
7    part_operation,
8    part_operation::PartOperation,
9};
10use geop_core_math::{
11    geop_error::{GeopError, GeopResult, WithContext},
12    scalars::Scalar,
13    vector::Vector3,
14    with_context,
15};
16use geop_core_program::{part::StepContext, reader::Reader};
17use geop_core_topology::build::BodySpec;
18use geop_ops_model::{
19    BodyNames, ModelOperation, NamedModel, Namer, build_model, parameters::file_choices,
20};
21use serde::{Deserialize, Serialize};
22
23/// Adds the mesh of the STL file `file` to the part: its triangles as
24/// faces, joined where they share corners, and the triangles that lie in
25/// one plane made one face (see [`BodySpec::of_triangles`] and
26/// [`BodySpec::merged`]). A solid named `import_stl(I)` for the operation
27/// `I` where every edge is shared by two triangles, a sheet otherwise;
28/// each face, edge and vertex named `import_stl(I,fK)`, `eK`, `vK` in the
29/// order the merged mesh has them. Corners at the very same coordinates are
30/// one; the file is read in either form, text (ASCII STL) or binary.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
32pub struct ImportStl;
33
34#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
35pub struct ImportStlArgs {
36    /// The STL file, relative to the program.
37    pub file: String,
38}
39
40/// The extension of an STL file.
41pub const STL_EXTENSIONS: [&str; 1] = ["stl"];
42
43/// Whether `path` names an STL file.
44pub fn is_stl_file(path: &str) -> bool {
45    path.to_ascii_lowercase().ends_with(".stl")
46}
47
48part_operation!(ImportStl(ImportStlArgs));
49
50impl PartOperation for ImportStl {
51    const KIND: &'static str = "import_stl";
52    const INFO: geop_core_editor::editable::OperationInfo =
53        geop_core_editor::editable::OperationInfo::new(
54            "Import STL",
55            "Add the mesh of an STL file next to the program, as a solid or a sheet of its triangles, those in one plane one face.",
56            geop_core_editor::editable::OperationGroup::Output,
57            OperationTier::Small,
58        )
59        .reads(&STL_EXTENSIONS)
60        .icon(crate::icons::IMPORT_STL);
61    type Args = ImportStlArgs;
62    type Session = ();
63
64    /// No file yet.
65    fn new_args(_: &Context) -> ImportStlArgs {
66        ImportStlArgs::default()
67    }
68
69    fn run(ctx: &mut StepContext, args: &ImportStlArgs) -> GeopResult<()> {
70        let _step = ctx.step();
71        build_model::<Self>(ctx, args)
72    }
73
74    /// The file: an STL file of the workspace, chosen in its explorer, or
75    /// one added from elsewhere.
76    fn form<'a>(
77        context: &'a Context<'a>,
78        args: &ImportStlArgs,
79        _: &FormState<()>,
80    ) -> EditForm<'a, ImportStlArgs> {
81        let mut f = EditForm::<ImportStlArgs>::default();
82        let files = file_choices(context.runner, is_stl_file);
83        f.file(
84            "file",
85            "file",
86            args.file.clone(),
87            files,
88            &STL_EXTENSIONS,
89            |Edit { args, .. }, file| args.file = file.to_string(),
90        );
91        f
92    }
93}
94
95impl ModelOperation for ImportStl {
96    fn build(
97        model: &mut NamedModel,
98        reader: &Reader,
99        step: &str,
100        args: &ImportStlArgs,
101    ) -> GeopResult<()> {
102        let ctx = with_context!("import_stl({step}, file={:?})", args.file);
103        if args.file.is_empty() {
104            return Err(GeopError::new("choose an STL file to import")).with_context(ctx);
105        }
106        let (_, bytes) = reader.read_bytes(&args.file).with_context(ctx)?;
107        let namer = Namer::new("import_stl", step)?;
108        add_mesh(model, &namer, &bytes).with_context(ctx)
109    }
110}
111
112/// Adds the mesh of the STL file `file` — its bytes, in either form — to
113/// `model`, named by `namer` (see [`ImportStl`]).
114pub fn add_mesh(model: &mut NamedModel, namer: &Namer, file: &[u8]) -> GeopResult<()> {
115    let (points, triangles) = read_stl(file)?;
116    let spec = BodySpec::of_triangles(&points, &triangles)?;
117    let (spec, _) = spec.merged(|_| true, |_| true)?;
118    let named = |prefix: &str, n: usize| -> Vec<String> {
119        (0..n)
120            .map(|k| namer.name(&[&format!("{prefix}{k}")]))
121            .collect()
122    };
123    let names = BodyNames {
124        vertices: named("v", spec.vertices.len()),
125        edges: named("e", spec.edges.len()),
126        faces: named("f", spec.faces.len()),
127        solid: spec.solid.then(|| namer.root()),
128    };
129    model.build_body(spec, names)?;
130    Ok(())
131}
132
133/// The corners and triangles of the STL file `file`, corners at the very
134/// same coordinates one. Binary where its length is that of a binary STL
135/// of as many triangles as its header says — 84 bytes and 50 a triangle,
136/// which a text file starting `solid` (as some binary headers do too) all
137/// but never is — and text (ASCII STL) otherwise. The facets' normals are
138/// not read: the order of the corners says which way a triangle faces.
139pub fn read_stl(file: &[u8]) -> GeopResult<(Vec<Vector3>, Vec<[usize; 3]>)> {
140    let mut mesh = Mesh::default();
141    let count = file
142        .get(80..84)
143        .map(|n| u32::from_le_bytes([n[0], n[1], n[2], n[3]]) as usize);
144    if let Some(count) = count
145        && file.len() == 84 + 50 * count
146    {
147        for (k, facet) in file[84..].as_chunks::<50>().0.iter().enumerate() {
148            let corner = |c: usize| {
149                let at = 12 + 12 * c;
150                [0, 1, 2].map(|i| {
151                    let b = &facet[at + 4 * i..at + 4 * i + 4];
152                    f64::from(f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
153                })
154            };
155            let corners = [corner(0), corner(1), corner(2)];
156            let mut triangle = [0; 3];
157            for (t, xyz) in triangle.iter_mut().zip(corners) {
158                *t = mesh.corner(xyz).ok_or_else(|| {
159                    GeopError::new(format!("triangle {k}: a corner is no number: {xyz:?}"))
160                })?;
161            }
162            mesh.triangles.push(triangle);
163        }
164        return Ok((mesh.points, mesh.triangles));
165    }
166    let Ok(text) = std::str::from_utf8(file) else {
167        return Err(GeopError::new(format!(
168            "the file is no STL: not text, and {} bytes are no binary STL of {} triangles",
169            file.len(),
170            count.map_or("its".to_string(), |n| n.to_string())
171        )));
172    };
173    if !text.trim_start().starts_with("solid") {
174        return Err(GeopError::new(
175            "the file is no STL: its text does not start with `solid`, and its length is no binary STL's",
176        ));
177    }
178    let mut corners: Vec<usize> = Vec::with_capacity(3);
179    for (number, line) in text.lines().enumerate() {
180        let mut words = line.split_whitespace();
181        match words.next() {
182            Some("vertex") => {
183                let mut xyz = [f64::NAN; 3];
184                for c in &mut xyz {
185                    if let Some(v) = words.next().and_then(|w| w.parse::<f64>().ok()) {
186                        *c = v;
187                    }
188                }
189                let p = mesh.corner(xyz).ok_or_else(|| {
190                    GeopError::new(format!(
191                        "line {}: a vertex is three numbers: {line:?}",
192                        number + 1
193                    ))
194                })?;
195                corners.push(p);
196            }
197            Some("endloop") => {
198                let [a, b, c] = corners[..] else {
199                    return Err(GeopError::new(format!(
200                        "line {}: a facet has {} corners, not three",
201                        number + 1,
202                        corners.len()
203                    )));
204                };
205                mesh.triangles.push([a, b, c]);
206                corners.clear();
207            }
208            _ => {}
209        }
210    }
211    Ok((mesh.points, mesh.triangles))
212}
213
214/// A mesh being read: its corners, each once, and its triangles.
215#[derive(Default)]
216struct Mesh {
217    points: Vec<Vector3>,
218    index: std::collections::HashMap<[u64; 3], usize>,
219    triangles: Vec<[usize; 3]>,
220}
221
222impl Mesh {
223    /// The corner at `xyz`, the one there already if there is: none if a
224    /// coordinate is no finite number.
225    fn corner(&mut self, xyz: [f64; 3]) -> Option<usize> {
226        if !xyz.iter().all(|c| c.is_finite()) {
227            return None;
228        }
229        let key = xyz.map(|c| (c + 0.0).to_bits());
230        let points = &mut self.points;
231        Some(*self.index.entry(key).or_insert_with(|| {
232            points.push(Vector3::from_array(xyz.map(Scalar::from_f64)));
233            points.len() - 1
234        }))
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use geop_ops_model::testing::assert_valid;
242
243    /// The unit cube as an ASCII STL, two triangles a side.
244    fn cube_stl() -> String {
245        let quads = [
246            [[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0]],
247            [[0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]],
248            [[0, 0, 0], [1, 0, 0], [1, 0, 1], [0, 0, 1]],
249            [[0, 1, 0], [0, 1, 1], [1, 1, 1], [1, 1, 0]],
250            [[0, 0, 0], [0, 0, 1], [0, 1, 1], [0, 1, 0]],
251            [[1, 0, 0], [1, 1, 0], [1, 1, 1], [1, 0, 1]],
252        ];
253        let mut text = String::from("solid cube\n");
254        for [a, b, c, d] in quads {
255            for tri in [[a, b, c], [a, c, d]] {
256                text.push_str("facet normal 0 0 0\nouter loop\n");
257                for p in tri {
258                    text.push_str(&format!("vertex {} {} {}\n", p[0], p[1], p[2]));
259                }
260                text.push_str("endloop\nendfacet\n");
261            }
262        }
263        text.push_str("endsolid cube\n");
264        text
265    }
266
267    /// A cube's twelve triangles: one valid box of six faces.
268    #[test]
269    fn a_cube_mesh_is_a_box() {
270        let mut model = NamedModel::new();
271        let namer = Namer::new("import_stl", "i").unwrap();
272        add_mesh(&mut model, &namer, cube_stl().as_bytes()).unwrap();
273        assert_valid(&model);
274        let solid = model.solid_id("import_stl(i)").unwrap();
275        assert_eq!(model.topology().solid_faces(solid).unwrap().len(), 6);
276        assert_eq!(model.topology().edges.len(), 12);
277    }
278
279    /// The cube's triangles as a binary STL: an 80-byte header — starting
280    /// `solid`, as some exporters write it — the count, and 50 bytes a
281    /// triangle.
282    fn cube_binary() -> Vec<u8> {
283        let (points, triangles) = read_stl(cube_stl().as_bytes()).unwrap();
284        let mut file = b"solid cube, binary".to_vec();
285        file.resize(80, b' ');
286        file.extend((triangles.len() as u32).to_le_bytes());
287        for triangle in &triangles {
288            file.extend([0u8; 12]);
289            for &corner in triangle {
290                for c in points[corner].to_array() {
291                    file.extend((c.to_f64() as f32).to_le_bytes());
292                }
293            }
294            file.extend([0u8; 2]);
295        }
296        file
297    }
298
299    /// The same cube read from a binary STL: the same box.
300    #[test]
301    fn a_binary_cube_mesh_is_the_same_box() {
302        let binary = cube_binary();
303        assert_eq!(binary.len(), 84 + 50 * 12);
304        assert_eq!(
305            read_stl(&binary).unwrap(),
306            read_stl(cube_stl().as_bytes()).unwrap()
307        );
308        let mut model = NamedModel::new();
309        let namer = Namer::new("import_stl", "i").unwrap();
310        add_mesh(&mut model, &namer, &binary).unwrap();
311        assert_valid(&model);
312        let solid = model.solid_id("import_stl(i)").unwrap();
313        assert_eq!(model.topology().solid_faces(solid).unwrap().len(), 6);
314    }
315
316    /// A binary STL chosen in the dialog's file field, stored next to the
317    /// program as the front end stores a file of bytes — a `data:` URL of
318    /// them — is offered, read and imported: a solid named after the step.
319    #[test]
320    fn a_binary_stl_chosen_in_the_dialog_is_imported() {
321        use geop_core_editor::{
322            editing::{Control, Value},
323            editor::{Command, Editor},
324            registry::Registry,
325            testing::{dialog, handled},
326        };
327        let registry = geop_ops_model::register(Registry::default())
328            .and_then(crate::register)
329            .unwrap();
330        let mut editor = Editor::new(registry, std::collections::BTreeMap::new());
331        let stored = geop_core_program::workspace::content(cube_binary());
332        assert!(stored.starts_with(geop_core_program::workspace::BYTES_PREFIX));
333        let files = std::collections::BTreeMap::from([("cube.stl".to_string(), Some(stored))]);
334        assert!(editor.handle(Command::Files { files }).error.is_none());
335        let update = editor.handle(Command::Load {
336            program: geop_core_program::program::Program::new(),
337            path: Some("part.geop".into()),
338        });
339        assert!(update.error.is_none(), "{:?}", update.error);
340        let update = editor.handle(Command::New {
341            kind: "import_stl".into(),
342        });
343        let step = update.step.expect("a step is edited");
344        let Some(Control::File { files, upload, .. }) = step.presentation.dialog.get("file") else {
345            panic!("the file is a file field");
346        };
347        assert!(
348            (files.iter()).any(|f| f.path == "cube.stl" && f.accepted),
349            "{files:?}"
350        );
351        assert_eq!(upload, &["stl"]);
352        let update = editor.handle(dialog("file", Value::Choice("cube.stl".into())));
353        let step = update.step.expect("a step is edited");
354        assert!(step.error.is_none(), "{:?}", step.error);
355        handled(&mut editor, Command::Commit);
356        let scene = editor.handle(Command::Show).scene.expect("the scene");
357        assert_eq!(scene.part.solids, ["import_stl(import_stl1)"]);
358    }
359
360    /// What is neither form is refused, saying so: bytes of no binary
361    /// STL's length, text not starting `solid`, a vertex short of a
362    /// number.
363    #[test]
364    fn what_is_no_stl_is_refused() {
365        let mut short = cube_binary();
366        short.pop();
367        let error = read_stl(&short).unwrap_err();
368        assert!(format!("{error}").contains("no binary STL"), "{error}");
369        let error = read_stl(b"facet").unwrap_err();
370        assert!(format!("{error}").contains("`solid`"), "{error}");
371        let error = read_stl(b"solid\nvertex 1 2\n").unwrap_err();
372        assert!(format!("{error}").contains("three numbers"), "{error}");
373    }
374}