From 7135407268dee061d1f44a236aaa5d5e54fff50a Mon Sep 17 00:00:00 2001 From: bherbruck Date: Tue, 13 Oct 2026 20:10:55 -0400 Subject: [PATCH] Mesh a cylinder wall whose seam edges a cut split without fans across it (#921) A boolean that cuts a pocket into the top of a cylinder (Shell does this) splits the outer wall's straight seam edges at the pocket's floor. The wall's parameter grid has no division along its rulings, so the triangulation fanned out from the two split vertices to the far rims and its triangles cut through the part: the shelled Bolted Flange sample drew and exported its rim cut off (mesh volume 29201 mm3 for a 40796 mm3 body). Give the grid a line through each vertex that splits a boundary segment running along a ruling, in a direction that needs no division of its own and within the grid budget. Fixes #819. Co-authored-by: Claude Opus 5.5 --- crates/kernel/src/display_mesh_tests.rs | 39 +++++++++++++ .../src/tessellation/triangulation.rs | 55 ++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/crates/kernel/src/display_mesh_tests.rs b/crates/kernel/src/display_mesh_tests.rs index 86e15341..f2b0a17f 100644 --- a/crates/kernel/src/display_mesh_tests.rs +++ b/crates/kernel/src/display_mesh_tests.rs @@ -348,3 +348,42 @@ fn round_faces_of_every_kind_are_divided_by_the_angle() { check(name, b); } } + +/// The signed volume a closed mesh encloses. +fn mesh_volume(m: &Mesh) -> f64 { + m.triangles.iter().filter_map(|t| m.tri(t)).map(|[a, b, c]| a.dot(b.cross(c)) / 6.0).sum() +} + +#[test] +fn a_cylinder_wall_split_along_its_seam_meshes_round() { + // A cup: a Ø100 × 10 disc with a Ø96 pocket 8 deep cut into its top, as Shell leaves it. + // The cut splits the outer wall's straight seam edges at the pocket's floor (z = 2); the + // wall used to be meshed as fans from those two vertices to the far rims, triangles that cut + // through the disc (the shelled Bolted Flange sample showed its rim cut off). + let disc = |r: f64, z0: f64, z1: f64| { + extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::ZERO, r), holes: vec![] }], z0, z1).unwrap().pop().unwrap() + }; + let cup = crate::ops::boolean(&disc(50.0, 0.0, 10.0), &disc(48.0, 2.0, 20.0), crate::BoolOp::Cut).unwrap().unwrap(); + let exact = PI * (50.0 * 50.0 * 10.0 - 48.0 * 48.0 * 8.0); + let outer: Vec = (0..cup.face_count()) + .map(|i| matches!(face_surface(&cup, i), Some(FaceSurface::Cylinder { radius, .. }) if (radius - 50.0).abs() < 1e-6)) + .collect(); + assert_eq!(outer.iter().filter(|o| **o).count(), 2); + for tol in [display_tol(&cup), 0.1, 0.01] { + let m = cup.tessellate(tol).unwrap(); + assert_eq!(open_sides(&m, 1e-6), 0); + let v = mesh_volume(&m); + assert!((v - exact).abs() < exact * 0.01, "tolerance {tol}: mesh volume {v:.1}, exact {exact:.1}"); + // Every vertex of the outer wall's triangles lies on it, and none spans its height + // from one of the split vertices across the rim. + for (t, f) in m.triangles.iter().zip(&m.tri_face) { + if !outer.get(*f as usize).copied().unwrap_or(false) { + continue; + } + let [a, b, c] = m.tri(t).unwrap(); + let centre = (a + b + c) * (1.0 / 3.0); + let r = Vec2::new(centre.x, centre.y).len(); + assert!(r > 50.0 - 10.0 * tol.max(0.05), "tolerance {tol}: an outer wall triangle passes {r:.2} mm from the axis"); + } + } +} diff --git a/vendor/truck-meshalgo/src/tessellation/triangulation.rs b/vendor/truck-meshalgo/src/tessellation/triangulation.rs index cb7282de..0349d9bc 100644 --- a/vendor/truck-meshalgo/src/tessellation/triangulation.rs +++ b/vendor/truck-meshalgo/src/tessellation/triangulation.rs @@ -755,7 +755,11 @@ fn insert_surface( (division(range.0), division(range.1)) } else { let (u, v) = bounded_surface_division(&surface, range, tol, cap); - balanced_grid(&surface, u, v, cap) + let (u_ruled, v_ruled) = (u.len() <= 3, v.len() <= 3); + let (u, v) = balanced_grid(&surface, u, v, cap); + let v = with_ruling_vertices(v, polyline, 1, v_ruled, u.len(), cap); + let u = with_ruling_vertices(u, polyline, 0, u_ruled, v.len(), cap); + (u, v) }; let insert_res: Vec>> = udiv .into_iter() @@ -970,6 +974,55 @@ fn balanced_grid(surface: &impl PreMeshableSurface, udiv: Vec, vdiv: Vec, polyline: &PolyBoundary, axis: usize, ruled: bool, other: usize, cap: usize) -> Vec { + let (Some(&first), Some(&last)) = (div.first(), div.last()) else { return div }; + let span = last - first; + if !ruled || !(span.is_finite() && span > 0.0) { + return div; + } + let across = 1 - axis; + // The extent of the face across the rulings, to tell a segment along one. + let (lo, hi) = polyline.0.iter().flatten().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), p| (lo.min(p[across]), hi.max(p[across]))); + let width = hi - lo; + if !(width.is_finite() && width > 0.0) { + return div; + } + let (same, apart) = (width * 1e-9, span * 1e-6); + let mut extra: Vec = Vec::new(); + for w in &polyline.0 { + for (a, b) in w.iter().circular_tuple_windows() { + if (a[across] - b[across]).abs() > same { + continue; + } + for t in [a[axis], b[axis]] { + let inside = t > first + apart && t < last - apart; + if inside && t.is_finite() && div.iter().chain(&extra).all(|d| (d - t).abs() > apart) { + extra.push(t); + } + } + if extra.len() > MAX_RULING_LINES { + return div; + } + } + } + if extra.is_empty() || (div.len() + extra.len()).saturating_mul(other) > cap { + return div; + } + let mut out = div; + out.extend(extra); + out.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + out +} + /// Converts triangulation into `PolygonMesh`. fn triangulation_into_polymesh<'a>( vertices: VertexIterator<'a, SPoint2, (), CdtEdge<()>, ()>,