diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 21186f45..263c8233 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -1715,6 +1715,53 @@ fn region_samples(r: &Region2) -> Vec { out } +/// The region with each hole filled whose inside the tool would sweep with no change to the +/// targets: all empty for a cut (the profile drawn round a hole in the part), all material for a +/// join. Then the tool's walls don't lie on the part's walls there (the boolean can't intersect +/// walls lying on each other, #1360), and what it cuts or joins is the same. A hole is filled only +/// when points across it at levels through the tool are all empty (or all material) and no +/// vertex of the targets lies inside the column over it (an island in it, a floor). +fn fill_void_holes(st: &ModelState, r: &Region2, op: Operation, targets: &[String], plane: &Plane, (lo, hi): (f64, f64)) -> Region2 { + if r.holes.is_empty() || !matches!(op, Operation::Cut | Operation::Join) || hi - lo <= 0.0 { + return r.clone(); + } + let bodies: Vec<&ModelBody> = st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)).collect(); + if bodies.is_empty() { + return r.clone(); + } + let meshes: Vec> = bodies.iter().map(|b| b.mesh()).collect(); + let size = bodies.iter().map(|b| b.body.size()).fold(0.0, f64::max); + let margin = (size * 1e-4).max(1e-4); + let n = plane.normal(); + let inside_any = |p: Vec3| meshes.iter().any(|m| m.contains(p)); + let want = op == Operation::Join; + let mut holes = Vec::with_capacity(r.holes.len()); + for h in &r.holes { + let column = Region2 { outer: h.ccw(), holes: vec![] }; + let samples = region_samples(&column); + let swept = (1..=8).all(|k| { + let z = lo + (hi - lo) * (k as f64 - 0.5) / 8.0; + samples.iter().all(|s| inside_any(plane.to_world(*s) + n * z) == want) + }); + // No boundary of the targets within the column (shrunk off its wall, short of its ends). + let clear = swept + && meshes.iter().all(|m| { + m.positions.iter().all(|p| { + let z = (*p - plane.origin).dot(n); + let q = plane.to_local(*p); + !(z > lo + margin + && z < hi - margin + && column.contains(q) + && h.segs.iter().all(|sg| sg.point_at(sg.closest_param(q)).dist(q) > margin)) + }) + }); + if !clear { + holes.push(h.clone()); + } + } + Region2 { outer: r.outer.clone(), holes } +} + /// Grow profile edges that lie on a face of a target body away from the profile, by a small /// amount, when the space they grow into is already material (join) or empty (cut). `probe` /// maps a sketch point to the world points where the tool would be (along the extrude, around @@ -2850,6 +2897,7 @@ fn feature_tools_with_constructor( let probe = |p: Vec2| (1..4).map(|k| ss.plane.to_world(p) + n * (lo + off + (hi - lo) * k as f64 / 4.0)).collect::>(); let original = r; let r = grow_profile_for_coplanar(st, r, *operation, targets, &probe); + let r = fill_void_holes(st, &r, *operation, targets, &ss.plane, (l2, h2)); untrimmed &= l2 == lo + off && h2 == hi + off && &r == original; tools.extend(centered_extrude(&ss.plane, &r, l2, h2, Some(&ss))?); footprints.push(r); diff --git a/crates/engine/src/cmd/features_more_tests.rs b/crates/engine/src/cmd/features_more_tests.rs index 765ec5eb..413c0478 100644 --- a/crates/engine/src/cmd/features_more_tests.rs +++ b/crates/engine/src/cmd/features_more_tests.rs @@ -1016,6 +1016,48 @@ fn fillets_on_tapered_bosses_saved_before_still_find_their_edges() { assert_eq!(s.world_state().bodies.len(), 1); } +/// #1360: a ring drawn round a hole, its inner circle on the hole's wall, cut into a chamfered +/// disc. The ring's hole lies over the part's hole all the way down the cut, so the tool takes +/// it in (a disc instead of a ring cuts the same): its wall no longer lies on the hole's wall, +/// which the boolean couldn't intersect. Over a hole whose floor the cut reaches, the ring's hole +/// stays: the material under the floor is not cut (the cut may still fail there, as before). +#[test] +fn a_ring_cut_round_a_hole_takes_in_the_hole_it_is_drawn_round() { + let ring = |hole: Value| { + let mut s = Session::default(); + for (c, p) in [ + ("sketch.create", json!({"plane":"XY"})), + ("sketch.circle.center", json!({"center":[0,0],"radius":40})), + ("sketch.finish", json!({})), + ("solid.extrude", json!({"distance":10})), + ("solid.hole", hole), + ("solid.chamfer", json!({"edges":[[40,0,10]],"distance":1})), + ("sketch.create", json!({"plane":{"face":[0,0,10]}})), + ("sketch.circle.center", json!({"center":[20,0],"radius":3})), + ("sketch.circle.center", json!({"center":[20,0],"radius":6})), + ("sketch.finish", json!({})), + ] { + run(&mut s, c, p); + } + let before = volume(&mut s); + // Profile 2: the ring between the hole's wall and the larger circle. + let r = s.execute("solid.extrude", &json!({"direction":"negative","distance":"3","operation":"cut","profiles":[2]})); + let clean = r + .as_ref() + .ok() + .and_then(|v| v["feature"].as_u64()) + .and_then(|id| s.model.result(id)) + .is_some_and(|f| f.boolean_diagnostics.events.is_empty()); + (r.is_ok(), before - volume(&mut s), clean) + }; + let taken = std::f64::consts::PI * (36.0 - 9.0) * 3.0; + let (ok, through, clean) = ring(json!({"position":[20,0,10],"diameter":6})); + assert!(ok && clean && rel(through, taken) < 1e-3, "{through} vs {taken}"); + // A blind hole 1 deep: under its floor the ring's hole is material, and nothing of it goes. + let (ok, blind, _) = ring(json!({"position":[20,0,10],"diameter":6,"depth":1})); + assert!(if ok { rel(blind, taken) < 1e-3 } else { blind.abs() < 1e-6 }, "{ok} {blind} vs {taken}"); +} + /// A line of text cut into a face: the letters' curves (the font's quadratics) are read /// exactly, and the cut is decided in 2D in one boolean, at any depth and where the text runs /// off the face. The B-rep boolean failed on many letters at once ("no result" at 3.5 mm, and