Skip to main content

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/// The ring points of a tapered revolution to weld, from the axis flags of
81/// `[start.outer, end.outer, start.holes.., end.holes..]` (#364).
82///
83/// A point on the axis at both ends and at the same place there is fixed
84/// through the blend (the blend of equal coordinates is exact) and under the
85/// rotation, so its stations coincide and it is welded as in a plain
86/// revolution. A point on the axis at a single end leaves it through the
87/// blend, and one that slides along the axis has stations that are distinct
88/// points of the axis: the end caps then meet the axis in segments of
89/// different extent, which no closed mesh joins without a vertex where one
90/// ends. Both are refused by name, not welded. Positions along the axis that
91/// agree within the tolerance count as one place (#367): the projections of a
92/// tilted axis are not bitwise equal. The largest difference is returned, to
93/// be charged to the chord budget.
94fn tapered_welds(
95    start: &Rings,
96    end: &Rings,
97    flags: &[bool],
98    axis_origin: Point3,
99    axis_direction: Vec3,
100    tolerance: Tolerance,
101) -> GeomResult<(Vec<bool>, Scalar)> {
102    let outer = start.outer.len();
103    let holes: usize = start.holes.iter().map(Vec::len).sum();
104    let at_start = flags[..outer]
105        .iter()
106        .chain(&flags[2 * outer..2 * outer + holes]);
107    let at_end = flags[outer..2 * outer]
108        .iter()
109        .chain(&flags[2 * outer + holes..]);
110    let points = |rings: &Rings| -> Vec<Point2> {
111        rings
112            .outer
113            .iter()
114            .chain(rings.holes.iter().flatten())
115            .copied()
116            .collect()
117    };
118    let (from, to) = (points(start), points(end));
119    let refuse = |input| GeomError::UnsupportedInput {
120        backend: crate::BACKEND_ID,
121        operation: axiolid_contracts::Operation::Sweep,
122        input,
123    };
124    let mut welded = Vec::with_capacity(outer + holes);
125    let mut slide: Scalar = 0.0;
126    for (f, (&here, &there)) in at_start.zip(at_end).enumerate() {
127        if here != there {
128            return Err(refuse(
129                "tapered revolution of a profile touching the axis at one end only",
130            ));
131        }
132        if here {
133            // Projected points of a tilted axis are not bitwise equal when
134            // they are at one place: compare their positions along the axis
135            // within the tolerance, and charge the difference (#367).
136            let shift = crate::revolve::slide_along_axis(
137                axis_origin,
138                axis_direction,
139                from[f],
140                to[f],
141                tolerance,
142            );
143            match shift {
144                Some(shift) => slide = slide.max(shift),
145                None => {
146                    return Err(refuse(
147                        "tapered revolution of a profile whose vertex on the axis slides along it",
148                    ))
149                }
150            }
151        }
152        welded.push(here);
153    }
154    Ok((welded, slide))
155}
156
157/// Revolve between two profiles.
158///
159/// Unlike a plain revolution this can never close: a full turn would have
160/// to meet the start profile with the end profile, which are different by
161/// construction. It is always capped.
162///
163/// A ring vertex that stays in place on the axis is meshed once (#364); one
164/// that touches the axis at a single end, or slides along it, is refused.
165///
166/// Every point of the surface the blended rings sweep lies within
167/// `tolerance.linear()` of the mesh (#231). With `t` in `[0, 1]` along the
168/// turn, a ring point moves as `f(t) = R(angle t) q(t)` with `q` affine in
169/// `t` (the blend), so `f'' = angle^2 R'' q + 2 angle R' q'`, and
170/// `|R'' v|` and `|R' v|` are the distance of `v` from the axis direction,
171/// at most `|v|`. Hence `|f''| <= angle^2 rho_max + 2 |angle| d_max`, with
172/// `rho_max` the largest distance of a start or end ring vertex from the
173/// axis (a convex function of the blend, so largest at an end) and `d_max`
174/// the largest distance a vertex travels between the two profiles. Taylor's
175/// remainder bounds the distance of `f` from its chord over a step of
176/// `1/n` by `|f''|_max / (8 n^2)`, and the chords between two stations span
177/// the bilinear patch of each wall quad, which is within
178/// `loft::quad_deviation` of its two triangles; the step count
179/// grows until the sum of the two fits.
180pub fn tapered_revolve(
181    start: &Rings,
182    end: &Rings,
183    axis_origin: Point3,
184    axis_direction: Vec3,
185    angle: Scalar,
186    tolerance: Tolerance,
187) -> GeomResult<TriMesh> {
188    if !angle.is_finite() || angle == 0.0 {
189        return Err(GeomError::InvalidInput(format!(
190            "revolution angle must be finite and non-zero, got {angle}"
191        )));
192    }
193    let dir = axis_direction.normalize_or_zero();
194    if dir == Vec3::ZERO {
195        return Err(GeomError::InvalidInput(
196            "revolution axis must be a finite non-zero direction".to_owned(),
197        ));
198    }
199    // Refuses a structural mismatch of the two profiles before it is judged.
200    blend_rings(start, end, 1.0)?;
201    // The blend is linear in each vertex and a half-plane is convex, so
202    // vertices strictly on one side at both ends stay there throughout.
203    // Judging both profiles' vertices together also refuses a section that
204    // starts on one side and ends on the other (#351).
205    // Each ring stays a ring of its own: an isolated touch is judged by a
206    // vertex's neighbours in its ring.
207    // A partial turn closes an isolated touch into a sector point, a full
208    // one pinches there, as in `revolve`. A leaning axis is judged over the
209    // whole blend.
210    let full = (angle.abs() - core::f64::consts::TAU).abs() <= 1e-9;
211    let contact = crate::revolve::check_blend_axis_side(
212        start,
213        end,
214        axis_origin,
215        axis_direction,
216        tolerance,
217        full,
218    )?;
219    // Vertices within the tolerance of the axis are projected onto it, and
220    // the projection adds to the deviation (#367).
221    let (start, end) = (&contact.start, &contact.end);
222    let far = blend_rings(start, end, 1.0)?;
223    let (welded, slide) = tapered_welds(
224        start,
225        end,
226        &contact.flags,
227        axis_origin,
228        axis_direction,
229        tolerance,
230    )?;
231    let mut max_r: Scalar = 0.0;
232    let starts = start.outer.iter().chain(start.holes.iter().flatten());
233    let ends = far.outer.iter().chain(far.holes.iter().flatten());
234    let mut travel: Scalar = 0.0;
235    for (p, q) in starts.zip(ends) {
236        for p in [p, q] {
237            let v = Point3::new(p.x, p.y, 0.0) - axis_origin;
238            max_r = max_r.max((v - dir * dir.dot(v)).length());
239        }
240        travel = travel.max((*q - *p).length());
241    }
242    let budget = crate::revolve::budget_after_projection(tolerance, contact.projection + slide)?;
243    let curvature = angle * angle * max_r + 2.0 * angle.abs() * travel;
244    let mut n = crate::revolve::steps(max_r, angle, budget)?;
245    let stations = loop {
246        let mut stations = Vec::with_capacity(n + 1);
247        for s in (0..=n).rev() {
248            let t = (s as Scalar) / (n as Scalar);
249            let ring = blend_rings(start, end, t)?;
250            let a = angle * t;
251            stations.push(loft::place(&ring, |p| {
252                crate::revolve::rotate(Point3::new(p.x, p.y, 0.0), axis_origin, dir, a)
253            }));
254        }
255        let chord = curvature / (8.0 * (n * n) as Scalar);
256        let twist = stations
257            .windows(2)
258            .map(|w| loft::span_deviation(&w[0], &w[1]))
259            .fold(0.0, Scalar::max);
260        if chord + twist <= budget {
261            break stations;
262        }
263        n = crate::revolve::grow(n)?;
264    };
265    let mut mesh = loft::loft_tapered_welded(&far, start, &stations, &welded)?;
266    // Same cause and fix as `revolve::revolve` (#221): the station order
267    // above only winds the walls outward for a positive angle, and `angle`
268    // and `dir` matter only through their product, so a sign-of-`angle`
269    // check alone cannot be made consistent with a sign flip of `dir` too.
270    // Settle it from the built mesh's own orientation instead.
271    if matches!(crate::extrude::outward_orientation(&mesh), Some(false)) {
272        for triangle in mesh.indices.as_chunks_mut::<3>().0 {
273            triangle.swap(1, 2);
274        }
275    }
276    Ok(mesh)
277}
278
279/// Sweep a profile along a sampled directrix with a fixed reference.
280///
281/// The reference direction is held constant, so the profile does not twist
282/// with the path's torsion. That is what distinguishes this from a Frenet
283/// sweep, whose frame rotates with the curve's binormal.
284pub fn fixed_reference_sweep(
285    rings: &Rings,
286    path: &[Point3],
287    reference: Vec3,
288) -> GeomResult<TriMesh> {
289    let frames = frames_along(path, None, |_| reference)?;
290    let stations: Vec<Station> = frames
291        .iter()
292        .map(|f| loft::place(rings, |p| loft::at(f, p)))
293        .collect();
294    loft::loft(rings, &stations, false)
295}
296
297/// A directrix sampled into chords for a sweep that bounds its walls
298/// (#231).
299#[derive(Debug, Clone, Default, PartialEq)]
300pub struct SampledPath {
301    /// The samples, in the direction of travel.
302    pub points: Vec<Point3>,
303    /// The exact tangents at the first and the last sample, in the
304    /// direction of travel, when the samples chord ONE smooth curve.
305    ///
306    /// `Some` places the end sections square to the curve rather than to
307    /// its end chords, which lean by half the turn of a chord and tilt an
308    /// end cap by up to `reach * sin(turn / 2)`, and opts the sweep into
309    /// its chord bound. `None` marks a path whose chords are the path
310    /// itself (a line or a polyline, whose corners have no curvature to
311    /// refine) or one this crate cannot vouch is smooth, such as a
312    /// composite; such a path is swept as given. A chain of segments and
313    /// arcs is bounded piece by piece by [`crate::pipe`] instead (#232).
314    pub end_tangents: Option<[Vec3; 2]>,
315}
316
317/// Rounds of halving the directrix budget before a sweep refuses.
318const MAX_REFINEMENTS: usize = 16;
319
320/// Stations for a sweep whose walls stay within `budget` of the surface
321/// its rings trace along a smooth directrix (#231).
322///
323/// `frames_at(b)` samples the directrix to chord budget `b` and returns
324/// its frames, and whether the sampled path is smooth
325/// ([`SampledPath::end_tangents`]); a path that is not is swept once, as
326/// given. For a smooth one the bound per span between two stations is
327///
328/// - the distance of the directrix from its chord: the smaller of `b` and
329///   `(L/2) tan(phi/4)`, with `L` the chord and `phi` the turn of the
330///   tangent across it, which is exactly the sagitta `R (1 - cos(phi/2))`
331///   of a circular arc of radius `R`;
332/// - plus `reach (1 - cos(psi/2))`, with `psi` the rotation between the two
333///   frames and `reach` the furthest ring point from the directrix: the
334///   extra sagitta of a profile point swept round with the frame;
335/// - plus [`loft::quad_deviation`] over the span's wall quads.
336///
337/// Along a circular arc whose frames turn with it (a rotation-minimising
338/// frame, or a fixed reference normal to the arc's plane) consecutive
339/// stations are rotations of each other about the arc's axis, so the first
340/// two terms are the sagitta `(R + reach)(1 - cos(phi/2))` of the circle
341/// the furthest profile point runs on, and the bound is the one
342/// [`crate::revolve`] proves. For other smooth curves the same terms are
343/// the second-order estimate of the sampled curve's own deviation. The
344/// budget is halved until every span fits, and refused with
345/// `BudgetExceeded` after `MAX_REFINEMENTS` rounds.
346fn bounded_stations(
347    rings: &Rings,
348    budget: Scalar,
349    mut frames_at: impl FnMut(Scalar) -> GeomResult<(Vec<Frame>, bool)>,
350) -> GeomResult<Vec<Station>> {
351    if !(budget.is_finite() && budget > 0.0) {
352        return Err(GeomError::InvalidInput(format!(
353            "sweep chord budget must be positive and finite, got {budget}"
354        )));
355    }
356    let reach = rings
357        .outer
358        .iter()
359        .chain(rings.holes.iter().flatten())
360        .map(|p| p.x.hypot(p.y))
361        .fold(0.0, Scalar::max);
362    let mut b = budget;
363    for _ in 0..MAX_REFINEMENTS {
364        let (frames, smooth) = frames_at(b)?;
365        let stations: Vec<Station> = frames
366            .iter()
367            .map(|f| loft::place(rings, |p| loft::at(f, p)))
368            .collect();
369        if !smooth {
370            return Ok(stations);
371        }
372        let worst = frames
373            .windows(2)
374            .zip(stations.windows(2))
375            .map(|(f, s)| span_bound(&f[0], &f[1], b, reach) + loft::span_deviation(&s[0], &s[1]))
376            .fold(0.0, Scalar::max);
377        if worst <= budget {
378            return Ok(stations);
379        }
380        b *= 0.5;
381    }
382    Err(GeomError::BudgetExceeded {
383        resource: "sweep directrix refinement",
384    })
385}
386
387/// The directrix and frame terms of [`bounded_stations`]'s span bound.
388pub(crate) fn span_bound(from: &Frame, to: &Frame, budget: Scalar, reach: Scalar) -> Scalar {
389    let (t0, t1) = (from.x.cross(from.y), to.x.cross(to.y));
390    let turn = t0.dot(t1).clamp(-1.0, 1.0).acos();
391    let chord = (to.origin - from.origin).length();
392    let directrix = budget.min(0.5 * chord * (0.25 * turn).tan());
393    let trace = from.x.dot(to.x) + from.y.dot(to.y) + t0.dot(t1);
394    let rotation = (0.5 * (trace - 1.0)).clamp(-1.0, 1.0).acos();
395    directrix + reach * (1.0 - (0.5 * rotation).cos())
396}
397
398/// [`fixed_reference_sweep`] whose walls stay within `budget` of the
399/// surface its rings trace along a smooth directrix (#231).
400///
401/// `sample(b)` samples the directrix to chord budget `b`; it is called
402/// again with a halved budget while the walls miss (see
403/// [`SampledPath`]). The rings' own distance from a curved profile is the
404/// caller's to add: the reference compiler flattens the profile to half
405/// its chord budget and passes the other half here.
406pub fn fixed_reference_sweep_within(
407    rings: &Rings,
408    mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
409    reference: Vec3,
410    budget: Scalar,
411) -> GeomResult<TriMesh> {
412    let stations = bounded_stations(rings, budget, |b| {
413        let path = sample(b)?;
414        let frames = frames_along(&path.points, path.end_tangents, |_| reference)?;
415        Ok((frames, path.end_tangents.is_some()))
416    })?;
417    loft::loft(rings, &stations, false)
418}
419
420/// [`surface_curve_sweep`] whose walls stay within `budget` of the surface
421/// its rings trace along a smooth directrix (#231).
422///
423/// `sample(b)` returns the directrix sampled to chord budget `b` and the
424/// reference surface's normal at each sample; see
425/// [`fixed_reference_sweep_within`].
426pub fn surface_curve_sweep_within(
427    rings: &Rings,
428    mut sample: impl FnMut(Scalar) -> GeomResult<(SampledPath, Vec<Vec3>)>,
429    budget: Scalar,
430) -> GeomResult<TriMesh> {
431    let stations = bounded_stations(rings, budget, |b| {
432        let (path, normals) = sample(b)?;
433        if normals.len() != path.points.len() {
434            return Err(GeomError::InvalidInput(
435                "a surface curve sweep needs one surface normal per directrix point".to_owned(),
436            ));
437        }
438        let frames = frames_along(&path.points, path.end_tangents, |i| normals[i])?;
439        Ok((frames, path.end_tangents.is_some()))
440    })?;
441    loft::loft(rings, &stations, false)
442}
443
444/// Build a frame at each path sample.
445///
446/// The tangent at an interior sample is the average of its two segment
447/// directions, which keeps the profile from kinking at a corner. Endpoints
448/// use the curve's exact tangents when `ends` gives them, else their single
449/// adjacent segment.
450fn frames_along(
451    path: &[Point3],
452    ends: Option<[Vec3; 2]>,
453    up: impl Fn(usize) -> Vec3,
454) -> GeomResult<Vec<Frame>> {
455    let tangents = tangents_along(path, ends)?;
456    let mut frames = Vec::with_capacity(path.len());
457    for (i, (point, tangent)) in path.iter().zip(tangents).enumerate() {
458        frames.push(Frame::from_reference(*point, tangent, up(i))?);
459    }
460    Ok(frames)
461}
462
463pub fn linear_extrusion_normals(path: &[Point3], direction: Vec3) -> GeomResult<Vec<Vec3>> {
464    if path.len() < 2 {
465        return Err(GeomError::InvalidInput(
466            "a linear-extrusion surface needs at least two directrix points".into(),
467        ));
468    }
469    let direction = direction.normalize_or_zero();
470    if direction == Vec3::ZERO {
471        return Err(GeomError::InvalidInput(
472            "linear-extrusion direction must be finite and non-zero".into(),
473        ));
474    }
475    let mut normals = Vec::with_capacity(path.len());
476    for i in 0..path.len() {
477        let tangent = if i == 0 {
478            path[1] - path[0]
479        } else if i + 1 == path.len() {
480            path[i] - path[i - 1]
481        } else {
482            (path[i] - path[i - 1]).normalize_or_zero()
483                + (path[i + 1] - path[i]).normalize_or_zero()
484        };
485        let normal = tangent.cross(direction).normalize_or_zero();
486        if normal == Vec3::ZERO {
487            return Err(GeomError::Degenerate(
488                "directrix tangent is parallel to linear-extrusion direction".into(),
489            ));
490        }
491        normals.push(normal);
492    }
493    Ok(normals)
494}
495
496/// Sweep a profile along a directrix lying on a reference surface.
497///
498/// The surface normal at each sample supplies the up direction, so the
499/// profile stays oriented to the surface rather than to a global axis.
500/// Callers pass the sampled normals because evaluating the surface belongs
501/// to the surface provider, not to this sweep.
502pub fn surface_curve_sweep(
503    rings: &Rings,
504    path: &[Point3],
505    normals: &[Vec3],
506) -> GeomResult<TriMesh> {
507    if normals.len() != path.len() {
508        return Err(GeomError::InvalidInput(
509            "a surface curve sweep needs one surface normal per directrix point".to_owned(),
510        ));
511    }
512    let frames = frames_along(path, None, |i| normals[i])?;
513    let stations: Vec<Station> = frames
514        .iter()
515        .map(|f| loft::place(rings, |p| loft::at(f, p)))
516        .collect();
517    loft::loft(rings, &stations, false)
518}
519
520/// Sweep a disk along a directrix, optionally hollow.
521///
522/// `fillet_radius` is refused rather than ignored. The model's own docs say
523/// a consumer that cannot round corners must refuse a `Some`, because
524/// silently sharpening a pipe run produces geometry that builds, renders,
525/// and is wrong.
526pub fn swept_disk(
527    path: &[Point3],
528    radius: Scalar,
529    inner_radius: Option<Scalar>,
530    fillet_radius: Option<Scalar>,
531    tolerance: Tolerance,
532) -> GeomResult<TriMesh> {
533    check_disk(radius, inner_radius, fillet_radius)?;
534    // A disk is just a circular profile, so the sweep reuses the shared
535    // loft. The section needs a frame that stays perpendicular to the path,
536    // but which perpendicular does not matter for a circle. A single fixed
537    // axis is NOT enough: a leg along that axis has no perpendicular
538    // component and was refused, and a leg nearly along it projects to a
539    // residue of arbitrary direction, rotating the ring between stations
540    // so the loft connects vertex k to a rotated vertex k and the volume
541    // collapses silently (axiolid/kernel#169). The reference is therefore
542    // carried along the path by rotation-minimising frames.
543    let rings = disk_rings(radius, inner_radius, tolerance.linear())?;
544    let frames = rotation_minimising_frames(path, None)?;
545    let stations: Vec<Station> = frames
546        .iter()
547        .map(|f| loft::place(&rings, |p| loft::at(f, p)))
548        .collect();
549    loft::loft(&rings, &stations, false)
550}
551
552/// [`swept_disk`] whose surface stays within `chord` of the exact tube
553/// round a smooth directrix (#231).
554///
555/// Half the budget chords the disk: a ring of `n` points inscribed in a
556/// circle of radius `r` is within `r (1 - cos(pi/n))` of it, and every
557/// station places it rigidly, so every point of the exact tube is within
558/// that of the surface the rings sweep. The other half bounds that surface
559/// against its triangles, as [`fixed_reference_sweep_within`] does, with
560/// the disk's radius as the reach: along a circular arc of radius `R` the
561/// rotation-minimising frames turn with the arc, so the stations are
562/// rotations of one ring about the arc's axis and the outer side of the
563/// tube, `R + r` from it, sets the step. `sample(b)` samples the directrix
564/// to chord budget `b`; see [`SampledPath`].
565pub fn swept_disk_within(
566    mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
567    radius: Scalar,
568    inner_radius: Option<Scalar>,
569    fillet_radius: Option<Scalar>,
570    chord: Scalar,
571) -> GeomResult<TriMesh> {
572    check_disk(radius, inner_radius, fillet_radius)?;
573    let rings = disk_rings(radius, inner_radius, 0.5 * chord)?;
574    let stations = bounded_stations(&rings, 0.5 * chord, |b| {
575        let path = sample(b)?;
576        let frames = rotation_minimising_frames(&path.points, path.end_tangents)?;
577        Ok((frames, path.end_tangents.is_some()))
578    })?;
579    loft::loft(&rings, &stations, false)
580}
581
582/// Refuse a fillet and validate the disk's radii.
583///
584/// `fillet_radius` is refused rather than ignored. The model's own docs say
585/// a consumer that cannot round corners must refuse a `Some`, because
586/// silently sharpening a pipe run produces geometry that builds, renders,
587/// and is wrong.
588fn check_disk(
589    radius: Scalar,
590    inner_radius: Option<Scalar>,
591    fillet_radius: Option<Scalar>,
592) -> GeomResult<()> {
593    if fillet_radius.is_some() {
594        return Err(GeomError::Unsupported {
595            backend: crate::BACKEND_ID,
596            operation: axiolid_contracts::Operation::Sweep,
597        });
598    }
599    check_radii(radius, inner_radius)
600}
601
602/// Validate a swept disk's outer and inner radii.
603pub(crate) fn check_radii(radius: Scalar, inner_radius: Option<Scalar>) -> GeomResult<()> {
604    if !radius.is_finite() || radius <= 0.0 {
605        return Err(GeomError::InvalidInput(format!(
606            "swept disk radius must be positive and finite, got {radius}"
607        )));
608    }
609    if let Some(inner) = inner_radius {
610        if !inner.is_finite() || inner <= 0.0 || inner >= radius {
611            return Err(GeomError::InvalidInput(format!(
612                "swept disk inner radius must be positive and below {radius}, got {inner}"
613            )));
614        }
615    }
616    Ok(())
617}
618
619/// Rotation-minimising frames along a sampled path, by double reflection.
620///
621/// Wang, Jüttler, Zheng and Liu, "Computation of Rotation Minimizing
622/// Frames", ACM TOG 27(1), 2008: each step reflects the previous frame in
623/// the bisector plane of the chord, then in the plane that maps the
624/// reflected tangent onto the next tangent. Two reflections are a rotation,
625/// so the reference stays unit length and perpendicular to the tangent at
626/// every station by construction; it can never become parallel to it. The
627/// frame has fourth-order accuracy in the step, and no twist beyond what
628/// the path's own torsion forces.
629///
630/// Tangents match `frames_along` (averaged at interior samples, the exact
631/// ones at the ends when `ends` gives them), so a corner is mitred the
632/// same way as every other sweep family.
633fn rotation_minimising_frames(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Frame>> {
634    let seed = seed_reference(path)?;
635    let tangents = tangents_along(path, ends)?;
636    frames_carried(path, &tangents, seed)
637}
638
639/// Rotation-minimising frames along `path` with the given unit tangent at
640/// each sample, the first frame's x being `seed` made perpendicular to the
641/// first tangent; see [`rotation_minimising_frames`].
642pub(crate) fn frames_carried(
643    path: &[Point3],
644    tangents: &[Vec3],
645    seed: Vec3,
646) -> GeomResult<Vec<Frame>> {
647    let mut reference = seed;
648    let mut frames = Vec::with_capacity(path.len());
649    for i in 0..path.len() {
650        frames.push(Frame::from_reference(path[i], tangents[i], reference)?);
651        let Some(next) = path.get(i + 1) else { break };
652        let chord = *next - path[i];
653        let c1 = chord.dot(chord);
654        if c1 == 0.0 {
655            // A repeated sample: the frame does not move. `tangents_along`
656            // has already refused a path whose tangent vanishes here.
657            continue;
658        }
659        let reflected_ref = reference - chord * (2.0 / c1 * chord.dot(reference));
660        let reflected_tan = tangents[i] - chord * (2.0 / c1 * chord.dot(tangents[i]));
661        let fix = tangents[i + 1] - reflected_tan;
662        let c2 = fix.dot(fix);
663        reference = if c2 == 0.0 {
664            reflected_ref
665        } else {
666            reflected_ref - fix * (2.0 / c2 * fix.dot(reflected_ref))
667        };
668    }
669    Ok(frames)
670}
671
672/// Unit tangent at each sample, averaged at interior samples; at the two
673/// ends the exact tangents when `ends` gives them, else the end chords.
674fn tangents_along(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Vec3>> {
675    if path.len() < 2 {
676        return Err(GeomError::InvalidInput(format!(
677            "a sweep directrix needs at least two points, got {}",
678            path.len()
679        )));
680    }
681    (0..path.len())
682        .map(|i| {
683            let raw = if i == 0 {
684                ends.map_or(path[1] - path[0], |[start, _]| start)
685            } else if i + 1 == path.len() {
686                ends.map_or(path[i] - path[i - 1], |[_, end]| end)
687            } else {
688                (path[i] - path[i - 1]).normalize_or_zero()
689                    + (path[i + 1] - path[i]).normalize_or_zero()
690            };
691            let tangent = raw.normalize_or_zero();
692            if tangent == Vec3::ZERO {
693                Err(GeomError::InvalidInput(
694                    "sweep tangent must be a non-zero direction".to_owned(),
695                ))
696            } else {
697                Ok(tangent)
698            }
699        })
700        .collect()
701}
702
703/// A circular profile, hollow when `inner` is given, each ring within
704/// `chord` of its circle.
705pub(crate) fn disk_rings(
706    radius: Scalar,
707    inner: Option<Scalar>,
708    chord: Scalar,
709) -> GeomResult<Rings> {
710    let circle = |r: Scalar, reverse: bool| -> GeomResult<Vec<Point2>> {
711        let n = crate::revolve::steps(r, core::f64::consts::TAU, chord)?;
712        let mut pts: Vec<Point2> = (0..n)
713            .map(|k| {
714                let a = core::f64::consts::TAU * (k as Scalar) / (n as Scalar);
715                Point2::new(r * a.cos(), r * a.sin())
716            })
717            .collect();
718        if reverse {
719            pts.reverse();
720        }
721        Ok(pts)
722    };
723    // A hole ring runs opposite the outer ring so the triangulator reads it
724    // as a void rather than a second island.
725    Ok(Rings {
726        outer: circle(radius, false)?,
727        holes: match inner {
728            Some(r) => vec![circle(r, true)?],
729            None => Vec::new(),
730        },
731    })
732}
733
734/// A reference direction guaranteed not to be parallel to the first
735/// segment.
736///
737/// A circular section has no preferred orientation, so any perpendicular
738/// will do; what matters is that it is never degenerate.
739fn seed_reference(path: &[Point3]) -> GeomResult<Vec3> {
740    if path.len() < 2 {
741        return Err(GeomError::InvalidInput(
742            "a swept disk directrix needs at least two points".to_owned(),
743        ));
744    }
745    let t = (path[1] - path[0]).normalize_or_zero();
746    if t == Vec3::ZERO {
747        return Err(GeomError::InvalidInput(
748            "a swept disk directrix must not start with a zero-length segment".to_owned(),
749        ));
750    }
751    let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
752    Ok(candidate - t * t.dot(candidate))
753}
754
755/// Loft explicit sections placed along a spine.
756///
757/// The caller has already resolved each section's profile and placement, so
758/// this only stitches them. Sections must share a ring structure: a spine
759/// whose sections differ in topology has no vertex correspondence, and
760/// pairing by index would weld unrelated points.
761pub fn sectioned_spine(sections: &[(Rings, Vec<Point3>)]) -> GeomResult<TriMesh> {
762    if sections.len() < 2 {
763        return Err(GeomError::InvalidInput(format!(
764            "a sectioned spine needs at least two sections, got {}",
765            sections.len()
766        )));
767    }
768    let stations: Vec<Station> = sections
769        .iter()
770        .map(|(rings, placed)| {
771            let mut it = placed.iter().copied();
772            let mut loops = Vec::with_capacity(1 + rings.holes.len());
773            loops.push((0..rings.outer.len()).filter_map(|_| it.next()).collect());
774            for hole in &rings.holes {
775                loops.push((0..hole.len()).filter_map(|_| it.next()).collect());
776            }
777            Station { loops }
778        })
779        .collect();
780    loft::loft(&sections[0].0, &stations, false)
781}