Skip to main content

axiolid_construct/
loft.rs

1//! Lofting a sequence of station rings into a solid.
2//!
3//! Every sweep family reduces to the same shape: place the profile at a
4//! series of stations along a path, stitch consecutive stations into
5//! walls, and cap the ends unless the path closes on itself. Extrusion is
6//! two stations; revolution is an arc of them; a sectioned spine supplies
7//! its own.
8//!
9//! Writing that once means winding, hole orientation and cap pairing are
10//! fixed in one place rather than re-derived per family.
11
12use axiolid_contracts::{GeomError, GeomResult};
13use axiolid_core::{Point2, Point3, Scalar, Vec3};
14use axiolid_mesh::TriMesh;
15
16use crate::profile::Rings;
17
18/// One station: the profile's rings already placed in 3D.
19#[derive(Debug, Clone)]
20pub struct Station {
21    /// Outer ring followed by each hole, in `Rings` order.
22    pub loops: Vec<Vec<Point3>>,
23}
24
25/// Place `rings` into 3D with a per-point mapping.
26///
27/// The mapping takes the profile's 2D point and returns its 3D image, so a
28/// caller expresses only what its family does: extrusion translates,
29/// revolution rotates, a spine sweep applies a frame.
30pub fn place(rings: &Rings, mut f: impl FnMut(Point2) -> Point3) -> Station {
31    let mut loops = Vec::with_capacity(1 + rings.holes.len());
32    loops.push(rings.outer.iter().map(|p| f(*p)).collect());
33    for hole in &rings.holes {
34        loops.push(hole.iter().map(|p| f(*p)).collect());
35    }
36    Station { loops }
37}
38
39/// Stitch stations into a closed solid.
40///
41/// `closed` wraps the last station onto the first, which welds a periodic
42/// seam by index rather than by two samplings agreeing numerically. Open
43/// lofts are capped with the profile triangulation at each end.
44///
45/// All stations must share the profile's ring structure: the walls pair
46/// vertices by position, so a differing ring length has no meaningful
47/// pairing and is refused rather than silently truncated.
48pub fn loft(rings: &Rings, stations: &[Station], closed: bool) -> GeomResult<TriMesh> {
49    if stations.len() < 2 {
50        return Err(GeomError::InvalidInput(format!(
51            "a loft needs at least two stations, got {}",
52            stations.len()
53        )));
54    }
55    let shape: Vec<usize> = stations[0].loops.iter().map(|r| r.len()).collect();
56    for s in stations {
57        let this: Vec<usize> = s.loops.iter().map(|r| r.len()).collect();
58        if this != shape {
59            return Err(GeomError::InvalidInput(
60                "loft stations must share the profile's ring structure".to_owned(),
61            ));
62        }
63    }
64    let per_station: usize = shape.iter().sum();
65    let mut positions: Vec<Point3> = Vec::with_capacity(stations.len() * per_station);
66    for s in stations {
67        for ring in &s.loops {
68            positions.extend(ring.iter().copied());
69        }
70    }
71    // A closed loft wraps onto station 0; an open one stops one short.
72    let spans = if closed {
73        stations.len()
74    } else {
75        stations.len() - 1
76    };
77    let mut indices: Vec<u32> = Vec::new();
78    for s in 0..spans {
79        let a0 = s * per_station;
80        let b0 = ((s + 1) % stations.len()) * per_station;
81        let mut base = 0usize;
82        for m in shape.iter() {
83            for k in 0..*m {
84                let kn = (k + 1) % *m;
85                let (a, b) = ((a0 + base + k) as u32, (a0 + base + kn) as u32);
86                let (c, d) = ((b0 + base + k) as u32, (b0 + base + kn) as u32);
87                // Both rings wind the same way here. A hole ring is already
88                // stored clockwise (profile.rs keeps holes reversed), which
89                // is what turns its wall normal inward; flipping the index
90                // order as well would invert it a second time.
91                indices.extend([a, b, d, a, d, c]);
92            }
93            base += *m;
94        }
95    }
96    // Caps. A closed loft needs none: the wall meets itself. An open one is
97    // bounded by the profile at each end, wound opposite so both face out.
98    if !closed {
99        let (points, tris) = crate::profile::triangulate(rings)?;
100        if points.len() != per_station {
101            return Err(GeomError::Degenerate(format!(
102                "cap triangulation produced {} points for {per_station} ring points",
103                points.len()
104            )));
105        }
106        let last = ((stations.len() - 1) * per_station) as u32;
107        for t in &tris {
108            indices.extend([t[0], t[2], t[1]]);
109            indices.extend([last + t[0], last + t[1], last + t[2]]);
110        }
111    }
112    Ok(TriMesh::new(positions, indices))
113}
114
115/// How far the bilinear patch over one wall quad can sit from the two
116/// triangles [`loft`] splits it into.
117///
118/// The quad joins ring points `a -> b` on one station to `c -> d` on the
119/// next, and [`loft`] cuts it along the diagonal `a-d` into `(a, b, d)` and
120/// `(a, d, c)`. The patch `B(x, y) = (1-y)((1-x)a + xb) + y((1-x)c + xd)`
121/// is the surface the chords between the two stations span: its row at
122/// fixed `x` is the chord between the two placements of one profile point,
123/// which is why the sweeps bound the exact surface against it. Every bound
124/// below holds pointwise, so the smallest is returned:
125///
126/// - `|T| / 4` with the twist `T = a - b - c + d`. On the half `x >= y`,
127///   `B - L = -y(1-x) T` where `L` is the point of `(a, b, d)` with the
128///   same barycentric weights, and `y(1-x) <= 1/4` there; the other half is
129///   symmetric. Always valid.
130/// - `|b - a|`: `B` is within `(1-y)x|b - a|` of the point
131///   `(1-y)a + y((1-x)c + xd)` of triangle `(a, d, c)`. Likewise `|c - a|`
132///   against `(a, b, d)`. These make a quad that collapses to a triangle on
133///   an axis (a ring point on a revolution axis) exact.
134/// - `2|d - c|`: `B` is within `xy|d - c|` of the point
135///   `(1-y)((1-x)a + xb) + yc` of the plane triangle `(a, b, c)`, and moving
136///   that triangle's `c` to `d` moves the point by at most `|d - c|`.
137///   Likewise `2|d - b|`.
138/// - The flattening bound. Take a plane with unit normal `n` and heights
139///   `h(p) = n.p - k`, project the corners onto it, and suppose the
140///   projected quad `a', b', d', c'` is convex. The projected patch is a
141///   convex combination of the projected corners, so it lies in that quad,
142///   which the two plane triangles `(a', b', d')`, `(a', d', c')` cover:
143///   each `B(x, y)` has a point `Z` there with `Z = B(x, y)` in the plane.
144///   Lifting `Z` back with the same weights gives a point of `(a, b, d)` or
145///   `(a, d, c)`, and the two heights are weighted means of corner heights,
146///   so the distance is at most the spread of the corner heights. Two
147///   planes are used. The plane through `a, b, c` leaves only `d` off it,
148///   at the height `g`: bound `|g|`. The plane parallel to both diagonals,
149///   halfway between them, puts `a, d` at `e` and `b, c` at `-e`, with
150///   `2|e|` the distance between the diagonals: bound `2|e|`, half of
151///   `|g|` for a twisted quad (where `|g| = 4|e|`). A planar convex quad,
152///   the wall a revolution builds about an axis in the profile's plane,
153///   gets 0.
154///
155/// The convexity test runs in floating point with a relative slack of
156/// `1e-12`: a quad that fails it by less leaves an uncovered sliver some
157/// `1e-12` of its size wide, far below any chord budget.
158pub fn quad_deviation(a: Point3, b: Point3, c: Point3, d: Point3) -> Scalar {
159    let twist = (a - b - c + d).length() / 4.0;
160    let mut best = twist
161        .min((b - a).length())
162        .min((c - a).length())
163        .min(2.0 * (d - c).length())
164        .min(2.0 * (d - b).length());
165    // The plane through a, b, c: only d leaves it.
166    if let Some(n) = unit((b - a).cross(c - a)) {
167        let g = (d - a).dot(n);
168        if convex_projection([a, b, d - n * g, c], n) {
169            best = best.min(g.abs());
170        }
171    }
172    // The plane halfway between the diagonals a-d and b-c.
173    if let Some(n) = unit((d - a).cross(c - b)) {
174        let e = 0.5 * (a - b).dot(n);
175        let (a2, d2) = (a - n * e, d - n * e);
176        let (b2, c2) = (b + n * e, c + n * e);
177        if convex_projection([a2, b2, d2, c2], n) {
178            best = best.min(2.0 * e.abs());
179        }
180    }
181    best
182}
183
184/// `v` scaled to unit length, or `None` when it has none to scale.
185fn unit(v: Vec3) -> Option<Vec3> {
186    let length = v.length();
187    (length > 0.0 && length.is_finite()).then(|| v / length)
188}
189
190/// Whether the polygon `p0 -> p1 -> p2 -> p3`, lying in a plane with unit
191/// normal `n`, is convex: it turns the same way, about `n`, at every
192/// corner (either way round), with a relative slack of `1e-12`.
193fn convex_projection(corners: [Point3; 4], n: Vec3) -> bool {
194    let scale = (0..4)
195        .map(|k| (corners[(k + 1) % 4] - corners[k]).length())
196        .fold(0.0, Scalar::max);
197    let slack = 1e-12 * scale * scale;
198    let turns: Vec<Scalar> = (0..4)
199        .map(|k| {
200            let (p, q, r) = (corners[k], corners[(k + 1) % 4], corners[(k + 2) % 4]);
201            (q - p).cross(r - q).dot(n)
202        })
203        .collect();
204    turns.iter().all(|t| *t >= -slack) || turns.iter().all(|t| *t <= slack)
205}
206
207/// The largest [`quad_deviation`] over the walls between two stations.
208///
209/// Pairs ring points exactly as [`loft`] does: point `k` with `k + 1`,
210/// wrapping, on every ring.
211pub(crate) fn span_deviation(from: &Station, to: &Station) -> Scalar {
212    let mut worst: Scalar = 0.0;
213    for (ring, next) in from.loops.iter().zip(&to.loops) {
214        let m = ring.len().min(next.len());
215        for k in 0..m {
216            let kn = (k + 1) % m;
217            worst = worst.max(quad_deviation(ring[k], ring[kn], next[k], next[kn]));
218        }
219    }
220    worst
221}
222
223/// An orthonormal frame at a point on a path.
224///
225/// A sweep needs a full frame, not just a tangent: the profile's x and y
226/// axes have to be carried along the path, and how they are carried is
227/// exactly what distinguishes the sweep families from each other.
228#[derive(Debug, Clone)]
229pub struct Frame {
230    /// Frame origin on the directrix.
231    pub origin: Point3,
232    /// Image of the profile's +x.
233    pub x: Vec3,
234    /// Image of the profile's +y.
235    pub y: Vec3,
236}
237
238impl Frame {
239    /// Frame whose z is `tangent` and whose x is `reference` made
240    /// perpendicular to it.
241    ///
242    /// Refuses a reference parallel to the tangent instead of silently
243    /// picking a fallback axis: the caller supplied a direction that cannot
244    /// orient the profile, and quietly substituting one rotates the section
245    /// by an arbitrary angle.
246    pub fn from_reference(origin: Point3, tangent: Vec3, reference: Vec3) -> GeomResult<Self> {
247        let t = tangent.normalize_or_zero();
248        if t == Vec3::ZERO {
249            return Err(GeomError::InvalidInput(
250                "sweep tangent must be a non-zero direction".to_owned(),
251            ));
252        }
253        let x = (reference - t * t.dot(reference)).normalize_or_zero();
254        if x == Vec3::ZERO {
255            return Err(GeomError::InvalidInput(
256                "sweep reference direction must not be parallel to the directrix".to_owned(),
257            ));
258        }
259        Ok(Self {
260            origin,
261            x,
262            y: t.cross(x),
263        })
264    }
265}
266
267/// Place a profile point using a frame.
268pub fn at(frame: &Frame, p: Point2) -> Point3 {
269    frame.origin + frame.x * p.x + frame.y * p.y
270}
271
272/// Linear blend of two profile rings.
273///
274/// Tapered families interpolate between a start and an end profile, so the
275/// blend belongs here rather than in each of them.
276pub fn blend(a: Point2, b: Point2, t: Scalar) -> Point2 {
277    Point2::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
278}
279
280/// Loft where the two ends carry different profiles.
281///
282/// [`loft`] caps both ends from one ring set, which is wrong the moment the
283/// ends differ: a tapered solid needs each cap triangulated from its own
284/// profile. The walls are identical, so only the caps are rebuilt here.
285pub fn loft_tapered(start: &Rings, end: &Rings, stations: &[Station]) -> GeomResult<TriMesh> {
286    let mut mesh = loft(start, stations, false)?;
287    let per_station: usize = stations[0].loops.iter().map(|r| r.len()).sum();
288    // Drop the caps `loft` built from `start` and rebuild the far one from
289    // `end`. Wall triangles come first, so truncating to the wall count is
290    // exact rather than a search.
291    let spans = stations.len() - 1;
292    let ring_edges: usize = stations[0].loops.iter().map(|r| r.len()).sum();
293    let wall_indices = spans * ring_edges * 6;
294    mesh.indices.truncate(wall_indices);
295    let (near_pts, near_tris) = crate::profile::triangulate(start)?;
296    let (far_pts, far_tris) = crate::profile::triangulate(end)?;
297    if near_pts.len() != per_station || far_pts.len() != per_station {
298        return Err(GeomError::Degenerate(
299            "tapered cap triangulation disagrees with the station rings".to_owned(),
300        ));
301    }
302    let last = (spans * per_station) as u32;
303    for t in &near_tris {
304        mesh.indices.extend([t[0], t[2], t[1]]);
305    }
306    for t in &far_tris {
307        mesh.indices.extend([last + t[0], last + t[1], last + t[2]]);
308    }
309    Ok(mesh)
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use axiolid_measure::proximity::closest_point_on_triangle;
316
317    /// The largest distance from a dense sampling of the bilinear patch to
318    /// the loft's two triangles, measured by brute force.
319    fn measured(a: Point3, b: Point3, c: Point3, d: Point3) -> Scalar {
320        let n = 64;
321        let mut worst: Scalar = 0.0;
322        for i in 0..=n {
323            for j in 0..=n {
324                let (x, y) = (i as Scalar / n as Scalar, j as Scalar / n as Scalar);
325                let p = (a * (1.0 - x) + b * x) * (1.0 - y) + (c * (1.0 - x) + d * x) * y;
326                let near = [[a, b, d], [a, d, c]]
327                    .iter()
328                    .filter_map(|t| closest_point_on_triangle(p, *t).ok())
329                    .map(|q| p.distance(q))
330                    .fold(Scalar::INFINITY, Scalar::min);
331                worst = worst.max(near);
332            }
333        }
334        worst
335    }
336
337    /// A deterministic spread of quads: planar convex, planar concave
338    /// (the loft's diagonal outside the quad), twisted, and collapsed.
339    fn quads() -> Vec<[Point3; 4]> {
340        let mut seed: u64 = 0x2310_5eed;
341        let mut next = move || {
342            seed = seed
343                .wrapping_mul(6_364_136_223_846_793_005)
344                .wrapping_add(1_442_695_040_888_963_407);
345            ((seed >> 11) as Scalar / (1u64 << 53) as Scalar) * 2.0 - 1.0
346        };
347        let mut out = vec![
348            // Planar trapezoid: a revolution's wall about an in-plane axis.
349            [
350                Point3::new(0.0, 0.0, 0.0),
351                Point3::new(1.0, 0.0, 0.0),
352                Point3::new(0.0, 1.0, 0.0),
353                Point3::new(1.2, 1.0, 0.0),
354            ],
355            // Planar, but `d` folded back across the diagonal a-d: concave.
356            [
357                Point3::new(0.0, 0.0, 0.0),
358                Point3::new(1.0, 0.0, 0.0),
359                Point3::new(0.0, 1.0, 0.0),
360                Point3::new(0.2, 0.2, 0.0),
361            ],
362            // A saddle: the hyperbolic paraboloid z = xy.
363            [
364                Point3::new(0.0, 0.0, 0.0),
365                Point3::new(1.0, 0.0, 0.0),
366                Point3::new(0.0, 1.0, 0.0),
367                Point3::new(1.0, 1.0, 1.0),
368            ],
369            // Collapsed onto an axis: a and b coincide.
370            [
371                Point3::new(0.0, 0.0, 0.0),
372                Point3::new(0.0, 0.0, 0.0),
373                Point3::new(0.0, 1.0, 0.0),
374                Point3::new(1.0, 1.0, 0.3),
375            ],
376        ];
377        for _ in 0..200 {
378            let mut q = [Point3::ZERO; 4];
379            for p in &mut q {
380                *p = Point3::new(next(), next(), 0.3 * next());
381            }
382            out.push(q);
383        }
384        out
385    }
386
387    #[test]
388    fn the_quad_bound_holds_for_every_quad() {
389        for [a, b, c, d] in quads() {
390            let bound = quad_deviation(a, b, c, d);
391            let got = measured(a, b, c, d);
392            assert!(
393                got <= bound * (1.0 + 1e-9) + 1e-12,
394                "quad {a:?} {b:?} {c:?} {d:?}: patch {got} from its triangles, bound {bound}"
395            );
396        }
397    }
398
399    #[test]
400    fn a_planar_convex_quad_costs_nothing() {
401        let [a, b, c, d] = quads()[0];
402        assert!(quad_deviation(a, b, c, d) <= 1e-15);
403    }
404
405    #[test]
406    fn a_twisted_quad_is_bounded_tightly() {
407        // z = xy: the patch's centre is |T| / 4 = 1/4 above the diagonal's
408        // midpoint, and 1/(4 sqrt 2) from the triangles' planes.
409        let [a, b, c, d] = quads()[2];
410        let bound = quad_deviation(a, b, c, d);
411        assert!((bound - 0.25).abs() <= 1e-12, "{bound}");
412        let got = measured(a, b, c, d);
413        assert!((got - 0.25 / 2f64.sqrt()).abs() <= 1e-3, "{got}");
414    }
415}