diff --git a/crates/kernel/src/blend.rs b/crates/kernel/src/blend.rs index 76b2465c..5d612258 100644 --- a/crates/kernel/src/blend.rs +++ b/crates/kernel/src/blend.rs @@ -823,6 +823,11 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul // 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(); + // Edges refused since the last blend that could have changed them: (pick, the box around the + // edge, the refusal). A blend only changes the body near the edges it rounds, so an edge + // refused away from every later blend would be refused the same way again: it is not tried + // again (a refusal costs a rolling-ball attempt, seconds each on a long curved edge). + let mut refused: Vec<(Vec3, (Vec3, Vec3), KernelError)> = Vec::new(); for _ in 0..(2 * edges.len() + 4) { if todo.is_empty() { return Ok(cur); @@ -835,13 +840,21 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul && let Some(Ok((res, rec))) = crate::loopblend::loop_blend(&solid, &ids, size, r, shape == Shape::Round) && let Ok(b) = guard(what, || Ok(res)).and_then(|res| record::rebuilt(res, &[&solid], &rec)) { + let near = edges_box(&solid, &ids, r + tol); + refused.retain(|(_, b, _)| !boxes_meet(*b, near)); cur = record::after(b, &cur, &counts); 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; + let mut done: Option<(Body, Vec, (Vec3, Vec3))> = None; for (j, p) in todo.iter().enumerate() { + if let Some((_, _, e)) = refused.iter().find(|(q, _, _)| q.dist(*p) < 1e-12) { + if first_err.is_none() || (matches!(e, KernelError::TooLarge(_)) && !matches!(first_err, Some(KernelError::TooLarge(_)))) { + first_err = Some(e.clone()); + } + continue; + } 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]))); @@ -861,6 +874,8 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul }) .map(|(i, _)| i) .collect(); + #[cfg(test)] + ROLLS.with(|n| n.set(n.get() + 1)); 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 })) }; @@ -888,22 +903,26 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul }, }, }; + // What this attempt could change: the edge and its smooth chain, with room for the blend. + let reach = edges_box(&solid, chain.as_deref().unwrap_or(&[edge.id()]), r + tol); match attempt.and_then(|((o, rec), js)| record::rebuilt(o, &[&solid], &rec).map(|b| (b, js))) { - Ok(x) => { - done = Some(x); + Ok((b, js)) => { + done = Some((b, js, reach)); break; } // Reported: the first refusal, unless a later one is a size a smaller radius may // fit (the others may be refused only until that edge is blended). Err(e) => { if first_err.is_none() || (matches!(e, KernelError::TooLarge(_)) && !matches!(first_err, Some(KernelError::TooLarge(_)))) { - first_err = Some(e); + first_err = Some(e.clone()); } + refused.push((*p, reach, e)); } } } match done { - Some((b, js)) => { + Some((b, js, near)) => { + refused.retain(|(_, b2, _)| !boxes_meet(*b2, near)); cur = record::after(b, &cur, &counts); let mut k = 0; todo.retain(|_| { @@ -918,6 +937,36 @@ fn blend(body: &Body, edges: &[Vec3], r: f64, shape: Shape, what: &str) -> Resul if todo.is_empty() { Ok(cur) } else { Err(unsupported("blending these edges together")) } } +#[cfg(test)] +thread_local! { + /// Rolling-ball attempts made by [`blend`] on this thread (tests count them). + pub(crate) static ROLLS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The box around these edges of `solid` (their ends and middles), grown by `margin`. +fn edges_box(solid: &Solid, ids: &[mt::EdgeID], margin: f64) -> (Vec3, Vec3) { + use mt::{BoundedCurve, ParametricCurve}; + let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY); + let mut hi = -lo; + for e in Body::unique_edges(solid).iter().filter(|e| ids.contains(&e.id())) { + let c = e.curve(); + let (t0, t1) = c.range_tuple(); + for k in 0..=8 { + let q = from_p3(c.subs(t0 + (t1 - t0) * f64::from(k) / 8.0)); + lo = lo.min(q); + hi = hi.max(q); + } + } + let m = Vec3::new(margin, margin, margin); + (lo - m, hi + m) +} + +/// Whether two boxes overlap (an empty box meets every box: nothing is known about it). +fn boxes_meet(a: (Vec3, Vec3), b: (Vec3, Vec3)) -> bool { + let empty = |x: (Vec3, Vec3)| !(x.0.x <= x.1.x && x.0.y <= x.1.y && x.0.z <= x.1.z); + empty(a) || empty(b) || (a.0.x <= b.1.x && b.0.x <= a.1.x && a.0.y <= b.1.y && b.0.y <= a.1.y && a.0.z <= b.1.z && b.0.z <= a.1.z) +} + /// 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. diff --git a/crates/kernel/src/curveblend_tests.rs b/crates/kernel/src/curveblend_tests.rs index e0ce2db0..5961291c 100644 --- a/crates/kernel/src/curveblend_tests.rs +++ b/crates/kernel/src/curveblend_tests.rs @@ -327,3 +327,27 @@ fn rolling_ball_past_the_end_seam_is_too_large_and_reduces() { } assert!(worst < 1e-3, "blend surface is {worst} mm off the analytic round at r {r}"); } + +#[test] +fn a_refused_edge_is_not_rolled_again_after_blends_far_from_it() { + // The crossing cylinders' seam refuses a round of 3 (too large). A bar joined to them has + // two straight edges at its far end that round on their own. Each blend there leaves the + // seam's surroundings as they were, so the seam is tried once, not once per blend, and the + // refusal is the same. + let (r1, r2, d, h) = (17.0, 9.5, 12.0, 10.0); + let body = boolean(&cylinder(Vec3::ZERO, Vec3::Z, r1, h).unwrap(), &cylinder(Vec3::new(d, 0.0, 0.0), Vec3::Z, r2, h).unwrap(), BoolOp::Union) + .unwrap() + .unwrap(); + let bar = crate::box_solid(Vec3::new(-80.0, -5.0, 0.0), Vec3::new(-10.0, 5.0, h)).unwrap(); + let body = boolean(&body, &bar, BoolOp::Union).unwrap().unwrap(); + let along = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d); + let seam = Vec3::new(along, (r1 * r1 - along * along).sqrt(), h / 2.0); + let ends = [Vec3::new(-80.0, -5.0, h / 2.0), Vec3::new(-80.0, 5.0, h / 2.0)]; + crate::blend::ROLLS.with(|n| n.set(0)); + let e = fillet(&body, &[seam, ends[0], ends[1]], 3.0).unwrap_err(); + assert!(matches!(e, KernelError::TooLarge(_)), "{e}"); + let rolls = crate::blend::ROLLS.with(|n| n.get()); + assert_eq!(rolls, 1, "the seam was rolled {rolls} times"); + // The far edges alone still round. + assert!(fillet(&body, &ends, 3.0).is_ok()); +}