A drain pad whose bore is the drain hole's wall (a ring joined flush under a floor, or overlapping it) now joins instead of failing with "coincident faces on curved bodies": the mesh intersection can't take two walls on one cylinder, and the coincident-face push only knew planes. Such unions now narrow one body's bore (its walls pushed toward the axis, clear of the other's), join, and cut the original bore's cylinder out again over that body's length, which is exact when the cylinder holds no material of either body (checked on samples). Inside that step a face is pushed into the other body only as deep as there is material behind it all over, so a narrowed rim over the other's bore can't fill it; then the other body's bore is narrowed instead. Test: drain_pads_flush_under_a_floor_with_a_hole (ring flush and 2 mm into a 1° floor, and a disc across the hole, against hand-calculated volumes)

This commit is contained in:
CypherNaugh_0x committed 2026-10-11 13:01:54 +02:00
1 parent a6bb8fc924
commit 8970254672
3 files changed
+230 -1

No files matched your search

+168 -1
View File
@@ -268,7 +268,22 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
}
for (n, d) in &back {
// a's face points along n; b's face points along −n at the same plane (−d).
let shift = if op == BoolOp::Union { d2 } else { -d2 };
let shift = if op == BoolOp::Union && crate::ops::narrowing_bores() {
// Pushed into `a`, b's face must have a's material behind it all over and that
// deep: over a hole in a's face (a narrowed bore's rim over the other body's
// bore) it would fill the hole, through a thin wall it would stick out the far
// side. Only checked inside `bore_union`, which then tries its other way; a
// general check turns some joins that come out slightly wrong today (a pad
// half past an edge) into failures, until such faces can be split first.
[d2, d2 * 0.25, d2 * 0.0625]
.into_iter()
.find(|depth| backed(a, b, -*n, -*d, *depth))
.ok_or_else(|| KernelError::Failed("pushing a face: part of it lies over a hole or past the edge of the other body".into()))?
} else if op == BoolOp::Union {
d2
} else {
-d2
};
b2 = push_faces(&b2, -*n, -*d, shift)?;
}
let Some(mut r) = crate::ops::boolean(&a2, &b2, op)? else { return Ok(None) };
@@ -286,6 +301,29 @@ pub(crate) fn boolean_apart(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Opt
Some(run())
}
/// Has `a` material behind every part of b's faces in the plane (m, d) (m their outward
/// normal, pointing into `a`), to `depth`? Checked at points inside the faces' triangles.
fn backed(a: &Body, b: &Body, m: Vec3, d: f64, depth: f64) -> bool {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
let faces: Vec<usize> = planes(b).into_iter().filter(|(_, p)| p.n.dot(m) > 1.0 - 1e-9 && (p.d - d).abs() < tol).map(|(i, _)| i).collect();
let (Ok(ma), Ok(mb)) = (a.tessellate_with(size * 1e-3, true), b.tessellate_with(size * 1e-3, true)) else { return false };
let inside = ma.inside_index();
let mut points = Vec::new();
for (t, fi) in mb.triangles.iter().zip(&mb.tri_face) {
if !faces.contains(&(*fi as usize)) {
continue;
}
let Some([p, q, r]) = mb.tri(t) else { continue };
let c = (p + q + r) / 3.0;
points.extend([c, c.lerp(p, 0.8), c.lerp(q, 0.8), c.lerp(r, 0.8)]);
if points.len() > 4000 {
break;
}
}
!points.is_empty() && points.iter().all(|p| [0.05, 0.5, 1.0].iter().all(|k| inside.contains(*p + m * (depth * k))))
}
/// Union of bodies that touch along one identical planar face, back to back (a body joined
/// with its mirror image): the two faces go and the shells are stitched along their edges.
/// `None` when no such pair of faces exists.
@@ -387,3 +425,132 @@ pub(crate) fn glue(a: &Body, b: &Body) -> Option<Result<Body>> {
}
None
}
/// A cylindrical face: its index, a point on the axis, the unit axis and the radius.
#[derive(Clone, Copy, Debug)]
struct CylFace {
index: usize,
origin: Vec3,
axis: Vec3,
radius: f64,
}
fn cylinders(b: &Body) -> Vec<CylFace> {
(0..b.face_count())
.filter_map(|index| match crate::face_surface(b, index)? {
crate::FaceSurface::Cylinder { origin, axis, radius } => Some(CylFace { index, origin, axis, radius }),
_ => None,
})
.collect()
}
/// Do the two cylinders lie on the same surface?
fn same_cylinder(x: &CylFace, y: &CylFace, tol: f64) -> bool {
let off = y.origin - x.origin;
x.axis.dot(y.axis).abs() > 1.0 - 1e-9 && (x.radius - y.radius).abs() < tol && (off - x.axis * off.dot(x.axis)).len() < tol
}
/// Do the bodies have cylindrical faces on a common cylinder?
pub(crate) fn has_coincident_cylinders(a: &Body, b: &Body) -> bool {
let tol = (a.size().max(b.size()) * 1e-7).max(1e-9) * 10.0;
let cb = cylinders(b);
cylinders(a).iter().any(|x| cb.iter().any(|y| same_cylinder(x, y, tol)))
}
/// Union of bodies with bores on a common cylinder (a drain pad's bore around the drain hole
/// it is joined over). The mesh intersection can't take the coincident walls, so the tool's
/// bore is narrowed (its walls pushed toward the axis, now clear of the target's), the bodies
/// are joined, and the cylinder of the original bore is cut out again over the tool's length.
/// That is exact when the cylinder holds no material of either body, which is checked on
/// sample points. Each way (the tool's bore narrowed, else the target's) gives a result only
/// when `accept` takes it. `None` when there are no coincident bores or no way works.
pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) -> Option<Body> {
let size = a.size().max(b.size());
let tol = (size * 1e-7).max(1e-9) * 10.0;
let mtol = size * 2e-3;
let (ma, mb) = (a.tessellate_with(mtol, true).ok()?, b.tessellate_with(mtol, true).ok()?);
let (ia, ib) = (ma.inside_index(), mb.inside_index());
let empty = |p: Vec3| !ia.contains(p) && !ib.contains(p);
let (ca, cb) = (cylinders(a), cylinders(b));
// (body to narrow, its mesh and inside test, the other body, their faces on the cylinder).
let ways = [(b, &mb, &ib, a, &cb, &ca), (a, &ma, &ia, b, &ca, &cb)];
for (tool, mesh, inside, target, ct, co) in ways {
for c in ct {
if !co.iter().any(|o| same_cylinder(c, o, tol)) {
continue;
}
let bore: Vec<usize> = ct.iter().filter(|x| same_cylinder(c, x, tol)).map(|x| x.index).collect();
// The tool's extent along the axis on that cylinder, and whether it is a bore (the
// material outside it) at a point of the face.
let (mut t0, mut t1) = (f64::INFINITY, f64::NEG_INFINITY);
let mut probe = None;
for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) {
if !bore.contains(&(*fi as usize)) {
continue;
}
let Some(ps) = mesh.tri(t) else { continue };
for p in ps {
let s = (p - c.origin).dot(c.axis);
t0 = t0.min(s);
t1 = t1.max(s);
}
if probe.is_none() {
probe = Some((ps[0] + ps[1] + ps[2]) / 3.0);
}
}
let Some(q) = probe else { continue };
if !(t1 - t0 > tol && c.radius > tol) {
continue;
}
let along = c.origin + c.axis * (q - c.origin).dot(c.axis);
let Some(radial) = (q - along).normalized() else { continue };
let on = along + radial * c.radius;
let e = (c.radius * 0.05).max(size * 1e-4);
if !(inside.contains(on + radial * e) && !inside.contains(on - radial * e)) {
continue;
}
// The cylinder over the tool's length holds no material of either body.
let (u, w) = (c.axis.any_perp(), c.axis.cross(c.axis.any_perp()));
let at = |s: f64, r: f64, ang: f64| c.origin + c.axis * s + (u * ang.cos() + w * ang.sin()) * r;
let clear = (1..=9).all(|i| {
let s = t0 + (t1 - t0) * f64::from(i) / 10.0;
empty(at(s, 0.0, 0.0))
&& (0..8).all(|k| {
let ang = f64::from(k) * std::f64::consts::FRAC_PI_4 + 0.3;
empty(at(s, c.radius * 0.5, ang)) && empty(at(s, c.radius * 0.9, ang))
})
});
if !clear {
continue;
}
// Past an end where the space is empty well beyond the radius too, the cut runs on a
// little (so its cap doesn't lie in a face's plane). Not where the other body's bore
// goes on: the cut's side would lie on its wall.
let ext = size * 0.01;
let past = |s: f64| {
empty(at(s, 0.0, 0.0))
&& (0..8).all(|k| {
let ang = f64::from(k) * std::f64::consts::FRAC_PI_4 + 0.3;
empty(at(s, c.radius * 0.9, ang)) && empty(at(s, c.radius * 1.1, ang)) && empty(at(s, c.radius * 1.5, ang))
})
};
let lo = if past(t0 - ext * 0.5) && past(t0 - ext) { t0 - ext } else { t0 };
let hi = if past(t1 + ext * 0.5) && past(t1 + ext) { t1 + ext } else { t1 };
let d = (c.radius * 0.2).min(size * 0.01);
let run = || -> Result<Option<Body>> {
let narrowed = crate::offset::offset_body(tool, |i, _| if bore.contains(&i) { d } else { 0.0 })?;
if !narrowed.validity().is_empty() {
return Err(KernelError::Failed("narrowing a bore: an unsound result".into()));
}
let Some(joined) = crate::ops::boolean(target, &narrowed, BoolOp::Union)? else { return Ok(None) };
let cut = crate::build::cylinder(at(lo, 0.0, 0.0), c.axis, c.radius, hi - lo)?;
crate::ops::boolean(&joined, &cut, BoolOp::Cut)
};
match run() {
Ok(Some(r)) if accept(&r) => return Some(r),
_ => continue,
}
}
}
None
}
+16
View File
@@ -175,6 +175,13 @@ thread_local! {
/// Set while a boolean runs on bodies whose coincident faces were pushed apart (no
/// second round of pushing).
static APART: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
/// Set while a union runs with a bore narrowed off a common cylinder (no second round).
static BORES: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Is a union with a narrowed bore running (see [`crate::coplanar::bore_union`])?
pub(crate) fn narrowing_bores() -> bool {
BORES.with(|c| c.get())
}
/// Boolean of two bodies. The result may be empty (`Ok(None)`) for a cut that removes
@@ -344,6 +351,15 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
}
}
}
// Bores on a common cylinder (a pad's bore around the hole it is joined over).
if op == BoolOp::Union && !BORES.with(|c| c.get()) && crate::coplanar::has_coincident_cylinders(a, b) {
BORES.with(|c| c.set(true));
let r = crate::coplanar::bore_union(a, b, &|body| check(body).0);
BORES.with(|c| c.set(false));
if let Some(body) = r {
return Ok(Some(body));
}
}
let mut empty_votes = 0;
let keep: Vec<Vec<Vec3>> = a.split_keep().iter().chain(b.split_keep()).cloned().collect();
for (attempt, j) in JITTER.iter().enumerate() {
+46
View File
@@ -290,6 +290,52 @@ fn moving_a_body_with_an_intersection_curve_edge_ends() {
assert!(rel(v0, v1) < 1e-6, "{v0} vs {v1}");
}
/// Drain pads joined flush under a floor with a drain hole. The floor (100 × 80, top sloping
/// 1°, so not a prism) has a Ø20 hole through it at (50, 40).
/// - A ring whose bore is the hole's wall (coincident cylinders): used to fail.
/// - The same ring 2 mm into the floor: used to fail too.
/// - A disc across the hole: worked before, and must stay exact.
#[test]
fn drain_pads_flush_under_a_floor_with_a_hole() {
let t = 1f64.to_radians().tan();
let hs = |n: Vec3, p: Vec3| {
let n = n.normalized().unwrap();
HalfSpace { n, d: n.dot(p) }
};
let slab = convex_polyhedron(&[
hs(Vec3::X, Vec3::new(100.0, 0.0, 0.0)),
hs(-Vec3::X, Vec3::ZERO),
hs(Vec3::Y, Vec3::new(0.0, 80.0, 0.0)),
hs(-Vec3::Y, Vec3::ZERO),
hs(-Vec3::Z, Vec3::ZERO),
hs(Vec3::new(-t, 0.0, 1.0), Vec3::new(0.0, 0.0, 10.0)),
])
.unwrap();
let hole = cylinder(Vec3::new(50.0, 40.0, -5.0), Vec3::Z, 10.0, 30.0).unwrap();
let floor = boolean(&slab, &hole, BoolOp::Cut).unwrap().unwrap();
// Top z = 10 + t x: the slab's volume is its mean height (at x = 50) times its area, the
// hole's likewise (its top is planar, centred at x = 50).
let v_floor = (10.0 + t * 50.0) * (8000.0 - PI * 100.0);
assert!(rel(measure(&floor).unwrap().volume, v_floor) < 2e-5, "{} vs {v_floor}", measure(&floor).unwrap().volume);
let ring = |z0: f64, z1: f64| {
let r = Region2 { outer: Loop2::circle(Vec2::new(50.0, 40.0), 20.0), holes: vec![Loop2::circle(Vec2::new(50.0, 40.0), 10.0).reversed()] };
extrude(&Plane::XY, &[r], z0, z1).unwrap().pop().unwrap()
};
// The ring's part inside the floor adds nothing: 5 mm of ring below it either way.
let v_ring = PI * (400.0 - 100.0) * 5.0;
for (z0, z1) in [(-5.0, 0.0), (-5.0, 2.0)] {
let u = boolean(&floor, &ring(z0, z1), BoolOp::Union).unwrap().unwrap();
let v = measure(&u).unwrap().volume;
assert!(rel(v, v_floor + v_ring) < 2e-5, "ring {z0}..{z1}: {v} vs {}", v_floor + v_ring);
assert!(u.validity().is_empty(), "{:?}", u.validity());
}
let disc = cylinder(Vec3::new(50.0, 40.0, -5.0), Vec3::Z, 20.0, 5.0).unwrap();
let u = boolean(&floor, &disc, BoolOp::Union).unwrap().unwrap();
let v = measure(&u).unwrap().volume;
let expect = v_floor + PI * 400.0 * 5.0;
assert!(rel(v, expect) < 2e-5, "disc across the hole: {v} vs {expect}");
}
#[test]
fn planar_booleans_with_coincident_faces() {
// L-bracket: a wall flush with three sides of its base.