From 74ce9041423f6798cf675914862ddaeef3ba8b3c Mon Sep 17 00:00:00 2001 From: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Date: Fri, 16 Oct 2026 18:26:14 +0200 Subject: [PATCH] Mesh export: the faces of an edge share its points exactly (#1753) Thanks @SychPL. --- .../engine/tests/mesh_export_regressions.rs | 77 +++++++++++++++++++ vendor/README.md | 9 +++ .../src/tessellation/triangulation.rs | 45 ++++++++++- 3 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 crates/engine/tests/mesh_export_regressions.rs diff --git a/crates/engine/tests/mesh_export_regressions.rs b/crates/engine/tests/mesh_export_regressions.rs new file mode 100644 index 00000000..1d51267a --- /dev/null +++ b/crates/engine/tests/mesh_export_regressions.rs @@ -0,0 +1,77 @@ +//! Mesh export regressions: a closed body exports as a closed mesh. + +use std::collections::HashMap; + +use serde_json::{Value, json}; +use solidcraft_engine::Session; +use solidcraft_io::{Format, export, read_3mf, read_stl}; + +fn run(s: &mut Session, script: Value) { + for c in script.as_array().into_iter().flatten() { + let (id, p) = (c["command"].as_str().unwrap_or_default(), &c["params"]); + if let Err(e) = s.execute(id, p) { + panic!("{id} {p}: {e}"); + } + } +} + +/// The edges (by vertex index) that are not used by exactly two triangles. +fn open_edges(triangles: &[[u32; 3]]) -> Vec<((u32, u32), u32)> { + let mut uses: HashMap<(u32, u32), u32> = HashMap::new(); + for t in triangles { + for (a, b) in [(t[0], t[1]), (t[1], t[2]), (t[2], t[0])] { + *uses.entry((a.min(b), a.max(b))).or_default() += 1; + } + } + uses.into_iter().filter(|(_, n)| *n != 2).collect() +} + +/// A bowl section (lines and arcs) revolved about Z, its rim cut by a large cylinder across it. +/// The edge where the cylinder meets the inner cone is an intersection curve; one of its samples +/// lay a few ten-thousandths of a millimetre off both surfaces, each face put the point on its +/// own surface, and the exported mesh had a crack there (four edges used once). +#[test] +fn a_rim_cut_by_a_cylinder_exports_closed() { + let mut s = Session::default(); + run( + &mut s, + json!([ + {"command": "sketch.create", "params": {"plane": "XZ", "name": "Section"}}, + {"command": "sketch.line", "params": {"points": [[0, 0], [48.8, 0], [50.0240096077, -1.2]]}}, + {"command": "sketch.arc.three_point", "params": {"start": [50.0240096077, -1.2], "end": [58.7867965644, -21.2132034356], "through": [52.5188160894, -12.0326443841]}}, + {"command": "sketch.line", "params": {"points": [[58.7867965644, -21.2132034356], [85.5735931288, -48], [82.1794805791, -48], [57.0897402896, -22.9102597104]]}}, + {"command": "sketch.arc.three_point", "params": {"start": [57.0897402896, -22.9102597104], "end": [48.3846976331, -7.0875], "through": [51.6125175266, -15.6176451113]}}, + {"command": "sketch.arc.three_point", "params": {"start": [48.3846976331, -7.0875], "end": [42.5300120096, -2.4], "through": [46.2800120096, -3.7162515012]}}, + {"command": "sketch.line", "params": {"points": [[42.5300120096, -2.4], [0, -2.4], [0, 0]]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.revolve", "params": {"sketch": "Section", "axis": "Z"}}, + {"command": "sketch.create", "params": {"plane": "YZ", "name": "RimCutter"}}, + {"command": "sketch.arc.three_point", "params": {"start": [-100, 11.6204810674], "end": [100, 46.4045454912], "through": [-52, 8]}}, + {"command": "sketch.line", "params": {"points": [[100, 46.4045454912], [100, 70], [-100, 70], [-100, 11.6204810674]]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"sketch": "RimCutter", "distance": 200, "direction": "symmetric", "operation": "cut"}} + ]), + ); + let st = s.model.state(); + assert_eq!(st.bodies.len(), 1); + + let (objects, _) = read_3mf(&export(&st, &[], Format::ThreeMf, "cove").unwrap()).unwrap(); + assert_eq!(objects.len(), 1); + let open = open_edges(&objects[0].triangles); + assert!(open.is_empty(), "3MF: {} edges not used twice, e.g. {:?}", open.len(), open.first()); + + // STL has no indices: vertices are the same where their coordinates are. + let mut index: HashMap<[u32; 3], u32> = HashMap::new(); + let triangles: Vec<[u32; 3]> = read_stl(&export(&st, &[], Format::StlBinary, "cove").unwrap()) + .unwrap() + .iter() + .map(|t| { + t.map(|p| { + let n = index.len() as u32; + *index.entry(p.map(f32::to_bits)).or_insert(n) + }) + }) + .collect(); + let open = open_edges(&triangles); + assert!(open.is_empty(), "STL: {} edges not used twice, e.g. {:?}", open.len(), open.first()); +} diff --git a/vendor/README.md b/vendor/README.md index 7100b9c2..d3c24a17 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -154,6 +154,15 @@ Changes: p11`, across that plane, at the middle). A bilinear patch equals the blend of its corners the division compared against, so a twisted ruled face (a loft between polylines) went out as two triangles per face (#1666); a flat cell such as a cone's trapezoid between rulings is not split. +- `tessellation/triangulation.rs`: a shell's face meshes share their edges' points exactly. A + face's mesh puts each point at its parameters on the face's own surface, so a point of an edge + that lies off a surface within tolerance (an intersection-curve sample whose projection did not + converge, an imported edge) came out in a different place on each of the edge's faces, and the + exported mesh had a crack there. In display and export meshes (`Deflection::rulings`), a + vertex at a point of a face's boundary loops that is farther than `TOLERANCE` from the nearest + point of the face's edges now goes to that point, when it is within the chord tolerance + (`onto_edges`). Points within `TOLERANCE` stay on the surface, so exact geometry meshes as + before. The meshes Boolean operations intersect keep truck's points. ## matext4cgmath 0.1.0 (Apache-2.0, © RICOS Co. Ltd., https://github.com/ricosjp/matext4cgmath) diff --git a/vendor/truck-meshalgo/src/tessellation/triangulation.rs b/vendor/truck-meshalgo/src/tessellation/triangulation.rs index 20ed2e97..9ab3306f 100644 --- a/vendor/truck-meshalgo/src/tessellation/triangulation.rs +++ b/vendor/truck-meshalgo/src/tessellation/triangulation.rs @@ -389,7 +389,14 @@ fn shell_create_polygon( .collect::>>(); let polygon: Option = (|| { let boundary = PolyBoundary::new(preboundary?, &surface, tol); - Some(trimming_tessellation(surface, &boundary, tol, cap)) + let mut mesh = trimming_tessellation(surface, &boundary, tol, cap); + // Display and export meshes only, as the ruling grid lines: the meshes Boolean + // operations intersect keep truck's points. + if tol.rulings { + let points: Vec = wires.iter().flat_map(Wire::edge_iter).flat_map(|e| e.curve().0).collect(); + onto_edges(&mut mesh, &boundary, &points, tol.chord); + } + Some(mesh) })(); // SolidCraft: faces with seam edges (a loop running along an edge both ways, as STEP // writes cylinders) are valid here; `debug_new` asserts simple loops in debug builds. @@ -400,6 +407,42 @@ fn shell_create_polygon( new_face } +/// SolidCraft: a face's mesh puts every point where its own surface passes the point's +/// parameters, and a boundary point's parameters are those its edge point projects to. An edge +/// that lies on a surface only within tolerance (a sample of an intersection curve whose +/// projection did not converge, an imported edge) then puts the point it shares with the next +/// face in a different place on each face, and the shell's mesh cracks there. Each vertex at a +/// point of the boundary loops (the same parameters, as the triangulation got them) that is +/// farther than `TOLERANCE` from the nearest point of the face's edges goes to that point, when +/// it is within `tol`, so the faces of an edge have the very same points along it. Within +/// `TOLERANCE` the points are already the same, and a face keeps them on its surface: grid +/// points can lie on a boundary between its points (the ruling lines of a plane), and moving +/// only the boundary points would bend the boundary there. +fn onto_edges(mesh: &mut PolygonMesh, boundary: &PolyBoundary, points: &[Point3], tol: f64) { + let key = |x: f64, y: f64| [spade_round(x).to_bits(), spade_round(y).to_bits()]; + let loops: rustc_hash::FxHashSet<[u64; 2]> = boundary.0.iter().flatten().map(|p| key(p.x, p.y)).collect(); + let on_loops: Vec = mesh.uv_coords().iter().map(|uv| loops.contains(&key(uv.x, uv.y))).collect(); + let cell = |p: Point3| [p.x, p.y, p.z].map(|x| (x / tol).floor() as i64); + let mut grid: HashMap<[i64; 3], Vec> = HashMap::default(); + points.iter().for_each(|p| grid.entry(cell(*p)).or_default().push(*p)); + for (p, _) in mesh.positions_mut().iter_mut().zip(on_loops).filter(|(_, on)| *on) { + let c = cell(*p); + let mut best = (tol * tol, None); + for d in (0..27).map(|k| [k % 3 - 1, k / 3 % 3 - 1, k / 9 - 1]) { + let near = [c[0].saturating_add(d[0]), c[1].saturating_add(d[1]), c[2].saturating_add(d[2])]; + for q in grid.get(&near).into_iter().flatten() { + let d2 = q.distance2(*p); + if d2 <= best.0 { + best = (d2, Some(*q)); + } + } + } + if let Some(q) = best.1.filter(|_| best.0 > TOLERANCE2) { + *p = q; + } + } +} + #[derive(Debug, Default, Clone)] struct PolyBoundaryPiece(Vec);