diff --git a/ROADMAP.md b/ROADMAP.md index 701e51cb..a866bac0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,7 +61,8 @@ headline counts in-scope tabs only and lists the deferred ones separately. 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), runs of - neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square), + neighbouring edges along a planar face's loop (two top edges of a box: mitred, ending square; + with the edge between them too, the corner becomes a sphere octant), 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 a60df794..c1c67cce 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -318,19 +318,6 @@ 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(); @@ -343,28 +330,174 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul } return Ok(cur); } - for p in edges { + // One at a time, in whatever order works: a run along a planar face's loop (bridging arcs + // that earlier blends left between selected edges, so corners round off), a single edge, + // or a chain of curved edges. + let mut todo: Vec = edges.to_vec(); + for _ in 0..(2 * edges.len() + 4) { + if todo.is_empty() { + return Ok(cur); + } let size = cur.size(); let tol = (size * 2e-3).max(1e-3); - let Some((idx, dist)) = cur.nearest_edge(*p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) }; - if dist > size * 0.05 + 1e-3 { - return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z]))); - } let solid = cur.deep_copy(); - let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?; - let out = match guard(what, || blend_one(&solid, &edge, size, r, shape)) { - Ok(o) => o, - // A curved edge, or one between curved faces: roll a ball along its smooth chain. - Err(e) if !curved_case(&solid, &edge) => return Err(e), - Err(e) => match smooth_chain(&solid, &edge) { - Some(ids) => guard(what, || crate::curveblend::curve_blend(&solid, &ids, size, r, shape == Shape::Round)) - .map_err(|e2| KernelError::Failed(format!("{e2} ({e})")))?, - None => return Err(e), - }, - }; - cur = Body::new(out)?; + if todo.len() >= 2 + && let Some((ids, covered)) = run_on_loop(&cur, &solid, &todo)? + && 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) + { + cur = b; + todo.retain(|p| !covered.iter().any(|q| q.dist(*p) < 1e-12)); + continue; + } + let mut first_err: Option = None; + let mut done: Option<(Body, Vec)> = None; + for (j, p) in todo.iter().enumerate() { + let Some((idx, dist)) = cur.nearest_edge(*p, tol)? else { return Err(KernelError::Invalid("the body has no edges".into())) }; + if dist > size * 0.05 + 1e-3 { + return Err(KernelError::Invalid(format!("no edge near {:?}", [p.x, p.y, p.z]))); + } + let edge = Body::unique_edges(&solid).into_iter().nth(idx).ok_or_else(|| KernelError::Invalid("edge index".into()))?; + let attempt = match guard(what, || blend_one(&solid, &edge, size, r, shape)) { + Ok(o) => Ok((o, vec![j])), + // A curved edge, or one between curved faces: roll a ball along its smooth chain. + Err(e) if !curved_case(&solid, &edge) => Err(e), + Err(e) => match smooth_chain(&solid, &edge) { + Some(ids) => { + let on_chain: Vec = todo + .iter() + .enumerate() + .filter(|(_, q)| { + cur.nearest_edge(**q, tol) + .ok() + .flatten() + .and_then(|(i, _)| Body::unique_edges(&solid).into_iter().nth(i)) + .is_some_and(|x| ids.contains(&x.id())) + }) + .map(|(i, _)| i) + .collect(); + guard(what, || crate::curveblend::curve_blend(&solid, &ids, size, r, shape == Shape::Round)) + .map(|o| (o, if on_chain.is_empty() { vec![j] } else { on_chain })) + .map_err(|e2| KernelError::Failed(format!("{e2} ({e})"))) + } + None => Err(e), + }, + }; + match attempt.and_then(|(o, js)| Body::new(o).map(|b| (b, js))) { + Ok(x) => { + done = Some(x); + break; + } + Err(e) => { + if first_err.is_none() { + first_err = Some(e); + } + } + } + } + match done { + Some((b, js)) => { + cur = b; + let mut k = 0; + todo.retain(|_| { + let keep = !js.contains(&k); + k += 1; + keep + }); + } + None => return Err(first_err.unwrap_or_else(|| unsupported("these edges"))), + } } - Ok(cur) + if todo.is_empty() { Ok(cur) } else { Err(unsupported("blending these edges together")) } +} + +/// Neighbouring edges along one boundary loop of a planar face that between them hold at least +/// two of the points' edges, joined by arcs that meet them smoothly (left by earlier blends): +/// the run's edge ids and the points it covers. +#[allow(clippy::type_complexity)] +fn run_on_loop(cur: &Body, solid: &Solid, pts: &[Vec3]) -> Result, Vec)>> { + use mt::{BoundedCurve, ParametricCurve}; + let size = cur.size(); + let tol = (size * 2e-3).max(1e-3); + let all = Body::unique_edges(solid); + let mut per: Vec<(Vec3, mt::EdgeID)> = Vec::new(); + for p in pts { + if let Some((idx, dist)) = cur.nearest_edge(*p, tol)? + && dist <= size * 0.05 + 1e-3 + && let Some(e) = all.get(idx) + { + per.push((*p, e.id())); + } + } + let dir = |e: &mt::Edge, at_end: bool| -> Option { + let c = e.oriented_curve(); + let (t0, t1) = c.range_tuple(); + let d = c.der(if at_end { t1 } else { t0 }); + Vec3::new(d.x, d.y, d.z).normalized() + }; + let mut best: Option<(Vec, Vec)> = None; + for f in solid.face_iter() { + if !matches!(f.oriented_surface(), mt::Surface::Plane(_)) { + continue; + } + for w in f.boundaries() { + let es: Vec = w.edge_iter().cloned().collect(); + let m = es.len(); + let sel = |i: usize| es.get(i % m).is_some_and(|e| per.iter().any(|(_, id)| *id == e.id())); + // A filler: an unselected arc meeting both neighbours smoothly. + let smooth = |a: &mt::Edge, b: &mt::Edge| match (dir(a, true), dir(b, false)) { + (Some(x), Some(y)) => x.dot(y) > 1.0 - 1e-6, + _ => false, + }; + let filler = |i: usize| { + let (Some(prev), Some(e), Some(next)) = (es.get((i + m - 1) % m), es.get(i % m), es.get((i + 1) % m)) else { return false }; + !matches!(e.curve(), mt::Curve::Line(_)) && smooth(prev, e) && smooth(e, next) + }; + let usable = |i: usize| sel(i) || filler(i); + if (0..m).all(usable) { + // The whole loop. + if (0..m).filter(|i| sel(*i)).count() >= 2 { + let ids: Vec = es.iter().map(|e| e.id()).collect(); + let covered: Vec = per.iter().filter(|(_, id)| ids.contains(id)).map(|(p, _)| *p).collect(); + if best.as_ref().is_none_or(|b| covered.len() > b.1.len()) { + best = Some((ids, covered)); + } + } + continue; + } + // Runs: start after an unusable edge; trim fillers off both ends. + for s0 in 0..m { + if usable(s0) && !usable(s0 + m - 1) { + let mut idx: Vec = (0..m).map(|j| s0 + j).take_while(|i| usable(*i)).collect(); + while idx.first().is_some_and(|i| !sel(*i)) { + idx.remove(0); + } + while idx.last().is_some_and(|i| !sel(*i)) { + idx.pop(); + } + if idx.iter().filter(|i| sel(**i)).count() < 2 { + continue; + } + let ids: Vec = idx.iter().filter_map(|i| es.get(i % m).map(|e| e.id())).collect(); + // Not yet when another selected edge meets one of the run's inner corners: + // that edge goes first, and the run then rounds the corner. + let inner: Vec = idx.iter().skip(1).filter_map(|i| es.get(i % m).map(|e| e.front().clone())).collect(); + let blocked = per + .iter() + .filter(|(_, id)| !ids.contains(id)) + .any(|(_, id)| all.iter().find(|e| e.id() == *id).is_some_and(|e| inner.iter().any(|v| v == e.front() || v == e.back()))); + if blocked { + continue; + } + let covered: Vec = per.iter().filter(|(_, id)| ids.contains(id)).map(|(p, _)| *p).collect(); + if best.as_ref().is_none_or(|b| covered.len() > b.1.len()) { + best = Some((ids, covered)); + } + } + } + } + } + Ok(best) } /// Is the edge curved, or does it lie between faces that aren't both planar? diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 4b70ea43..775b714e 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1195,3 +1195,19 @@ fn fillet_two_edges_meeting_at_a_corner() { 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); } + +#[test] +fn fillet_three_edges_at_a_corner() { + // The three edges meeting at one corner of a box: the corner becomes a sphere octant. + let b = box_solid(Vec3::ZERO, Vec3::new(40.0, 30.0, 20.0)).unwrap(); + let r = 3.0; + let f = fillet(&b, &[Vec3::new(20.0, 0.0, 20.0), Vec3::new(0.0, 15.0, 20.0), Vec3::new(0.0, 0.0, 10.0)], r).unwrap(); + let removed = 24000.0 - measure(&f).unwrap().volume; + // Three edges lose r²(1 − π/4) each along their length; the corner, a cube r³ less an + // octant of the ball, was counted three times over and is removed once (r³ − πr³/6). + let a = r * r * (1.0 - PI / 4.0); + let want = a * (40.0 + 30.0 + 20.0) - 3.0 * a * r + (r * r * r - PI * r * r * r / 6.0); + assert!(rel(removed, want) < 2e-3, "removed {removed} vs {want}"); + let m = measure(&f).unwrap().merged; + assert_eq!(m.face_types.get("sphere"), Some(&1), "{m:?}"); +}