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::