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    loft_welded(rings, stations, closed, &[])
50}
51
52/// [`loft`] with some ring points welded across stations (#363).
53///
54/// `welded[f]` marks the `f`-th ring point (outer ring first, then each hole,
55/// the order of a [`Station`]) as fixed under the sweep, as a point on a
56/// revolution axis is: its stations coincide, so they share one vertex, taken
57/// from the last station. A wall quad with one welded end collapses to its
58/// one triangle that is not degenerate; a quad with both ends welded lies
59/// along the fixed line and emits nothing. Caps index the shared vertices, so
60/// the solid stays closed. An empty `welded` welds nothing.
61pub(crate) fn loft_welded(
62    rings: &Rings,
63    stations: &[Station],
64    closed: bool,
65    welded: &[bool],
66) -> GeomResult<TriMesh> {
67    loft_capped(rings, rings, stations, closed, welded)
68}
69
70/// [`loft_welded`] with the first cap triangulated from `near` and the last
71/// from `far` (#364): the two ends of a tapered loft carry different
72/// profiles of one ring structure. `near` and `far` are the rings of the
73/// first and last station; a welded point is fixed at both.
74fn loft_capped(
75    near: &Rings,
76    far: &Rings,
77    stations: &[Station],
78    closed: bool,
79    welded: &[bool],
80) -> GeomResult<TriMesh> {
81    if stations.len() < 2 {
82        return Err(GeomError::InvalidInput(format!(
83            "a loft needs at least two stations, got {}",
84            stations.len()
85        )));
86    }
87    let shape: Vec<usize> = stations[0].loops.iter().map(|r| r.len()).collect();
88    for s in stations {
89        let this: Vec<usize> = s.loops.iter().map(|r| r.len()).collect();
90        if this != shape {
91            return Err(GeomError::InvalidInput(
92                "loft stations must share the profile's ring structure".to_owned(),
93            ));
94        }
95    }
96    let per_station: usize = shape.iter().sum();
97    if !welded.is_empty() && welded.len() != per_station {
98        return Err(GeomError::InvalidInput(
99            "welded flags must cover every ring point".to_owned(),
100        ));
101    }
102    let is_welded = |f: usize| welded.get(f).copied().unwrap_or(false);
103    // `id[s * per_station + f]` is the vertex of ring point `f` at station
104    // `s`; a welded point has one vertex, placed at the last station.
105    let mut positions: Vec<Point3> = Vec::with_capacity(stations.len() * per_station);
106    let mut id: Vec<u32> = vec![0; stations.len() * per_station];
107    let mut shared: Vec<Option<u32>> = vec![None; per_station];
108    let last_station = &stations[stations.len() - 1];
109    for (s, station) in stations.iter().enumerate() {
110        let mut f = 0;
111        for (ring, last_ring) in station.loops.iter().zip(&last_station.loops) {
112            for (point, last_point) in ring.iter().zip(last_ring) {
113                id[s * per_station + f] = match (is_welded(f), shared[f]) {
114                    (true, Some(v)) => v,
115                    (welded_here, _) => {
116                        let v = positions.len() as u32;
117                        positions.push(if welded_here { *last_point } else { *point });
118                        if welded_here {
119                            shared[f] = Some(v);
120                        }
121                        v
122                    }
123                };
124                f += 1;
125            }
126        }
127    }
128    // A closed loft wraps onto station 0; an open one stops one short.
129    let spans = if closed {
130        stations.len()
131    } else {
132        stations.len() - 1
133    };
134    let mut indices: Vec<u32> = Vec::new();
135    for s in 0..spans {
136        let a0 = s * per_station;
137        let b0 = ((s + 1) % stations.len()) * per_station;
138        let mut base = 0usize;
139        for m in shape.iter() {
140            for k in 0..*m {
141                let kn = (k + 1) % *m;
142                let (fk, fkn) = (base + k, base + kn);
143                let (a, b) = (id[a0 + fk], id[a0 + fkn]);
144                let (c, d) = (id[b0 + fk], id[b0 + fkn]);
145                // Both rings wind the same way here. A hole ring is already
146                // stored clockwise (profile.rs keeps holes reversed), which
147                // is what turns its wall normal inward; flipping the index
148                // order as well would invert it a second time.
149                match (is_welded(fk), is_welded(fkn)) {
150                    (false, false) => indices.extend([a, b, d, a, d, c]),
151                    (true, false) => indices.extend([a, b, d]),
152                    (false, true) => indices.extend([a, d, c]),
153                    (true, true) => {}
154                }
155            }
156            base += *m;
157        }
158    }
159    // Caps. A closed loft needs none: the wall meets itself. An open one is
160    // bounded by the profile at each end, wound opposite so both face out.
161    if !closed {
162        let (near_points, near_tris) = crate::profile::triangulate(near)?;
163        let (far_points, far_tris) = crate::profile::triangulate(far)?;
164        for points in [&near_points, &far_points] {
165            if points.len() != per_station {
166                return Err(GeomError::Degenerate(format!(
167                    "cap triangulation produced {} points for {per_station} ring points",
168                    points.len()
169                )));
170            }
171        }
172        let last = (stations.len() - 1) * per_station;
173        for t in &near_tris {
174            let first = t.map(|f| id[f as usize]);
175            indices.extend([first[0], first[2], first[1]]);
176        }
177        for t in &far_tris {
178            let end = t.map(|f| id[last + f as usize]);
179            indices.extend([end[0], end[1], end[2]]);
180        }
181    }
182    if !welded.is_empty() {
183        (positions, indices) = compact(positions, indices);
184    }
185    Ok(TriMesh::new(positions, indices))
186}
187
188/// Drop the vertices no triangle uses, keeping the order of the rest. A
189/// welded point whose ring neighbours are all welded has no wall quad and,
190/// in a closed loft, no cap either (#363).
191fn compact(positions: Vec<Point3>, mut indices: Vec<u32>) -> (Vec<Point3>, Vec<u32>) {
192    let mut used = vec![false; positions.len()];
193    for i in &indices {
194        used[*i as usize] = true;
195    }
196    let mut new_id = vec![0u32; positions.len()];
197    let mut kept = Vec::with_capacity(positions.len());
198    for (v, p) in positions.into_iter().enumerate() {
199        if used[v] {
200            new_id[v] = kept.len() as u32;
201            kept.push(p);
202        }
203    }
204    for i in &mut indices {
205        *i = new_id[*i as usize];
206    }
207    (kept, indices)
208}
209
210/// How far the bilinear patch over one wall quad can sit from the two
211/// triangles [`loft`] splits it into.
212///
213/// The quad joins ring points `a -> b` on one station to `c -> d` on the
214/// next, and [`loft`] cuts it along the diagonal `a-d` into `(a, b, d)` and
215/// `(a, d, c)`. The patch `B(x, y) = (1-y)((1-x)a + xb) + y((1-x)c + xd)`
216/// is the surface the chords between the two stations span: its row at
217/// fixed `x` is the chord between the two placements of one profile point,
218/// which is why the sweeps bound the exact surface against it. Every bound
219/// below holds pointwise, so the smallest is returned:
220///
221/// - `|T| / 4` with the twist `T = a - b - c + d`. On the half `x >= y`,
222///   `B - L = -y(1-x) T` where `L` is the point of `(a, b, d)` with the
223///   same barycentric weights, and `y(1-x) <= 1/4` there; the other half is
224///   symmetric. Always valid.
225/// - `|b - a|`: `B` is within `(1-y)x|b - a|` of the point
226///   `(1-y)a + y((1-x)c + xd)` of triangle `(a, d, c)`. Likewise `|c - a|`
227///   against `(a, b, d)`. These make a quad that collapses to a triangle on
228///   an axis (a ring point on a revolution axis) exact.
229/// - `2|d - c|`: `B` is within `xy|d - c|` of the point
230///   `(1-y)((1-x)a + xb) + yc` of the plane triangle `(a, b, c)`, and moving
231///   that triangle's `c` to `d` moves the point by at most `|d - c|`.
232///   Likewise `2|d - b|`.
233/// - The flattening bound. Take a plane with unit normal `n` and heights
234///   `h(p) = n.p - k`, project the corners onto it, and suppose the
235///   projected quad `a', b', d', c'` is convex. The projected patch is a
236///   convex combination of the projected corners, so it lies in that quad,
237///   which the two plane triangles `(a', b', d')`, `(a', d', c')` cover:
238///   each `B(x, y)` has a point `Z` there with `Z = B(x, y)` in the plane.
239///   Lifting `Z` back with the same weights gives a point of `(a, b, d)` or
240///   `(a, d, c)`, and the two heights are weighted means of corner heights,
241///   so the distance is at most the spread of the corner heights. Two
242///   planes are used. The plane through `a, b, c` leaves only `d` off it,
243///   at the height `g`: bound `|g|`. The plane parallel to both diagonals,
244///   halfway between them, puts `a, d` at `e` and `b, c` at `-e`, with
245///   `2|e|` the distance between the diagonals: bound `2|e|`, half of
246///   `|g|` for a twisted quad (where `|g| = 4|e|`). A planar convex quad,
247///   the wall a revolution builds about an axis in the profile's plane,
248///   gets 0.
249///
250/// The convexity test runs in floating point with a relative slack of
251/// `1e-12`: a quad that fails it by less leaves an uncovered sliver some
252/// `1e-12` of its size wide, far below any chord budget.
253pub fn quad_deviation(a: Point3, b: Point3, c: Point3, d: Point3) -> Scalar {
254    let twist = (a - b - c + d).length() / 4.0;
255    let mut best = twist
256        .min((b - a).length())
257        .min((c - a).length())
258        .min(2.0 * (d - c).length())
259        .min(2.0 * (d - b).length());
260    // The plane through a, b, c: only d leaves it.
261    if let Some(n) = unit((b - a).cross(c - a)) {
262        let g = (d - a).dot(n);
263        if convex_projection([a, b, d - n * g, c], n) {
264            best = best.min(g.abs());
265        }
266    }
267    // The plane halfway between the diagonals a-d and b-c.
268    if let Some(n) = unit((d - a).cross(c - b)) {
269        let e = 0.5 * (a - b).dot(n);
270        let (a2, d2) = (a - n * e, d - n * e);
271        let (b2, c2) = (b + n * e, c + n * e);
272        if convex_projection([a2, b2, d2, c2], n) {
273            best = best.min(2.0 * e.abs());
274        }
275    }
276    best
277}
278
279/// `v` scaled to unit length, or `None` when it has none to scale.
280fn unit(v: Vec3) -> Option<Vec3> {
281    let length = v.length();
282    (length > 0.0 && length.is_finite()).then(|| v / length)
283}
284
285/// Whether the polygon `p0 -> p1 -> p2 -> p3`, lying in a plane with unit
286/// normal `n`, is convex: it turns the same way, about `n`, at every
287/// corner (either way round), with a relative slack of `1e-12`.
288fn convex_projection(corners: [Point3; 4], n: Vec3) -> bool {
289    let scale = (0..4)
290        .map(|k| (corners[(k + 1) % 4] - corners[k]).length())
291        .fold(0.0, Scalar::max);
292    let slack = 1e-12 * scale * scale;
293    let turns: Vec<Scalar> = (0..4)
294        .map(|k| {
295            let (p, q, r) = (corners[k], corners[(k + 1) % 4], corners[(k + 2) % 4]);
296            (q - p).cross(r - q).dot(n)
297        })
298        .collect();
299    turns.iter().all(|t| *t >= -slack) || turns.iter().all(|t| *t <= slack)
300}
301
302/// The largest [`quad_deviation`] over the walls between two stations.
303///
304/// Pairs ring points exactly as [`loft`] does: point `k` with `k + 1`,
305/// wrapping, on every ring.
306pub(crate) fn span_deviation(from: &Station, to: &Station) -> Scalar {
307    let mut worst: Scalar = 0.0;
308    for (ring, next) in from.loops.iter().zip(&to.loops) {
309        let m = ring.len().min(next.len());
310        for k in 0..m {
311            let kn = (k + 1) % m;
312            worst = worst.max(quad_deviation(ring[k], ring[kn], next[k], next[kn]));
313        }
314    }
315    worst
316}
317
318/// An orthonormal frame at a point on a path.
319///
320/// A sweep needs a full frame, not just a tangent: the profile's x and y
321/// axes have to be carried along the path, and how they are carried is
322/// exactly what distinguishes the sweep families from each other.
323#[derive(Debug, Clone)]
324pub struct Frame {
325    /// Frame origin on the directrix.
326    pub origin: Point3,
327    /// Image of the profile's +x.
328    pub x: Vec3,
329    /// Image of the profile's +y.
330    pub y: Vec3,
331}
332
333impl Frame {
334    /// Frame whose z is `tangent` and whose x is `reference` made
335    /// perpendicular to it.
336    ///
337    /// Refuses a reference parallel to the tangent instead of silently
338    /// picking a fallback axis: the caller supplied a direction that cannot
339    /// orient the profile, and quietly substituting one rotates the section
340    /// by an arbitrary angle.
341    pub fn from_reference(origin: Point3, tangent: Vec3, reference: Vec3) -> GeomResult<Self> {
342        let t = tangent.normalize_or_zero();
343        if t == Vec3::ZERO {
344            return Err(GeomError::InvalidInput(
345                "sweep tangent must be a non-zero direction".to_owned(),
346            ));
347        }
348        let x = (reference - t * t.dot(reference)).normalize_or_zero();
349        if x == Vec3::ZERO {
350            return Err(GeomError::InvalidInput(
351                "sweep reference direction must not be parallel to the directrix".to_owned(),
352            ));
353        }
354        Ok(Self {
355            origin,
356            x,
357            y: t.cross(x),
358        })
359    }
360}
361
362/// Place a profile point using a frame.
363pub fn at(frame: &Frame, p: Point2) -> Point3 {
364    frame.origin + frame.x * p.x + frame.y * p.y
365}
366
367/// Linear blend of two profile rings.
368///
369/// Tapered families interpolate between a start and an end profile, so the
370/// blend belongs here rather than in each of them.
371pub fn blend(a: Point2, b: Point2, t: Scalar) -> Point2 {
372    Point2::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
373}
374
375/// Loft where the two ends carry different profiles.
376///
377/// [`loft`] caps both ends from one ring set, which is wrong the moment the
378/// ends differ: a tapered solid needs each cap triangulated from its own
379/// profile. The walls are identical, so only the caps are rebuilt here.
380pub fn loft_tapered(start: &Rings, end: &Rings, stations: &[Station]) -> GeomResult<TriMesh> {
381    loft_tapered_welded(start, end, stations, &[])
382}
383
384/// [`loft_tapered`] with some ring points welded across stations (#364), as
385/// [`loft_welded`] welds them: a point on a revolution axis at both ends
386/// stays on it through the blend.
387pub(crate) fn loft_tapered_welded(
388    start: &Rings,
389    end: &Rings,
390    stations: &[Station],
391    welded: &[bool],
392) -> GeomResult<TriMesh> {
393    loft_capped(start, end, stations, false, welded)
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use axiolid_measure::proximity::closest_point_on_triangle;
400
401    /// The largest distance from a dense sampling of the bilinear patch to
402    /// the loft's two triangles, measured by brute force.
403    fn measured(a: Point3, b: Point3, c: Point3, d: Point3) -> Scalar {
404        let n = 64;
405        let mut worst: Scalar = 0.0;
406        for i in 0..=n {
407            for j in 0..=n {
408                let (x, y) = (i as Scalar / n as Scalar, j as Scalar / n as Scalar);
409                let p = (a * (1.0 - x) + b * x) * (1.0 - y) + (c * (1.0 - x) + d * x) * y;
410                let near = [[a, b, d], [a, d, c]]
411                    .iter()
412                    .filter_map(|t| closest_point_on_triangle(p, *t).ok())
413                    .map(|q| p.distance(q))
414                    .fold(Scalar::INFINITY, Scalar::min);
415                worst = worst.max(near);
416            }
417        }
418        worst
419    }
420
421    /// A deterministic spread of quads: planar convex, planar concave
422    /// (the loft's diagonal outside the quad), twisted, and collapsed.
423    fn quads() -> Vec<[Point3; 4]> {
424        let mut seed: u64 = 0x2310_5eed;
425        let mut next = move || {
426            seed = seed
427                .wrapping_mul(6_364_136_223_846_793_005)
428                .wrapping_add(1_442_695_040_888_963_407);
429            ((seed >> 11) as Scalar / (1u64 << 53) as Scalar) * 2.0 - 1.0
430        };
431        let mut out = vec![
432            // Planar trapezoid: a revolution's wall about an in-plane axis.
433            [
434                Point3::new(0.0, 0.0, 0.0),
435                Point3::new(1.0, 0.0, 0.0),
436                Point3::new(0.0, 1.0, 0.0),
437                Point3::new(1.2, 1.0, 0.0),
438            ],
439            // Planar, but `d` folded back across the diagonal a-d: concave.
440            [
441                Point3::new(0.0, 0.0, 0.0),
442                Point3::new(1.0, 0.0, 0.0),
443                Point3::new(0.0, 1.0, 0.0),
444                Point3::new(0.2, 0.2, 0.0),
445            ],
446            // A saddle: the hyperbolic paraboloid z = xy.
447            [
448                Point3::new(0.0, 0.0, 0.0),
449                Point3::new(1.0, 0.0, 0.0),
450                Point3::new(0.0, 1.0, 0.0),
451                Point3::new(1.0, 1.0, 1.0),
452            ],
453            // Collapsed onto an axis: a and b coincide.
454            [
455                Point3::new(0.0, 0.0, 0.0),
456                Point3::new(0.0, 0.0, 0.0),
457                Point3::new(0.0, 1.0, 0.0),
458                Point3::new(1.0, 1.0, 0.3),
459            ],
460        ];
461        for _ in 0..200 {
462            let mut q = [Point3::ZERO; 4];
463            for p in &mut q {
464                *p = Point3::new(next(), next(), 0.3 * next());
465            }
466            out.push(q);
467        }
468        out
469    }
470
471    #[test]
472    fn the_quad_bound_holds_for_every_quad() {
473        for [a, b, c, d] in quads() {
474            let bound = quad_deviation(a, b, c, d);
475            let got = measured(a, b, c, d);
476            assert!(
477                got <= bound * (1.0 + 1e-9) + 1e-12,
478                "quad {a:?} {b:?} {c:?} {d:?}: patch {got} from its triangles, bound {bound}"
479            );
480        }
481    }
482
483    #[test]
484    fn a_planar_convex_quad_costs_nothing() {
485        let [a, b, c, d] = quads()[0];
486        assert!(quad_deviation(a, b, c, d) <= 1e-15);
487    }
488
489    #[test]
490    fn a_twisted_quad_is_bounded_tightly() {
491        // z = xy: the patch's centre is |T| / 4 = 1/4 above the diagonal's
492        // midpoint, and 1/(4 sqrt 2) from the triangles' planes.
493        let [a, b, c, d] = quads()[2];
494        let bound = quad_deviation(a, b, c, d);
495        assert!((bound - 0.25).abs() <= 1e-12, "{bound}");
496        let got = measured(a, b, c, d);
497        assert!((got - 0.25 / 2f64.sqrt()).abs() <= 1e-3, "{got}");
498    }
499}