diff --git a/crates/engine/src/tests/mesh_convert.rs b/crates/engine/src/tests/mesh_convert.rs index 993c7002..93335270 100644 --- a/crates/engine/src/tests/mesh_convert.rs +++ b/crates/engine/src/tests/mesh_convert.rs @@ -91,3 +91,39 @@ fn exported_binary_stl_with_a_bore_reconstructs_analytic_faces() { assert_eq!(body.faces(0.01).unwrap().len(), 10); std::fs::remove_file(path).unwrap(); } + +/// #784: STL is closed as written where faces meet (a fillet, a bored hole): every edge of the +/// file, keyed by its exact f32 ends, is shared by exactly two triangles, as in 3MF. +#[test] +fn stl_export_is_closed_where_faces_meet() { + let mut s = Session::default(); + run(&mut s, "solid.box", json!({"length": 30, "width": 20, "height": 10, "operation": "new", "body_name": "BoxFillet"})); + run(&mut s, "solid.fillet", json!({"body": "BoxFillet", "edges": [[15, 0, 10]], "radius": 2})); + run(&mut s, "solid.cylinder", json!({"diameter": 30, "height": 12, "base": [60, 0, 0], "operation": "new", "body_name": "CylHole"})); + run(&mut s, "solid.hole", json!({"position": [60, 0, 12], "diameter": 6})); + let state = s.model.state(); + for name in ["BoxFillet", "CylHole"] { + let body = &state.bodies.iter().find(|b| b.name == name).unwrap().body; + let expected = solidcraft_kernel::measure(body).unwrap().volume; + for format in [solidcraft_io::Format::StlBinary, solidcraft_io::Format::StlAscii] { + let bytes = solidcraft_io::export(&state, &[name.to_string()], format, name).unwrap(); + let tris = solidcraft_io::read_stl(&bytes).unwrap(); + let key = |p: [f32; 3]| p.map(f32::to_bits); + let mut edges = std::collections::HashMap::new(); + for t in &tris { + for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] { + let (a, b) = (key(a), key(b)); + *edges.entry(if a < b { (a, b) } else { (b, a) }).or_insert(0) += 1; + } + } + let open = edges.values().filter(|n| **n != 2).count(); + assert_eq!(open, 0, "{name} {format:?}: {open} edges not shared by exactly two triangles"); + let p = |v: [f32; 3]| Vec3::new(v[0] as f64, v[1] as f64, v[2] as f64); + let v: f64 = tris.iter().map(|t| p(t[0]).dot(p(t[1]).cross(p(t[2]))) / 6.0).sum(); + assert!((v - expected).abs() <= expected * 1e-3, "{name} {format:?}: STL volume {v}, body {expected}"); + let mf = solidcraft_io::export(&state, &[name.to_string()], solidcraft_io::Format::ThreeMf, name).unwrap(); + let (objs, _) = solidcraft_io::read_3mf(&mf).unwrap(); + assert_eq!(tris.len(), objs.iter().map(|o| o.triangles.len()).sum::(), "{name} {format:?}: STL and 3MF triangles"); + } + } +} diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 25c6969e..e7f299d1 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -222,6 +222,11 @@ pub fn export_mesh(b: &solidcraft_kernel::Body) -> Result { Ok(b.tessellate((b.size() * 2e-4).max(1e-3))?) } +/// Distance within which an exported mesh's vertices are welded (STL and 3MF alike). +fn weld_eps(b: &solidcraft_kernel::Body) -> f64 { + (b.size() * 1e-6).max(1e-7) +} + /// Binary STL of several meshes (one solid). pub fn stl_binary(meshes: &[Mesh], name: &str) -> Vec { let n: usize = meshes.iter().map(|m| m.triangles.len()).sum(); @@ -292,13 +297,22 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str) return Err(IoError::Empty); } Ok(match format { - Format::StlBinary | Format::StlAscii | Format::Obj => { + Format::StlBinary | Format::StlAscii => { + // Each body welded on its own (as for 3MF), so shared vertices are written identically + // and the file is closed where faces meet. + let meshes: Vec = sel + .iter() + .map(|b| { + let m = export_mesh(&b.body)?; + let (positions, triangles) = weld(&m.positions, &m.triangles, weld_eps(&b.body)); + Ok(Mesh { positions, triangles, ..Mesh::default() }) + }) + .collect::>()?; + if format == Format::StlBinary { stl_binary(&meshes, name) } else { stl_ascii(&meshes, name).into_bytes() } + } + Format::Obj => { let meshes: Vec<(String, Mesh)> = sel.iter().map(|b| Ok((b.name.clone(), export_mesh(&b.body)?))).collect::>()?; - match format { - Format::StlBinary => stl_binary(&meshes.into_iter().map(|(_, m)| m).collect::>(), name), - Format::StlAscii => stl_ascii(&meshes.into_iter().map(|(_, m)| m).collect::>(), name).into_bytes(), - _ => obj(&meshes).into_bytes(), - } + obj(&meshes).into_bytes() } Format::Step => { let bs: Vec = @@ -317,7 +331,7 @@ pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str) .iter() .map(|b| { let m = export_mesh(&b.body)?; - let eps = (b.body.size() * 1e-6).max(1e-7); + let eps = weld_eps(&b.body); // Faces with colours of their own tag their triangles. let paint = b.body.paint(); let mut looks = Vec::new(); diff --git a/crates/io/src/threemf.rs b/crates/io/src/threemf.rs index fbb58922..74495dbb 100644 --- a/crates/io/src/threemf.rs +++ b/crates/io/src/threemf.rs @@ -109,7 +109,7 @@ pub fn weld_tagged(positions: &[Vec3], triangles: &[[u32; 3]], tags: &[u32], eps 'search: for dx in -1..=1 { for dy in -1..=1 { for dz in -1..=1 { - for &i in grid.get(&[c[0] + dx, c[1] + dy, c[2] + dz]).into_iter().flatten() { + for &i in grid.get(&[c[0].saturating_add(dx), c[1].saturating_add(dy), c[2].saturating_add(dz)]).into_iter().flatten() { if verts.get(i as usize).is_some_and(|q| q.dist(*p) <= eps) { found = Some(i); break 'search; @@ -798,4 +798,15 @@ mod tests { assert!(objs.len() < 4_000, "the shared budget must stop the later items: {} built", objs.len()); assert!(warnings.iter().any(|w| w.contains("too many triangles")), "{warnings:?}"); } + + /// A body far from the origin (a pattern spacing of "1e12 mm" gets there) welds without + /// overflowing the grid's cell indices. + #[test] + fn weld_survives_extreme_coordinates() { + let o = Vec3::new(1e12, 0.0, 0.0); + let positions = [o, o + Vec3::new(0.1, 0.0, 0.0), o + Vec3::new(0.0, 0.1, 0.0), o + Vec3::new(0.0, 0.0, 0.1)]; + let triangles = [[0, 2, 1], [0, 1, 3], [1, 2, 3], [2, 0, 3]]; + let (v, t) = weld(&positions, &triangles, 1e-7); + assert_eq!((v.len(), t.len()), (4, 4)); + } }