diff --git a/crates/kernel/src/body_lump_tests.rs b/crates/kernel/src/body_lump_tests.rs index 19457af3..89aa73ad 100644 --- a/crates/kernel/src/body_lump_tests.rs +++ b/crates/kernel/src/body_lump_tests.rs @@ -37,6 +37,29 @@ fn check_nested(body: &Body) { } } +/// Robustness seed 15, shrunk: a sphere cut at a corner of a part with a sealed internal void far +/// from the sphere. The same cut on the part without the void works; with it, the boolean failed +/// ("no result", then "the exact fallback … takes planar faces only"). A cut from a box or a +/// cylinder on the voided part worked. +#[test] +fn sphere_cut_far_from_a_void_keeps_the_void() { + let block = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 40.0)).unwrap(); + let cavity = box_solid(Vec3::new(25.0, 25.0, 25.0), Vec3::new(30.0, 30.0, 30.0)).unwrap(); + let hollow = boolean(&block, &cavity, BoolOp::Cut).unwrap().unwrap(); + assert_eq!(hollow.solid.boundaries().len(), 2, "the cavity is a second shell"); + let ball = sphere(Vec3::new(0.0, 0.0, 40.0), 8.0).unwrap(); + let cut = boolean(&hollow, &ball, BoolOp::Cut).unwrap().unwrap(); + // The block, less the cavity, less the eighth of the ball inside the corner. + let want = 64_000.0 - 125.0 - std::f64::consts::PI * 512.0 / 6.0; + let got = measure(&cut).unwrap().volume; + assert!((got - want).abs() < want * 1e-3, "volume {got}, want {want}"); + assert_eq!(cut.solid.boundaries().len(), 2, "the cavity stays a sealed second shell"); + let mesh = cut.tessellate_with(0.01, true).unwrap(); + for (p, material) in [(Vec3::new(27.5, 27.5, 27.5), false), (Vec3::new(1.0, 1.0, 39.0), false), (Vec3::new(20.0, 20.0, 20.0), true)] { + assert_eq!(mesh.contains(p), material, "material at {p:?}"); + } +} + #[test] fn nested_cavities_stay_with_their_material_islands_for_every_shell_order() { let body = nested_island(); diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index a78e73e9..102b6e98 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -332,6 +332,15 @@ fn boolean_via(a: &Body, b: &Body, op: BoolOp, whole: &dyn Fn(&Body, &Body, Bool } } } + // A piece with sealed voids, cut clear of them: the outer shell alone, the voids put back. + if op == BoolOp::Cut && r.is_err() { + let (cut, report) = capture_boolean_diagnostics(|| cut_around_voids(a, b, whole)); + if let Ok(Some(x)) = cut { + r = Ok(Some(x)); + diagnostics = report; + diagnostics.push(BooleanFallback::new(BooleanStrategy::Piecewise)); + } + } if op == BoolOp::Union && let Ok(Some(body)) = &r && !preserves_planar_surfaces(a, b, body) @@ -374,6 +383,72 @@ fn boolean_via(a: &Body, b: &Body, op: BoolOp, whole: &dyn Fn(&Body, &Body, Bool r } +/// The most shells [`cut_around_voids`] takes apart. +const MAX_VOID_SHELLS: usize = 64; + +/// `a` cut by `b` around `a`'s voids: when `a` is one piece with sealed voids (inner shells) and +/// `b` stays clear of every void, the outer shell alone is cut and the voids are put back. The cut +/// of the whole failed on such bodies where the same cut of the outer shell works: a sphere cut +/// far from a void (robustness seed 15). `None` when `a` has no voids, `b` comes near one, or +/// the cut of the outer shell fails too. The result carries no history. +fn cut_around_voids(a: &Body, b: &Body, whole: &dyn Fn(&Body, &Body, BoolOp) -> Result>) -> Result> { + let shells = a.solid.boundaries().clone(); + if shells.len() < 2 || shells.len() > MAX_VOID_SHELLS || b.solid.boundaries().len() != 1 || a.lumps()?.len() != 1 { + return Ok(None); + } + let tol = (a.size() * 1e-3).max(1e-3); + let mut boxes = Vec::with_capacity(shells.len()); + for sh in &shells { + let piece = + guard("void shell", || Body::new(Solid::try_new(vec![sh.clone()]).map_err(|e| KernelError::Failed(format!("void shell: {e}")))?))?; + let m = piece.tessellate(tol)?; + if m.positions.is_empty() { + return Ok(None); + } + boxes.push(m.bounds()); + } + let extent = |i: usize| { + boxes.get(i).map_or(0.0, |x| { + let d = x.max - x.min; + d.x * d.y * d.z + }) + }; + let Some(outer) = (0..shells.len()).max_by(|i, j| extent(*i).total_cmp(&extent(*j))) else { return Ok(None) }; + let Some(ob) = boxes.get(outer) else { return Ok(None) }; + let tm = b.tessellate((b.size() * 1e-3).max(1e-3))?; + let tb = tm.bounds(); + // Clear of the tool by more than both meshes' deflection. + let gap = 4.0 * tol + (b.size() * 1e-3).max(1e-3); + let inside = |x: &solidcraft_geom::Aabb3| { + x.min.x >= ob.min.x && x.min.y >= ob.min.y && x.min.z >= ob.min.z && x.max.x <= ob.max.x && x.max.y <= ob.max.y && x.max.z <= ob.max.z + }; + let clear = |x: &solidcraft_geom::Aabb3| { + x.max.x + gap < tb.min.x + || tb.max.x + gap < x.min.x + || x.max.y + gap < tb.min.y + || tb.max.y + gap < x.min.y + || x.max.z + gap < tb.min.z + || tb.max.z + gap < x.min.z + }; + let mut voids = Vec::new(); + for (i, (sh, x)) in shells.iter().zip(&boxes).enumerate() { + if i == outer { + continue; + } + if !inside(x) || !clear(x) { + return Ok(None); + } + voids.push(sh.clone()); + } + let Some(outer_shell) = shells.get(outer) else { return Ok(None) }; + let alone = + guard("outer shell", || Body::new(Solid::try_new(vec![outer_shell.clone()]).map_err(|e| KernelError::Failed(format!("outer shell: {e}")))?))?; + let Some(cut) = boolean_via(&alone, b, BoolOp::Cut, whole)? else { return Ok(None) }; + let mut all = cut.solid.boundaries().clone(); + all.extend(voids); + Ok(Some(guard("voids back", || Body::new(Solid::try_new(all).map_err(|e| KernelError::Failed(format!("voids back: {e}")))?))?)) +} + /// Every face of an all-planar union must remain on an operand's original plane. /// Curved operands keep their existing surface-specific boolean handling. fn preserves_planar_surfaces(a: &Body, b: &Body, result: &Body) -> bool { diff --git a/tests/robustness/goldens.json b/tests/robustness/goldens.json index eae512b6..0728e06e 100644 --- a/tests/robustness/goldens.json +++ b/tests/robustness/goldens.json @@ -16,7 +16,7 @@ "12": {"area":4668.664174,"bodies":2,"edges":59,"error":null,"faces":25,"outcome":"ok","rejected":0,"shells":2,"steps":["extruded profile","chamfer","join cylinder","join box","cut cylinder","circular pattern"],"unsupported":3,"volume":11874.471611}, "13": {"area":1967.991667,"bodies":3,"edges":85,"error":null,"faces":37,"outcome":"ok","rejected":1,"shells":3,"steps":["revolved section","cut cylinder","join box","rectangular pattern","cut cylinder","cut box","fillet","hole"],"unsupported":0,"volume":2647.948638}, "14": {"area":10553.267409,"bodies":1,"edges":43,"error":null,"faces":20,"outcome":"ok","rejected":0,"shells":1,"steps":["extruded profile","cut cylinder","shell","fillet"],"unsupported":0,"volume":10369.008764}, - "15": {"area":1974.784048,"bodies":1,"edges":40,"error":"boolean Cut: the operation failed: no result; the operation ","faces":18,"outcome":"failed","rejected":1,"shells":2,"steps":["revolved section","fillet","cut box","rectangular pattern","cut sphere","cut cylinder","cut sphere"],"unsupported":0,"volume":4781.224576}, + "15": {"area":1957.859861,"bodies":1,"edges":49,"error":null,"faces":21,"outcome":"ok","rejected":1,"shells":2,"steps":["revolved section","fillet","cut box","rectangular pattern","cut sphere","cut cylinder","cut sphere"],"unsupported":0,"volume":4723.415743}, "16": {"area":8872.873884,"bodies":3,"edges":49,"error":null,"faces":23,"outcome":"ok","rejected":0,"shells":3,"steps":["box","join cylinder","circular pattern","hole"],"unsupported":0,"volume":44953.403148}, "17": {"area":6048.425782,"bodies":1,"edges":48,"error":null,"faces":19,"outcome":"ok","rejected":0,"shells":1,"steps":["revolved section","cut box","shell","cut cylinder"],"unsupported":0,"volume":2968.53154}, "18": {"area":5524.583817,"bodies":4,"edges":98,"error":"boolean Cut: the operation failed: no result","faces":44,"outcome":"failed","rejected":0,"shells":4,"steps":["extruded profile","join cylinder","circular pattern","cut cylinder","hole","cut cylinder","hole","circular pattern"],"unsupported":0,"volume":21568.016672}, @@ -121,6 +121,7 @@ {"reason":"#1213: a round or chamfer on an edge chain that runs on across a curved seam now builds (it was refused with 'a curved seam where the edge chain turns'). Seed 41's second fillet (three edges, radius 1) was one of those refusals and is now built. Replaying the seed's commands on the build before and after gives the same volume, area, faces and edges after each of steps 1 to 6; step 7, the fillet, is refused before and builds after (5292.19 to 5289.17 mm³, area 7395.26 to 7381.22 mm²); the run then reaches its eight steps one step sooner, so its last step is a cut sphere (5185.22 mm³) where it was a join cylinder (the generator draws its later steps from the new state).","seeds":[41]}, {"reason":"#391 after merging main with #1213: seed 41 now reaches its second fillet (built since #1213), and the end-snap fix again drops a sliver edge the cuts left (94 to 93 edges); its steps, outcome, faces and shells are unchanged, volume moves by 0.005 mm³ (5185.215737 to 5185.221068) and area by 0.0014 mm²","seeds":[41]}, {"reason":"the chamfer bug is on main too, see #1390","seeds":[57]}, - {"reason":"#1390: a chamfer of a convex edge that would grow the part is refused (TooLarge). Seed 57's 0.5 mm chamfer on the sphere cut's rim, which grew the part since #391 reordered its steps, is now refused, so the run goes on to its later steps and ends ok (16017.21 mm³, 24 faces)","seeds":[57]} + {"reason":"#1390: a chamfer of a convex edge that would grow the part is refused (TooLarge). Seed 57's 0.5 mm chamfer on the sphere cut's rim, which grew the part since #391 reordered its steps, is now refused, so the run goes on to its later steps and ends ok (16017.21 mm³, 24 faces)","seeds":[57]}, + {"reason":"seed 15 now cuts around a sealed void","seeds":[15]} ] }