From ccc36dc1f9b3e1ec5dd046cf981fbc5c9b838155 Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Mon, 12 Oct 2026 21:48:27 -0400 Subject: [PATCH] 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 --- crates/kernel/src/offset.rs | 115 ++++++++++++++++++++++++++++-------- crates/kernel/src/tests.rs | 26 ++++++++ 2 files changed, 117 insertions(+), 24 deletions(-) diff --git a/crates/kernel/src/offset.rs b/crates/kernel/src/offset.rs index 66f006d5..8e5113a5 100644 --- a/crates/kernel/src/offset.rs +++ b/crates/kernel/src/offset.rs @@ -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 { + let mut out: Vec = Vec::new(); + let mut seen: Vec = 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, Vec) { + let own: Vec = 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 = HashMap::new(); // Corners whose moved faces meet at several points (see `split_corner`). let mut splits: HashMap)>> = HashMap::new(); @@ -766,22 +816,7 @@ pub(crate) fn offset_body(b: &Body, shift: impl Fn(usize, Vec3) -> f64) -> Resul let fs: Vec = 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 = 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 = 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 = 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, 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 = (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 } }; diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index a2ac4c02..d8585384 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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}"); + } + } +}