From 3086c32a9a1b62da07ec7e42215bedffd4c13512 Mon Sep 17 00:00:00 2001 From: CypherNaugh_0x <9931495+CypherNaught-0x@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:32:48 +0200 Subject: [PATCH] A round hole elsewhere on a body no longer breaks a loft cut beside it: a loft cut from a pocket floor (556.68 x 99 down 4 mm to 80 x 80), with a 35 mm hole 66 mm away that it never touches, failed with a kernel panic in TroughFall, while the same cut without the hole, or with the hole made afterwards, worked The loft's top lies in the pocket floor. truck's boolean cannot take coincident faces, and the exact polygon fallback (planar_boolean) only takes bodies whose faces are all planar, so the hole's cylinder ruled out the only path that handles this cut. planar_boolean now first tries a sliced form: when an axis-aligned slab holds the tool and none of the body's curved faces, the body is split on the slab's planes (planes clear of the body's own faces), the planar middle piece takes the exact boolean, and the pieces outside are glued back on along the cut faces (the middle piece is healed first so its edges match theirs). Pieces in several parts are refused rather than glued into one shell. split_by_plane's half-space code is now ops::half_space, which this uses. Tests: loft_cut_beside_a_round_hole replays examples/regressions/loft_cut_beside_round_hole.json and checks the exact volume (slab - pocket - prismatoid - hole; fails on main). With the hole made after the loft, the body is the same (volume, area, 19 faces, 42 edges). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/boolean_regression_tests.rs | 10 ++ crates/kernel/src/ops.rs | 20 ++-- crates/kernel/src/polybool.rs | 106 ++++++++++++++++++ .../loft_cut_beside_round_hole.json | 24 ++++ 4 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 examples/regressions/loft_cut_beside_round_hole.json diff --git a/crates/engine/src/boolean_regression_tests.rs b/crates/engine/src/boolean_regression_tests.rs index 8d8492ca..ccbb9a4b 100644 --- a/crates/engine/src/boolean_regression_tests.rs +++ b/crates/engine/src/boolean_regression_tests.rs @@ -24,6 +24,16 @@ fn slab_pocket_loft() -> (f64, f64) { (slab - 557.68 * 100.0 * 120.0, loft) } +#[test] +fn loft_cut_beside_a_round_hole() { + let s = replay(include_str!("../../../examples/regressions/loft_cut_beside_round_hole.json")); + let (pocketed, loft) = slab_pocket_loft(); + let hole = std::f64::consts::PI * 17.5 * 17.5 * 25.0; + let want = pocketed - loft - hole; + let got = volume(&s, "Body1"); + assert!((got - want).abs() < 1e-6 * want, "{got} vs {want}"); +} + #[test] fn shallow_loft_cut_from_the_top_face() { let s = replay(include_str!("../../../examples/regressions/shallow_loft_cut_from_top_face.json")); diff --git a/crates/kernel/src/ops.rs b/crates/kernel/src/ops.rs index 14de038f..718a7f6d 100644 --- a/crates/kernel/src/ops.rs +++ b/crates/kernel/src/ops.rs @@ -515,6 +515,15 @@ pub fn transform(body: &Body, translate: Vec3, origin: Vec3, axis: Vec3, angle: /// Split a body with a plane: the parts on the positive and the negative side of the plane /// (only the non-empty ones). pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result> { + let mut out = Vec::new(); + for positive in [true, false] { + out.extend(half_space(body, plane, positive)?); + } + Ok(out) +} + +/// The part of a body on one side of a plane (`None` when nothing is there). +pub(crate) fn half_space(body: &Body, plane: &solvecraft_geom::Plane, positive: bool) -> Result> { body.require_brep("split")?; let size = body.size(); let mut bb = solvecraft_geom::Aabb3::EMPTY; @@ -532,12 +541,7 @@ pub fn split_by_plane(body: &Body, plane: &solvecraft_geom::Plane) -> Result Result> { a.require_brep("a boolean")?; b.require_brep("a boolean")?; let (Some(pa), Some(pb)) = (planar_polys(a), planar_polys(b)) else { + if let Some(r) = sliced_planar_boolean(a, b, op) { + return r; + } return Err(KernelError::Failed("not supported yet: coincident faces on curved bodies".into())); }; let size = a.size().max(b.size()); polys_boolean(pa, pb, size, op, size * 1e-9) } +/// [`planar_boolean`] of a body with curved faces and an all-planar one, when a slab between +/// 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>> { + let pb = planar_polys(b)?; + let size = a.size().max(b.size()); + let tol = (a.size() * 1e-3).max(1e-3); + let infos = a.faces(tol).ok()?; + let mesh = a.tessellate(tol).ok()?; + let mut curved: HashMap = HashMap::new(); + for (t, fi) in mesh.triangles.iter().zip(&mesh.tri_face) { + if infos.get(*fi as usize)?.plane_normal.is_some() { + continue; + } + let bx = curved.entry(*fi).or_insert(solvecraft_geom::Aabb3::EMPTY); + for p in mesh.tri(t)? { + bx.add(p); + } + } + let mut tool = solvecraft_geom::Aabb3::EMPTY; + for p in pb.iter().flat_map(|p| &p.v) { + tool.add(*p); + } + let at = |v: Vec3, k: usize| [v.x, v.y, v.z].get(k).copied().unwrap_or(0.0); + let axes = [Vec3::X, Vec3::Y, Vec3::Z]; + // Planar faces of `a` square to an axis: a cut must not land in one of their planes. + let flat: Vec<(Vec3, Vec3)> = infos.iter().filter_map(|f| Some((f.plane_normal?, f.centroid))).collect(); + let gap = size * 1e-4; + let place = |k: usize, near: f64, far: f64| -> Option { + let axis = *axes.get(k)?; + [0.5, 0.37, 0.63, 0.25, 0.75] + .iter() + .map(|t| near + (far - near) * t) + .find(|x| !flat.iter().any(|(n, c)| n.dot(axis).abs() > 1.0 - 1e-9 && (at(*c, k) - x).abs() < gap)) + }; + // An axis along which every curved face lies clear of the tool: the cuts either side. + let (k, lo, hi) = (0..3).find_map(|k| { + let (t0, t1) = (at(tool.min, k), at(tool.max, k)); + let (mut below, mut above) = (None::, None::); + for bx in curved.values() { + let (c0, c1) = (at(bx.min, k), at(bx.max, k)); + if c1 < t0 - gap { + below = Some(below.map_or(c1, |x| x.max(c1))); + } else if c0 > t1 + gap { + above = Some(above.map_or(c0, |x| x.min(c0))); + } else { + return None; + } + } + let lo = match below { + Some(c) => Some(place(k, t0, c)?), + None => None, + }; + let hi = match above { + Some(c) => Some(place(k, t1, c)?), + None => None, + }; + Some((k, lo, hi)) + })?; + let (u, w) = match k { + 0 => (Vec3::Y, Vec3::Z), + 1 => (Vec3::Z, Vec3::X), + _ => (Vec3::X, Vec3::Y), + }; + let axis = *axes.get(k)?; + let run = || -> Result> { + let fail = |m: &str| KernelError::Failed(format!("planar boolean beside curved faces: {m}")); + let mut middle = a.clone(); + let mut outside = Vec::new(); + for (cut, keep_positive) in [(lo, true), (hi, false)] { + let Some(x) = cut else { continue }; + let plane = solvecraft_geom::Plane::new(axis * x, u, w).ok_or_else(|| fail("plane"))?; + outside.extend(crate::ops::half_space(&middle, &plane, !keep_positive)?); + middle = crate::ops::half_space(&middle, &plane, keep_positive)?.ok_or_else(|| fail("nothing beside the tool"))?; + } + let pm = planar_polys(&middle).ok_or_else(|| fail("the middle piece is not planar"))?; + let r = polys_boolean(pm, pb, size, op, size * 1e-9)?; + if op == BoolOp::Intersect { + return Ok(r); + } + // The rebuild leaves the cut faces' edges split where other polygons were cut; healed, + // they match the pieces outside edge for edge. + let mut pieces: Vec = match r { + Some(m) => vec![Body::new(crate::heal::heal_keep(m.deep_copy(), size, a.split_keep()))?], + None => Vec::new(), + }; + // Each piece outside meets the middle along its cut face (glued one shell to another). + if pieces.iter().chain(&outside).any(|x| x.solid.boundaries().len() != 1) { + return Err(fail("a piece in several parts")); + } + for o in outside { + let joined = pieces.iter().enumerate().find_map(|(i, m)| Some((i, crate::coplanar::glue(m, &o)?.ok()?))); + let (i, j) = joined.ok_or_else(|| fail("a piece does not join back"))?; + if let Some(slot) = pieces.get_mut(i) { + *slot = j; + } + } + crate::ops::join_pieces(pieces) + }; + Some(run()) +} + fn polys_boolean(pa: Vec, pb: Vec, size: f64, op: BoolOp, eps: f64) -> Result> { let polys = csg(pa, pb, op, eps)?; if polys.is_empty() { diff --git a/examples/regressions/loft_cut_beside_round_hole.json b/examples/regressions/loft_cut_beside_round_hole.json new file mode 100644 index 00000000..3816cd49 --- /dev/null +++ b/examples/regressions/loft_cut_beside_round_hole.json @@ -0,0 +1,24 @@ +{ + "description": "A loft cut from a pocket floor beside a round hole it never touches (the hole made first). Slab 900 x 480 x 160, a 557.68 x 100 pocket 120 deep, a 35 mm hole 25 deep 66 mm from the pocket, then a loft cut from a rectangle on the pocket floor (0.5 mm inside its walls) down 4 mm to an 80 x 80 square. The loft's top lies in the pocket floor, and the hole's cylinder kept the exact planar boolean from taking it: the cut failed with a kernel panic. Result: the same body as with the hole made after the loft.", + "commands": [ + {"command": "sketch.create", "params": {"plane": "XY", "offset": 690, "name": "Outline"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [0, -480], "p1": [900, 0]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": 160, "name": "Slab"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 850, "name": "BowlTop"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [171.16, -151.16], "p1": [728.84, -51.16]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": -120, "operation": "cut", "name": "BowlPocket"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 850, "name": "TapHole"}}, + {"command": "sketch.circle.center", "params": {"center": [795, -101.16], "radius": 17.5}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.extrude", "params": {"distance": -25, "operation": "cut", "name": "TapCut"}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 730, "name": "TroughInset"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [171.66, -150.66], "p1": [728.34, -51.66]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "sketch.create", "params": {"plane": "XY", "offset": 726, "name": "DrainSeat"}}, + {"command": "sketch.rectangle.two_point", "params": {"p0": [410, -141.16], "p1": [490, -61.16]}}, + {"command": "sketch.finish", "params": {}}, + {"command": "solid.loft", "params": {"sections": [{"sketch": "TroughInset"}, {"sketch": "DrainSeat"}], "operation": "cut", "name": "TroughFall"}} + ] +}