fix(kernel): fillet that uses up a side face makes a full round (#317)
A constant-radius fillet whose setback equals the width of a neighbouring
face was refused ("the blend is larger than the neighbouring faces"), so
r = 3 on the vertical edges of a 6 mm wide box failed while 2.999 worked.
The corner edge is now consumed: the rail ends at its far vertex and a side
face used up at both ends is removed, its remaining edge becoming the rail.
A face used up at one end only, and radii past the limit, are still refused.
Refs #199
Claude-Session: https://claude.ai/code/session_01TWc1smHYwTSVrVc8SNvS44
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a3ff816176
commit
a9e4830ded
3 files changed
+109
-17
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 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).
|
||||
let mut s = Session::default();
|
||||
run(&mut s, "solid.box", json!({"length": 14, "width": 6, "height": 10, "corner": [14, 0, 0]}));
|
||||
run(&mut s, "solid.fillet", json!({"edges": [[14, 0, 5], [28, 0, 5], [14, 6, 5], [28, 6, 5]], "radius": "3"}));
|
||||
let expect = 840.0 - (4.0 - PI) * 9.0 * 10.0;
|
||||
assert!(rel(volume(&mut s), expect) < 2e-4, "{} vs {expect}", volume(&mut s));
|
||||
assert!(s.execute("solid.fillet", &json!({"edges": [[21, 0, 10]], "radius": 3.5})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_extrude_from_a_chamfered_top_face() {
|
||||
let mut s = Session::default();
|
||||
|
||||
+65
-17
@@ -43,10 +43,15 @@ pub(crate) fn plane_normal(f: &mt::Face) -> Option<Vec3> {
|
||||
/// Replace edges in a face's oriented boundary (by id, keeping orientation), and insert extra
|
||||
/// edges where consecutive edges no longer connect.
|
||||
pub(crate) fn rebuild_face(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, connectors: &[mt::Edge]) -> Result<mt::Face> {
|
||||
rebuild_face_dropping(f, subst, &[], connectors)
|
||||
}
|
||||
|
||||
/// [`rebuild_face`], also leaving out the `dropped` edges (consumed by a blend).
|
||||
fn rebuild_face_dropping(f: &mt::Face, subst: &HashMap<mt::EdgeID, mt::Edge>, dropped: &[mt::EdgeID], connectors: &[mt::Edge]) -> Result<mt::Face> {
|
||||
let mut wires = Vec::new();
|
||||
for w in f.absolute_boundaries() {
|
||||
let mut edges: Vec<mt::Edge> = Vec::new();
|
||||
for e in w.edge_iter() {
|
||||
for e in w.edge_iter().filter(|e| !dropped.contains(&e.id())) {
|
||||
let ne = match subst.get(&e.id()) {
|
||||
Some(n) => {
|
||||
// `n` was built in the orientation of the stored edge; match this use.
|
||||
@@ -201,7 +206,8 @@ fn blend_geometry(
|
||||
) -> Result<Solid> {
|
||||
let s = setback(r, phi, shape);
|
||||
let tol = (size * 1e-7).max(1e-9);
|
||||
// End faces and the side edges at each end.
|
||||
// End faces and the side edges at each end. A side edge that the fillet's setback uses up
|
||||
// exactly is consumed: the rail ends at its far vertex (`ca`, `cb`).
|
||||
struct End {
|
||||
v: mt::Vertex,
|
||||
g: usize,
|
||||
@@ -209,6 +215,8 @@ fn blend_geometry(
|
||||
e2: mt::Edge,
|
||||
a: Vec3,
|
||||
b: Vec3,
|
||||
ca: Option<mt::Vertex>,
|
||||
cb: Option<mt::Vertex>,
|
||||
}
|
||||
let mut ends: Vec<End> = Vec::new();
|
||||
for (v, p) in [(&v0, p0), (&v1, p1)] {
|
||||
@@ -238,19 +246,27 @@ fn blend_geometry(
|
||||
.ok_or_else(|| unsupported("unexpected corner topology"))
|
||||
};
|
||||
let (e1, e2) = (shared(i1)?, shared(i2)?);
|
||||
for (e, q, t, fi) in [(&e1, a, t1, i1), (&e2, b, t2, i2)] {
|
||||
let mut consumed: [Option<mt::Vertex>; 2] = [None, None];
|
||||
for (k, (e, q, t, fi)) in [(&e1, a, t1, i1), (&e2, b, t2, i2)].into_iter().enumerate() {
|
||||
if !matches!(e.curve(), mt::Curve::Line(_)) {
|
||||
return Err(unsupported("the edges at the corners must be straight"));
|
||||
}
|
||||
let other = if e.front() == v { vtx(e.back()) } else { vtx(e.front()) };
|
||||
let far = if e.front() == v { e.back().clone() } else { e.front().clone() };
|
||||
let other = vtx(&far);
|
||||
let delta = other - p;
|
||||
if shape == Shape::Round {
|
||||
// Fillets retain the square-end check: the original corner edge
|
||||
// must run in the setback direction and have enough length left.
|
||||
// must run in the setback direction and have enough length left,
|
||||
// or exactly the setback (the edge is consumed).
|
||||
let along = delta.dot(t);
|
||||
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along <= s + tol {
|
||||
if (delta - t * along).len() > tol * 100.0 + 1e-7 || along < s - tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
if along <= s + tol
|
||||
&& let Some(slot) = consumed.get_mut(k)
|
||||
{
|
||||
*slot = Some(far);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let len = delta.len();
|
||||
@@ -265,7 +281,8 @@ fn blend_geometry(
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
}
|
||||
ends.push(End { v: v.clone(), g, e1, e2, a, b });
|
||||
let [ca, cb] = consumed;
|
||||
ends.push(End { v: v.clone(), g, e1, e2, a, b, ca, cb });
|
||||
}
|
||||
let [end0, end1] = &ends[..] else { return Err(KernelError::Failed("ends".into())) };
|
||||
if end0.g == end1.g {
|
||||
@@ -274,10 +291,32 @@ fn blend_geometry(
|
||||
if (end1.a - end0.a).dot(d) <= tol || (end1.b - end0.b).dot(d) <= tol {
|
||||
return Err(unsupported("the blend is larger than the neighbouring faces"));
|
||||
}
|
||||
let a0 = builder::vertex(p3(end0.a));
|
||||
let b0 = builder::vertex(p3(end0.b));
|
||||
let a1 = builder::vertex(p3(end1.a));
|
||||
let b1 = builder::vertex(p3(end1.b));
|
||||
// A side face whose corner edges are consumed at both ends is used up: its remaining edge,
|
||||
// parallel to the old edge at the setback, becomes the rail and the face is removed.
|
||||
let used_up = |fi: usize, c0: &Option<mt::Vertex>, c1: &Option<mt::Vertex>| -> Result<Option<mt::Edge>> {
|
||||
let (Some(x0), Some(x1)) = (c0, c1) else {
|
||||
return match (c0, c1) {
|
||||
(None, None) => Ok(None),
|
||||
_ => Err(unsupported("the blend uses up a neighbouring face at one end only")),
|
||||
};
|
||||
};
|
||||
let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?;
|
||||
if f.boundaries().len() != 1 || f.edge_iter().count() != 4 {
|
||||
return Err(unsupported("the blend uses up a neighbouring face with more edges"));
|
||||
}
|
||||
let rail = f
|
||||
.edge_iter()
|
||||
.find(|e| matches!(e.curve(), mt::Curve::Line(_)) && ((e.front() == x0 && e.back() == x1) || (e.front() == x1 && e.back() == x0)))
|
||||
.ok_or_else(|| unsupported("the blend uses up a neighbouring face with more edges"))?;
|
||||
Ok(Some(if rail.front() == x0 { rail.clone() } else { rail.inverse() }))
|
||||
};
|
||||
let rail_a = used_up(i1, &end0.ca, &end1.ca)?;
|
||||
let rail_b = used_up(i2, &end0.cb, &end1.cb)?;
|
||||
let vertex = |c: &Option<mt::Vertex>, p: Vec3| c.clone().unwrap_or_else(|| builder::vertex(p3(p)));
|
||||
let a0 = vertex(&end0.ca, end0.a);
|
||||
let b0 = vertex(&end0.cb, end0.b);
|
||||
let a1 = vertex(&end1.ca, end1.a);
|
||||
let b1 = vertex(&end1.cb, end1.b);
|
||||
// Corner curves on the end faces (A → B) and the blend surface. The arc centre is r from
|
||||
// both faces (inside the material for a convex edge, outside for a concave one); the arc's
|
||||
// middle is the point nearest the old edge.
|
||||
@@ -294,13 +333,19 @@ fn blend_geometry(
|
||||
};
|
||||
let c0 = corner(&a0, &b0, p0);
|
||||
let c1 = corner(&a1, &b1, p1);
|
||||
let la = builder::line(&a0, &a1);
|
||||
let lb = builder::line(&b0, &b1);
|
||||
let la = rail_a.clone().unwrap_or_else(|| builder::line(&a0, &a1));
|
||||
let lb = rail_b.clone().unwrap_or_else(|| builder::line(&b0, &b1));
|
||||
|
||||
// Substitutions: side edges at the ends now stop at A/B; the blended edge moves to A or B.
|
||||
// Substitutions: side edges at the ends now stop at A/B (consumed ones are dropped); the
|
||||
// blended edge moves to A or B.
|
||||
let mut subst: HashMap<mt::EdgeID, mt::Edge> = HashMap::new();
|
||||
let mut dropped: Vec<mt::EdgeID> = Vec::new();
|
||||
for (end, a, b) in [(end0, &a0, &b0), (end1, &a1, &b1)] {
|
||||
for (e, nv) in [(&end.e1, a), (&end.e2, b)] {
|
||||
for (e, nv, used) in [(&end.e1, a, &end.ca), (&end.e2, b, &end.cb)] {
|
||||
if used.is_some() {
|
||||
dropped.push(e.id());
|
||||
continue;
|
||||
}
|
||||
let (f, bk) = (e.absolute_front(), e.absolute_back());
|
||||
let ne = if f == &end.v { builder::line(nv, bk) } else { builder::line(f, nv) };
|
||||
subst.insert(e.id(), ne);
|
||||
@@ -308,6 +353,9 @@ fn blend_geometry(
|
||||
}
|
||||
let mut new_faces: Vec<mt::Face> = Vec::with_capacity(faces.len() + 1);
|
||||
for (i, f) in faces.iter().enumerate() {
|
||||
if (i == i1 && rail_a.is_some()) || (i == i2 && rail_b.is_some()) {
|
||||
continue;
|
||||
}
|
||||
let nf = if i == i1 {
|
||||
let mut m = subst.clone();
|
||||
m.insert(edge.id(), la.clone());
|
||||
@@ -317,9 +365,9 @@ fn blend_geometry(
|
||||
m.insert(edge.id(), lb.clone());
|
||||
rebuild_face(f, &m, &[])?
|
||||
} else if i == end0.g {
|
||||
rebuild_face(f, &subst, std::slice::from_ref(&c0))?
|
||||
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c0))?
|
||||
} else if i == end1.g {
|
||||
rebuild_face(f, &subst, std::slice::from_ref(&c1))?
|
||||
rebuild_face_dropping(f, &subst, &dropped, std::slice::from_ref(&c1))?
|
||||
} else {
|
||||
(*f).clone()
|
||||
};
|
||||
|
||||
@@ -181,6 +181,39 @@ fn fillets_on_several_edges_and_angles() {
|
||||
assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err());
|
||||
}
|
||||
|
||||
#[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).
|
||||
let b = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
|
||||
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
|
||||
let full = 840.0 - (4.0 - PI) * 9.0 * 10.0;
|
||||
for order in [mids.to_vec(), mids.iter().rev().copied().collect()] {
|
||||
let f = fillet(&b, &order, 3.0).unwrap();
|
||||
let m = measure(&f).unwrap();
|
||||
assert!(rel(m.volume, full) < 2e-4, "{} vs {full}", m.volume);
|
||||
// Top, bottom, the two long sides and four quarter rounds; the end faces are gone.
|
||||
assert_eq!(m.faces, 8, "{m:?}");
|
||||
assert_eq!(f.lumps().unwrap().len(), 1);
|
||||
assert!(f.validity().is_empty(), "{:?}", f.validity());
|
||||
}
|
||||
// One end only: two rounds meeting on one end face.
|
||||
let f = fillet(&b, &mids[1..4].iter().step_by(2).copied().collect::<Vec<_>>(), 3.0).unwrap();
|
||||
assert!(rel(measure(&f).unwrap().volume, 840.0 - (4.0 - PI) * 9.0 * 5.0) < 2e-4);
|
||||
assert!(f.validity().is_empty());
|
||||
// A 6 x 6 post rounded on all four edges with r = 3 is a cylinder.
|
||||
let post = box_solid(Vec3::ZERO, Vec3::new(6.0, 6.0, 10.0)).unwrap();
|
||||
let corners = [Vec3::new(0.0, 0.0, 5.0), Vec3::new(6.0, 0.0, 5.0), Vec3::new(6.0, 6.0, 5.0), Vec3::new(0.0, 6.0, 5.0)];
|
||||
let c = fillet(&post, &corners, 3.0).unwrap();
|
||||
assert!(rel(measure(&c).unwrap().volume, PI * 9.0 * 10.0) < 2e-4, "{}", measure(&c).unwrap().volume);
|
||||
assert!(c.validity().is_empty());
|
||||
// Past the limit there is no face left to consume: still refused, the body unchanged.
|
||||
for r in [3.0 + 1e-3, 3.5] {
|
||||
let e = fillet(&b, &mids, r).unwrap_err();
|
||||
assert!(e.to_string().contains("larger than the neighbouring faces"), "{r}: {e}");
|
||||
}
|
||||
assert_eq!(measure(&b).unwrap().volume, 840.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapered_extrudes() {
|
||||
// 40 x 40 square, 30 up, 10° outward: a frustum.
|
||||
|
||||
Reference in new issue
Block a user