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}"); + } + } +}