diff --git a/ROADMAP.md b/ROADMAP.md index d50cb9cb..6ff525c9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -58,7 +58,8 @@ headline counts in-scope tabs only and lists the deferred ones separately. - Fillets and chamfers: straight edges (convex or concave) between planar faces with perpendicular planar end faces; whole smooth loops of a planar face, picked whole or by one edge (tangent chain), including circles and walls at any angle (boss bases and tops, bores, pocket floors, - plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), + plate outlines), loops with sharp corners between straight edges (the blends meet in mitres), runs of + neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square), rounded corners rounded again (sphere octants, tori at concave corners); every edge of a convex planar body or of an extruded part; fillets inside patterns; closed chains of curved edges between any two smooth sides (a branch pipe on a main pipe), by a rolling ball (the diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index d9714194..a60df794 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -318,6 +318,19 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul cur = blend(&cur, &rest, r, shape, what)?; return blend(&cur, &loop_pts, r, shape, what); } + // Neighbouring edges along a planar face's loop (sharp corners between them: the blends + // meet in mitres), ending where unblended edges go on. + if edges.len() >= 2 { + let size = cur.size(); + let solid = cur.deep_copy(); + let ids = edge_ids(&cur, &solid, edges)?; + if ids.len() == edges.len() + && let Some(Ok(res)) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round) + && let Ok(b) = guard(what, || Ok(res)).and_then(Body::new) + { + return Ok(b); + } + } if let Some(groups) = loop_groups(&cur, edges)? { for g in groups { let size = cur.size(); diff --git a/crates/kernel/src/loopblend.rs b/crates/kernel/src/loopblend.rs index 1dc4e9d6..713d1478 100644 --- a/crates/kernel/src/loopblend.rs +++ b/crates/kernel/src/loopblend.rs @@ -39,6 +39,12 @@ impl Seg { Seg::Line { a, .. } | Seg::Arc { a, .. } => a, } } + fn end(&self) -> Vec3 { + match *self { + Seg::Line { b, .. } => b, + Seg::Arc { a, c, axis, angle, .. } => turn(a, c, axis, angle), + } + } /// Unit tangent (direction of travel) at a point of the segment. fn tangent(&self, p: Vec3) -> Option { match *self { @@ -144,10 +150,21 @@ pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f let Some(n) = plane_normal(f) else { continue }; for (wi, w) in f.boundaries().iter().enumerate() { let ids: Vec = w.edge_iter().map(|e| e.id()).collect(); - if ids.len() != edge_ids.len() || !edge_ids.iter().all(|e| ids.contains(e)) { + if !edge_ids.iter().all(|e| ids.contains(e)) { continue; } - return Some(build(solid, si, &faces, fi, wi, n, size, tol, r, round)); + if ids.len() == edge_ids.len() { + return Some(build(solid, si, &faces, fi, wi, n, size, tol, r, round, None)); + } + // A run of neighbouring edges of the loop (its ends meet unblended edges). + let m = ids.len(); + let inside = |i: usize| ids.get(i % m).is_some_and(|x| edge_ids.contains(x)); + let starts: Vec = (0..m).filter(|i| inside(*i) && !inside(*i + m - 1)).collect(); + if let [s0] = starts[..] + && (0..edge_ids.len()).all(|j| inside(s0 + j)) + { + return Some(build(solid, si, &faces, fi, wi, n, size, tol, r, round, Some((s0, edge_ids.len())))); + } } } } @@ -155,10 +172,28 @@ pub(crate) fn loop_blend(solid: &Solid, edge_ids: &[mt::EdgeID], size: f64, r: f } #[allow(clippy::too_many_arguments)] -fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: Vec3, size: f64, tol: f64, r: f64, round: bool) -> Result { +fn build( + solid: &Solid, + si: usize, + faces: &[&mt::Face], + fi: usize, + wi: usize, + n: Vec3, + size: f64, + tol: f64, + r: f64, + round: bool, + run: Option<(usize, usize)>, +) -> Result { let f = faces.get(fi).ok_or_else(|| KernelError::Failed("face".into()))?; let wire = f.boundaries().get(wi).cloned().ok_or_else(|| KernelError::Failed("loop".into()))?; - let edges: Vec = wire.edge_iter().cloned().collect(); + let all_edges: Vec = wire.edge_iter().cloned().collect(); + // The blended edges in loop order: the whole loop, or a run of it (open). + let open = run.is_some(); + let edges: Vec = match run { + None => all_edges, + Some((s0, len)) => (0..len).filter_map(|j| all_edges.get((s0 + j) % all_edges.len().max(1)).cloned()).collect(), + }; let edge_segs: Vec = edges .iter() .map(|e| seg_of(e, (size * 1e-5).max(1e-6))) @@ -169,9 +204,15 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: let owner: Vec = split.iter().map(|x| x.0).collect(); let segs: Vec = split.iter().map(|x| x.1).collect(); let k = segs.len(); + // Joints: k round a whole loop; k + 1 along a run (its two ends included). + let nj = if open { k + 1 } else { k }; + let nxt = |i: usize| if open { i + 1 } else { (i + 1) % k }; // Joints: smooth, or sharp between two lines (the blends then meet in a mitre). - let mut mitre = vec![false; k]; + let mut mitre = vec![false; nj]; for i in 0..k { + if open && i == 0 { + continue; + } let (Some(prev), Some(cur)) = (segs.get((i + k - 1) % k), segs.get(i)) else { continue }; let p = cur.start(); let (Some(ta), Some(tb)) = (prev.tangent(p), cur.tangent(p)) else { return Err(unsupported("degenerate loop edge")) }; @@ -256,12 +297,14 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: _ => None, } }; - let mut pa = Vec::with_capacity(k); - let mut pb = Vec::with_capacity(k); - let mut centres = Vec::with_capacity(k); - for i in 0..k { - let p = segs.get(i).map(|s| s.start()).unwrap_or_default(); - let (m, t, sb, dc) = frame(i, p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?; + let mut pa = Vec::with_capacity(nj); + let mut pb = Vec::with_capacity(nj); + let mut centres = Vec::with_capacity(nj); + // Joint i: the start of piece i (the end of the last piece for a run's far end). + let joint_point = |i: usize| if i < k { segs.get(i).map(|s| s.start()) } else { segs.last().map(|s| s.end()) }; + for i in 0..nj { + let p = joint_point(i).unwrap_or_default(); + let (m, t, sb, dc) = frame(i.min(k - 1), p).ok_or_else(|| unsupported("the face and wall meet at an angle the blend can't take"))?; centres.push(p + (m + t).normalized().unwrap_or(m) * dc); if !mitre.get(i).copied().unwrap_or(false) { pa.push(p + m * sb); @@ -287,14 +330,11 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: } // The offset arcs must not turn inside out; an arc of exactly the blend's radius shrinks // to a point (a rounded corner rounded again: the blend there is a sphere). - let mut collapsed = vec![false; k]; + let mut collapsed = vec![false; k.max(nj)]; for (i, s) in segs.iter().enumerate() { if let Seg::Arc { a, c, .. } = *s { let toward = into(s, a).dot(c - a) > 0.0; - if toward - && (a.dist(c) - r).abs() <= tol * 10.0 - && !mitre.get(i).copied().unwrap_or(true) - && !mitre.get((i + 1) % k).copied().unwrap_or(true) + if toward && (a.dist(c) - r).abs() <= tol * 10.0 && !mitre.get(i).copied().unwrap_or(true) && !mitre.get(nxt(i)).copied().unwrap_or(true) { if let Some(x) = collapsed.get_mut(i) { *x = true; @@ -312,7 +352,7 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: for i in 0..k { if collapsed.get(i).copied().unwrap_or(false) && let Some(v) = va.get(i).cloned() - && let Some(slot) = va.get_mut((i + 1) % k) + && let Some(slot) = va.get_mut(nxt(i)) { *slot = v; } @@ -320,12 +360,12 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: let vb: Vec = 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(s), c) = (segs.get(i), centre(i)) else { return Vec3::ZERO }; - c + (s.start() - c).normalized().unwrap_or(n) * r + let (Some(p), c) = (joint_point(i), centre(i)) else { return Vec3::ZERO }; + c + (p - c).normalized().unwrap_or(n) * r }; // Profiles at the joints (face side → wall side). - let mut profiles: Vec = Vec::with_capacity(k); - for i in 0..k { + let mut profiles: Vec = Vec::with_capacity(nj); + for i in 0..nj { let (Some(a), Some(b)) = (va.get(i), vb.get(i)) else { return Err(KernelError::Failed("joint".into())) }; if !round { profiles.push(builder::line(a, b)); @@ -368,7 +408,7 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: if std::ptr::eq(vs, va.as_slice()) && collapsed.get(i).copied().unwrap_or(false) { return None; } - let (a, b) = (vs.get(i)?, vs.get((i + 1) % k)?); + let (a, b) = (vs.get(i)?, vs.get(nxt(i))?); Some(match *s { Seg::Line { .. } => builder::line(a, b), Seg::Arc { mid, .. } => builder::circle_arc(a, b, p3(offset(mid, s))), @@ -391,16 +431,28 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: (0..k).map(|i| rail(&vb, i, &wall_off)).collect::>().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 joint_vertex: Vec> = (0..k) + let mut joint_vertex: Vec> = (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 = HashMap::new(); + let mut seam_ends: HashMap, Option)> = HashMap::new(); let loop_ids: Vec = 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 { for e in g.edge_iter() { - if loop_ids.contains(&e.id()) || seam_subst.contains_key(&e.id()) || !(e.front() == v || e.back() == v) { + if loop_ids.contains(&e.id()) || !(e.front() == v || e.back() == v) { + continue; + } + // Once per edge and joint (an edge between both ends of a run is met twice). + if seam_ends.get(&e.id()).is_some_and(|x: &(mt::Edge, Option, Option)| { + (x.0.absolute_front() == v && x.1.is_some()) || (x.0.absolute_back() == v && x.2.is_some()) + }) { continue; } let (fa, fb) = (e.absolute_front().clone(), e.absolute_back().clone()); @@ -408,17 +460,31 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: if !matches!(e.curve(), mt::Curve::Line(_)) { return Err(unsupported("the wall seams must be straight")); } - let along = (from_p3(other.point()) - from_p3(v.point())).dot(pb.get(i).copied().unwrap_or_default() - from_p3(v.point())); - let reach = (pb.get(i).copied().unwrap_or_default() - from_p3(v.point())).len2(); + let face_side = on_face(&e); + 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(); if along <= reach + tol { - return Err(unsupported("the blend is taller than the walls")); + return Err(unsupported(if face_side { "the blend is longer than the next edge" } else { "the blend is taller than the walls" })); + } + let nb = if face_side { va.get(i) } else { vb.get(i) }.ok_or_else(|| KernelError::Failed("joint".into()))?; + let slot = seam_ends.entry(e.id()).or_insert_with(|| (e.absolute_clone(), None, None)); + if &fa == v { + slot.1 = Some(nb.clone()); + } else { + slot.2 = Some(nb.clone()); } - let nb = vb.get(i).ok_or_else(|| KernelError::Failed("joint".into()))?; - let ne = if &fa == v { builder::line(nb, &fb) } else { builder::line(&fa, nb) }; - seam_subst.insert(e.id(), ne); } } } + for (id, (e, a, b)) in seam_ends { + let a = a.unwrap_or_else(|| e.absolute_front().clone()); + let b = b.unwrap_or_else(|| e.absolute_back().clone()); + if from_p3(a.point()).dist(from_p3(b.point())) < tol * 10.0 { + return Err(unsupported("the blend is longer than the next edge")); + } + seam_subst.insert(id, builder::line(&a, &b)); + } // Rebuild faces. let idx_of = |id: mt::EdgeID| loop_ids.iter().position(|x| *x == id); let rebuild = |g: &mt::Face, rails: &[Option]| -> Result { @@ -444,6 +510,28 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: }; es.push(ne); } + // A run's end: the face's edge and the wall's seam now stop at the two ends of the + // end profile, which closes the gap between them. + if open { + let mut fixed: Vec = Vec::with_capacity(es.len() + 2); + let m = es.len(); + for j in 0..m { + let (Some(cur), Some(next)) = (es.get(j), es.get((j + 1) % m)) else { continue }; + fixed.push(cur.clone()); + if cur.back() == next.front() { + continue; + } + for ji in [0, nj - 1] { + let (Some(a), Some(b), Some(pr)) = (va.get(ji), vb.get(ji), profiles.get(ji)) else { continue }; + if cur.back() == a && next.front() == b { + fixed.push(pr.clone()); + } else if cur.back() == b && next.front() == a { + fixed.push(pr.inverse()); + } + } + } + es = fixed; + } wires.push(mt::Wire::from(es)); } crate::heal::absolute_face(g, wires).ok_or_else(|| KernelError::Failed("loop blend: a face could not be rebuilt".into())) @@ -465,7 +553,7 @@ fn build(solid: &Solid, si: usize, faces: &[&mt::Face], fi: usize, wi: usize, n: use mt::{ParametricSurface3D, SearchNearestParameter}; for i in 0..k { let (Some(s), Some(pr), Some(pr_next), Some(fr), Some(wr)) = - (segs.get(i), profiles.get(i), profiles.get((i + 1) % k), face_rails.get(i), wall_rails.get(i)) + (segs.get(i), profiles.get(i), profiles.get(nxt(i)), face_rails.get(i), wall_rails.get(i)) else { continue; }; diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index a4e9ae59..4b70ea43 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -138,8 +138,11 @@ fn fillets_on_several_edges_and_angles() { let removed = r * r / (phi / 2.0).tan() - r * r * (PI - phi) / 2.0; let v0 = measure(&p).unwrap().volume; assert!(rel(measure(&fp).unwrap().volume, v0 - removed * 10.0) < 2e-4, "{} vs {}", measure(&fp).unwrap().volume, v0 - removed * 10.0); - // Adjacent edges at a blended corner are not supported yet (an error, not a crash). - assert!(fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(0.0, 15.0, 20.0)], 2.0).is_err()); + // Adjacent edges meeting at a corner: their blends meet in a mitre. + let two = fillet(&b, &[Vec3::new(0.0, 0.0, 10.0), Vec3::new(0.0, 15.0, 20.0)], 2.0).unwrap(); + let cut = 24000.0 - measure(&two).unwrap().volume; + let a = 4.0 * (1.0 - PI / 4.0); + assert!(cut > a * (20.0 + 30.0 - 2.0) && cut < a * 50.0, "{cut}"); // Concave edge: not supported. let u = boolean(&b, &box_solid(Vec3::new(10.0, 10.0, 15.0), Vec3::new(20.0, 20.0, 30.0)).unwrap(), BoolOp::Union).unwrap().unwrap(); assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err()); @@ -1175,3 +1178,20 @@ fn booleans_found_by_the_survey() { let vu = measure(&u).unwrap().volume; assert!(vu > PI * 64.0 * 20.0 && vu < PI * 64.0 * 20.0 + 1620.0, "{vu}"); } + +#[test] +fn fillet_two_edges_meeting_at_a_corner() { + // Two top edges of a box meeting at a corner (the vertical edge there stays sharp): the + // fillets meet in a mitre and stop square at the far walls. + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(); + let f = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0), Vec3::new(0.0, 15.0, 20.0)], 3.0).unwrap(); + let removed = 24000.0 - measure(&f).unwrap().volume; + // Each edge loses r²(1 − π/4) along its length; at the mitred corner the two cuts overlap + // in a region of volume r³(1 − π/4)·… — bounded between the sum less one corner and the sum. + let a = 9.0 * (1.0 - PI / 4.0); + let (lo, hi) = (a * (40.0 + 30.0) - a * 3.0, a * (40.0 + 30.0)); + assert!(removed > lo && removed < hi, "removed {removed} in ({lo}, {hi})"); + // Three edges: front, left and back. + let f3 = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0), Vec3::new(0.0, 15.0, 20.0), Vec3::new(20.0, 30.0, 20.0)], 3.0).unwrap(); + assert!(measure(&f3).unwrap().volume < measure(&f).unwrap().volume); +}