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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: bherbruck <[email protected]>
This commit is contained in:
4 files changed
+108
-13
No files matched your search
@@ -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).
|
||||
|
||||
@@ -698,7 +698,7 @@ fn smooth_chain(solid: &Solid, edge: &mt::Edge) -> Option<Vec<mt::EdgeID>> {
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
fn edge_mid(e: &mt::Edge) -> Option<Vec3> {
|
||||
pub(crate) fn edge_mid(e: &mt::Edge) -> Option<Vec3> {
|
||||
use mt::{BoundedCurve, ParametricCurve};
|
||||
let c = e.curve();
|
||||
let (t0, t1) = c.range_tuple();
|
||||
|
||||
@@ -372,7 +372,40 @@ fn build(
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
let vb: Vec<mt::Vertex> = 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<Option<mt::Vertex>> = (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<mt::EdgeID> = 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<Option<mt::Vertex>> = 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<mt::Vertex> = 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<mt::Edge> =
|
||||
(0..k).map(|i| rail(&vb, i, &wall_off)).collect::<Option<_>>().ok_or_else(|| KernelError::Failed("rail".into()))?;
|
||||
let wall_rails: Vec<mt::Edge> = 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<mt::Edge> {
|
||||
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::<Result<_>>()?
|
||||
} else {
|
||||
(0..k).map(|i| rail(&vb, i, &wall_off)).collect::<Option<_>>().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<Option<mt::Vertex>> = (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<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
let mut seam_ends: HashMap<mt::EdgeID, (mt::Edge, Option<mt::Vertex>, Option<mt::Vertex>)> = HashMap::new();
|
||||
let loop_ids: Vec<mt::EdgeID> = 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<Option<mt::Edge>> = wall_rails.iter().cloned().map(Some).collect();
|
||||
rebuild(g, &wr)?
|
||||
} else if g.edge_iter().any(|e| seam_subst.contains_key(&e.id())) {
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user