From 77b33efd8f0e8498f4d0d00697419d0992150759 Mon Sep 17 00:00:00 2001 From: Erikas <5955795+erkexzcx@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:26:23 +0300 Subject: [PATCH] Rolling-ball blends: a strip ends on its joint where its neighbour is too short to lend sections (#1667) (#1702) Thanks @erkexzcx. --- crates/kernel/src/curveblend.rs | 31 ++++++++++++----------- crates/kernel/src/curveblend_tests.rs | 36 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/crates/kernel/src/curveblend.rs b/crates/kernel/src/curveblend.rs index d7234712..37b606a5 100644 --- a/crates/kernel/src/curveblend.rs +++ b/crates/kernel/src/curveblend.rs @@ -1723,10 +1723,14 @@ fn curve_blend_sized(solid: &Solid, ids: &[mt::EdgeID], size: f64, size_law: Siz continue; } let h = length(k) / (samples - 1) as f64; - let spaced = |nb: usize, from_end: bool| -> Result>> { + // A neighbour shorter than two of the strip's steps lends none: its own sections are + // spaced far closer (a small round beside a long line), and borrowing them made the + // strip overshoot and fold back on itself past the joint, which its faces' meshes + // then folded with (#1667). The strip ends on the joint's section on its own. + let spaced = |nb: usize, from_end: bool| -> Result> { let l = length(nb); if !(l > 2.0 * h && h > 0.0) { - return Ok(None); + return Ok(Vec::new()); } let mut out = Vec::with_capacity(2); for j in [1.0, 2.0] { @@ -1735,7 +1739,7 @@ fn curve_blend_sized(solid: &Solid, ids: &[mt::EdgeID], size: f64, size_law: Siz if from_end { out.reverse(); } - Ok(Some(out)) + Ok(out) }; let (prev, next) = ((k + n - 1) % n, (k + 1) % n); let list = esec.get(k).ok_or_else(|| KernelError::Failed("section".into()))?; @@ -1748,23 +1752,22 @@ fn curve_blend_sized(solid: &Solid, ids: &[mt::EdgeID], size: f64, size_law: Siz _ => false, }; let before = match (closed || k > 0) && rolls(prev) { - true => spaced(prev, true)? - .map(|b| if smooth([list.first(), list.get(1), list.get(2)], [list.first(), b.get(1), b.first()]) { b } else { Vec::new() }), - false => Some(Vec::new()), + true => { + let b = spaced(prev, true)?; + if smooth([list.first(), list.get(1), list.get(2)], [list.first(), b.get(1), b.first()]) { b } else { Vec::new() } + } + false => Vec::new(), }; let after = match (closed || k + 1 < n) && rolls(next) { - true => spaced(next, false)?.map(|a| { + true => { + let a = spaced(next, false)?; let m = list.len(); let tail = [list.last(), m.checked_sub(2).and_then(|i| list.get(i)), m.checked_sub(3).and_then(|i| list.get(i))]; if smooth(tail, [list.last(), a.first(), a.get(1)]) { a } else { Vec::new() } - }), - false => Some(Vec::new()), + } + false => Vec::new(), }; - let fit = match (before, after) { - (Some(b), Some(a)) => crate::guard("fit curve blend", || fit_strip(list, &b, &a, round))?, - _ => crate::guard("fit curve blend", || fit_sections(&esec, k, closed, round))?, - }; - let mut fit = fit; + let mut fit = crate::guard("fit curve blend", || fit_strip(list, &before, &after, round))?; fit.span = guides.get(k).and_then(Option::as_ref).map(|g| g.span).unwrap_or((0.0, 1.0)); fits.push(Some(fit)); } diff --git a/crates/kernel/src/curveblend_tests.rs b/crates/kernel/src/curveblend_tests.rs index cf88c611..12fc36b1 100644 --- a/crates/kernel/src/curveblend_tests.rs +++ b/crates/kernel/src/curveblend_tests.rs @@ -693,3 +693,39 @@ fn chamfer_a_chain_of_a_line_and_an_arc_into_a_slanted_end() { let got = volume(&body) - volume(&made); assert!((got - want).abs() < want * 1e-3, "removed {got} vs {want}"); } + +/// #1667: an R1 round around the end of a long bar whose four long edges are R5. The chain is +/// 290 mm lines and quarter turns of 7.85 mm, so the lines' sections are spaced far wider than +/// the turns'; borrowing the turns' sections for the lines' ends made their strips overshoot and +/// fold back past the joints, the strips' meshes folded with them, and the measured volume came +/// out thousands of mm³ off. The R1 pipe is exact: the corner's section, `1 - π/4`, along the +/// lines, and round each turn's axis at the section's centroid (Pappus). +#[test] +fn round_along_long_lines_and_small_turns_measures_its_volume() { + use std::f64::consts::{FRAC_PI_2, FRAC_PI_4}; + let (w, d, h) = (300.0, 60.0, 40.0); + let bar = crate::build::box_solid(Vec3::new(-w / 2.0, 0.0, 0.0), Vec3::new(w / 2.0, d, h)).unwrap(); + let long = [Vec3::new(-w / 2.0, d / 2.0, 0.0), Vec3::new(w / 2.0, d / 2.0, 0.0), Vec3::new(-w / 2.0, d / 2.0, h), Vec3::new(w / 2.0, d / 2.0, h)]; + let rounded = fillet(&bar, &long, 5.0).unwrap(); + // The rolling ball along the end face's loop (the exact blends would take this loop; a + // loop next to free-form faces, as in the issue, comes here). + let ids: Vec = crate::body::Body::unique_edges(&rounded.solid) + .into_iter() + .filter(|e| [e.front(), e.back()].iter().all(|v| crate::blend::vtx(v).y.abs() < 1e-9)) + .map(|e| e.id()) + .collect(); + assert_eq!(ids.len(), 8); + let (solid, _) = crate::guard("test", || curve_blend(&rounded.solid, &ids, rounded.size(), 1.0, true)).unwrap(); + let body = Body::new(solid).unwrap(); + assert!(body.validity().is_empty(), "{:?}", body.validity()); + let section = 1.0 - FRAC_PI_4; + // The section's centroid lies this far from its corner along each side. + let inset = (5.0 / 6.0 - FRAC_PI_4) / section; + let lines = 2.0 * (w - 10.0) + 2.0 * (h - 10.0); + let turns = 4.0 * FRAC_PI_2 * (5.0 - inset); + let want = w * d * h - 4.0 * 25.0 * section * d - section * (lines + turns); + let m = crate::measure(&body).unwrap(); + assert!((m.volume - want).abs() < 1e-5 * want, "{} vs {want}", m.volume); + // Centred across the bar, as the part is. + assert!(m.centroid.x.abs() < 1e-3, "centre of mass x {}", m.centroid.x); +}