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.
19pub struct Station {
20    /// Outer ring followed by each hole, in `Rings` order.
21    pub loops: Vec<Vec<Point3>>,
22}
23
24/// Place `rings` into 3D with a per-point mapping.
25///
26/// The mapping takes the profile's 2D point and returns its 3D image, so a
27/// caller expresses only what its family does: extrusion translates,
28/// revolution rotates, a spine sweep applies a frame.
29pub fn place(rings: &Rings, mut f: impl FnMut(Point2) -> Point3) -> Station {
30    let mut loops = Vec::with_capacity(1 + rings.holes.len());
31    loops.push(rings.outer.iter().map(|p| f(*p)).collect());
32    for hole in &rings.holes {
33        loops.push(hole.iter().map(|p| f(*p)).collect());
34    }
35    Station { loops }
36}
37
38/// Stitch stations into a closed solid.
39///
40/// `closed` wraps the last station onto the first, which welds a periodic
41/// seam by index rather than by two samplings agreeing numerically. Open
42/// lofts are capped with the profile triangulation at each end.
43///
44/// All stations must share the profile's ring structure: the walls pair
45/// vertices by position, so a differing ring length has no meaningful
46/// pairing and is refused rather than silently truncated.
47pub fn loft(rings: &Rings, stations: &[Station], closed: bool) -> GeomResult<TriMesh> {
48    if stations.len() < 2 {
49        return Err(GeomError::InvalidInput(format!(
50            "a loft needs at least two stations, got {}",
51            stations.len()
52        )));
53    }
54    let shape: Vec<usize> = stations[0].loops.iter().map(|r| r.len()).collect();
55    for s in stations {
56        let this: Vec<usize> = s.loops.iter().map(|r| r.len()).collect();
57        if this != shape {
58            return Err(GeomError::InvalidInput(
59                "loft stations must share the profile's ring structure".to_owned(),
60            ));
61        }
62    }
63    let per_station: usize = shape.iter().sum();
64    let mut positions: Vec<Point3> = Vec::with_capacity(stations.len() * per_station);
65    for s in stations {
66        for ring in &s.loops {
67            positions.extend(ring.iter().copied());
68        }
69    }
70    // A closed loft wraps onto station 0; an open one stops one short.
71    let spans = if closed {
72        stations.len()
73    } else {
74        stations.len() - 1
75    };
76    let mut indices: Vec<u32> = Vec::new();
77    for s in 0..spans {
78        let a0 = s * per_station;
79        let b0 = ((s + 1) % stations.len()) * per_station;
80        let mut base = 0usize;
81        for m in shape.iter() {
82            for k in 0..*m {
83                let kn = (k + 1) % *m;
84                let (a, b) = ((a0 + base + k) as u32, (a0 + base + kn) as u32);
85                let (c, d) = ((b0 + base + k) as u32, (b0 + base + kn) as u32);
86                // Both rings wind the same way here. A hole ring is already
87                // stored clockwise (profile.rs keeps holes reversed), which
88                // is what turns its wall normal inward; flipping the index
89                // order as well would invert it a second time.
90                indices.extend([a, b, d, a, d, c]);
91            }
92            base += *m;
93        }
94    }
95    // Caps. A closed loft needs none: the wall meets itself. An open one is
96    // bounded by the profile at each end, wound opposite so both face out.
97    if !closed {
98        let (points, tris) = crate::profile::triangulate(rings)?;
99        if points.len() != per_station {
100            return Err(GeomError::Degenerate(format!(
101                "cap triangulation produced {} points for {per_station} ring points",
102                points.len()
103            )));
104        }
105        let last = ((stations.len() - 1) * per_station) as u32;
106        for t in &tris {
107            indices.extend([t[0], t[2], t[1]]);
108            indices.extend([last + t[0], last + t[1], last + t[2]]);
109        }
110    }
111    Ok(TriMesh::new(positions, indices))
112}
113
114/// An orthonormal frame at a point on a path.
115///
116/// A sweep needs a full frame, not just a tangent: the profile's x and y
117/// axes have to be carried along the path, and how they are carried is
118/// exactly what distinguishes the sweep families from each other.
119pub struct Frame {
120    /// Frame origin on the directrix.
121    pub origin: Point3,
122    /// Image of the profile's +x.
123    pub x: Vec3,
124    /// Image of the profile's +y.
125    pub y: Vec3,
126}
127
128impl Frame {
129    /// Frame whose z is `tangent` and whose x is `reference` made
130    /// perpendicular to it.
131    ///
132    /// Refuses a reference parallel to the tangent instead of silently
133    /// picking a fallback axis: the caller supplied a direction that cannot
134    /// orient the profile, and quietly substituting one rotates the section
135    /// by an arbitrary angle.
136    pub fn from_reference(origin: Point3, tangent: Vec3, reference: Vec3) -> GeomResult<Self> {
137        let t = tangent.normalize_or_zero();
138        if t == Vec3::ZERO {
139            return Err(GeomError::InvalidInput(
140                "sweep tangent must be a non-zero direction".to_owned(),
141            ));
142        }
143        let x = (reference - t * t.dot(reference)).normalize_or_zero();
144        if x == Vec3::ZERO {
145            return Err(GeomError::InvalidInput(
146                "sweep reference direction must not be parallel to the directrix".to_owned(),
147            ));
148        }
149        Ok(Self {
150            origin,
151            x,
152            y: t.cross(x),
153        })
154    }
155}
156
157/// Place a profile point using a frame.
158pub fn at(frame: &Frame, p: Point2) -> Point3 {
159    frame.origin + frame.x * p.x + frame.y * p.y
160}
161
162/// Linear blend of two profile rings.
163///
164/// Tapered families interpolate between a start and an end profile, so the
165/// blend belongs here rather than in each of them.
166pub fn blend(a: Point2, b: Point2, t: Scalar) -> Point2 {
167    Point2::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
168}
169
170/// Loft where the two ends carry different profiles.
171///
172/// [`loft`] caps both ends from one ring set, which is wrong the moment the
173/// ends differ: a tapered solid needs each cap triangulated from its own
174/// profile. The walls are identical, so only the caps are rebuilt here.
175pub fn loft_tapered(start: &Rings, end: &Rings, stations: &[Station]) -> GeomResult<TriMesh> {
176    let mut mesh = loft(start, stations, false)?;
177    let per_station: usize = stations[0].loops.iter().map(|r| r.len()).sum();
178    // Drop the caps `loft` built from `start` and rebuild the far one from
179    // `end`. Wall triangles come first, so truncating to the wall count is
180    // exact rather than a search.
181    let spans = stations.len() - 1;
182    let ring_edges: usize = stations[0].loops.iter().map(|r| r.len()).sum();
183    let wall_indices = spans * ring_edges * 6;
184    mesh.indices.truncate(wall_indices);
185    let (near_pts, near_tris) = crate::profile::triangulate(start)?;
186    let (far_pts, far_tris) = crate::profile::triangulate(end)?;
187    if near_pts.len() != per_station || far_pts.len() != per_station {
188        return Err(GeomError::Degenerate(
189            "tapered cap triangulation disagrees with the station rings".to_owned(),
190        ));
191    }
192    let last = (spans * per_station) as u32;
193    for t in &near_tris {
194        mesh.indices.extend([t[0], t[2], t[1]]);
195    }
196    for t in &far_tris {
197        mesh.indices.extend([last + t[0], last + t[1], last + t[2]]);
198    }
199    Ok(mesh)
200}