fix: shell a body whose curved edge is rounded smaller than the wall (#476)

Moved in by more than its radius, a round collapses and its neighbours meet
along a new edge. That edge was always built as a straight line keyed by its
ends, so on a disc whose circular edge is rounded the four arcs where the floor
meets the side became one chord used by four faces, and the shell failed with
"This shell is not oriented and closed" (#319).

The new edge now runs where the edge's face meets the face across the round
(the round's far side, crossed as a strip), through the edge's middle moved
onto both: a line or a circular arc, checked to lie on both moved faces, and
refused otherwise. Twins are the new edges with the same ends through the same
middle.

Fixes #319

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Irakli KourtisandClaude Opus 5.5 authored and GitHub committed 2026-10-12 21:48:27 -04:00
1 parent 0fe032438f
commit ccc36dc1f9
2 files changed
+117 -24

No files matched your search

+91 -24
View File
@@ -757,6 +757,56 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
))
})
.collect();
// Across the collapsed rounds next to edge `e` (its faces `fs`): a round is a strip, crossed
// to its side that shares no vertex with the edge, through further collapsed rounds.
let opposite_side = |e: &mt::Edge, fs: &[usize]| -> Vec<usize> {
let mut out: Vec<usize> = Vec::new();
let mut seen: Vec<usize> = Vec::new();
let mut queue: Vec<(usize, [mt::VertexID; 2])> =
fs.iter().copied().filter(|i| is_collapsed(*i)).map(|i| (i, [e.front().id(), e.back().id()])).collect();
while let Some((cf, avoid)) = queue.pop() {
if seen.contains(&cf) {
continue;
}
seen.push(cf);
let Some(face) = faces.get(cf) else { continue };
for side in face.edge_iter() {
if avoid.contains(&side.front().id()) || avoid.contains(&side.back().id()) {
continue;
}
for nb in efaces.get(&side.id()).into_iter().flatten().copied() {
if nb == cf || fs.contains(&nb) {
continue;
}
if is_collapsed(nb) {
queue.push((nb, [side.front().id(), side.back().id()]));
} else if !out.contains(&nb) {
out.push(nb);
}
}
}
}
out
};
// For a point on a collapsed round touching the faces `at`: the faces it keeps (`at`'s
// own, not collapsed), and the faces along the rounds it is on, nearest `p` first.
let across = |at: &[usize], p: Vec3| -> (Vec<usize>, Vec<usize>) {
let own: Vec<usize> = at.iter().copied().filter(|i| !is_collapsed(*i)).collect();
let mut cand: Vec<(f64, usize)> = expand(at)
.into_iter()
.filter(|nb| !own.contains(nb))
.map(|nb| {
let d = edge_pts
.iter()
.filter(|(fs, _)| fs.contains(&nb) && fs.iter().any(|i| is_collapsed(*i)))
.flat_map(|(_, q)| q.iter().map(|x| x.dist(p)))
.fold(f64::INFINITY, f64::min);
(d, nb)
})
.collect();
cand.sort_by(|x, y| x.0.total_cmp(&y.0));
(own, cand.into_iter().map(|(_, nb)| nb).collect())
};
let mut newpos: HashMap<mt::VertexID, Vec3> = HashMap::new();
// Corners whose moved faces meet at several points (see `split_corner`).
let mut splits: HashMap<mt::VertexID, Vec<(Vec3, Vec<usize>)>> = HashMap::new();
@@ -766,22 +816,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
let fs: Vec<usize> = if at.iter().any(|i| is_collapsed(*i)) {
// On a collapsed round: its own faces, then the faces along the rounds it is on,
// nearest first, until they fix a point.
let group = expand(at);
let mut own: Vec<usize> = at.iter().copied().filter(|i| !is_collapsed(*i)).collect();
let mut cand: Vec<(f64, usize)> = group
.iter()
.copied()
.filter(|nb| !own.contains(nb))
.map(|nb| {
let d = edge_pts
.iter()
.filter(|(fs, _)| fs.contains(&nb) && fs.iter().any(|i| is_collapsed(*i)))
.flat_map(|(_, q)| q.iter().map(|x| x.dist(p)))
.fold(f64::INFINITY, f64::min);
(d, nb)
})
.collect();
cand.sort_by(|x, y| x.0.total_cmp(&y.0));
let (mut own, cand) = across(at, p);
let rank = |ids: &[usize]| -> usize {
let mut basis: Vec<Vec3> = Vec::new();
for i in ids {
@@ -796,7 +831,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
}
basis.len()
};
for (_, c) in cand {
for c in cand {
if rank(&own) >= 3 {
break;
}
@@ -904,7 +939,8 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
let mut edges: HashMap<mt::EdgeID, (mt::Edge, bool)> = HashMap::new();
// Moved faces and edges; an edge where neighbours of a collapsed face meet is new.
let mut rec = crate::record::Rebuild::default();
let mut merged: HashMap<(mt::VertexID, mt::VertexID), mt::Edge> = HashMap::new();
// New edges along collapsed rounds: ends and points along them, for their twins.
let mut merged: Vec<(mt::VertexID, mt::VertexID, Vec<Vec3>, mt::Edge)> = Vec::new();
for e in b.solid.edge_iter() {
if edges.contains_key(&e.id()) {
continue;
@@ -921,13 +957,44 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul
continue;
}
if touches {
// Along a collapsed round: a straight edge, shared with its twin from the
// round's other side.
let ne = match merged.get(&(a.id(), c.id())).or_else(|| merged.get(&(c.id(), a.id()))) {
Some(x) => x.clone(),
// Along a collapsed round: where the edge's own face meets the face across the
// round, between the edge's moved ends, through the edge's middle moved onto
// both: a line (planes, or a plane along a cylinder's axis) or an arc (a plane
// square to a coaxial cylinder); faces that meet in another curve are refused.
// Shared with its twin from the round's other side: the new edge with the same
// ends through the same middle (two arcs of one circle have the same ends).
use mt::{BoundedCurve, ParametricCurve};
let fs = efaces.get(&e.id()).ok_or_else(|| fail("edge faces"))?;
let cv = e.curve();
let (t0, t1) = cv.range_tuple();
let mid = from_p3(cv.subs((t0 + t1) / 2.0));
// The face across: the strip's far side (`opposite_side`), the nearest of them
// when several; the nearest along the rounds when the strip has none.
let (mut meet, cand) = across(fs, mid);
let far = opposite_side(&e, fs);
meet.extend(cand.iter().find(|c| far.contains(c)).or(cand.first()));
if meet.len() < 2 {
return Err(fail("an edge along a collapsed round with no face across it"));
}
let (o, s, n) = pick(&meet);
let m = move_point(mid, &o, &s, &n, size * 1e-6).ok_or_else(|| fail("faces that don't meet along a collapsed round"))?;
let near = size * 1e-5;
let twin = merged.iter().find(|(x, y, pts, _)| {
((*x == a.id() && *y == c.id()) || (*x == c.id() && *y == a.id()))
&& pts.windows(2).any(|w| w.first().zip(w.get(1)).is_some_and(|(p, q)| m.dist_to_segment(*p, *q) < near))
});
let ne = match twin {
Some((_, _, _, x)) => x.clone(),
None => {
let x = builder::line(a, c);
merged.insert((a.id(), c.id()), x.clone());
let (pa, pc) = (from_p3(a.point()), from_p3(c.point()));
let x = if m.dist_to_segment(pa, pc) < near { builder::line(a, c) } else { builder::circle_arc(a, c, p3(m)) };
let xc = x.curve();
let (u0, u1) = xc.range_tuple();
let pts: Vec<Vec3> = (0..=16).map(|j| from_p3(xc.subs(u0 + (u1 - u0) * j as f64 / 16.0))).collect();
if !pts.iter().all(|p| n.iter().all(|sf| sf.dist(*p).abs() < near)) {
return Err(fail("not supported yet: faces meeting along a collapsed round in a curve other than a line or circle"));
}
merged.push((a.id(), c.id(), pts, x.clone()));
x
}
};
+26
View File
@@ -2123,3 +2123,29 @@ fn shell_open_beside_a_round_as_large_as_the_wall_is_refused() {
let f = fillet(&b, &loop_, 1.5).unwrap();
assert!(shell(&f, &bottom, 2.0).unwrap().validity().is_empty());
}
/// A disc whose circular edge is rounded smaller than the wall (#319): moved in by the
/// thickness the round collapses to a sharp edge, where the floor meets the side along a
/// circle (two arcs with the same ends), not a straight line.
#[test]
fn shell_of_a_disc_rounded_smaller_than_the_wall() {
use std::f64::consts::PI;
let (rd, h, r) = (15.0, 5.0, 1.5);
let disc = cylinder(Vec3::new(0.0, 0.0, -h), Vec3::Z, rd, h).unwrap();
// The round takes a corner of area r²(1 − π/4) whose centroid is r(10 − 3π)/(3(4 − π))
// in from the rim, swept round the axis (Pappus).
let corner = r * r * (1.0 - PI / 4.0) * 2.0 * PI * (rd - r * (10.0 - 3.0 * PI) / (3.0 * (4.0 - PI)));
let (top, bottom) = ([Vec3::ZERO], [Vec3::new(0.0, 0.0, -h)]);
for (edge_z, open) in [(-h, &top), (-h, &bottom), (0.0, &bottom)] {
let rounded = fillet(&disc, &[Vec3::new(0.0, rd, edge_z), Vec3::new(0.0, -rd, edge_z)], r).unwrap();
let v0 = PI * rd * rd * h - corner;
for t in [1.6, 2.0] {
let s = shell(&rounded, open, t).unwrap_or_else(|e| panic!("round at z {edge_z}, t {t}: {e}"));
assert!(s.validity().is_empty(), "round at z {edge_z}, t {t}: {:?}", s.validity());
// The cavity: a plain cylinder the thickness in from the side and the closed end.
let want = v0 - PI * (rd - t).powi(2) * (h - t);
let v = measure(&s).unwrap().volume;
assert!(rel(v, want) < 1e-3, "round at z {edge_z}, t {t}: {v} vs {want}");
}
}
}