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