diff --git a/Cargo.lock b/Cargo.lock index d5df2981..e1e31115 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3965,6 +3965,7 @@ dependencies = [ "spade", "truck-base", "truck-geometry", + "truck-modeling", "truck-polymesh", "truck-topology", ] diff --git a/crates/kernel/src/boolean_record.rs b/crates/kernel/src/boolean_record.rs index e24d31f0..e52383bf 100644 --- a/crates/kernel/src/boolean_record.rs +++ b/crates/kernel/src/boolean_record.rs @@ -116,9 +116,30 @@ pub(crate) fn shapeops(h: &truck_shapeops::History, tool: &Solid, tool_edges: Op Some((faces, edges)) } +/// Samples along each edge when checking that its curve evaluates. +const EVALUATION_SAMPLES: usize = 32; + +/// Whether every edge of `solid` evaluates along its whole range. An intersection curve projects +/// its leader's point onto both surfaces, which fails to converge where a boolean made the edge +/// from an approximation that strayed (#1252). Such a result is no solid to keep: the mesher, +/// healing and measuring all sample the edge and stumble over the gap. +pub(crate) fn edges_evaluate(solid: &Solid) -> bool { + use truck_meshalgo::prelude::TrySubs; + use truck_modeling::BoundedCurve; + solid.boundaries().iter().flat_map(|shell| shell.edge_iter()).all(|edge| { + let curve = edge.curve(); + let (t0, t1) = curve.range_tuple(); + (0..=EVALUATION_SAMPLES).all(|k| { + let t = t0 + (t1 - t0) * k as f64 / EVALUATION_SAMPLES as f64; + curve.try_subs(t).is_some_and(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite()) + }) + }) +} + /// The body made of `solid` healed (keeping faces apart along `keep`), with the history whose /// faces and edges (in `solid`'s orders) are given before healing, carried through it. -/// Operands with `counts` faces and edges. +/// Operands with `counts` faces and edges. A result with an edge that does not evaluate is +/// refused (see [`edges_evaluate`]). pub(crate) fn heal_build( solid: Solid, size: f64, @@ -128,6 +149,9 @@ pub(crate) fn heal_build( counts: &[(usize, usize)], ) -> Result
{ let (healed, map) = crate::heal::heal_traced(solid, size, keep); + if !edges_evaluate(&healed) { + return Err(crate::KernelError::Failed("an edge of the result does not evaluate".into())); + } let carried = faces.zip(edges).zip(map).and_then(|((f, e), m)| m.carry(&f, &e)); let (faces, edges) = carried.unzip(); record::build(healed, counts, faces, edges.as_ref()) diff --git a/crates/kernel/src/intersection_curve_tests.rs b/crates/kernel/src/intersection_curve_tests.rs index c1d8bef1..c4eee490 100644 --- a/crates/kernel/src/intersection_curve_tests.rs +++ b/crates/kernel/src/intersection_curve_tests.rs @@ -58,3 +58,59 @@ fn open_intersection_keeps_endpoints_on_both_planes() { assert_eq!(curve.subs(hi), end); assert_eq!(curve.subs((lo + hi) / 2.0), Point3::new(2.0, 0.0, 0.0)); } + +/// A triangular face whose first edge is an intersection curve of two planes: the parallel planes +/// `z = 0` and `z = 1` when `support_gap` is `1.0` (the projection onto both never converges), the +/// same plane twice when it is `0.0` (the leader lies on both and always evaluates). +fn triangle_with_intersection_edge(support_gap: f64) -> truck_modeling::Shell { + use truck_modeling::{Curve, Edge, Face, IntersectionCurve, Leader, Line, Shell, Surface, Vertex, Wire}; + let plane = |z: f64| Surface::Plane(Plane::new(Point3::new(0.0, 0.0, z), Point3::new(1.0, 0.0, z), Point3::new(0.0, 1.0, z))); + let (v0, v1, v2) = (Vertex::new(Point3::new(0.0, 0.0, 0.5)), Vertex::new(Point3::new(1.0, 0.0, 0.5)), Vertex::new(Point3::new(0.5, 1.0, 0.5))); + let leader = Leader::Polyline(PolylineCurve(vec![Point3::new(0.0, 0.0, 0.5), Point3::new(1.0, 0.0, 0.5)])); + let bad = IntersectionCurve::new_unchecked(Box::new(plane(0.5 - support_gap / 2.0)), Box::new(plane(0.5 + support_gap / 2.0)), leader, 0.01); + let line = |a: &Vertex, b: &Vertex| Edge::new(a, b, Curve::Line(Line(a.point(), b.point()))); + let wire = Wire::from(vec![Edge::new(&v0, &v1, Curve::IntersectionCurve(bad)), line(&v1, &v2), line(&v2, &v0)]); + Shell::from(vec![Face::new(vec![wire], plane(0.5))]) +} + +/// Meshing an edge whose intersection curve does not evaluate (#1252): `IntersectionCurve::subs` +/// unwraps a failed projection, and the mesher sampled the edge through it. The two supports here +/// are parallel planes, so the projection never converges. Nothing may panic (wasm aborts on one, +/// and the native guard prints the message first) and the mesh stays finite. +#[test] +fn meshing_an_edge_that_does_not_evaluate_neither_panics_nor_goes_non_finite() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use truck_meshalgo::prelude::RobustMeshableShape; + let shell = triangle_with_intersection_edge(1.0); + + let me = std::thread::current().id(); + let count = Arc::new(AtomicUsize::new(0)); + let c2 = count.clone(); + let prev = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |_| { + if std::thread::current().id() == me { + c2.fetch_add(1, Ordering::SeqCst); + } + })); + let meshed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| shell.robust_triangulation(0.01))); + std::panic::set_hook(prev); + assert_eq!(count.load(Ordering::SeqCst), 0, "panics while meshing"); + let meshed = meshed.unwrap(); + for face in meshed.face_iter() { + if let Some(mesh) = face.surface() { + assert!(mesh.positions().iter().all(|p| p.x.is_finite() && p.y.is_finite() && p.z.is_finite())); + } + } +} + +/// A boolean's result with an edge that does not evaluate is refused with an error (#1252). The +/// mesher's panic on such an edge used to reject the attempt; now that it no longer panics, the +/// result must be refused explicitly or a plausible-looking mesh would let it through. +#[test] +fn a_result_with_an_edge_that_does_not_evaluate_is_refused() { + use truck_modeling::Solid; + let solid = |gap: f64| Solid::new_unchecked(vec![triangle_with_intersection_edge(gap)]); + assert!(!crate::boolean_record::edges_evaluate(&solid(1.0))); + assert!(crate::boolean_record::edges_evaluate(&solid(0.0))); +} diff --git a/vendor/README.md b/vendor/README.md index 2af44493..71e49046 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -75,6 +75,13 @@ Changes: and the face was then left unmeshed, which also failed every boolean with the body (#882). A walk that succeeds is kept as it was: where a loop starts decides how it is laid out across a periodic surface's seam. +- `tessellation/mod.rs`, `triangulation.rs`: edges are sampled through a new `TrySubs` trait + (`try_subs`, `approx_subs`), a bound of `PolylineableCurve`, instead of `subs`. truck's + `IntersectionCurve::subs` unwraps a projection onto its two surfaces that can fail to + converge, which panicked while meshing (#1252). A failed sample at an interval's end uses the + intersection curve's leader point, so the polyline stays finite; a failed interior probe is + NaN, which already counts as flat. `truck-shapeops`' `ShapeOpsCurve` carries the same bound, + and `truck-modeling` is now a regular dependency (it was a dev-dependency). - `tessellation/triangulation.rs`: the inside test of a face's boundary skips loops whose parameter box is clear of the point (it tested every segment of every loop, so a face with hundreds of holes meshed in quadratic time). diff --git a/vendor/truck-meshalgo/Cargo.toml b/vendor/truck-meshalgo/Cargo.toml index 99cf60e6..73c8199c 100644 --- a/vendor/truck-meshalgo/Cargo.toml +++ b/vendor/truck-meshalgo/Cargo.toml @@ -68,6 +68,9 @@ version = "0.5.0" [dependencies.truck-geometry] version = "0.5.0" +[dependencies.truck-modeling] +version = "0.6.0" + [dependencies.truck-polymesh] version = "0.6.0" diff --git a/vendor/truck-meshalgo/src/tessellation/mod.rs b/vendor/truck-meshalgo/src/tessellation/mod.rs index 0bc21fe6..f9d64f02 100644 --- a/vendor/truck-meshalgo/src/tessellation/mod.rs +++ b/vendor/truck-meshalgo/src/tessellation/mod.rs @@ -25,12 +25,51 @@ pub fn preview_face