axiolid_construct/
sweep.rs

1//! The sweep families that place a profile along a path.
2//!
3//! Each differs only in how the profile is carried: a tapered family blends
4//! two profiles, a fixed-reference sweep keeps one direction, a
5//! surface-curve sweep takes its up vector from a surface normal. The
6//! stitching is shared with every other sweep in `loft`.
7
8use axiolid_contracts::{GeomError, GeomResult};
9use axiolid_core::{Point2, Point3, Scalar, Tolerance, Vec3};
10use axiolid_mesh::TriMesh;
11
12use crate::loft::{self, Frame, Station};
13use crate::profile::Rings;
14
15/// Blend two ring sets at parameter `t`.
16///
17/// Refuses a structural mismatch rather than truncating: two profiles with
18/// different ring counts have no correspondence, and pairing them by index
19/// would silently weld unrelated points.
20fn blend_rings(a: &Rings, b: &Rings, t: Scalar) -> GeomResult<Rings> {
21    if a.outer.len() != b.outer.len() || a.holes.len() != b.holes.len() {
22        return Err(GeomError::InvalidInput(
23            "tapered sweep profiles must share their ring structure".to_owned(),
24        ));
25    }
26    let mut holes = Vec::with_capacity(a.holes.len());
27    for (ha, hb) in a.holes.iter().zip(&b.holes) {
28        if ha.len() != hb.len() {
29            return Err(GeomError::InvalidInput(
30                "tapered sweep holes must share their point count".to_owned(),
31            ));
32        }
33        holes.push(
34            ha.iter()
35                .zip(hb)
36                .map(|(p, q)| loft::blend(*p, *q, t))
37                .collect(),
38        );
39    }
40    Ok(Rings {
41        outer: a
42            .outer
43            .iter()
44            .zip(&b.outer)
45            .map(|(p, q)| loft::blend(*p, *q, t))
46            .collect(),
47        holes,
48    })
49}
50
51/// Extrude between two profiles, blending linearly along the direction.
52///
53/// Two stations suffice: the blend is linear, so intermediate ones would
54/// add vertices without adding shape.
55pub fn tapered_extrude(
56    start: &Rings,
57    end: &Rings,
58    direction: Vec3,
59    depth: Scalar,
60) -> GeomResult<TriMesh> {
61    if !depth.is_finite() || depth <= 0.0 {
62        return Err(GeomError::InvalidInput(format!(
63            "extrusion depth must be positive and finite, got {depth}"
64        )));
65    }
66    let d = direction.normalize_or_zero();
67    if d == Vec3::ZERO {
68        return Err(GeomError::InvalidInput(
69            "extrusion direction must be a non-zero vector".to_owned(),
70        ));
71    }
72    // Caps use the START rings, so the end cap is only correct when both
73    // profiles share a structure. blend_rings enforces that.
74    let far = blend_rings(start, end, 1.0)?;
75    let s0 = loft::place(start, |p| Point3::new(p.x, p.y, 0.0));
76    let s1 = loft::place(&far, |p| Point3::new(p.x, p.y, 0.0) + d * depth);
77    loft::loft_tapered(start, &far, &[s0, s1])
78}
79
80/// Revolve between two profiles.
81///
82/// Unlike a plain revolution this can never close: a full turn would have
83/// to meet the start profile with the end profile, which are different by
84/// construction. It is always capped.
85pub fn tapered_revolve(
86    start: &Rings,
87    end: &Rings,
88    axis_origin: Point3,
89    axis_direction: Vec3,
90    angle: Scalar,
91    tolerance: Tolerance,
92) -> GeomResult<TriMesh> {
93    if !angle.is_finite() || angle == 0.0 {
94        return Err(GeomError::InvalidInput(format!(
95            "revolution angle must be finite and non-zero, got {angle}"
96        )));
97    }
98    let dir = axis_direction.normalize_or_zero();
99    if dir == Vec3::ZERO {
100        return Err(GeomError::InvalidInput(
101            "revolution axis must be a finite non-zero direction".to_owned(),
102        ));
103    }
104    let far = blend_rings(start, end, 1.0)?;
105    let mut max_r: Scalar = 0.0;
106    for p in start.outer.iter().chain(far.outer.iter()) {
107        let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
108        max_r = max_r.max((v - dir * dir.dot(v)).length());
109    }
110    let n = crate::revolve::steps(max_r, angle, tolerance.linear());
111    let mut stations = Vec::with_capacity(n + 1);
112    for s in 0..=n {
113        let t = (s as Scalar) / (n as Scalar);
114        let ring = blend_rings(start, end, t)?;
115        let a = angle * t;
116        stations.push(loft::place(&ring, |p| {
117            crate::revolve::rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, a)
118        }));
119    }
120    let stations: Vec<_> = stations.into_iter().rev().collect();
121    loft::loft_tapered(&far, start, &stations)
122}
123
124/// Sweep a profile along a sampled directrix with a fixed reference.
125///
126/// The reference direction is held constant, so the profile does not twist
127/// with the path's torsion. That is what distinguishes this from a Frenet
128/// sweep, whose frame rotates with the curve's binormal.
129pub fn fixed_reference_sweep(
130    rings: &Rings,
131    path: &[Point3],
132    reference: Vec3,
133) -> GeomResult<TriMesh> {
134    let frames = frames_along(path, |_| reference)?;
135    let stations: Vec<Station> = frames
136        .iter()
137        .map(|f| loft::place(rings, |p| loft::at(f, p)))
138        .collect();
139    loft::loft(rings, &stations, false)
140}
141
142/// Build a frame at each path sample.
143///
144/// The tangent at an interior sample is the average of its two segment
145/// directions, which keeps the profile from kinking at a corner. Endpoints
146/// use their single adjacent segment.
147fn frames_along(path: &[Point3], up: impl Fn(usize) -> Vec3) -> GeomResult<Vec<Frame>> {
148    if path.len() < 2 {
149        return Err(GeomError::InvalidInput(format!(
150            "a sweep directrix needs at least two points, got {}",
151            path.len()
152        )));
153    }
154    let mut frames = Vec::with_capacity(path.len());
155    for i in 0..path.len() {
156        let tangent = if i == 0 {
157            path[1] - path[0]
158        } else if i + 1 == path.len() {
159            path[i] - path[i - 1]
160        } else {
161            (path[i] - path[i - 1]).normalize_or_zero()
162                + (path[i + 1] - path[i]).normalize_or_zero()
163        };
164        frames.push(Frame::from_reference(path[i], tangent, up(i))?);
165    }
166    Ok(frames)
167}
168
169pub fn linear_extrusion_normals(path: &[Point3], direction: Vec3) -> GeomResult<Vec<Vec3>> {
170    if path.len() < 2 {
171        return Err(GeomError::InvalidInput(
172            "a linear-extrusion surface needs at least two directrix points".into(),
173        ));
174    }
175    let direction = direction.normalize_or_zero();
176    if direction == Vec3::ZERO {
177        return Err(GeomError::InvalidInput(
178            "linear-extrusion direction must be finite and non-zero".into(),
179        ));
180    }
181    let mut normals = Vec::with_capacity(path.len());
182    for i in 0..path.len() {
183        let tangent = if i == 0 {
184            path[1] - path[0]
185        } else if i + 1 == path.len() {
186            path[i] - path[i - 1]
187        } else {
188            (path[i] - path[i - 1]).normalize_or_zero()
189                + (path[i + 1] - path[i]).normalize_or_zero()
190        };
191        let normal = tangent.cross(direction).normalize_or_zero();
192        if normal == Vec3::ZERO {
193            return Err(GeomError::Degenerate(
194                "directrix tangent is parallel to linear-extrusion direction".into(),
195            ));
196        }
197        normals.push(normal);
198    }
199    Ok(normals)
200}
201
202/// Sweep a profile along a directrix lying on a reference surface.
203///
204/// The surface normal at each sample supplies the up direction, so the
205/// profile stays oriented to the surface rather than to a global axis.
206/// Callers pass the sampled normals because evaluating the surface belongs
207/// to the surface provider, not to this sweep.
208pub fn surface_curve_sweep(
209    rings: &Rings,
210    path: &[Point3],
211    normals: &[Vec3],
212) -> GeomResult<TriMesh> {
213    if normals.len() != path.len() {
214        return Err(GeomError::InvalidInput(
215            "a surface curve sweep needs one surface normal per directrix point".to_owned(),
216        ));
217    }
218    let frames = frames_along(path, |i| normals[i])?;
219    let stations: Vec<Station> = frames
220        .iter()
221        .map(|f| loft::place(rings, |p| loft::at(f, p)))
222        .collect();
223    loft::loft(rings, &stations, false)
224}
225
226/// Sweep a disk along a directrix, optionally hollow.
227///
228/// `fillet_radius` is refused rather than ignored. The model's own docs say
229/// a consumer that cannot round corners must refuse a `Some`, because
230/// silently sharpening a pipe run produces geometry that builds, renders,
231/// and is wrong.
232pub fn swept_disk(
233    path: &[Point3],
234    radius: Scalar,
235    inner_radius: Option<Scalar>,
236    fillet_radius: Option<Scalar>,
237    tolerance: Tolerance,
238) -> GeomResult<TriMesh> {
239    if fillet_radius.is_some() {
240        return Err(GeomError::Unsupported {
241            backend: crate::BACKEND_ID,
242            operation: axiolid_contracts::Operation::Sweep,
243        });
244    }
245    if !radius.is_finite() || radius <= 0.0 {
246        return Err(GeomError::InvalidInput(format!(
247            "swept disk radius must be positive and finite, got {radius}"
248        )));
249    }
250    if let Some(inner) = inner_radius {
251        if !inner.is_finite() || inner <= 0.0 || inner >= radius {
252            return Err(GeomError::InvalidInput(format!(
253                "swept disk inner radius must be positive and below {radius}, got {inner}"
254            )));
255        }
256    }
257    // A disk is just a circular profile, so the sweep reuses the shared
258    // loft. The section needs a frame that stays perpendicular to the path,
259    // but which perpendicular does not matter for a circle. A single fixed
260    // axis is NOT enough: a leg along that axis has no perpendicular
261    // component and was refused, and a leg nearly along it projects to a
262    // residue of arbitrary direction, rotating the ring between stations
263    // so the loft connects vertex k to a rotated vertex k and the volume
264    // collapses silently (axiolid/kernel#169). The reference is therefore
265    // carried along the path by rotation-minimising frames.
266    let rings = disk_rings(radius, inner_radius, tolerance)?;
267    let frames = rotation_minimising_frames(path)?;
268    let stations: Vec<Station> = frames
269        .iter()
270        .map(|f| loft::place(&rings, |p| loft::at(f, p)))
271        .collect();
272    loft::loft(&rings, &stations, false)
273}
274
275/// Rotation-minimising frames along a sampled path, by double reflection.
276///
277/// Wang, Jüttler, Zheng and Liu, "Computation of Rotation Minimizing
278/// Frames", ACM TOG 27(1), 2008: each step reflects the previous frame in
279/// the bisector plane of the chord, then in the plane that maps the
280/// reflected tangent onto the next tangent. Two reflections are a rotation,
281/// so the reference stays unit length and perpendicular to the tangent at
282/// every station by construction; it can never become parallel to it. The
283/// frame has fourth-order accuracy in the step, and no twist beyond what
284/// the path's own torsion forces.
285///
286/// Tangents match `frames_along` (averaged at interior samples), so a
287/// corner is mitred the same way as every other sweep family.
288fn rotation_minimising_frames(path: &[Point3]) -> GeomResult<Vec<Frame>> {
289    let seed = seed_reference(path)?;
290    let tangents = tangents_along(path)?;
291    let mut reference = seed;
292    let mut frames = Vec::with_capacity(path.len());
293    for i in 0..path.len() {
294        frames.push(Frame::from_reference(path[i], tangents[i], reference)?);
295        let Some(next) = path.get(i + 1) else { break };
296        let chord = *next - path[i];
297        let c1 = chord.dot(chord);
298        if c1 == 0.0 {
299            // A repeated sample: the frame does not move. `tangents_along`
300            // has already refused a path whose tangent vanishes here.
301            continue;
302        }
303        let reflected_ref = reference - chord * (2.0 / c1 * chord.dot(reference));
304        let reflected_tan = tangents[i] - chord * (2.0 / c1 * chord.dot(tangents[i]));
305        let fix = tangents[i + 1] - reflected_tan;
306        let c2 = fix.dot(fix);
307        reference = if c2 == 0.0 {
308            reflected_ref
309        } else {
310            reflected_ref - fix * (2.0 / c2 * fix.dot(reflected_ref))
311        };
312    }
313    Ok(frames)
314}
315
316/// Unit tangent at each sample, averaged at interior samples.
317fn tangents_along(path: &[Point3]) -> GeomResult<Vec<Vec3>> {
318    if path.len() < 2 {
319        return Err(GeomError::InvalidInput(format!(
320            "a sweep directrix needs at least two points, got {}",
321            path.len()
322        )));
323    }
324    (0..path.len())
325        .map(|i| {
326            let raw = if i == 0 {
327                path[1] - path[0]
328            } else if i + 1 == path.len() {
329                path[i] - path[i - 1]
330            } else {
331                (path[i] - path[i - 1]).normalize_or_zero()
332                    + (path[i + 1] - path[i]).normalize_or_zero()
333            };
334            let tangent = raw.normalize_or_zero();
335            if tangent == Vec3::ZERO {
336                Err(GeomError::InvalidInput(
337                    "sweep tangent must be a non-zero direction".to_owned(),
338                ))
339            } else {
340                Ok(tangent)
341            }
342        })
343        .collect()
344}
345
346/// A circular profile, hollow when `inner` is given.
347fn disk_rings(radius: Scalar, inner: Option<Scalar>, tolerance: Tolerance) -> GeomResult<Rings> {
348    let circle = |r: Scalar, reverse: bool| -> Vec<Point2> {
349        let n = crate::revolve::steps(r, core::f64::consts::TAU, tolerance.linear());
350        let mut pts: Vec<Point2> = (0..n)
351            .map(|k| {
352                let a = core::f64::consts::TAU * (k as Scalar) / (n as Scalar);
353                Point2::new(r * a.cos(), r * a.sin())
354            })
355            .collect();
356        if reverse {
357            pts.reverse();
358        }
359        pts
360    };
361    // A hole ring runs opposite the outer ring so the triangulator reads it
362    // as a void rather than a second island.
363    Ok(Rings {
364        outer: circle(radius, false),
365        holes: inner.map(|r| vec![circle(r, true)]).unwrap_or_default(),
366    })
367}
368
369/// A reference direction guaranteed not to be parallel to the first
370/// segment.
371///
372/// A circular section has no preferred orientation, so any perpendicular
373/// will do; what matters is that it is never degenerate.
374fn seed_reference(path: &[Point3]) -> GeomResult<Vec3> {
375    if path.len() < 2 {
376        return Err(GeomError::InvalidInput(
377            "a swept disk directrix needs at least two points".to_owned(),
378        ));
379    }
380    let t = (path[1] - path[0]).normalize_or_zero();
381    if t == Vec3::ZERO {
382        return Err(GeomError::InvalidInput(
383            "a swept disk directrix must not start with a zero-length segment".to_owned(),
384        ));
385    }
386    let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
387    Ok(candidate - t * t.dot(candidate))
388}
389
390/// Loft explicit sections placed along a spine.
391///
392/// The caller has already resolved each section's profile and placement, so
393/// this only stitches them. Sections must share a ring structure: a spine
394/// whose sections differ in topology has no vertex correspondence, and
395/// pairing by index would weld unrelated points.
396pub fn sectioned_spine(sections: &[(Rings, Vec<Point3>)]) -> GeomResult<TriMesh> {
397    if sections.len() < 2 {
398        return Err(GeomError::InvalidInput(format!(
399            "a sectioned spine needs at least two sections, got {}",
400            sections.len()
401        )));
402    }
403    let stations: Vec<Station> = sections
404        .iter()
405        .map(|(rings, placed)| {
406            let mut it = placed.iter().copied();
407            let mut loops = Vec::with_capacity(1 + rings.holes.len());
408            loops.push((0..rings.outer.len()).filter_map(|_| it.next()).collect());
409            for hole in &rings.holes {
410                loops.push((0..hole.len()).filter_map(|_| it.next()).collect());
411            }
412            Station { loops }
413        })
414        .collect();
415    loft::loft(&sections[0].0, &stations, false)
416}