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(surface: &S, loops: &[Vec] triangulation::preview_face(surface, loops, steps) } +/// SolidCraft: sampling a curve that may fail to evaluate. truck's `IntersectionCurve::subs` +/// unwraps the projection of its leader's point onto both surfaces, which does not converge +/// everywhere; the mesher samples through this trait so that a failed sample is data, not a panic. +pub trait TrySubs: ParametricCurve3D { + /// The point at `t`, or `None` where the curve does not evaluate. + fn try_subs(&self, t: f64) -> Option { Some(self.subs(t)) } + /// A point near the curve at `t` that always exists (an intersection curve's leader). + fn approx_subs(&self, t: f64) -> Point3 { self.subs(t) } +} + +impl TrySubs for truck_geometry::prelude::Line {} +impl TrySubs for truck_geometry::prelude::BSplineCurve {} +impl TrySubs for truck_geometry::prelude::NurbsCurve {} +impl TrySubs for PolylineCurve {} + +impl TrySubs for truck_geometry::prelude::IntersectionCurve +where + C: ParametricCurve3D, + S: ParametricSurface3D + SearchNearestParameter, +{ + fn try_subs(&self, t: f64) -> Option { self.search_triple(t).map(|triple| triple.0) } + fn approx_subs(&self, t: f64) -> Point3 { self.leader().subs(t) } +} + +impl TrySubs for truck_modeling::Curve { + fn try_subs(&self, t: f64) -> Option { + match self { + truck_modeling::Curve::IntersectionCurve(curve) => TrySubs::try_subs(curve, t), + other => Some(other.subs(t)), + } + } + fn approx_subs(&self, t: f64) -> Point3 { + match self { + truck_modeling::Curve::IntersectionCurve(curve) => TrySubs::approx_subs(curve, t), + other => other.subs(t), + } + } +} + /// Gathered the traits used in tessellation. pub trait PolylineableCurve: - ParametricCurve3D + BoundedCurve + ParameterDivision1D + Parallelizable { + ParametricCurve3D + BoundedCurve + ParameterDivision1D + Parallelizable + TrySubs { } impl< - C: ParametricCurve3D + BoundedCurve + ParameterDivision1D + Parallelizable, + C: ParametricCurve3D + BoundedCurve + ParameterDivision1D + Parallelizable + TrySubs, > PolylineableCurve for C { } diff --git a/vendor/truck-meshalgo/src/tessellation/triangulation.rs b/vendor/truck-meshalgo/src/tessellation/triangulation.rs index 558d202a..773f8f41 100644 --- a/vendor/truck-meshalgo/src/tessellation/triangulation.rs +++ b/vendor/truck-meshalgo/src/tessellation/triangulation.rs @@ -863,15 +863,20 @@ pub(super) fn face_cap(faces: usize) -> usize { /// tolerance is still halved while it turns more than the deflection's angle. fn bounded_polyline(curve: &C, range: (f64, f64), d: Deflection) -> PolylineCurve { let (tol, turn2) = (d.chord, d.turn2()); - let mut out = vec![curve.subs(range.0)]; + // A sample that does not evaluate (an intersection curve whose projection fails): an interval + // end falls back to the curve's approximation so the polyline stays finite; an interior probe + // is NaN, which counts as flat. + let end = |t: f64| curve.try_subs(t).unwrap_or_else(|| curve.approx_subs(t)); + let probe = |t: f64| curve.try_subs(t).unwrap_or(Point3::new(f64::NAN, f64::NAN, f64::NAN)); + let mut out = vec![end(range.0)]; let mut stack: Vec<(f64, f64, usize)> = vec![(range.0, range.1, 0)]; while let Some((a, b, depth)) = stack.pop() { - let (pa, pb) = (curve.subs(a), curve.subs(b)); + let (pa, pb) = (end(a), end(b)); let t = a + (b - a) * 0.4729; let chord = pa + (pb - pa) * 0.4729; - let d2 = curve.subs(t).distance2(chord); + let d2 = probe(t).distance2(chord); let tm = 0.5 * (a + b); - let dm = curve.subs(tm).distance2(pa.midpoint(pb)); + let dm = probe(tm).distance2(pa.midpoint(pb)); let flat = !(d2 >= tol * tol || dm >= tol * tol); let flat = flat && !d.turns(turn2, d2.max(dm), pa.distance2(pb)); if flat || depth >= 24 || out.len() + stack.len() >= MAX_EDGE_POINTS { diff --git a/vendor/truck-shapeops/src/transversal/integrate/mod.rs b/vendor/truck-shapeops/src/transversal/integrate/mod.rs index d5252a47..dd45a8a4 100644 --- a/vendor/truck-shapeops/src/transversal/integrate/mod.rs +++ b/vendor/truck-shapeops/src/transversal/integrate/mod.rs @@ -27,6 +27,7 @@ impl ShapeOpsSurface for S where S: ParametricSurface3D /// Only solids consisting of edges whose curve is implemented this trait can be used for set operations. pub trait ShapeOpsCurve: ParametricCurve3D + + TrySubs + ParameterDivision1D + Cut + Invertible @@ -37,6 +38,7 @@ pub trait ShapeOpsCurve: + Sync { } impl ShapeOpsCurve for C where C: ParametricCurve3D + + TrySubs + ParameterDivision1D + Cut + Invertible