Merge pull request #126 from CypherNaught-0x/fix/flush-pad-over-hole
Drain pads whose bore is the drain hole's wall join instead of failing (coincident cylinders)
This commit is contained in:
3 files changed
+238
-1
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -347,6 +354,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() {
|
||||
|
||||
@@ -290,6 +290,60 @@ 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}");
|
||||
// A boss ring standing on the sloping top around the hole, its foot just under the top
|
||||
// everywhere: it adds its annulus times its height above the top at the centre.
|
||||
let foot = 10.0 + t * 30.0 - 0.2;
|
||||
let u = boolean(&floor, &ring(foot, 14.0), BoolOp::Union).unwrap().unwrap();
|
||||
let v = measure(&u).unwrap().volume;
|
||||
let expect = v_floor + PI * 300.0 * (14.0 - (10.0 + t * 50.0));
|
||||
assert!(rel(v, expect) < 2e-5, "boss ring on the slope: {v} vs {expect}");
|
||||
assert!(u.validity().is_empty(), "{:?}", u.validity());
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
Reference in new issue
Block a user