diff --git a/crates/engine/src/cmd/patterns_tests.rs b/crates/engine/src/cmd/patterns_tests.rs index ee9e7e38..94083c1d 100644 --- a/crates/engine/src/cmd/patterns_tests.rs +++ b/crates/engine/src/cmd/patterns_tests.rs @@ -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}"); +} diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 8a5fdca3..b839dec4 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -733,6 +733,19 @@ fn boolean_whole(a: &Body, b: &Body, op: BoolOp) -> Result> { 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); diff --git a/crates/kernel/src/polybool.rs b/crates/kernel/src/polybool.rs index e9c376f8..970205dc 100644 --- a/crates/kernel/src/polybool.rs +++ b/crates/kernel/src/polybool.rs @@ -431,7 +431,7 @@ pub fn planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Result> { /// 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>> { +pub(crate) fn sliced_planar_boolean(a: &Body, b: &Body, op: BoolOp) -> Option>> { 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 Option Option Option<(Vec, 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) -> Option { diff --git a/crates/kernel/src/splitface.rs b/crates/kernel/src/splitface.rs index 88038c6b..6ea12585 100644 --- a/crates/kernel/src/splitface.rs +++ b/crates/kernel/src/splitface.rs @@ -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> { + 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> { 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> { + 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 = 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 { 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 { + // 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()))?;