From c33fe37798d75be57a9aacb666b1f61e716be46d Mon Sep 17 00:00:00 2001 From: Iraklis Kourtis Date: Mon, 12 Oct 2026 12:17:07 -0400 Subject: [PATCH] fix(kernel): loop fillet as tall as its wall uses the wall up (#318) * fix(kernel): name the zero-thickness rim when a shell can't open beside a round Shelling a body through a face that a convex round meets tangentially, with the round at least as large as the thickness, puts the cavity wall through the rim where the round meets the open face. Both shell strategies then fail and the first one's generic error ("a corner whose faces don't meet the same way after the move") was reported. The shell now says why and what to change. Successful shells are unchanged. Found by fuzz seed 33, which reaches this case now that a disc's two edges can be rounded to half its thickness. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 * fix(kernel): loop fillet as tall as its wall uses the wall up Rounding a loop with a radius equal to the height of its walls was refused ("the blend is taller than the walls"): r = 2 on the top edge of a 2 mm disc failed while 1.999 worked. When every wall seam ends exactly where the wall-side rail would, the walls are consumed: their far edges become the blend's rails and the walls and seams are removed. Walls used up only in part, and radii past the limit, are still refused. Refs #190 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44 --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: bherbruck --- crates/engine/src/tests.rs | 11 +++++ crates/kernel/src/blend.rs | 2 +- crates/kernel/src/loopblend.rs | 77 ++++++++++++++++++++++++++++------ crates/kernel/src/tests.rs | 31 ++++++++++++++ 4 files changed, 108 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index d58a6d4d..6acd799a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -22,6 +22,17 @@ fn rel(a: f64, b: f64) -> f64 { (a - b).abs() / b.abs().max(1e-12) } +#[test] +fn round_as_tall_as_a_disc_uses_up_its_wall() { + // #190: a disc 13 in radius and 2 high, its top edge rounded by 2. + let mut s = Session::default(); + run(&mut s, "solid.cylinder", json!({"radius": 13, "height": 2})); + run(&mut s, "solid.fillet", json!({"edges": [[0, 13, 2], [0, -13, 2]], "radius": "2 mm"})); + let (a, c) = ((1.0 - PI / 4.0) * 4.0, 2.0 * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI))); + let expect = PI * 169.0 * 2.0 - a * 2.0 * PI * (13.0 - c); + assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s)); +} + #[test] fn fillet_as_wide_as_a_face_rounds_it_off() { // #199: 14 x 6 x 10 box, the four vertical edges at r = 3 (half the 6 mm end faces). diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index c94afc87..ec651c89 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -698,7 +698,7 @@ fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option> { Some(ids) } -fn edge_mid(e: &mt::Edge) -> Option { +pub(crate) fn edge_mid(e: &mt::Edge) -> Option { use mt::{BoundedCurve, ParametricCurve}; let c = e.curve(); let (t0, t1) = c.range_tuple(); diff --git a/crates/kernel/src/loopblend.rs b/crates/kernel/src/loopblend.rs index fb540bf7..31c6ce00 100644 --- a/crates/kernel/src/loopblend.rs +++ b/crates/kernel/src/loopblend.rs @@ -372,7 +372,40 @@ fn build( *slot = v; } } - let vb: Vec = pb.iter().map(|p| builder::vertex(p3(*p))).collect(); + // Model vertices at the joints (the second half of a circle starts at none). + let mut joint_vertex: Vec> = (0..k) + .map(|i| if i == 0 || owner.get(i) != owner.get(i - 1) { owner.get(i).and_then(|o| edges.get(*o)).map(|e| e.front().clone()) } else { None }) + .collect(); + if open { + joint_vertex.push(edges.last().map(|e| e.back().clone())); + } + let loop_ids: Vec = edges.iter().map(|e| e.id()).collect(); + let on_face = |e: &mt::Edge| f.edge_iter().any(|x| x.id() == e.id()); + // A blend as tall as its walls uses them up: each wall seam ends exactly where the + // wall-side rail would, at a vertex of the walls' far boundary, which becomes the rail. + let wall_end: Vec> = joint_vertex + .iter() + .zip(&pb) + .map(|(v, target)| { + let v = v.as_ref()?; + faces.iter().flat_map(|g| g.edge_iter()).find_map(|e| { + if loop_ids.contains(&e.id()) || on_face(&e) || !(e.front() == v || e.back() == v) || !matches!(e.curve(), mt::Curve::Line(_)) { + return None; + } + let other = if e.front() == v { e.back().clone() } else { e.front().clone() }; + (from_p3(other.point()).dist(*target) <= tol * 10.0).then_some(other) + }) + }) + .collect(); + let used_up = wall_end.iter().filter(|x| x.is_some()).count(); + if used_up > 0 && (used_up != nj || open) { + return Err(unsupported("the blend uses up only part of the walls")); + } + let walls_used_up = used_up > 0; + let vb: Vec = match walls_used_up { + true => wall_end.into_iter().flatten().collect(), + false => pb.iter().map(|p| builder::vertex(p3(*p))).collect(), + }; let centre = |i: usize| centres.get(i).copied().unwrap_or_default(); let profile_mid = |i: usize| { let (Some(p), c) = (joint_point(i), centre(i)) else { return Vec3::ZERO }; @@ -442,21 +475,34 @@ fn build( if face_rails.iter().zip(&collapsed).any(|(r, c)| r.is_none() && !c) { return Err(KernelError::Failed("rail".into())); } - let wall_rails: Vec = - (0..k).map(|i| rail(&vb, i, &wall_off)).collect::>().ok_or_else(|| KernelError::Failed("rail".into()))?; + let wall_rails: Vec = if walls_used_up { + // Each piece's wall rail is the wall's far edge between the joints, where the offset + // rail lies (checked at the piece's middle). + (0..k) + .map(|i| -> Result { + let (Some(a), Some(b), Some(s), Some(wf)) = (vb.get(i), vb.get(nxt(i)), segs.get(i), walls.get(i)) else { + return Err(KernelError::Failed("rail".into())); + }; + let mid = match *s { + Seg::Line { a, b } => (a + b) * 0.5, + Seg::Arc { mid, .. } => mid, + }; + let want = wall_off(mid, s); + let g = faces.get(*wf).ok_or_else(|| KernelError::Failed("wall".into()))?; + g.edge_iter() + .find(|e| (e.front() == a && e.back() == b) || (e.front() == b && e.back() == a)) + .filter(|e| crate::blend::edge_mid(e).is_some_and(|m| m.dist(want) <= tol * 10.0)) + .map(|e| if e.front() == a { e.clone() } else { e.inverse() }) + .ok_or_else(|| unsupported("the blend uses up walls whose far edge is not where the blend ends")) + }) + .collect::>()? + } else { + (0..k).map(|i| rail(&vb, i, &wall_off)).collect::>().ok_or_else(|| KernelError::Failed("rail".into()))? + }; // Seams between neighbouring walls at the joints get shorter. - // Model vertices at the joints (the second half of a circle starts at none). - let mut joint_vertex: Vec> = (0..k) - .map(|i| if i == 0 || owner.get(i) != owner.get(i - 1) { owner.get(i).and_then(|o| edges.get(*o)).map(|e| e.front().clone()) } else { None }) - .collect(); - if open { - joint_vertex.push(edges.last().map(|e| e.back().clone())); - } // At a run's ends the face's next loop edges stop at the face-side point. - let on_face = |e: &mt::Edge| f.edge_iter().any(|x| x.id() == e.id()); let mut seam_subst: HashMap = HashMap::new(); let mut seam_ends: HashMap, Option)> = HashMap::new(); - let loop_ids: Vec = edges.iter().map(|e| e.id()).collect(); for (i, v) in joint_vertex.iter().enumerate() { let Some(v) = v else { continue }; for g in faces { @@ -476,6 +522,10 @@ fn build( return Err(unsupported("the wall seams must be straight")); } let face_side = on_face(&e); + // A used-up wall goes, seams and all. + if walls_used_up && !face_side { + continue; + } let target = if face_side { pa.get(i) } else { pb.get(i) }.copied().unwrap_or_default(); let along = (from_p3(other.point()) - from_p3(v.point())).dot(target - from_p3(v.point())); let reach = (target - from_p3(v.point())).len2(); @@ -558,6 +608,9 @@ fn build( let rebuilt = if gi == fi { rebuild(g, &face_rails)? } else if walls.contains(&gi) { + if walls_used_up { + continue; + } let wr: Vec> = wall_rails.iter().cloned().map(Some).collect(); rebuild(g, &wr)? } else if g.edge_iter().any(|e| seam_subst.contains_key(&e.id())) { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index c914ca4c..854573c4 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -181,6 +181,37 @@ fn fillets_on_several_edges_and_angles() { assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err()); } +#[test] +fn loop_fillet_as_tall_as_its_wall_consumes_it() { + // The removed cross-section (a square of side r less a quarter disc) and its centroid's + // distance from the wall: r(10 − 3π) / (3(4 − π)). + let spandrel = |r: f64| ((1.0 - PI / 4.0) * r * r, r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI))); + // #190: a disc 13 in radius and 2 high, its top circle rounded by 2. Pappus for the ring. + let disc = cylinder(Vec3::ZERO, Vec3::Z, 13.0, 2.0).unwrap(); + let f = fillet(&disc, &[Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)], 2.0).unwrap(); + let (a, c) = spandrel(2.0); + let want = PI * 169.0 * 2.0 - a * 2.0 * PI * (13.0 - c); + let m = measure(&f).unwrap(); + assert!(rel(m.volume, want) < 2e-4, "{} vs {want}", m.volume); + // Top, round and bottom: the wall is gone. + assert_eq!(m.merged.faces, 3, "{:?}", m.merged); + assert!(f.validity().is_empty(), "{:?}", f.validity()); + // A 40 x 30 x 5 plate, its whole top outline rounded by 5 (mitred at the corners). + let plate = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 5.0)).unwrap(); + let top = [Vec3::new(20.0, 0.0, 5.0), Vec3::new(40.0, 15.0, 5.0), Vec3::new(20.0, 30.0, 5.0), Vec3::new(0.0, 15.0, 5.0)]; + let f = fillet(&plate, &top, 5.0).unwrap(); + let (a, c) = spandrel(5.0); + let want = 6000.0 - a * 2.0 * ((40.0 - 2.0 * c) + (30.0 - 2.0 * c)); + let m = measure(&f).unwrap(); + assert!(rel(m.volume, want) < 2e-4, "{} vs {want}", m.volume); + assert!(f.validity().is_empty(), "{:?}", f.validity()); + // Taller than the wall: still refused, the body unchanged. + for r in [2.0 + 1e-3, 2.5] { + let e = fillet(&disc, &[Vec3::new(0.0, 13.0, 2.0), Vec3::new(0.0, -13.0, 2.0)], r).unwrap_err(); + assert!(e.to_string().contains("taller than the walls"), "{r}: {e}"); + } +} + #[test] fn fillet_consuming_a_side_face_makes_a_full_round() { // 14 x 6 x 10: r = 3 on the four vertical edges uses up both 6 mm end faces (#199).