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

+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;