From 9cabc3532d1f10ceaea57c28d6fd3f1a92db3e99 Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:39:20 +0200 Subject: [PATCH] STL export welds shared vertices like the 3MF (#816) STL (binary and ASCII) now welds each body's export mesh with the same tolerance as 3MF, so filleted and drilled bodies are closed. The weld grid's neighbour lookup no longer overflows for bodies far from the origin. Fixes #784 --- crates/engine/src/tests/mesh_convert.rs | 36 +++++++++++++++++++++++++ crates/io/src/lib.rs | 28 ++++++++++++++----- crates/io/src/threemf.rs | 13 ++++++++- 3 files changed, 69 insertions(+), 8 deletions(-) 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)); + } }