diff --git a/ROADMAP.md b/ROADMAP.md index 9fa5b3c9..2a84333a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -84,7 +84,8 @@ headline counts in-scope tabs only and lists the deferred ones separately. - Free-form curves cut at a tangent touch (no clean crossing) enter that profile as polylines. Sweeps along 3D sketch curves follow their polyline (smooth walls when the path is smooth). - Shell and offset faces work on bodies of planes, cylinders, cones and spheres (convex or not: - filleted boxes, bosses, domes, countersinks); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; loft is ruled. + filleted boxes, bosses, domes, countersinks); draft on convex planar bodies and on walls (planes, cylinders → cones) between caps; lofts through three or more sections are smooth (B-spline sides through every section), two + sections ruled. ## Kernel diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 2405dff9..5a716b36 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -4,7 +4,7 @@ use mt::builder; use solvecraft_geom::{Loop2, Plane, Region2, Seg2, Vec2, Vec3}; use truck_modeling as mt; -use crate::body::{Body, Solid, p3, v3}; +use crate::body::{Body, Solid, from_p3, p3, v3}; use crate::{KernelError, Result, guard}; const MAX_SEGS: usize = 20_000; @@ -556,6 +556,12 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result { } wires.push(uniform_wire(p, &r)?); } + // Three sections or more: one smooth surface through them all for each segment. + if wires.len() >= 3 + && let Ok(b) = guard("loft", || smooth_loft(&wires)) + { + return Ok(b); + } guard("loft", || { let mut faces: Vec = Vec::new(); for w in wires.windows(2) { @@ -567,6 +573,74 @@ pub fn loft(sections: &[(Plane, Loop2)]) -> Result { }) } +/// Points across the sections (one per section, in order) as a smooth B-spline: cubic through +/// four or more, quadratic through three. +fn across(pts: &[Vec3]) -> Option> { + match pts { + [a, b, c] => { + let m = *b * 2.0 - (*a + *c) * 0.5; + mt::BSplineCurve::try_new(mt::KnotVec::bezier_knot(2), vec![p3(*a), p3(m), p3(*c)]).ok() + } + _ => interpolate_cubic(pts), + } +} + +/// 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. +fn smooth_loft(wires: &[mt::Wire]) -> Result { + let fail = |m: &str| KernelError::Failed(format!("smooth loft: {m}")); + let edges: Vec> = wires.iter().map(|w| w.edge_iter().cloned().collect()).collect(); + let n = edges.first().map(Vec::len).ok_or_else(|| fail("no sections"))?; + if edges.iter().any(|e| e.len() != n) { + return Err(fail("sections with different edge counts")); + } + let curve_of = |e: &mt::Edge| -> Option> { + match e.curve() { + mt::Curve::BSplineCurve(c) if e.orientation() => Some(c), + _ => None, + } + }; + let (Some(first), Some(last)) = (edges.first(), edges.last()) else { return Err(fail("sections")) }; + // Seams: through the k-th vertex of every section. + let mut seams: Vec = Vec::with_capacity(n); + for k in 0..n { + let pts: Vec = edges.iter().filter_map(|es| es.get(k).map(|e| from_p3(e.front().point()))).collect(); + let c = across(&pts).ok_or_else(|| fail("seam"))?; + let (Some(a), Some(b)) = (first.get(k), last.get(k)) else { return Err(fail("seam")) }; + seams.push(mt::Edge::new(a.front(), b.front(), mt::Curve::BSplineCurve(c))); + } + let mut faces = Vec::with_capacity(n); + for k in 0..n { + let curves: Vec> = + edges.iter().map(|es| es.get(k).and_then(curve_of)).collect::>().ok_or_else(|| fail("section curves"))?; + let c0 = curves.first().ok_or_else(|| fail("section curves"))?; + let uk = c0.knot_vec().clone(); + let m = c0.control_points().len(); + if curves.iter().any(|c| c.control_points().len() != m || c.knot_vec() != &uk) { + return Err(fail("sections parameterised differently")); + } + let mut ctrl: Vec> = Vec::with_capacity(m); + 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"))?; + vk = Some(c.knot_vec().clone()); + ctrl.push(c.control_points().clone()); + } + let vk = vk.ok_or_else(|| fail("surface"))?; + let surface = mt::BSplineSurface::try_new((uk, vk), ctrl).map_err(|e| fail(&e.to_string()))?; + let (Some(e0), Some(e1), Some(s0), Some(s1)) = (first.get(k), last.get(k), seams.get(k), seams.get((k + 1) % n)) else { + return Err(fail("face")); + }; + // u along the section edge, v across the sections: counter-clockwise in (u, v). + let w: mt::Wire = vec![e0.clone(), s1.clone(), e1.inverse(), s0.inverse()].into(); + faces.push(mt::Face::try_new(vec![w], mt::Surface::BSplineSurface(surface)).map_err(|e| fail(&e.to_string()))?); + } + let (Some(w0), Some(w1)) = (wires.first(), wires.last()) else { return Err(fail("sections")) }; + cap_and_close(faces, std::slice::from_ref(w0), std::slice::from_ref(w1)) +} + /// A 3D path segment for sweeps. #[derive(Clone, Copy, Debug)] pub enum PathSeg { diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 98924243..9bc41cc5 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -1238,3 +1238,26 @@ fn shell_with_spherical_and_conical_faces() { let v = measure(&s).unwrap().volume; assert!(v > 0.1 * v0 && v < 0.6 * v0, "{v} of {v0}"); } + +#[test] +fn smooth_loft_through_three_sections() { + // Squares 40 → 20 → 40 wide, 0, 20 and 40 up: the middle is pinched smoothly (no edge there). + let sq = |s: f64| Loop2::polygon(&[Vec2::new(-s, -s), Vec2::new(s, -s), Vec2::new(s, s), Vec2::new(-s, s)]); + let l = loft(&[(Plane::XY, sq(20.0)), (Plane::XY.offset(20.0), sq(10.0)), (Plane::XY.offset(40.0), sq(20.0))]).unwrap(); + let m = measure(&l).unwrap(); + // Smooth: between the ruled (two frustums, 2 · 20 · (1600 + 400 + 800)/3) and the box. + let ruled = 2.0 * 20.0 * (1600.0 + 400.0 + 800.0) / 3.0; + assert!(m.volume < ruled && m.volume > 40.0 * 400.0, "{}", m.volume); + // Four sides and two caps; the middle section leaves no edges. + assert_eq!(m.merged.faces, 6, "{:?}", m.merged); + assert_eq!(m.merged.edges, 12, "{:?}", m.merged); + // A circle, a square and a circle: valid and between the inner and outer bounds. + let c = loft(&[ + (Plane::XY, Loop2::circle(Vec2::ZERO, 10.0)), + (Plane::XY.offset(15.0), sq(12.0)), + (Plane::XY.offset(30.0), Loop2::circle(Vec2::ZERO, 8.0)), + ]) + .unwrap(); + let v = measure(&c).unwrap().volume; + assert!(v > PI * 64.0 * 30.0 && v < 576.0 * 30.0, "{v}"); +}