1use 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#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
32pub struct ImportStl;
33
34#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
35pub struct ImportStlArgs {
36 pub file: String,
38}
39
40pub const STL_EXTENSIONS: [&str; 1] = ["stl"];
42
43pub 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 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 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
112pub 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
133pub 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#[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 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 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 #[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 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 #[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 #[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 #[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}