Join a floor into the hole it fills: grow the boss into the wall around it (#1212) (#1217)

Join a floor into the hole it fills: grow the boss into the wall around it (#1212).
This commit is contained in:
Tobias Breckle authored and GitHub committed 2026-10-14 18:28:17 -04:00
1 parent a4a8b48567
commit a629236650
6 files changed
+253 -22

No files matched your search

+49 -4
View File
@@ -14,6 +14,8 @@ pub enum BooleanStrategy {
ShiftedOperands,
CoplanarSeparation,
CommonBore,
/// A boss in the other body's bore, grown into the material around it (nothing recut).
GrownBoss,
PlanarFallback,
ToolNudge,
Piecewise,
@@ -34,6 +36,15 @@ pub struct BooleanFallback {
pub tolerance_mm: f64,
}
impl BooleanStrategy {
/// Whether a result made this way can differ from the exact one: the tool was moved
/// against the other body, or part of where they meet was ignored. The other retries work
/// on temporary geometry or tolerances only, and their results are the exact ones.
pub fn alters_result(self) -> bool {
matches!(self, BooleanStrategy::ToolNudge | BooleanStrategy::UnverifiedShortcut)
}
}
impl BooleanFallback {
pub(crate) fn new(strategy: BooleanStrategy) -> Self {
Self { strategy, translation_mm: 0., rotation_rad: 0., face_shift_mm: 0., tolerance_mm: 0. }
@@ -47,6 +58,7 @@ impl BooleanFallback {
}
BooleanStrategy::CoplanarSeparation => format!("temporary coincident-face separation up to {:.6e} mm per face", self.face_shift_mm),
BooleanStrategy::CommonBore => format!("temporary bore offset {:.6e} mm, followed by recutting the original bore", self.face_shift_mm),
BooleanStrategy::GrownBoss => format!("boss grown {:.6e} mm into the material around the bore it fills", self.face_shift_mm),
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(),
@@ -79,13 +91,26 @@ impl BooleanDiagnostics {
self.events.iter().find(|event| event.strategy == BooleanStrategy::UnverifiedShortcut).map(BooleanFallback::description)
}
/// Every retry the result took, for a message about the boolean (one that failed its
/// checks lists what was tried).
pub fn summary(&self) -> Option<String> {
self.described(&self.events.iter().collect::<Vec<_>>())
}
/// The note a feature carries: the retries that can make its result differ from the exact
/// one ([`BooleanStrategy::alters_result`]). Exact retries are in `events` all the same
/// (`timeline.inspect`), but they leave the result as it should be: no reason to warn.
pub fn warning(&self) -> Option<String> {
if self.events.is_empty() && !self.truncated {
self.described(&self.events.iter().filter(|e| e.strategy.alters_result()).collect::<Vec<_>>())
}
fn described(&self, events: &[&BooleanFallback]) -> Option<String> {
if events.is_empty() && !self.truncated {
return None;
}
let mut descriptions: Vec<String> = self.events.iter().take(4).map(BooleanFallback::description).collect();
if self.events.len() > 4 {
descriptions.push(format!("{} additional retries", self.events.len() - 4));
let mut descriptions: Vec<String> = events.iter().take(4).map(|e| e.description()).collect();
if events.len() > 4 {
descriptions.push(format!("{} additional retries", events.len() - 4));
}
if self.truncated {
descriptions.push(format!("report capped at {MAX_EVENTS} retries"));
@@ -169,6 +194,26 @@ mod tests {
assert!(capture_boolean_diagnostics(|| {}).1.events.is_empty());
}
/// Only retries whose result can differ from the exact one make a warning; the summary
/// lists every retry.
#[test]
fn only_retries_that_alter_the_result_warn() {
let report = |strategies: &[BooleanStrategy]| {
capture_boolean_diagnostics(|| {
for s in strategies {
record(BooleanFallback::new(*s));
}
})
.1
};
let exact = report(&[BooleanStrategy::ShiftedOperands, BooleanStrategy::GrownBoss, BooleanStrategy::CoplanarSeparation]);
assert!(exact.warning().is_none(), "{exact:?}");
assert!(exact.summary().is_some_and(|s| s.contains("shifted operands") && s.contains("boss grown")));
let nudged = report(&[BooleanStrategy::GrownBoss, BooleanStrategy::ToolNudge]);
let warning = nudged.warning().unwrap();
assert!(warning.contains("tool displacement") && !warning.contains("boss grown"), "{warning}");
}
#[test]
fn exact_operations_and_failed_calls_have_no_retry_report() {
use solidcraft_geom::Vec3;
+135 -4
View File
@@ -989,6 +989,9 @@ pub(crate) fn has_coincident_cylinders(a: &Body, b: &Body) -> bool {
/// This is not a general emptiness or Boolean-equivalence certificate. Each direction
/// returns a result only when `accept` takes it; otherwise this returns `None`. The result's
/// history is over `[a, b]`.
///
/// A boss standing in the other body's bore on that cylinder (a disc joined into the bottom of
/// a hole it fills) is grown instead ([`grown_boss_union`]).
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;
@@ -997,10 +1000,10 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
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 operand number, its mesh and inside test, the other body, their faces
// on the cylinder).
let ways = [(b, 1, &mb, &ib, a, &cb, &ca), (a, 0, &ma, &ia, b, &ca, &cb)];
for (tool, tool_operand, mesh, inside, target, ct, co) in ways {
// (body to narrow, its operand number, its mesh and inside test, the other body and its
// inside test, their faces on the cylinder).
let ways = [(b, 1, &mb, &ib, a, &ia, &cb, &ca), (a, 0, &ma, &ia, b, &ib, &ca, &cb)];
for (tool, tool_operand, mesh, inside, target, target_inside, ct, co) in ways {
for c in ct {
if !co.iter().any(|o| same_cylinder(c, o, tol)) {
continue;
@@ -1032,6 +1035,13 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
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) {
let boss = Boss { tool, operand: tool_operand, cylinder: c, faces: &bore, t0, t1 };
if let Some(r) = grown_boss_union(a, b, &boss, target_inside, size, accept) {
return Some(r);
}
continue;
}
if !(inside.contains(on + radial * e) && !inside.contains(on - radial * e)) {
continue;
}
@@ -1099,6 +1109,127 @@ pub(crate) fn bore_union(a: &Body, b: &Body, accept: &dyn Fn(&Body) -> bool) ->
None
}
/// A tool (operand `operand`) with faces on a cylinder it shares with the other body, the
/// material inside them (a boss), and its extent `t0..t1` along the axis there.
struct Boss<'a> {
tool: &'a Body,
operand: usize,
cylinder: &'a CylFace,
faces: &'a [usize],
t0: f64,
t1: f64,
}
/// Union of `a` and `b` where one has a boss on a cylinder that is a bore of the other body (a disc joined into the bottom of a hole it fills): the boss's walls
/// coincide with the bore's, which the mesh intersection can't take. Grown outward by a step,
/// the boss reaches into the material around the bore instead, and joining it adds nothing
/// more, provided that material is there all along the boss. The ring the step adds is sampled
/// against the other body for that, and `accept` checks the result as a whole. The result's
/// history is over `[a, b]` (the grown tool keeps the tool's faces and edges).
fn grown_boss_union(
a: &Body,
b: &Body,
boss: &Boss,
target_inside: &solidcraft_geom::InsideIndex,
size: f64,
accept: &dyn Fn(&Body) -> bool,
) -> Option<Body> {
let c = boss.cylinder;
let d = (c.radius * 0.2).min(size * 0.01);
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;
// The other body's material all around the boss, as far out as it grows, over its length.
let backed = (1..=9).all(|i| {
let s = boss.t0 + (boss.t1 - boss.t0) * f64::from(i) / 10.0;
(0..16).all(|k| {
let ang = f64::from(k) * std::f64::consts::FRAC_PI_8 + 0.3;
[0.1, 0.5, 0.95].iter().all(|f| target_inside.contains(at(s, c.radius + d * f, ang)))
})
});
if !backed {
return None;
}
// Ends where the bore comes out of the other body and the boss's cap lies flush with the
// face it comes out of (a floor at the bottom of a through hole): faces in one plane, which
// the intersection can't take either. There the cap is moved out by the same step, and the
// stub that leaves past the face is deleted afterwards, the face healing over it.
let tol = (size * 1e-7).max(1e-9) * 10.0;
let target = if boss.operand == 1 { a } else { b };
let along = |p: Vec3| (p - c.origin).dot(c.axis);
let radial = |p: Vec3| (p - c.origin - c.axis * along(p)).len();
let plane_at = |body: &Body, i: usize, s: f64, out: f64| {
matches!(crate::face_surface(body, i), Some(crate::FaceSurface::Plane { normal, point })
if normal.dot(c.axis) * out > 1.0 - 1e-9 && (along(point) - s).abs() < tol)
};
let mut caps: Vec<(usize, f64, f64)> = Vec::new();
for (s, out) in [(boss.t0, -1.0), (boss.t1, 1.0)] {
let open = (0..8).all(|k| {
let ang = f64::from(k) * std::f64::consts::FRAC_PI_4 + 0.3;
!target_inside.contains(at(s + out * d * 0.5, c.radius + d * 0.5, ang))
});
if !open || !(0..target.face_count()).any(|i| plane_at(target, i, s, out)) {
continue;
}
let cap = boss
.tool
.solid
.face_iter()
.enumerate()
.position(|(i, f)| plane_at(boss.tool, i, s, out) && f.vertex_iter().all(|v| radial(from_p3(v.point())) < c.radius + tol));
// A flush end with no cap of its own to move: the faces would stay in one plane.
caps.push((cap?, s, out));
}
let counts = record::counts(&[a, b]);
let run = || -> Result<Option<Body>> {
let grown = crate::offset::offset_body(boss.tool, |i, _| if boss.faces.contains(&i) || caps.iter().any(|x| x.0 == i) { d } else { 0.0 })?;
if !grown.validity().is_empty() {
return Err(KernelError::Failed("growing a boss: an unsound result".into()));
}
// In the operands' order, so the history is over [a, b] once the grown tool's own is
// composed in.
let joined = if boss.operand == 1 { crate::ops::boolean(a, &grown, BoolOp::Union)? } else { crate::ops::boolean(&grown, b, BoolOp::Union)? };
let Some(joined) = joined else { return Ok(None) };
let joined = {
let h = crate::boolean_record::through(&joined, boss.operand, grown.history());
joined.with_checked_history(h, &counts)
};
if caps.is_empty() {
return Ok(Some(joined));
}
// The stubs: faces wholly past a flush end, within the grown boss, reaching out to its cap.
let stubs: Vec<usize> = joined
.solid
.face_iter()
.enumerate()
.filter(|(_, f)| {
let ps: Vec<Vec3> = f.vertex_iter().map(|v| from_p3(v.point())).collect();
caps.iter().any(|&(_, s, out)| {
let past = |p: &Vec3| (along(*p) - s) * out;
!ps.is_empty() && ps.iter().all(|p| past(p) > -tol && radial(*p) < c.radius + d * 1.01) && ps.iter().any(|p| past(p) > d * 0.5)
})
})
.map(|(i, _)| i)
.collect();
if stubs.is_empty() {
return Err(KernelError::Failed("growing a boss: no stub past its flush end".into()));
}
let healed = crate::delete_faces(&joined, &stubs)?;
let h = crate::boolean_record::through(&healed, 0, joined.history());
Ok(Some(healed.with_checked_history(h, &counts)))
};
let (result, diagnostics) = crate::capture_boolean_diagnostics(run);
match result {
Ok(Some(r)) if accept(&r) => {
crate::boolean_diagnostics::merge(diagnostics);
let mut event = crate::BooleanFallback::new(crate::BooleanStrategy::GrownBoss);
event.face_shift_mm = d;
crate::boolean_diagnostics::record(event);
Some(r)
}
_ => None,
}
}
#[cfg(test)]
#[path = "coplanar_tests.rs"]
mod tests;
+21
View File
@@ -75,3 +75,24 @@ fn faces_with_room_are_pushed_apart() {
let (added, lumps, how) = change(&block, &pad, BoolOp::Union);
assert!((added - 30_000.0).abs() < 1e-6 && lumps == 1 && used(&how, BooleanStrategy::CoplanarSeparation), "{added}, {how:?}");
}
/// A disc joined into a round hole it fills (issue: "boolean Union: no result" for a 1 mm
/// floor extruded into a through hole of a part that isn't a prism). The disc's wall lies on
/// the hole's, and at either end its face lies in the block's bottom or top face: it adds its
/// own volume, the block stays one piece, and the hole is closed where the disc is.
#[test]
fn a_disc_joined_into_the_hole_it_fills() {
let hole = cylinder(Vec3::new(150.0, 50.0, -1.0), Vec3::Z, 20.0, 22.0).unwrap();
let drilled = boolean(&block(200.0, 100.0, 20.0), &hole, BoolOp::Cut).unwrap().unwrap();
for (z0, z1) in [(0.0, 1.0), (5.0, 8.0), (19.0, 20.0)] {
let disc = cylinder(Vec3::new(150.0, 50.0, z0), Vec3::Z, 20.0, z1 - z0).unwrap();
let want = measure(&disc).unwrap().volume;
let (r, how) = capture_boolean_diagnostics(|| boolean(&drilled, &disc, BoolOp::Union));
let r = r.unwrap().unwrap();
let added = measure(&r).unwrap().volume - measure(&drilled).unwrap().volume;
assert!((added - want).abs() < want * 1e-3, "{z0}..{z1}: {added} vs {want}, {how:?}");
assert!(r.lumps().unwrap().len() == 1 && r.validity().is_empty(), "{z0}..{z1}: {:?}", r.validity());
// Exact: nothing of the tool is moved.
assert!(used(&how, BooleanStrategy::GrownBoss) && !used(&how, BooleanStrategy::ToolNudge), "{z0}..{z1}: {how:?}");
}
}
+13 -11
View File
@@ -362,7 +362,7 @@ fn boolean_via(a: &Body, b: &Body, op: BoolOp, whole: &dyn Fn(&Body, &Body, Bool
&& let Ok(result) = &r
&& let Err(e) = crate::boolean_guard::check_retry(a, b, op, result.as_ref())
{
let tried = diagnostics.warning().unwrap_or_default();
let tried = diagnostics.summary().unwrap_or_default();
r = Err(match e {
KernelError::Failed(m) => KernelError::Failed(format!("{m} ({})", tried.trim_start_matches("Boolean retry: "))),
other => other,
@@ -593,6 +593,18 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
return Ok(Some(body));
}
}
// Bores on a common cylinder (a pad's bore around the hole it is joined over, a disc filling
// a hole). Asked before faces are pushed apart: that doesn't part walls on one cylinder, and
// its tries at the general boolean, all failing on those walls, cost seconds.
if op == BoolOp::Union && !BORES.with(|c| c.get()) && crate::coplanar::has_coincident_cylinders(a, b) {
BORES.with(|c| c.set(true));
let (r, diagnostics) = capture_boolean_diagnostics(|| crate::coplanar::bore_union(a, b, &|body| check(body).0));
BORES.with(|c| c.set(false));
if let Some(body) = r {
merge(diagnostics);
return Ok(Some(body));
}
}
// Coincident planar faces: push them apart (exactly). Tried once, before the general
// boolean; why it failed is reported if everything else fails too.
let mut apart_failed = String::new();
@@ -646,16 +658,6 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
{
return Ok(Some(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, diagnostics) = capture_boolean_diagnostics(|| crate::coplanar::bore_union(a, b, &|body| check(body).0));
BORES.with(|c| c.set(false));
if let Some(body) = r {
merge(diagnostics);
return Ok(Some(body));
}
}
// Faces back to back over part of their area: imprinted on each other and glued (exact; held
// to the retry guard's checks all the same).
if op == BoolOp::Union && crate::coplanar::faces_touch(a, b) {