From 3159fc0dd058ba3bcf2832ebd1d814894e3da33e Mon Sep 17 00:00:00 2001 From: Scott Bowden <102687882+sjbowden@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:13:16 -0700 Subject: [PATCH] fix(kernel): a small overlap is not taken for bodies apart or inside (#484) When sample points say two bodies are apart or one is inside the other, the facetings are now checked for surfaces passing through each other; if they do, the boolean is computed instead of shortcut. If it still can't be intersected, the sampled answer is kept as a flagged, unverified result, and Interference lists such pairs as "could not be checked" with the reason. Fixes #442 and #443. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/features_more_tests.rs | 23 ++++ crates/engine/src/cmd/joints.rs | 30 ++++- crates/engine/src/cmd/joints_tests.rs | 51 ++++++++ crates/geom/src/mesh.rs | 114 ++++++++++++++++ crates/geom/src/tests.rs | 52 ++++++++ crates/kernel/src/boolean_diagnostics.rs | 13 ++ crates/kernel/src/ops.rs | 131 ++++++++++++++++--- crates/ui-egui/src/dialogs_assembly.rs | 17 ++- 8 files changed, 406 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 7656e345..885ae1fd 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -602,3 +602,26 @@ fn cylinder_auto_inference_refuses_ambiguity_curves_and_seam_crossing_atomically assert_eq!(exact_cylinder_volume(&s), volume); } } + +/// A cone that takes only a 1 mm chamfer off a cylinder's top edge leaves nearly all of the +/// cylinder inside it; the intersection is still the chamfered cylinder (#443). +#[test] +fn loft_intersect_that_trims_one_edge_changes_the_body() { + let mut s = Session::default(); + run(&mut s, "sketch.create", json!({"plane": "XY"})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": 35})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.extrude", json!({"distance": 10, "body_name": "Body"})); + for (name, offset, radius) in [("A", -1, 45), ("B", 11, 33)] { + run(&mut s, "sketch.create", json!({"plane": "XY", "offset": offset, "name": name})); + run(&mut s, "sketch.circle.center", json!({"center": [0, 0], "radius": radius})); + run(&mut s, "sketch.finish", json!({})); + } + run(&mut s, "solid.loft", json!({"sections": [{"sketch": "A"}, {"sketch": "B"}], "operation": "intersect"})); + // r 35 up to z = 9, then a frustum to r 34 at z = 10. + let want = std::f64::consts::PI * (35.0 * 35.0 * 9.0 + (35.0 * 35.0 + 35.0 * 34.0 + 34.0 * 34.0) / 3.0); + let got = volume(&mut s); + assert!((got - want).abs() < want * 2e-4, "{got} vs {want}"); + let m = measure(&mut s); + assert_eq!(m["bodies"][0]["face_types"]["cone"], 1, "{m}"); +} diff --git a/crates/engine/src/cmd/joints.rs b/crates/engine/src/cmd/joints.rs index b3fd4593..8a652a5d 100644 --- a/crates/engine/src/cmd/joints.rs +++ b/crates/engine/src/cmd/joints.rs @@ -37,7 +37,7 @@ pub static COMMANDS: &[CommandSpec] = &[ CommandSpec::new("joint.list", "List Joints", list).noundo().params("→ joints (type, occurrences, values, limits), origins, links, degrees of freedom, conflicts"), CommandSpec::new("joint.solve", "Solve Joints", solve_cmd).params("re-place the occurrences from the joints → conflicts, errors, dof"), CommandSpec::new("inspect.interference", "Interference", interference).at("SOLID", "INSPECT").icon("interference").noundo().params( - "bodies?: [names] (default: all, in world placement) → pairs that overlap with the overlap volume, area, bounding box and centre", + "bodies?: [names] (default: all, in world placement) → interferences: pairs that overlap with the overlap volume, area, bounding box and centre; unchecked: pairs that could not be checked, with the reason", ), ]; @@ -531,7 +531,7 @@ fn interference(s: &mut Session, p: &Value) -> Result { if bodies.len() > 200 { return Err(bad(cmd, "too many bodies (200 at most)")); } - let mut hits = Vec::new(); + let (mut hits, mut unchecked) = (Vec::new(), Vec::new()); for (i, a) in bodies.iter().enumerate() { for b in bodies.iter().skip(i + 1) { let (ba, bb) = (a.mesh().bounds(), b.mesh().bounds()); @@ -544,14 +544,34 @@ fn interference(s: &mut Session, p: &Value) -> Result { if apart { continue; } - let Ok(Some(o)) = solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect) else { continue }; - let Ok(m) = solidcraft_kernel::measure(&o) else { continue }; + // A pair whose shared part can't be worked out is reported, not passed as clear. + let (shared, report) = + solidcraft_kernel::capture_boolean_diagnostics(|| solidcraft_kernel::boolean(&a.body, &b.body, solidcraft_kernel::BoolOp::Intersect)); + let mut skip = |reason: String| unchecked.push(json!({"a": a.name, "b": b.name, "reason": reason})); + let shared = match shared { + Ok(o) => o, + Err(e) => { + skip(e.to_string()); + continue; + } + }; + if let Some(reason) = report.unverified() { + skip(reason); + } + let Some(o) = shared else { continue }; + let m = match solidcraft_kernel::measure(&o) { + Ok(m) => m, + Err(e) => { + skip(e.to_string()); + continue; + } + }; if m.volume > 1e-9 * a.body.size().max(1.0).powi(3) { hits.push(json!({"a": a.name, "b": b.name, "volume_mm3": m.volume, "area_mm2": m.area, "center": m.centroid, "bbox": {"min": m.bbox.min, "max": m.bbox.max}})); } } } - Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len()})) + Ok(json!({"interferences": hits, "count": hits.len(), "checked": bodies.len(), "unchecked": unchecked})) } #[cfg(test)] diff --git a/crates/engine/src/cmd/joints_tests.rs b/crates/engine/src/cmd/joints_tests.rs index 11a905d3..2370f340 100644 --- a/crates/engine/src/cmd/joints_tests.rs +++ b/crates/engine/src/cmd/joints_tests.rs @@ -151,6 +151,57 @@ fn rigid_cycles_solve_and_conflicts_report() { assert!(s.execute("joint.create", &json!({"type": "hinge"})).is_err()); } +/// A 30 x 30 x 3 plate with a through slot whose x = 20 wall carries one rib (0.3 tall, 0.8 +/// wide), and a peg in the slot with 0.15 clearance all round. +fn ribbed_slot_and_peg(s: &mut Session, peg_x1: f64) { + run(s, "sketch.create", json!({"plane": "XY"})); + run(s, "sketch.rectangle.two_point", json!({"p0": [0, 0], "p1": [30, 30]})); + run(s, "sketch.finish", json!({})); + run(s, "solid.extrude", json!({"distance": 3, "body_name": "Plate"})); + run(s, "sketch.create", json!({"plane": "XY", "offset": -1})); + let slot = json!([[10, 5], [20, 5], [20, 14.6], [19.7, 14.6], [19.7, 15.4], [20, 15.4], [20, 25], [10, 25]]); + run(s, "sketch.line", json!({"points": slot, "closed": true})); + run(s, "sketch.finish", json!({})); + run(s, "solid.extrude", json!({"distance": 5, "operation": "cut", "targets": ["Plate"]})); + run(s, "sketch.create", json!({"plane": "XY"})); + run(s, "sketch.rectangle.two_point", json!({"p0": [10.15, 5.15], "p1": [peg_x1, 24.85]})); + run(s, "sketch.finish", json!({})); + run(s, "solid.extrude", json!({"distance": 3, "operation": "new", "body_name": "Peg"})); +} + +/// The peg overlaps only the rib: 0.15 x 0.8 x 3 = 0.36 mm³ (#442). +#[test] +fn interference_finds_a_peg_overlapping_a_small_rib() { + let mut s = Session::default(); + ribbed_slot_and_peg(&mut s, 19.85); + let i = run(&mut s, "inspect.interference", json!({})); + assert_eq!(i["count"], 1, "{i}"); + assert!((i["interferences"][0]["volume_mm3"].as_f64().unwrap_or(0.0) - 0.36).abs() < 1e-6, "{i}"); + let (lo, hi) = (&i["interferences"][0]["bbox"]["min"], &i["interferences"][0]["bbox"]["max"]); + assert!((lo[0].as_f64().unwrap_or(0.0) - 19.7).abs() < 1e-6 && (hi[0].as_f64().unwrap_or(0.0) - 19.85).abs() < 1e-6, "{i}"); +} + +/// An arc revolved up to a post ends on the post's wall, where the two bodies can't be +/// intersected: the pair is listed as unchecked instead of passing as clear. +#[test] +fn interference_lists_a_pair_it_could_not_check() { + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"base": [0, 14, -5], "radius": 4, "height": 15, "body_name": "Post"})); + run(&mut s, "sketch.create", json!({"name": "Profile", "plane": "XZ"})); + run(&mut s, "sketch.rectangle.two_point", json!({"p0": [10, 0], "p1": [15, 5]})); + run(&mut s, "sketch.finish", json!({})); + run(&mut s, "solid.revolve", json!({"sketch": "Profile", "axis": {"origin": [0, 0, 0], "dir": [0, 0, 1]}, "to": [4, 14, 2], "body_name": "Arc"})); + let i = run(&mut s, "inspect.interference", json!({})); + assert_eq!(i["count"], 0, "{i}"); + let unchecked = i["unchecked"].as_array().cloned().unwrap_or_default(); + assert_eq!(unchecked.len(), 1, "{i}"); + assert!(unchecked[0]["reason"].as_str().is_some_and(|r| r.starts_with("not verified") && !r.contains("displacement")), "{i}"); + // Bodies that are clear of each other, or overlap plainly, leave the list empty. + let mut s = two_cubes(); + let i = run(&mut s, "inspect.interference", json!({})); + assert_eq!(i["unchecked"].as_array().map(Vec::len), Some(0), "{i}"); +} + #[test] fn motion_links_and_interference() { let mut s = two_cubes(); diff --git a/crates/geom/src/mesh.rs b/crates/geom/src/mesh.rs index f11c57cf..0bf84c20 100644 --- a/crates/geom/src/mesh.rs +++ b/crates/geom/src/mesh.rs @@ -76,6 +76,40 @@ pub struct Mesh { pub edge_faces: Vec>, } +fn boxes_meet(a: &Aabb3, b: &Aabb3) -> bool { + a.min.x <= b.max.x && b.min.x <= a.max.x && a.min.y <= b.max.y && b.min.y <= a.max.y && a.min.z <= b.max.z && b.min.z <= a.max.z +} + +/// Two triangles that pass through each other (see [`Mesh::crosses`]). +fn triangles_cross(t: &[Vec3; 3], u: &[Vec3; 3], tol: f64) -> bool { + // Signed distances of one triangle's corners from the other's plane, when they lie more + // than `tol` to both sides of it. + let sides = |tri: &[Vec3; 3], plane: &[Vec3; 3]| -> Option<(Vec3, [f64; 3])> { + let n = (plane[1] - plane[0]).cross(plane[2] - plane[0]).normalized()?; + let d = [n.dot(tri[0] - plane[0]), n.dot(tri[1] - plane[0]), n.dot(tri[2] - plane[0])]; + let (lo, hi) = d.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), v| (lo.min(*v), hi.max(*v))); + (lo < -tol && hi > tol).then_some((n, d)) + }; + let (Some((nu, dt)), Some((nt, du))) = (sides(t, u), sides(u, t)) else { return false }; + let Some(line) = nt.cross(nu).normalized() else { return false }; + // Where a triangle's edges pass through the other's plane, measured along the line the + // two planes share. + let reach = |tri: &[Vec3; 3], d: &[f64; 3]| -> (f64, f64) { + let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY); + for (i, j) in [(0, 1), (1, 2), (2, 0)] { + let (p, q, dp, dq) = (tri[i], tri[j], d[i], d[j]); + if (dp < 0.0) != (dq < 0.0) && dp != dq { + let at = line.dot(p + (q - p) * (dp / (dp - dq))); + lo = lo.min(at); + hi = hi.max(at); + } + } + (lo, hi) + }; + let ((t0, t1), (u0, u1)) = (reach(t, &dt), reach(u, &du)); + t1.min(u1) - t0.max(u0) > tol +} + /// Mass properties of a closed mesh. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct MeshMeasure { @@ -281,6 +315,86 @@ impl Mesh { dirs.iter().filter(|d| self.ray_crossings(p, **d) % 2 == 1).count() >= 2 } + /// Whether the surfaces of two meshes pass through each other by more than `tol`: some + /// triangle of one has corners more than `tol` to both sides of a triangle of the other, + /// and the reverse, and they share a stretch of the line where their planes meet longer + /// than `tol`. Faces that only touch, lie on each other or stop on the other surface don't + /// count, nor do two facetings of one curved surface that are within `tol` of it. Bodies + /// can still overlap without crossing: one inside the other, or sharing a slab between + /// faces that are parallel or in one plane. + pub fn crosses(&self, o: &Mesh, tol: f64) -> bool { + if !tol.is_finite() { + return false; + } + let tol = tol.max(0.0); + let (ba, bb) = (self.bounds(), o.bounds()); + let region = Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }; + if region.is_empty() { + return false; + } + let near = |m: &Mesh| -> Vec<([Vec3; 3], Aabb3)> { + m.triangles + .iter() + .filter_map(|t| { + let t = m.tri(t)?; + let mut bx = Aabb3::EMPTY; + t.iter().for_each(|p| bx.add(*p)); + (!bx.is_empty() && boxes_meet(&bx, ®ion)).then_some((t, bx)) + }) + .collect() + }; + let (ta, tb) = (near(self), near(o)); + if ta.is_empty() || tb.is_empty() { + return false; + } + // A grid over the shared box holds the other mesh's triangles. + let n = ((tb.len() as f64).cbrt().ceil() as usize).clamp(1, 24); + let size = region.size(); + let cell = |v: f64, lo: f64, s: f64| -> usize { if s > 0.0 { (((v - lo) / s * n as f64).floor().max(0.0) as usize).min(n - 1) } else { 0 } }; + let span = |bx: &Aabb3| -> [(usize, usize); 3] { + [ + (cell(bx.min.x, region.min.x, size.x), cell(bx.max.x, region.min.x, size.x)), + (cell(bx.min.y, region.min.y, size.y), cell(bx.max.y, region.min.y, size.y)), + (cell(bx.min.z, region.min.z, size.z), cell(bx.max.z, region.min.z, size.z)), + ] + }; + let mut cells: Vec> = vec![Vec::new(); n * n * n]; + for (i, (_, bx)) in tb.iter().enumerate() { + let [x, y, z] = span(bx); + for ix in x.0..=x.1 { + for iy in y.0..=y.1 { + for iz in z.0..=z.1 { + if let Some(c) = cells.get_mut((ix * n + iy) * n + iz) { + c.push(i as u32); + } + } + } + } + } + // Per triangle of `o`: the last triangle of `self` tested against it, plus one. + let mut seen = vec![0usize; tb.len()]; + for (i, (t, bx)) in ta.iter().enumerate() { + let [x, y, z] = span(bx); + for ix in x.0..=x.1 { + for iy in y.0..=y.1 { + for iz in z.0..=z.1 { + for j in cells.get((ix * n + iy) * n + iz).into_iter().flatten() { + let (Some(mark), Some((u, ub))) = (seen.get_mut(*j as usize), tb.get(*j as usize)) else { continue }; + if *mark == i + 1 { + continue; + } + *mark = i + 1; + if boxes_meet(bx, ub) && triangles_cross(t, u, tol) { + return true; + } + } + } + } + } + } + false + } + /// Closest intersection of a ray with the mesh: (distance along `dir`, triangle index). pub fn raycast(&self, o: Vec3, dir: Vec3) -> Option<(f64, usize)> { let mut best: Option<(f64, usize)> = None; diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index d265ba10..c71c8f3c 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -233,3 +233,55 @@ fn split_at_and_closest_param() { assert!(l.end().dist(q) < 1e-12 && r.start().dist(q) < 1e-12); assert!((quarter.closest_param(Vec2::new(20.0, 20.0)) - 0.5).abs() < 1e-9); } + +/// An axis-aligned box as a closed mesh, with `n` x `n` quads on each side. +fn box_mesh(min: Vec3, max: Vec3, n: usize) -> Mesh { + let mut m = Mesh::default(); + let s = max - min; + let at = |c: [f64; 3]| min + Vec3::new(s.x * c[0], s.y * c[1], s.z * c[2]); + for axis in 0..3 { + for side in [0.0, 1.0] { + for i in 0..n { + for j in 0..n { + let corner = |a: usize, b: usize| { + let mut c = [0.0; 3]; + c[axis] = side; + c[(axis + 1) % 3] = a as f64 / n as f64; + c[(axis + 2) % 3] = b as f64 / n as f64; + at(c) + }; + let k = m.positions.len() as u32; + m.positions.extend([corner(i, j), corner(i + 1, j), corner(i + 1, j + 1), corner(i, j + 1)]); + m.triangles.extend(if side > 0.0 { [[k, k + 1, k + 2], [k, k + 2, k + 3]] } else { [[k, k + 2, k + 1], [k, k + 3, k + 2]] }); + } + } + } + } + m +} + +#[test] +fn meshes_cross_only_where_their_surfaces_pass_through_each_other() { + let cube = |x: f64, y: f64, n: usize| box_mesh(Vec3::new(x, y, 0.0), Vec3::new(x + 10.0, y + 10.0, 10.0), n); + let a = cube(0.0, 0.0, 1); + // A corner 0.15 deep in the other cube (0.02 % of its volume), and a deep overlap. + for (x, y, n) in [(9.85, 3.0, 1), (9.85, 3.0, 7), (9.85, 9.85, 2), (5.0, 5.0, 3)] { + assert!(a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}"); + assert!(cube(x, y, n).crosses(&a, 0.01), "{x} {y} {n}"); + } + // Apart, touching on a face or an edge, and the same body. + for (x, y, n) in [(10.5, 0.0, 1), (10.0, 0.0, 1), (10.0, 3.0, 5), (10.0, 10.0, 2), (0.0, 0.0, 1), (0.0, 0.0, 4)] { + assert!(!a.crosses(&cube(x, y, n), 0.01), "{x} {y} {n}"); + } + // One inside the other, standing on a face, and sharing a face plane from inside. + let inner = box_mesh(Vec3::new(2.0, 2.0, 2.0), Vec3::new(8.0, 8.0, 8.0), 2); + let on_top = box_mesh(Vec3::new(2.0, 2.0, 10.0), Vec3::new(8.0, 8.0, 14.0), 2); + let flush = box_mesh(Vec3::new(2.0, 2.0, 4.0), Vec3::new(8.0, 8.0, 10.0), 2); + assert!(!a.crosses(&inner, 0.01) && !inner.crosses(&a, 0.01)); + assert!(!a.crosses(&on_top, 0.01) && !a.crosses(&flush, 0.01)); + // A slab overlap between faces that are parallel or in one plane has no crossing. + assert!(!a.crosses(&cube(9.85, 0.0, 1), 0.01)); + // Passing through by less than the tolerance doesn't count. + assert!(!a.crosses(&cube(9.995, 3.0, 1), 0.01)); + assert!(!a.crosses(&cube(9.85, 3.0, 1), f64::NAN)); +} diff --git a/crates/kernel/src/boolean_diagnostics.rs b/crates/kernel/src/boolean_diagnostics.rs index ba357b7f..f29dc10e 100644 --- a/crates/kernel/src/boolean_diagnostics.rs +++ b/crates/kernel/src/boolean_diagnostics.rs @@ -17,6 +17,9 @@ pub enum BooleanStrategy { PlanarFallback, ToolNudge, Piecewise, + /// No retry gave a result, and the answer from sample points was kept although the bodies' + /// facetings disagree with it. + UnverifiedShortcut, } /// Numerical adjustments used by a successful retry, in millimetres/radians. @@ -47,6 +50,10 @@ impl BooleanFallback { BooleanStrategy::PlanarFallback => "exact planar fallback (no displacement)".into(), BooleanStrategy::ToolNudge => format!("tool displacement {:.6e} mm, rotation {:.6e} rad", self.translation_mm, self.rotation_rad), BooleanStrategy::Piecewise => "piecewise retry (no displacement)".into(), + BooleanStrategy::UnverifiedShortcut => format!( + "not verified: the bodies' facets pass through each other by more than {:.6e} mm, but they could not be intersected there; that part is ignored", + self.tolerance_mm + ), } } } @@ -66,6 +73,12 @@ impl BooleanDiagnostics { } } + /// Why the result is not verified, when it ignores a place where the bodies seem to pass + /// through each other ([`BooleanStrategy::UnverifiedShortcut`]). + pub fn unverified(&self) -> Option { + self.events.iter().find(|event| event.strategy == BooleanStrategy::UnverifiedShortcut).map(BooleanFallback::description) + } + pub fn warning(&self) -> Option { if self.events.is_empty() && !self.truncated { return None; diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index f9561f70..5f15de10 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -89,11 +89,12 @@ fn mismatch(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh, result: &solid /// How one body sits relative to another, from sample points: (fraction of `b`'s samples /// inside `a`, fraction of `a`'s samples inside `b`). -fn overlap(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh) -> Option<(f64, f64)> { - if a.triangles.len() + b.triangles.len() > 80_000 { - return None; - } - let (ia, ib) = (a.inside_index(), b.inside_index()); +fn overlap( + a: &solidcraft_geom::Mesh, + b: &solidcraft_geom::Mesh, + ia: &solidcraft_geom::InsideIndex, + ib: &solidcraft_geom::InsideIndex, +) -> Option<(f64, f64)> { let frac = |inner: &solidcraft_geom::Mesh, inner_i: &solidcraft_geom::InsideIndex, outer_i: &solidcraft_geom::InsideIndex| -> Option { let bx = inner.bounds(); let s = bx.size(); @@ -110,12 +111,59 @@ fn overlap(a: &solidcraft_geom::Mesh, b: &solidcraft_geom::Mesh) -> Option<(f64, } (n >= 50).then(|| hit as f64 / n as f64) }; - Some((frac(b, &ib, &ia)?, frac(a, &ia, &ib)?)) + Some((frac(b, ib, ia)?, frac(a, ia, ib)?)) +} + +/// Whether some facet of `m` has material just under it (`depth` in from its middle) that is +/// inside the other body (`inside`) or outside it. With `within`, only facets in that box. +fn under_surface( + m: &solidcraft_geom::Mesh, + own: &solidcraft_geom::InsideIndex, + other: &solidcraft_geom::InsideIndex, + depth: f64, + inside: bool, + within: Option<&solidcraft_geom::Aabb3>, +) -> bool { + m.triangles.iter().filter_map(|t| m.tri(t)).any(|[p, q, r]| { + let Some(n) = (q - p).cross(r - p).normalized() else { return false }; + let c = (p + q + r) * (1.0 / 3.0) - n * depth; + let near = within.is_none_or(|b| c.x >= b.min.x && c.x <= b.max.x && c.y >= b.min.y && c.y <= b.max.y && c.z >= b.min.z && c.z <= b.max.z); + // A wall thinner than `depth` puts the point outside its own body: no evidence. + near && other.contains(c) == inside && own.contains(c) + }) +} + +/// Whether the bodies' facetings show that the samples' answer (see [`trivial`]) is wrong: +/// their surfaces pass through each other, or a facet of one has material under it on the +/// wrong side of the other. Overlaps and gaps shallower than `tol` are not seen. +fn contradicted( + (ma, ia): (&solidcraft_geom::Mesh, &solidcraft_geom::InsideIndex), + (mb, ib): (&solidcraft_geom::Mesh, &solidcraft_geom::InsideIndex), + (disjoint, b_in_a, a_in_b): (bool, bool, bool), + tol: f64, +) -> bool { + if !(disjoint || b_in_a || a_in_b) { + return false; + } + if ma.crosses(mb, tol) { + return true; + } + if disjoint { + let (ba, bb) = (ma.bounds(), mb.bounds()); + let shared = solidcraft_geom::Aabb3 { min: ba.min.max(bb.min), max: ba.max.min(bb.max) }; + return !shared.is_empty() && (under_surface(ma, ia, ib, tol, true, Some(&shared)) || under_surface(mb, ib, ia, tol, true, Some(&shared))); + } + (a_in_b && under_surface(ma, ia, ib, tol, false, None)) || (b_in_a && !a_in_b && under_surface(mb, ib, ia, tol, false, None)) } /// Booleans that need no intersection: one body clear of the other, or inside it. These are /// common with coincident curved faces (a hole drilled where one already is, a body joined /// with itself), which the intersection can't handle. `counts`: the operands' faces and edges. +/// `tol`: how far the meshes' surfaces may pass through each other and still count as touching +/// (they are facetings of the bodies, so a few times their tolerance). +/// +/// The second value is false when the facetings contradict the samples ([`contradicted`]): +/// the answer is then only what to fall back on if the bodies can't be intersected. fn trivial( a: &Body, b: &Body, @@ -123,12 +171,21 @@ fn trivial( ma: &solidcraft_geom::Mesh, mb: &solidcraft_geom::Mesh, counts: &[(usize, usize)], -) -> Option> { - let (b_in_a, a_in_b) = overlap(ma, mb)?; + tol: f64, +) -> Option<(Option, bool)> { + if ma.triangles.len() + mb.triangles.len() > 80_000 { + return None; + } + let (ia, ib) = (ma.inside_index(), mb.inside_index()); + let (b_in_a, a_in_b) = overlap(ma, mb, &ia, &ib)?; const NONE: f64 = 0.003; const ALL: f64 = 0.997; let disjoint = b_in_a < NONE && a_in_b < NONE; - match op { + // The samples only say that nearly all of one body is clear of, or inside, the other. A + // small part can still be on the wrong side (a peg overlapping a rib, a chamfer taken off + // one edge), and then the bodies have to be intersected. + let sure = !contradicted((ma, &ia), (mb, &ib), (disjoint, b_in_a > ALL, a_in_b > ALL), tol); + let answer = match op { // Sampling only selects this candidate. Validate every shell boundary and the // complete material/void nesting before preserving a disconnected union. BoolOp::Union if disjoint => { @@ -151,7 +208,8 @@ fn trivial( Some(Some(candidate)) } _ => None, - } + }; + answer.map(|r| (r, sure)) } #[cfg(test)] @@ -167,13 +225,40 @@ mod shortcut_tests { let other = crate::sphere(direction * distance, 10.0).unwrap(); let other_mesh = other.tessellate(0.03).unwrap(); let counts = record::counts(&[&outer, &other]); - assert!(trivial(&outer, &other, BoolOp::Union, &mesh, &other_mesh, &counts).is_none()); - assert!(trivial(&other, &outer, BoolOp::Union, &other_mesh, &mesh, &counts).is_none()); + assert!(trivial(&outer, &other, BoolOp::Union, &mesh, &other_mesh, &counts, 0.09).is_none()); + assert!(trivial(&other, &outer, BoolOp::Union, &other_mesh, &mesh, &counts, 0.09).is_none()); } let protruding = crate::sphere(direction * 9.001, 1.0).unwrap(); let protruding_mesh = protruding.tessellate(0.03).unwrap(); let counts = record::counts(&[&outer, &protruding]); - assert!(trivial(&outer, &protruding, BoolOp::Cut, &mesh, &protruding_mesh, &counts).is_none()); + assert!(trivial(&outer, &protruding, BoolOp::Cut, &mesh, &protruding_mesh, &counts, 0.09).is_none()); + } + + /// Bodies sharing less than the samples can see still meet: no shortcut, and the boolean + /// gives the shared part. + #[test] + fn a_small_overlap_is_not_taken_for_apart_or_inside() { + let cube = |x: f64, y: f64| crate::box_solid(Vec3::new(x, y, 0.0), Vec3::new(x + 10.0, y + 10.0, 10.0)).unwrap(); + let a = cube(0.0, 0.0); + // A 0.01 slab between parallel faces (0.1 % of each cube), and a 0.02 x 0.02 corner. + for (b, want) in [(cube(9.99, 0.0), 1.0), (cube(9.98, 9.98), 0.004)] { + let (ma, mb) = (a.tessellate(0.001).unwrap(), b.tessellate(0.001).unwrap()); + let counts = record::counts(&[&a, &b]); + for op in [BoolOp::Intersect, BoolOp::Cut, BoolOp::Union] { + assert!(trivial(&a, &b, op, &ma, &mb, &counts, 0.003).is_none_or(|(_, sure)| !sure), "{op:?} {want}"); + } + let shared = boolean(&a, &b, BoolOp::Intersect).unwrap().unwrap(); + assert!((crate::measure(&shared).unwrap().volume - want).abs() < 1e-9, "{want}"); + let cut = boolean(&a, &b, BoolOp::Cut).unwrap().unwrap(); + assert!((crate::measure(&cut).unwrap().volume - (1000.0 - want)).abs() < 1e-9, "{want}"); + } + // Touching on a face, and one cube well inside the other, still take the shortcut. + let (touching, inner) = (cube(10.0, 0.0), crate::box_solid(Vec3::new(2.0, 2.0, 2.0), Vec3::new(8.0, 8.0, 8.0)).unwrap()); + let ma = a.tessellate(0.001).unwrap(); + for (b, op) in [(&touching, BoolOp::Intersect), (&inner, BoolOp::Intersect), (&inner, BoolOp::Union)] { + let counts = record::counts(&[&a, b]); + assert!(trivial(&a, b, op, &ma, &b.tessellate(0.001).unwrap(), &counts, 0.003).is_some_and(|(_, sure)| sure), "{op:?}"); + } } } @@ -338,10 +423,16 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { (consistent, v) }; // One body clear of or inside the other: no intersection needed. + // Kept for the end when the facetings disagree with it: bodies that touch along curved + // faces can pass through each other slightly, and often can't be intersected. + let mut doubted = None; if let (Some(ma), Some(mb)) = &meshes - && let Some(r) = trivial(a, b, op, ma, mb, &counts) + && let Some((r, sure)) = trivial(a, b, op, ma, mb, &counts, 3.0 * tol_m) { - return Ok(r); + if sure { + return Ok(r); + } + doubted = Some(r); } let mut last = String::new(); // Coincident faces make the intersection fail after many retries; push them apart first. @@ -522,7 +613,15 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { Err(e) => last = format!("{last}; nudged: {e}"), } } - Err(KernelError::Failed(format!("boolean {op:?}: {last}"))) + match doubted { + Some(r) => { + let mut event = BooleanFallback::new(BooleanStrategy::UnverifiedShortcut); + event.tolerance_mm = 3.0 * tol_m; + record(event); + Ok(r) + } + None => Err(KernelError::Failed(format!("boolean {op:?}: {last}"))), + } } /// `r`, a boolean of the target and `nb` (the tool nudged), with its history over the target and diff --git a/crates/ui-egui/src/dialogs_assembly.rs b/crates/ui-egui/src/dialogs_assembly.rs index 9bfd6d02..52ff9f30 100644 --- a/crates/ui-egui/src/dialogs_assembly.rs +++ b/crates/ui-egui/src/dialogs_assembly.rs @@ -635,20 +635,29 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) { row_label(ui, "Checked"); ui.label(format!("{} bodies", r["checked"].as_u64().unwrap_or(0))); ui.end_row(); - if hits.is_empty() { + let unchecked = r["unchecked"].as_array().cloned().unwrap_or_default(); + if hits.is_empty() && unchecked.is_empty() { row_label(ui, "Result"); ui.label(RichText::new("no interference").color(t.text_dim)); ui.end_row(); return; } - row_label(ui, "Interferes"); - ui.label(RichText::new("Volume").strong()); - ui.end_row(); + if !hits.is_empty() { + row_label(ui, "Interferes"); + ui.label(RichText::new("Volume").strong()); + ui.end_row(); + } for h in hits { row_label(ui, &format!("{} · {}", h["a"].as_str().unwrap_or(""), h["b"].as_str().unwrap_or(""))); ui.label(RichText::new(crate::prefs::show_mm(app, h["volume_mm3"].as_f64().unwrap_or(0.0), 3)).color(t.error)); ui.end_row(); } + // Pairs the check could not work out: not clear, not known to interfere. + for u in unchecked { + row_label(ui, &format!("{} · {}", u["a"].as_str().unwrap_or(""), u["b"].as_str().unwrap_or(""))); + ui.label(RichText::new("could not be checked").color(t.warning)).on_hover_text(u["reason"].as_str().unwrap_or("")); + ui.end_row(); + } } // ---- commands ----