From e489e56f3def90df9de2bd3eeeecfeb2ac768afd Mon Sep 17 00:00:00 2001 From: Giovanni <1319291+gvocale@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:59:11 -0400 Subject: [PATCH] Loft: surfaces follow sections by distance along them (#1084) Loft faces between sections are spaced by chord length instead of by parameter, so uneven sections no longer twist the surface. Fixes #968. --- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/loft_spacing.rs | 77 +++++++++++++++++++++++++ crates/kernel/src/build.rs | 49 +++++++++++++--- 3 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/tests/loft_spacing.rs diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index f614d5ec..c43b2a14 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -279,6 +279,7 @@ mod boolean_diagnostics; mod component_frames; mod large_step_import; mod loft_cut; +mod loft_spacing; mod mirror_planes; mod path_scope; mod primitive_points; diff --git a/crates/engine/src/tests/loft_spacing.rs b/crates/engine/src/tests/loft_spacing.rs new file mode 100644 index 00000000..ca99661f --- /dev/null +++ b/crates/engine/src/tests/loft_spacing.rs @@ -0,0 +1,77 @@ +//! Lofted round tubes through sections that are not evenly spaced. +//! +//! A loft's surface used to pass through its sections at uniform parameters, so a section close to +//! its neighbour (a bore that runs 5 mm past the end of a tube whose other sections are 12 mm +//! apart, or 60 mm in a snail) made the surface overshoot between the others, by several times the +//! tube's radius. The overshoot also made booleans with the tube fail for some section counts and +//! not for their neighbours. + +use std::f64::consts::PI; + +use super::*; + +/// `sections` circles of radius `radius` centred on an arc of radius `path` turning `angle` +/// (radians), in planes normal to the arc, lofted into the body `body`; `over` extends the +/// sections past both ends along the tangent. +fn tube(s: &mut Session, body: &str, radius: f64, path: f64, angle: f64, sections: usize, over: f64) { + let mut stations: Vec<[f64; 4]> = (0..sections) + .map(|i| { + let t = angle * i as f64 / (sections - 1) as f64; + [path * t.cos(), path * t.sin(), -t.sin(), t.cos()] + }) + .collect(); + if over > 0.0 { + let (a, b) = (stations[0], stations[sections - 1]); + stations.insert(0, [a[0] - over * a[2], a[1] - over * a[3], a[2], a[3]]); + stations.push([b[0] + over * b[2], b[1] + over * b[3], b[2], b[3]]); + } + let mut list = Vec::new(); + for (i, [x, y, tx, ty]) in stations.iter().enumerate() { + let name = format!("{body}{i}"); + run(s, "sketch.create", json!({"plane": {"origin": [x, y, 0], "x_dir": [0, 0, 1], "y_dir": [ty, -tx, 0]}, "name": name})); + run(s, "sketch.circle.center", json!({"center": [0, 0], "radius": radius})); + run(s, "sketch.finish", json!({})); + list.push(json!({"sketch": name})); + } + run(s, "solid.loft", json!({"sections": list, "operation": "new", "name": format!("Loft{body}"), "body_name": body})); +} + +fn bounds(s: &mut Session, body: &str) -> ([f64; 3], [f64; 3]) { + let m = run(s, "inspect.measure", json!({})); + let b = m["bodies"].as_array().unwrap().iter().find(|b| b["name"] == body).unwrap(); + let get = |k: &str| -> [f64; 3] { + let v = b["bbox"][k].as_array().unwrap(); + [v[0].as_f64().unwrap(), v[1].as_f64().unwrap(), v[2].as_f64().unwrap()] + }; + (get("min"), get("max")) +} + +#[test] +fn a_bore_with_a_short_overrun_stays_inside_its_tube() { + // 14 sections 12 mm apart, and one more 5 mm beyond each end: the bore is a tube of radius 18 + // around the arc (z within 18 of the arc's plane), and used to reach z = 88. + let mut s = Session::default(); + tube(&mut s, "Bore", 18.0, 100.0, PI / 2.0, 14, 5.0); + let (lo, hi) = bounds(&mut s, "Bore"); + assert!(hi[2] < 18.0 + 0.1 && lo[2] > -18.0 - 0.1, "z from {} to {}", lo[2], hi[2]); + let want = PI * 18.0 * 18.0 * (100.0 * PI / 2.0 + 10.0); + let got = volume(&mut s); + assert!(rel(got, want) < 2e-3, "{got} vs {want}"); +} + +#[test] +fn a_hollow_elbow_is_cut_for_any_number_of_sections() { + let (path, outer, inner) = (100.0, 30.0, 18.0); + // Each of these gave "no result" (the first six) or failed in another way. + for (degrees, sections) in [(60.0, 5), (60.0, 7), (90.0, 10), (90.0, 14), (120.0, 7), (120.0, 13)] { + let angle = degrees * PI / 180.0; + let mut s = Session::default(); + tube(&mut s, "Outer", outer, path, angle, sections, 0.0); + tube(&mut s, "Inner", inner, path, angle, sections, 5.0); + run(&mut s, "solid.combine", json!({"target": "Outer", "tools": ["Inner"], "operation": "cut", "name": "Hollow"})); + assert_eq!(s.model.state().bodies.len(), 1, "{degrees} degrees, {sections} sections"); + let want = PI * (outer * outer - inner * inner) * path * angle; + let got = volume(&mut s); + assert!(rel(got, want) < 3e-3, "{degrees} degrees, {sections} sections: {got} vs {want}"); + } +} diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 149893cc..2bae7fe7 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -1398,18 +1398,52 @@ pub(crate) fn ruled_loft_faces(wires: &[mt::Wire], edge_sources: &[Vec Option> { +/// Points across the sections (one per section, in order) as a smooth B-spline through them at the +/// parameters `u` (rising from 0 to 1, one per section): cubic through four or more, quadratic +/// through three. +fn across(pts: &[Vec3], u: &[f64]) -> Option> { match pts { [a, b, c] => { - let m = *b * 2.0 - (*a + *c) * 0.5; + // The quadratic Bezier through `b` at t = u[1]. + let t = *u.get(1)?; + if !(t > 0.0 && t < 1.0) { + return None; + } + let m = (*b - *a * ((1.0 - t) * (1.0 - t)) - *c * (t * t)) * (1.0 / (2.0 * t * (1.0 - t))); mt::BSplineCurve::try_new(mt::KnotVec::bezier_knot(2), vec![p3(*a), p3(m), p3(*c)]).ok() } - _ => interpolate_cubic(pts), + _ => interpolate_cubic_at(pts, u), } } +/// The parameters of the sections along a loft: in proportion to the distance between them (the +/// mean over the wires' vertices), so that unevenly spaced sections get a surface that follows +/// them. At a uniform parameter a section close to its neighbour makes the surface overshoot +/// between the others (by several times the section's size when the spacing differs tenfold). +/// Evenly spaced sections give the uniform parameters. +fn section_parameters(edges: &[Vec]) -> Vec { + let m = edges.len(); + let uniform = || (0..m).map(|i| i as f64 / (m.max(2) - 1) as f64).collect::>(); + let corners: Vec> = edges.iter().map(|es| es.iter().map(|e| from_p3(e.front().point())).collect()).collect(); + let mut u = vec![0.0]; + for w in corners.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return uniform() }; + let n = a.len().min(b.len()); + if n == 0 { + return uniform(); + } + let step = a.iter().zip(b).map(|(p, q)| p.dist(*q)).sum::() / n as f64; + u.push(u.last().copied().unwrap_or(0.0) + step); + } + let Some(total) = u.last().copied().filter(|t| t.is_finite() && *t > 0.0) else { return uniform() }; + let u: Vec = u.iter().map(|x| x / total).collect(); + // A section on top of its neighbour: no parameter for it, so the uniform ones. + if u.windows(2).any(|w| w.get(1).zip(w.first()).is_none_or(|(b, a)| b - a < 1e-6)) { + return uniform(); + } + u +} + /// A loft whose side faces are B-spline surfaces through every section (smooth across the /// middle sections, which leave no edges). Every section wire comes from `uniform_wire`, so the /// k-th edges of all sections share one knot vector and their control points pair up. Face k @@ -1432,6 +1466,7 @@ pub(crate) fn smooth_loft_faces(wires: &[mt::Wire], numbers: &[Vec>], } }; let (Some(first), Some(last)) = (edges.first(), edges.last()) else { return Err(fail("sections")) }; + let along = section_parameters(&edges); // Seams: through the k-th vertex of every section (an open wire's last one at the end of // its last edge). let vertex = |e: &mt::Edge, end: bool| if end { e.back().clone() } else { e.front().clone() }; @@ -1440,7 +1475,7 @@ pub(crate) fn smooth_loft_faces(wires: &[mt::Wire], numbers: &[Vec>], for k in 0..seam_count { let (at, end) = if k < n { (k, false) } else { (n - 1, true) }; let pts: Vec = edges.iter().filter_map(|es| es.get(at).map(|e| from_p3(vertex(e, end).point()))).collect(); - let c = across(&pts).ok_or_else(|| fail("seam"))?; + let c = across(&pts, &along).ok_or_else(|| fail("seam"))?; let (Some(a), Some(b)) = (first.get(at), last.get(at)) else { return Err(fail("seam")) }; seams.push(mt::Edge::new(&vertex(a, end), &vertex(b, end), mt::Curve::BSplineCurve(c))); } @@ -1458,7 +1493,7 @@ pub(crate) fn smooth_loft_faces(wires: &[mt::Wire], numbers: &[Vec>], let mut vk = None; for j in 0..m { let pts: Vec = curves.iter().filter_map(|c| c.control_points().get(j).map(|p| from_p3(*p))).collect(); - let c = across(&pts).ok_or_else(|| fail("surface"))?; + let c = across(&pts, &along).ok_or_else(|| fail("surface"))?; vk = Some(c.knot_vec().clone()); ctrl.push(c.control_points().clone()); }