From d114c5854071d94385fab07585e9e3bb8712d951 Mon Sep 17 00:00:00 2001 From: Thodoris Tsagkogeorgas Date: Wed, 14 Oct 2026 16:37:12 +0300 Subject: [PATCH] A chamfer of a hole corner is one bevel (#814) (#1067) A chamfer of a hole corner, where the mitre extension runs into the wall, now takes the local blend and comes out as one bevel instead of a notched cut; where that blend can't build the edge, the cutters that used to succeed are kept (#814). --- crates/kernel/src/blend.rs | 108 +++++++++++++++++++++++++++++-------- 1 file changed, 86 insertions(+), 22 deletions(-) diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 51cfc950..dbcb09ca 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -2022,12 +2022,14 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { // Each step's result carries its history over `[body]`. let counts = record::counts(&[body]); // All cutters at once (their union mitres the corners), else one edge at a time. + // A hole corner's mitre extension runs into the wall, and the union of those cutters + // notches it. Those edges take the local blend below, which is one bevel (#814). let tools: Result> = edges.iter().enumerate().map(|(i, p)| chamfer_tool(body, *p, distance, i)).collect(); - if let Ok(tools) = tools { - // The union so far, with its history over the cutters it joined. + let notch = tools.as_ref().ok().is_some_and(|ts| ts.iter().any(|t| t.extension_inside)); + // The union of the cutters, cut from the body they were built on. + let batch = |tools: &[Cutter]| -> Option { let mut acc: Option<(Body, Option)> = None; - let mut ok = true; - for t in &tools { + for t in tools { let next = match &acc { None => Some((t.body.clone(), Some(History::identity(&t.body)))), Some((a, h)) => crate::ops::boolean(a, &t.body, crate::BoolOp::Union).ok().flatten().map(|u| { @@ -2035,24 +2037,25 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { (u, h) }), }; - if next.is_none() { - ok = false; - break; - } + next.as_ref()?; acc = next; } - if ok - && let Some((tool, h)) = acc - && let Ok(Some(r)) = crate::ops::boolean(body, &tool, crate::BoolOp::Cut) - { - let h = cut_history(crate::boolean_record::through(&r, 1, h.as_ref()), body, &tools); - return Ok(r.with_checked_history(h, &counts)); - } + let (tool, h) = acc?; + let r = crate::ops::boolean(body, &tool, crate::BoolOp::Cut).ok().flatten()?; + let h = cut_history(crate::boolean_record::through(&r, 1, h.as_ref()), body, tools); + Some(r.with_checked_history(h, &counts)) + }; + if !notch + && let Ok(tools) = &tools + && let Some(r) = batch(tools) + { + return Ok(r); } // At a pocket rim the cutters cannot extend beyond the selected edges. Rebuild // the corners locally, allowing adjacent chamfers to reach different depths // on their common wall seam. - if edges.len() > 1 + if !notch + && edges.len() > 1 && let Ok(result) = crate::blend_corner::chamfer(body, edges, distance) { return Ok(result); @@ -2087,13 +2090,45 @@ pub fn chamfer(body: &Body, edges: &[Vec3], distance: f64) -> Result { let start = cur.clone(); let one_by_one = || -> Result { let mut cur = start.clone(); + // Set once a step has changed the body, so a later miss cannot restart from the cutters. + let mut progressed = false; + // A chain running on into curved faces, or an edge ending at one: as for fillets. + let blend_or_curved = |cur: &Body, p: &Vec3| -> Result { + blend(cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer") + .or_else(|e| blend_with(cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer", true).map_err(|_| e)) + }; for p in edges { - let step = match chamfer_tool(&cur, *p, distance, 0).and_then(|t| cut_one(&cur, &t)) { - Ok(Some(b)) => b, - // A chain running on into curved faces, or an edge ending at one: as for fillets. - _ => blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer") - .or_else(|e| blend_with(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer", true).map_err(|_| e))?, + let tool = chamfer_tool(&cur, *p, distance, 0); + // The extension is inside the wall. The local blend is one bevel on a hole corner. + // Where that blend cannot build the edge, the cutter is what used to succeed (#814). + let inside = tool.as_ref().ok().is_some_and(|t| t.extension_inside); + let step = if inside { + match blend(&cur, std::slice::from_ref(p), distance, Shape::Flat, "chamfer") { + Ok(b) => b, + Err(miss) => match tool.and_then(|t| cut_one(&cur, &t)) { + Ok(Some(b)) => b, + // Healing can leave an edge the blend cannot end, and a cutter rebuilt on + // the healed body cannot cut it. Nothing has been applied yet, so the + // cutters built on the original body are what used to succeed + // (oracle/69-sm-tray). A hole corner does not get here: its bevel builds. + _ if !progressed => { + if let Ok(tools) = &tools + && let Some(r) = batch(tools) + { + return Ok(r); + } + blend_or_curved(&cur, p).map_err(|_| miss)? + } + _ => blend_or_curved(&cur, p).map_err(|_| miss)?, + }, + } + } else { + match tool.and_then(|t| cut_one(&cur, &t)) { + Ok(Some(b)) => b, + _ => blend_or_curved(&cur, p)?, + } }; + progressed = true; cur = record::after(step, &cur, &counts); } Ok(cur) @@ -2205,6 +2240,9 @@ pub(crate) struct Cutter { body: Body, face: Option, edge: usize, + /// The mitre extension at mid-edge lies in the body. On a convex edge it stays outside. + /// On a hole corner it runs into the walls, and the cutter bites them (#814). + extension_inside: bool, } impl Cutter { @@ -2265,6 +2303,10 @@ fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, b let a_ext = a + ab * (s * 0.5 + reach * 0.05); let b_ext = b - ab * (s * 0.5 + reach * 0.07); let e_far = base + out * reach; + // The cross-section sits past the edge ends, so test the offset at mid-edge. The cutter + // is still built: a caller that cannot blend the edge keeps it (#814). + let mid_edge = (f.p0 + f.p1) * 0.5; + let extension_inside = cur.contains(mid_edge + (a_ext - base)) || cur.contains(mid_edge + (b_ext - base)); // The material beyond both ends must be empty (the cutter runs past them). let mid = (a + b + base) / 3.0; for probe in [mid + f.d * (ext * 0.5), mid + f.d * (len + ext * 1.5)] { @@ -2283,7 +2325,7 @@ fn chamfer_tool_sides(cur: &Body, p: Vec3, s: f64, sides: Option<(ChamferSide, b let region = solidcraft_geom::Region2 { outer: lp, holes: vec![] }; let body = crate::build::extrude(&plane, &[region], 0.0, len + 2.0 * ext)?.pop().ok_or_else(|| KernelError::Failed("chamfer tool".into()))?; let face = body.history().and_then(|h| h.faces.iter().position(|f| f.origins == [FaceOrigin::Profile { segment: chamfer_segment }])); - Ok(Cutter { body, face, edge }) + Ok(Cutter { body, face, edge, extension_inside }) } #[cfg(test)] @@ -2481,6 +2523,28 @@ mod tests { } } + #[test] + fn a_chamfer_of_a_hole_corner_is_one_bevel() { + let plate = crate::build::box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 10.0)).unwrap(); + let pocket = crate::build::box_solid(Vec3::new(10.0, 10.0, -1.0), Vec3::new(30.0, 30.0, 12.0)).unwrap(); + let body = crate::ops::boolean(&plate, &pocket, crate::BoolOp::Cut).unwrap().unwrap(); + let vol = |b: &Body| b.tessellate(1e-4).unwrap().measure().volume; + let v0 = vol(&body); + let one = chamfer(&body, &[Vec3::new(10.0, 10.0, 5.0)], 2.0).unwrap(); + assert!(one.validity().is_empty(), "{:?}", one.validity()); + assert_eq!(one.unmeshed_faces(), 0); + let added = vol(&one) - v0; + // The bevel fills the hole corner: a right triangle of legs 2 mm, times the 10 mm edge. + assert!(added > 19.0 && added < 21.0, "added {added}, want 20"); + assert_eq!(one.face_count(), body.face_count() + 1, "one bevel face"); + let corners = [Vec3::new(10.0, 10.0, 5.0), Vec3::new(30.0, 10.0, 5.0), Vec3::new(30.0, 30.0, 5.0), Vec3::new(10.0, 30.0, 5.0)]; + let all = chamfer(&body, &corners, 2.0).unwrap(); + assert!(all.validity().is_empty(), "{:?}", all.validity()); + let added = vol(&all) - v0; + assert!(added > 76.0 && added < 84.0, "added {added}, want 80"); + assert_eq!(all.face_count(), body.face_count() + 4, "one bevel face per corner"); + } + #[test] fn a_chamfer_ends_across_two_faces() { // A plate's outline chamfered, then the mitre at a corner chamfered: its low end meets