Booleans: planar cuts beside rounds take the exact route before the retries (#1197) (#1445)

Planar cuts beside rounds take the exact route before the boolean retries (#1197).
This commit is contained in:
Erikas authored and GitHub committed 2026-10-15 13:31:41 -04:00
1 parent 56747edb46
commit 17b48d9e7e
4 files changed
+153 -3

No files matched your search

+28
View File
@@ -402,3 +402,31 @@ fn pattern_of_touching_cuts_needs_no_fallback() {
assert_eq!(m["bodies"][0][k], single["bodies"][0][k], "{k}");
}
}
/// A pattern of cuts beside a round, on a bar whose long top edges are rounded, takes the exact
/// planar route for every copy rather than the general boolean's retries: the teeth touch side
/// by side and never reach the rounds (#1197). Retries and volume, not timing (see
/// `scales_linearly`).
#[test]
fn pattern_of_cuts_beside_a_round_needs_no_retries() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"corner": [-10, 0, 0], "length": 20, "width": 200, "height": 4, "name": "Bar"}));
run(&mut s, "solid.fillet", json!({"edges": [[-10, 100, 4], [10, 100, 4]], "radius": 1}));
run(&mut s, "sketch.create", json!({"plane": "YZ", "name": "Tooth"}));
run(&mut s, "sketch.line", json!({"points": [[20, 4], [24, 2], [24, 5], [20, 5]], "closed": true}));
run(&mut s, "sketch.finish", json!({}));
let cut =
run(&mut s, "solid.extrude", json!({"sketch": "Tooth", "distance": 16, "direction": "symmetric", "operation": "cut", "name": "Tooth cut"}));
let teeth =
run(&mut s, "solid.pattern.rectangular", json!({"features": ["Tooth cut"], "dir1": [0, 1, 0], "count1": 40, "spacing1": 4, "name": "Teeth"}));
for created in [&cut, &teeth] {
let id = created["feature"].as_u64().unwrap();
let result = s.model.result(id).unwrap();
assert!(result.error.is_none() && result.warning.is_none(), "{result:?}");
assert!(result.boolean_diagnostics.events.is_empty(), "{:?}", result.boolean_diagnostics);
}
// The bar, less the rounds' corners and 40 teeth (a 4 x 2 triangle below the top, 16 wide).
let expected = 16000.0 - 400.0 * (1.0 - PI / 4.0) - 40.0 * 4.0 * 16.0;
let v = volume(&mut s);
assert!(rel(v, expected) < 1e-5, "{v} vs {expected}");
}
+13
View File
@@ -733,6 +733,19 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
None
};
for (attempt, j) in JITTER.iter().enumerate() {
// The operands as they lie failed. A planar tool beside the target's curved faces (a tooth
// or a pocket cut next to a round edge) is decided exactly in the slab it lies in, where
// the target is planar too, before the shifted tries: that is where they end anyway, as a
// retry, after costing several times as much (on every copy of a pattern, #1197).
if attempt == 1 && matches!(op, BoolOp::Cut | BoolOp::Intersect) {
let (sliced, diagnostics) = capture_boolean_diagnostics(|| crate::polybool::sliced_planar_boolean(a, b, op));
if let Some(Ok(Some(body))) = sliced
&& check(&body).0
{
merge(diagnostics);
return Ok(Some(body));
}
}
let shift = mt::Vector3::new(j[0], j[1], j[2]) * (size * 0.01);
for k in [2e-3, 5e-4] {
let tol = (size * k).max(1e-5);
+47 -1
View File
@@ -431,7 +431,7 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result<Option<Body>> {
/// two parallel planes holds `b` and none of `a`'s curved faces (a pocket cut beside a round
/// hole): `a` is split across the slab, the middle piece takes the exact boolean, and the
/// pieces outside are joined back on. `None` when no such slab exists.
fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option<Body>>> {
let pb = planar_polys(b, 1).ok()?;
let size = a.size().max(b.size());
let tol = (a.size() * 1e-3).max(1e-3);
@@ -447,6 +447,10 @@ fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option
bx.add(p);
}
}
// All planar: the exact boolean itself, not this.
if curved.is_empty() {
return None;
}
let mut tool = solidcraft_geom::Aabb3::EMPTY;
for p in pb.iter().flat_map(|p| &p.v) {
tool.add(*p);
@@ -504,6 +508,13 @@ fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option
for (slot, (cut, keep_positive)) in [(2, (lo, true)), (4, (hi, false))] {
let Some(x) = cut else { continue };
let plane = solidcraft_geom::Plane::new(axis * x, u, w).ok_or_else(|| fail("plane"))?;
// Split along the plane (an imprint: no boolean); two booleans with half boxes
// where the split can't be sorted into one piece kept and the rest away.
if let Some((away, kept)) = split_beside(&middle, &plane, axis, x, keep_positive) {
outside.extend(away);
middle = kept;
continue;
}
let (away, kept) = (crate::ops::half_box(&middle.0, &plane, !keep_positive)?, crate::ops::half_box(&middle.0, &plane, keep_positive)?);
let (h_away, h_kept) = (recorded::kept(&away, slot, n), recorded::kept(&kept, slot + 1, n));
if let Some(o) = crate::ops::boolean(&middle.0, &away, BoolOp::Intersect)? {
@@ -584,6 +595,41 @@ fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option<Result<Option
Some(run())
}
/// `piece` split by `plane` (at `x` along `axis`): the parts on the side away from the tool
/// and the one part on its side (positive along `axis` when `keep_positive`), each with its
/// history over the operands `piece`'s is over. `None` when the split leaves no part on either
/// side, or several on the tool's.
fn split_beside(piece: &Traced, plane: &solidcraft_geom::Plane, axis: Vec3, x: f64, keep_positive: bool) -> Option<(Vec<Traced>, Traced)> {
let parts = crate::splitface::split_body(&piece.0, &crate::splitface::SplitTool::Plane(*plane)).ok()?;
if parts.len() < 2 {
return None;
}
let tol = piece.0.size() * 1e-6 + 1e-9;
let (mut away, mut kept) = (Vec::new(), Vec::new());
for part in parts {
let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
for v in part.solid.vertex_iter() {
let t = from_p3(v.point()).dot(axis) - x;
lo = lo.min(t);
hi = hi.max(t);
}
let h = match (part.history(), piece.1.as_ref()) {
(Some(hp), Some(h)) => recorded::composed(hp.clone().substituted(0, h)),
_ => None,
};
let positive = if lo >= -tol {
true
} else if hi <= tol {
false
} else {
return None;
};
if positive == keep_positive { kept.push((part, h)) } else { away.push((part, h)) }
}
let kept = kept.pop().filter(|_| kept.is_empty())?;
(!away.is_empty()).then_some((away, kept))
}
/// `h` over `[a, b]`: the operands after them (the boxes a piece was cut with) dropped, which
/// fails where something of them is left.
fn dropped(h: Option<History>) -> Option<History> {
+65 -2
View File
@@ -124,6 +124,9 @@ fn trace_tol(face: &mt::Face, size: f64) -> f64 {
/// Trace the zero set of `g` over a face: chains of exact points.
fn trace(face: &mt::Face, g: &Field, size: f64) -> Result<Vec<Chain>> {
if let Some(chains) = trace_flat(face, g, size) {
return Ok(chains);
}
let tol = trace_tol(face, size);
let shell: mt::Shell = vec![face.clone()].into();
let meshed = shell.robust_triangulation(tol);
@@ -290,6 +293,53 @@ fn trace(face: &mt::Face, g: &Field, size: f64) -> Result<Vec<Chain>> {
Ok(chains)
}
/// A plane across a flat face (a plane, or a B-spline surface that is one) with straight edges,
/// exactly: the line the two planes meet in, inside the face between pairs of its crossings with
/// the boundary, taken in order along the line. Which side a corner is on is decided once, at
/// the corner, so a plane through a corner crosses the boundary there once or not at all, as it
/// should. `None` (traced on a mesh instead) for any other face or tool, and where an edge lies
/// in the plane.
fn trace_flat(face: &mt::Face, g: &Field, size: f64) -> Option<Vec<Chain>> {
let Field::Plane { o, n } = g else { return None };
let surface = face.surface();
let Field::Plane { n: m, .. } = surface_field(&surface) else { return None };
let along = m.cross(*n).normalized()?;
let tol = size * 1e-7 + 1e-9;
let mut hits: Vec<mt::Point3> = Vec::new();
for e in face.boundaries().iter().flat_map(|w| w.edge_iter()) {
let (a, b) = (from_p3(e.front().point()), from_p3(e.back().point()));
let (ga, gb) = ((a - *o).dot(*n), (b - *o).dot(*n));
// Straight: a line, or a curve (a B-spline of degree one) whose points all lie on its
// chord; such an edge crosses the plane once at most.
let straight = matches!(e.curve(), mt::Curve::Line(_)) || {
let c = e.curve();
let (t0, t1) = c.range_tuple();
(1..8).all(|k| from_p3(c.subs(t0 + (t1 - t0) * k as f64 / 8.0)).dist_to_segment(a, b) <= tol)
};
if !straight || (ga.abs() <= tol && gb.abs() <= tol) || !ga.is_finite() || !gb.is_finite() {
return None;
}
if (ga >= 0.0) != (gb >= 0.0) {
hits.push(p3(a + (b - a) * (ga / (ga - gb)).clamp(0.0, 1.0)));
}
}
if !hits.len().is_multiple_of(2) {
return None;
}
hits.sort_by(|a, b| from_p3(*a).dot(along).total_cmp(&from_p3(*b).dot(along)));
let uv = |p: mt::Point3| surface.search_nearest_parameter(p, None, 50);
let mut chains = Vec::new();
for pair in hits.chunks(2) {
let [a, b] = pair else { return None };
// A corner the plane only touches: no line.
if a.distance(*b) <= tol {
continue;
}
chains.push(Chain { pts: vec![(*a, uv(*a)?), (*b, uv(*b)?)], closed: false });
}
Some(chains)
}
/// Where the zero set crosses a boundary edge of the face (exact, by bisection on the curve).
fn boundary_hits(face: &mt::Face, g: &Field) -> Vec<Hit> {
let mut out = Vec::new();
@@ -1084,9 +1134,22 @@ pub(crate) fn split_any(body: &Body, faces: &[usize], tool: &SplitTool) -> Resul
})
}
/// A point well inside a face (a mesh triangle's centre where the field is largest) and the
/// field there.
/// The field at a point well inside a face (a mesh triangle's centre where the field is
/// largest; for a plane, the face's corner farthest from it).
fn side_of(face: &mt::Face, g: &Field, size: f64) -> Result<f64> {
// A face the plane does not cross is on the side of whichever corner lies off it (the
// farthest, against rounding); a face with every corner on the plane is meshed.
if let Field::Plane { .. } = g {
let far = face
.boundaries()
.iter()
.flat_map(|w| w.vertex_iter())
.map(|v| g.eval(from_p3(v.point())))
.fold(0.0, |b: f64, x| if x.abs() > b.abs() { x } else { b });
if far.abs() > size * 1e-6 {
return Ok(far);
}
}
let shell: mt::Shell = vec![face.clone()].into();
let meshed = shell.robust_triangulation((size * 2e-3).max(1e-4));
let pm = meshed.face_iter().next().and_then(|f| f.surface()).ok_or_else(|| KernelError::Failed("a face could not be meshed".into()))?;