From 8c1b1478cedff1fd9e0f3cf1d21ef7d8e33b48e4 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sun, 11 Oct 2026 05:33:50 +0200 Subject: [PATCH] =?UTF-8?q?Joins=20and=20cuts=20of=20round=20pads,=20bosse?= =?UTF-8?q?s=20and=20holes=20across=20a=20floor=20that=20falls=20a=20degre?= =?UTF-8?q?e=20or=20two=20no=20longer=20fail:=20the=20B-rep=20results=20we?= =?UTF-8?q?re=20right,=20but=20the=20plausibility=20check=20meshed=20them?= =?UTF-8?q?=20without=20refinement,=20and=20where=20the=20shallow=20cut=20?= =?UTF-8?q?trims=20a=20round=20side=20to=20a=20thin=20band=20the=20plain?= =?UTF-8?q?=20mesh=20spans=20it=20with=20triangles=20cutting=20through=20t?= =?UTF-8?q?he=20cylinder,=20so=20the=20check=20saw=20missing=20material=20?= =?UTF-8?q?(0.8=20to=201.6=20%=20of=20samples=20misclassified),=20rejected?= =?UTF-8?q?=20the=20result,=20and=20the=20planar=20fallback=20then=20refus?= =?UTF-8?q?ed=20the=20round=20tool.=20The=20check=20now=20meshes=20the=20o?= =?UTF-8?q?perands=20and=20the=20result=20with=20refinement=20on=20curved?= =?UTF-8?q?=20faces=20(as=20measure=20does).=20Test:=20round=5Fpads=5Fcros?= =?UTF-8?q?sing=5Fa=5Fshallow=5Ffall=20(a=201=C2=B0=20fall=20and=20a=202?= =?UTF-8?q?=C2=B0=20four-face=20fall=20toward=20a=20drain)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/kernel/src/ops.rs | 9 ++++-- crates/kernel/src/tests.rs | 59 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 045f48ba..7b0318a8 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -249,9 +249,12 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { let size = a.size().max(b.size()); // The checks mesh both bodies; where they meet in a region much thinner than they are (a // thin part trimmed by a long one), a step from their size would facet the small faces - // there coarsely enough to fail the membership test on a correct result. + // there coarsely enough to fail the membership test on a correct result. The meshes are + // refined: a round side trimmed to a thin band (a short boss crossing a gently sloping + // floor) is otherwise spanned by triangles cutting through it, which reads as missing + // material. let tol_m = (size * 5e-4).min(meeting_size(a, b) * 2e-3).max(size * 1e-5); - let meshes = (a.tessellate(tol_m).ok(), b.tessellate(tol_m).ok()); + let meshes = (a.tessellate_with(tol_m, true).ok(), b.tessellate_with(tol_m, true).ok()); let (va, vb) = match &meshes { (Some(ma), Some(mb)) => (ma.measure().volume, mb.measure().volume), _ => (volume(a), volume(b)), @@ -260,7 +263,7 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { // Volume bounds, then membership against the operands (catches misclassified pieces), on // one mesh of the result. Returns (plausible, volume). let check = |r: &Body| -> (bool, f64) { - let Ok(mr) = r.tessellate(tol_m) else { return (false, f64::NAN) }; + let Ok(mr) = r.tessellate_with(tol_m, true) else { return (false, f64::NAN) }; let v = mr.measure().volume; let bounds = v > 0.0 && match op { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index b65c41a5..56a52453 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -290,6 +290,65 @@ fn moving_a_body_with_an_intersection_curve_edge_ends() { assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}"); } +/// A trough whose floor falls 1° (or 2° toward a drain, in four faces), and short round pads +/// and bosses crossing the fall. The B-rep results were right, but the plausibility check meshed +/// them without refinement: on a round side trimmed to a thin band by the shallow cut, the plain +/// mesh spans the band with triangles cutting through the cylinder, so the check saw missing +/// material, rejected the result, and the planar fallback refused the round pad. +#[test] +fn round_pads_crossing_a_shallow_fall() { + let hs = |n: Vec3, p: Vec3| { + let n = n.normalized().unwrap(); + HalfSpace { n, d: n.dot(p) } + }; + let walls = |floor: &[HalfSpace]| { + let mut h = vec![ + hs(Vec3::X, Vec3::new(290.0, 0.0, 0.0)), + hs(-Vec3::X, Vec3::new(10.0, 0.0, 0.0)), + hs(Vec3::Y, Vec3::new(0.0, 140.0, 0.0)), + hs(-Vec3::Y, Vec3::new(0.0, 10.0, 0.0)), + hs(Vec3::Z, Vec3::new(0.0, 0.0, 90.0)), + ]; + h.extend_from_slice(floor); + convex_polyhedron(&h).unwrap() + }; + let block = box_solid(Vec3::ZERO, Vec3::new(300.0, 150.0, 80.0)).unwrap(); + // One fall along x at 1°: floor z = 8 + t (x − 10). + let t = 1f64.to_radians().tan(); + let fall = boolean(&block, &walls(&[hs(Vec3::new(t, 0.0, -1.0), Vec3::new(10.0, 0.0, 8.0))]), BoolOp::Cut).unwrap().unwrap(); + let v0 = measure(&fall).unwrap().volume; + for x in [60.0, 100.0] { + // A pad from z = 9 to 12 (its bottom just under the floor, its top 2–3 mm above). + let pad = cylinder(Vec3::new(x, 75.0, 9.0), Vec3::Z, 15.0, 3.0).unwrap(); + let u = boolean(&fall, &pad, BoolOp::Union).unwrap().unwrap(); + // The part of the pad above the floor: the floor under the disc is planar, so its mean + // height is the height at the centre (where the floor is below z = 9 everywhere). + let floor = |x: f64| 8.0 + t * (x - 10.0); + let above = if floor(x - 15.0) >= 9.0 { PI * 225.0 * (12.0 - floor(x)) } else { f64::NAN }; + let v = measure(&u).unwrap().volume; + if above.is_finite() { + assert!(rel(v, v0 + above) < 1e-5, "pad at x {x}: {v} vs {}", v0 + above); + } else { + assert!(v > v0 && v < v0 + PI * 225.0 * 3.0, "pad at x {x}: {v}"); + } + } + // Four faces falling 2° toward a drain point, and a pad over a valley edge and a wall. + let t2 = 2f64.to_radians().tan(); + let d = Vec3::new(240.0, 110.0, 8.0); + let hopper = walls(&[ + hs(Vec3::new(t2, 0.0, -1.0), d), + hs(Vec3::new(-t2, 0.0, -1.0), d), + hs(Vec3::new(0.0, t2, -1.0), d), + hs(Vec3::new(0.0, -t2, -1.0), d), + ]); + let trough = boolean(&block, &hopper, BoolOp::Cut).unwrap().unwrap(); + let pad = cylinder(Vec3::new(270.0, 70.0, 8.0), Vec3::Z, 25.0, 5.0).unwrap(); + let u = boolean(&trough, &pad, BoolOp::Union).unwrap().unwrap(); + // Integrated over the disc (outside the wall at x = 290): about 6517 mm³ above the floor. + let gain = measure(&u).unwrap().volume - measure(&trough).unwrap().volume; + assert!((gain - 6517.0).abs() < 10.0, "{gain}"); +} + #[test] fn planar_booleans_with_coincident_faces() { // L-bracket: a wall flush with three sides of its base.