Planar cuts beside rounds take the exact route before the boolean retries (#1197).
This commit is contained in:
1 parent
56747edb46
commit
17b48d9e7e
4 files changed
+153
-3
No files matched your search
@@ -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}");
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
Reference in new issue
Block a user