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. A chain of segments and
202    /// arcs is bounded piece by piece by [`crate::pipe`] instead (#232).
203    pub end_tangents: Option<[Vec3; 2]>,
204}
205
206/// Rounds of halving the directrix budget before a sweep refuses.
207const MAX_REFINEMENTS: usize = 16;
208
209/// Stations for a sweep whose walls stay within `budget` of the surface
210/// its rings trace along a smooth directrix (#231).
211///
212/// `frames_at(b)` samples the directrix to chord budget `b` and returns
213/// its frames, and whether the sampled path is smooth
214/// ([`SampledPath::end_tangents`]); a path that is not is swept once, as
215/// given. For a smooth one the bound per span between two stations is
216///
217/// - the distance of the directrix from its chord: the smaller of `b` and
218///   `(L/2) tan(phi/4)`, with `L` the chord and `phi` the turn of the
219///   tangent across it, which is exactly the sagitta `R (1 - cos(phi/2))`
220///   of a circular arc of radius `R`;
221/// - plus `reach (1 - cos(psi/2))`, with `psi` the rotation between the two
222///   frames and `reach` the furthest ring point from the directrix: the
223///   extra sagitta of a profile point swept round with the frame;
224/// - plus [`loft::quad_deviation`] over the span's wall quads.
225///
226/// Along a circular arc whose frames turn with it (a rotation-minimising
227/// frame, or a fixed reference normal to the arc's plane) consecutive
228/// stations are rotations of each other about the arc's axis, so the first
229/// two terms are the sagitta `(R + reach)(1 - cos(phi/2))` of the circle
230/// the furthest profile point runs on, and the bound is the one
231/// [`crate::revolve`] proves. For other smooth curves the same terms are
232/// the second-order estimate of the sampled curve's own deviation. The
233/// budget is halved until every span fits, and refused with
234/// `BudgetExceeded` after `MAX_REFINEMENTS` rounds.
235fn bounded_stations(
236    rings: &Rings,
237    budget: Scalar,
238    mut frames_at: impl FnMut(Scalar) -> GeomResult<(Vec<Frame>, bool)>,
239) -> GeomResult<Vec<Station>> {
240    if !(budget.is_finite() && budget > 0.0) {
241        return Err(GeomError::InvalidInput(format!(
242            "sweep chord budget must be positive and finite, got {budget}"
243        )));
244    }
245    let reach = rings
246        .outer
247        .iter()
248        .chain(rings.holes.iter().flatten())
249        .map(|p| p.x.hypot(p.y))
250        .fold(0.0, Scalar::max);
251    let mut b = budget;
252    for _ in 0..MAX_REFINEMENTS {
253        let (frames, smooth) = frames_at(b)?;
254        let stations: Vec<Station> = frames
255            .iter()
256            .map(|f| loft::place(rings, |p| loft::at(f, p)))
257            .collect();
258        if !smooth {
259            return Ok(stations);
260        }
261        let worst = frames
262            .windows(2)
263            .zip(stations.windows(2))
264            .map(|(f, s)| span_bound(&f[0], &f[1], b, reach) + loft::span_deviation(&s[0], &s[1]))
265            .fold(0.0, Scalar::max);
266        if worst <= budget {
267            return Ok(stations);
268        }
269        b *= 0.5;
270    }
271    Err(GeomError::BudgetExceeded {
272        resource: "sweep directrix refinement",
273    })
274}
275
276/// The directrix and frame terms of [`bounded_stations`]'s span bound.
277pub(crate) fn span_bound(from: &Frame, to: &Frame, budget: Scalar, reach: Scalar) -> Scalar {
278    let (t0, t1) = (from.x.cross(from.y), to.x.cross(to.y));
279    let turn = t0.dot(t1).clamp(-1.0, 1.0).acos();
280    let chord = (to.origin - from.origin).length();
281    let directrix = budget.min(0.5 * chord * (0.25 * turn).tan());
282    let trace = from.x.dot(to.x) + from.y.dot(to.y) + t0.dot(t1);
283    let rotation = (0.5 * (trace - 1.0)).clamp(-1.0, 1.0).acos();
284    directrix + reach * (1.0 - (0.5 * rotation).cos())
285}
286
287/// [`fixed_reference_sweep`] whose walls stay within `budget` of the
288/// surface its rings trace along a smooth directrix (#231).
289///
290/// `sample(b)` samples the directrix to chord budget `b`; it is called
291/// again with a halved budget while the walls miss (see
292/// [`SampledPath`]). The rings' own distance from a curved profile is the
293/// caller's to add: the reference compiler flattens the profile to half
294/// its chord budget and passes the other half here.
295pub fn fixed_reference_sweep_within(
296    rings: &Rings,
297    mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
298    reference: Vec3,
299    budget: Scalar,
300) -> GeomResult<TriMesh> {
301    let stations = bounded_stations(rings, budget, |b| {
302        let path = sample(b)?;
303        let frames = frames_along(&path.points, path.end_tangents, |_| reference)?;
304        Ok((frames, path.end_tangents.is_some()))
305    })?;
306    loft::loft(rings, &stations, false)
307}
308
309/// [`surface_curve_sweep`] whose walls stay within `budget` of the surface
310/// its rings trace along a smooth directrix (#231).
311///
312/// `sample(b)` returns the directrix sampled to chord budget `b` and the
313/// reference surface's normal at each sample; see
314/// [`fixed_reference_sweep_within`].
315pub fn surface_curve_sweep_within(
316    rings: &Rings,
317    mut sample: impl FnMut(Scalar) -> GeomResult<(SampledPath, Vec<Vec3>)>,
318    budget: Scalar,
319) -> GeomResult<TriMesh> {
320    let stations = bounded_stations(rings, budget, |b| {
321        let (path, normals) = sample(b)?;
322        if normals.len() != path.points.len() {
323            return Err(GeomError::InvalidInput(
324                "a surface curve sweep needs one surface normal per directrix point".to_owned(),
325            ));
326        }
327        let frames = frames_along(&path.points, path.end_tangents, |i| normals[i])?;
328        Ok((frames, path.end_tangents.is_some()))
329    })?;
330    loft::loft(rings, &stations, false)
331}
332
333/// Build a frame at each path sample.
334///
335/// The tangent at an interior sample is the average of its two segment
336/// directions, which keeps the profile from kinking at a corner. Endpoints
337/// use the curve's exact tangents when `ends` gives them, else their single
338/// adjacent segment.
339fn frames_along(
340    path: &[Point3],
341    ends: Option<[Vec3; 2]>,
342    up: impl Fn(usize) -> Vec3,
343) -> GeomResult<Vec<Frame>> {
344    let tangents = tangents_along(path, ends)?;
345    let mut frames = Vec::with_capacity(path.len());
346    for (i, (point, tangent)) in path.iter().zip(tangents).enumerate() {
347        frames.push(Frame::from_reference(*point, tangent, up(i))?);
348    }
349    Ok(frames)
350}
351
352pub fn linear_extrusion_normals(path: &[Point3], direction: Vec3) -> GeomResult<Vec<Vec3>> {
353    if path.len() < 2 {
354        return Err(GeomError::InvalidInput(
355            "a linear-extrusion surface needs at least two directrix points".into(),
356        ));
357    }
358    let direction = direction.normalize_or_zero();
359    if direction == Vec3::ZERO {
360        return Err(GeomError::InvalidInput(
361            "linear-extrusion direction must be finite and non-zero".into(),
362        ));
363    }
364    let mut normals = Vec::with_capacity(path.len());
365    for i in 0..path.len() {
366        let tangent = if i == 0 {
367            path[1] - path[0]
368        } else if i + 1 == path.len() {
369            path[i] - path[i - 1]
370        } else {
371            (path[i] - path[i - 1]).normalize_or_zero()
372                + (path[i + 1] - path[i]).normalize_or_zero()
373        };
374        let normal = tangent.cross(direction).normalize_or_zero();
375        if normal == Vec3::ZERO {
376            return Err(GeomError::Degenerate(
377                "directrix tangent is parallel to linear-extrusion direction".into(),
378            ));
379        }
380        normals.push(normal);
381    }
382    Ok(normals)
383}
384
385/// Sweep a profile along a directrix lying on a reference surface.
386///
387/// The surface normal at each sample supplies the up direction, so the
388/// profile stays oriented to the surface rather than to a global axis.
389/// Callers pass the sampled normals because evaluating the surface belongs
390/// to the surface provider, not to this sweep.
391pub fn surface_curve_sweep(
392    rings: &Rings,
393    path: &[Point3],
394    normals: &[Vec3],
395) -> GeomResult<TriMesh> {
396    if normals.len() != path.len() {
397        return Err(GeomError::InvalidInput(
398            "a surface curve sweep needs one surface normal per directrix point".to_owned(),
399        ));
400    }
401    let frames = frames_along(path, None, |i| normals[i])?;
402    let stations: Vec<Station> = frames
403        .iter()
404        .map(|f| loft::place(rings, |p| loft::at(f, p)))
405        .collect();
406    loft::loft(rings, &stations, false)
407}
408
409/// Sweep a disk along a directrix, optionally hollow.
410///
411/// `fillet_radius` is refused rather than ignored. The model's own docs say
412/// a consumer that cannot round corners must refuse a `Some`, because
413/// silently sharpening a pipe run produces geometry that builds, renders,
414/// and is wrong.
415pub fn swept_disk(
416    path: &[Point3],
417    radius: Scalar,
418    inner_radius: Option<Scalar>,
419    fillet_radius: Option<Scalar>,
420    tolerance: Tolerance,
421) -> GeomResult<TriMesh> {
422    check_disk(radius, inner_radius, fillet_radius)?;
423    // A disk is just a circular profile, so the sweep reuses the shared
424    // loft. The section needs a frame that stays perpendicular to the path,
425    // but which perpendicular does not matter for a circle. A single fixed
426    // axis is NOT enough: a leg along that axis has no perpendicular
427    // component and was refused, and a leg nearly along it projects to a
428    // residue of arbitrary direction, rotating the ring between stations
429    // so the loft connects vertex k to a rotated vertex k and the volume
430    // collapses silently (axiolid/kernel#169). The reference is therefore
431    // carried along the path by rotation-minimising frames.
432    let rings = disk_rings(radius, inner_radius, tolerance.linear())?;
433    let frames = rotation_minimising_frames(path, None)?;
434    let stations: Vec<Station> = frames
435        .iter()
436        .map(|f| loft::place(&rings, |p| loft::at(f, p)))
437        .collect();
438    loft::loft(&rings, &stations, false)
439}
440
441/// [`swept_disk`] whose surface stays within `chord` of the exact tube
442/// round a smooth directrix (#231).
443///
444/// Half the budget chords the disk: a ring of `n` points inscribed in a
445/// circle of radius `r` is within `r (1 - cos(pi/n))` of it, and every
446/// station places it rigidly, so every point of the exact tube is within
447/// that of the surface the rings sweep. The other half bounds that surface
448/// against its triangles, as [`fixed_reference_sweep_within`] does, with
449/// the disk's radius as the reach: along a circular arc of radius `R` the
450/// rotation-minimising frames turn with the arc, so the stations are
451/// rotations of one ring about the arc's axis and the outer side of the
452/// tube, `R + r` from it, sets the step. `sample(b)` samples the directrix
453/// to chord budget `b`; see [`SampledPath`].
454pub fn swept_disk_within(
455    mut sample: impl FnMut(Scalar) -> GeomResult<SampledPath>,
456    radius: Scalar,
457    inner_radius: Option<Scalar>,
458    fillet_radius: Option<Scalar>,
459    chord: Scalar,
460) -> GeomResult<TriMesh> {
461    check_disk(radius, inner_radius, fillet_radius)?;
462    let rings = disk_rings(radius, inner_radius, 0.5 * chord)?;
463    let stations = bounded_stations(&rings, 0.5 * chord, |b| {
464        let path = sample(b)?;
465        let frames = rotation_minimising_frames(&path.points, path.end_tangents)?;
466        Ok((frames, path.end_tangents.is_some()))
467    })?;
468    loft::loft(&rings, &stations, false)
469}
470
471/// Refuse a fillet and validate the disk's radii.
472///
473/// `fillet_radius` is refused rather than ignored. The model's own docs say
474/// a consumer that cannot round corners must refuse a `Some`, because
475/// silently sharpening a pipe run produces geometry that builds, renders,
476/// and is wrong.
477fn check_disk(
478    radius: Scalar,
479    inner_radius: Option<Scalar>,
480    fillet_radius: Option<Scalar>,
481) -> GeomResult<()> {
482    if fillet_radius.is_some() {
483        return Err(GeomError::Unsupported {
484            backend: crate::BACKEND_ID,
485            operation: axiolid_contracts::Operation::Sweep,
486        });
487    }
488    check_radii(radius, inner_radius)
489}
490
491/// Validate a swept disk's outer and inner radii.
492pub(crate) fn check_radii(radius: Scalar, inner_radius: Option<Scalar>) -> GeomResult<()> {
493    if !radius.is_finite() || radius <= 0.0 {
494        return Err(GeomError::InvalidInput(format!(
495            "swept disk radius must be positive and finite, got {radius}"
496        )));
497    }
498    if let Some(inner) = inner_radius {
499        if !inner.is_finite() || inner <= 0.0 || inner >= radius {
500            return Err(GeomError::InvalidInput(format!(
501                "swept disk inner radius must be positive and below {radius}, got {inner}"
502            )));
503        }
504    }
505    Ok(())
506}
507
508/// Rotation-minimising frames along a sampled path, by double reflection.
509///
510/// Wang, Jüttler, Zheng and Liu, "Computation of Rotation Minimizing
511/// Frames", ACM TOG 27(1), 2008: each step reflects the previous frame in
512/// the bisector plane of the chord, then in the plane that maps the
513/// reflected tangent onto the next tangent. Two reflections are a rotation,
514/// so the reference stays unit length and perpendicular to the tangent at
515/// every station by construction; it can never become parallel to it. The
516/// frame has fourth-order accuracy in the step, and no twist beyond what
517/// the path's own torsion forces.
518///
519/// Tangents match `frames_along` (averaged at interior samples, the exact
520/// ones at the ends when `ends` gives them), so a corner is mitred the
521/// same way as every other sweep family.
522fn rotation_minimising_frames(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Frame>> {
523    let seed = seed_reference(path)?;
524    let tangents = tangents_along(path, ends)?;
525    frames_carried(path, &tangents, seed)
526}
527
528/// Rotation-minimising frames along `path` with the given unit tangent at
529/// each sample, the first frame's x being `seed` made perpendicular to the
530/// first tangent; see [`rotation_minimising_frames`].
531pub(crate) fn frames_carried(
532    path: &[Point3],
533    tangents: &[Vec3],
534    seed: Vec3,
535) -> GeomResult<Vec<Frame>> {
536    let mut reference = seed;
537    let mut frames = Vec::with_capacity(path.len());
538    for i in 0..path.len() {
539        frames.push(Frame::from_reference(path[i], tangents[i], reference)?);
540        let Some(next) = path.get(i + 1) else { break };
541        let chord = *next - path[i];
542        let c1 = chord.dot(chord);
543        if c1 == 0.0 {
544            // A repeated sample: the frame does not move. `tangents_along`
545            // has already refused a path whose tangent vanishes here.
546            continue;
547        }
548        let reflected_ref = reference - chord * (2.0 / c1 * chord.dot(reference));
549        let reflected_tan = tangents[i] - chord * (2.0 / c1 * chord.dot(tangents[i]));
550        let fix = tangents[i + 1] - reflected_tan;
551        let c2 = fix.dot(fix);
552        reference = if c2 == 0.0 {
553            reflected_ref
554        } else {
555            reflected_ref - fix * (2.0 / c2 * fix.dot(reflected_ref))
556        };
557    }
558    Ok(frames)
559}
560
561/// Unit tangent at each sample, averaged at interior samples; at the two
562/// ends the exact tangents when `ends` gives them, else the end chords.
563fn tangents_along(path: &[Point3], ends: Option<[Vec3; 2]>) -> GeomResult<Vec<Vec3>> {
564    if path.len() < 2 {
565        return Err(GeomError::InvalidInput(format!(
566            "a sweep directrix needs at least two points, got {}",
567            path.len()
568        )));
569    }
570    (0..path.len())
571        .map(|i| {
572            let raw = if i == 0 {
573                ends.map_or(path[1] - path[0], |[start, _]| start)
574            } else if i + 1 == path.len() {
575                ends.map_or(path[i] - path[i - 1], |[_, end]| end)
576            } else {
577                (path[i] - path[i - 1]).normalize_or_zero()
578                    + (path[i + 1] - path[i]).normalize_or_zero()
579            };
580            let tangent = raw.normalize_or_zero();
581            if tangent == Vec3::ZERO {
582                Err(GeomError::InvalidInput(
583                    "sweep tangent must be a non-zero direction".to_owned(),
584                ))
585            } else {
586                Ok(tangent)
587            }
588        })
589        .collect()
590}
591
592/// A circular profile, hollow when `inner` is given, each ring within
593/// `chord` of its circle.
594pub(crate) fn disk_rings(
595    radius: Scalar,
596    inner: Option<Scalar>,
597    chord: Scalar,
598) -> GeomResult<Rings> {
599    let circle = |r: Scalar, reverse: bool| -> GeomResult<Vec<Point2>> {
600        let n = crate::revolve::steps(r, core::f64::consts::TAU, chord)?;
601        let mut pts: Vec<Point2> = (0..n)
602            .map(|k| {
603                let a = core::f64::consts::TAU * (k as Scalar) / (n as Scalar);
604                Point2::new(r * a.cos(), r * a.sin())
605            })
606            .collect();
607        if reverse {
608            pts.reverse();
609        }
610        Ok(pts)
611    };
612    // A hole ring runs opposite the outer ring so the triangulator reads it
613    // as a void rather than a second island.
614    Ok(Rings {
615        outer: circle(radius, false)?,
616        holes: match inner {
617            Some(r) => vec![circle(r, true)?],
618            None => Vec::new(),
619        },
620    })
621}
622
623/// A reference direction guaranteed not to be parallel to the first
624/// segment.
625///
626/// A circular section has no preferred orientation, so any perpendicular
627/// will do; what matters is that it is never degenerate.
628fn seed_reference(path: &[Point3]) -> GeomResult<Vec3> {
629    if path.len() < 2 {
630        return Err(GeomError::InvalidInput(
631            "a swept disk directrix needs at least two points".to_owned(),
632        ));
633    }
634    let t = (path[1] - path[0]).normalize_or_zero();
635    if t == Vec3::ZERO {
636        return Err(GeomError::InvalidInput(
637            "a swept disk directrix must not start with a zero-length segment".to_owned(),
638        ));
639    }
640    let candidate = if t.x.abs() < 0.9 { Vec3::X } else { Vec3::Y };
641    Ok(candidate - t * t.dot(candidate))
642}
643
644/// Loft explicit sections placed along a spine.
645///
646/// The caller has already resolved each section's profile and placement, so
647/// this only stitches them. Sections must share a ring structure: a spine
648/// whose sections differ in topology has no vertex correspondence, and
649/// pairing by index would weld unrelated points.
650pub fn sectioned_spine(sections: &[(Rings, Vec<Point3>)]) -> GeomResult<TriMesh> {
651    if sections.len() < 2 {
652        return Err(GeomError::InvalidInput(format!(
653            "a sectioned spine needs at least two sections, got {}",
654            sections.len()
655        )));
656    }
657    let stations: Vec<Station> = sections
658        .iter()
659        .map(|(rings, placed)| {
660            let mut it = placed.iter().copied();
661            let mut loops = Vec::with_capacity(1 + rings.holes.len());
662            loops.push((0..rings.outer.len()).filter_map(|_| it.next()).collect());
663            for hole in &rings.holes {
664                loops.push((0..hole.len()).filter_map(|_| it.next()).collect());
665            }
666            Station { loops }
667        })
668        .collect();
669    loft::loft(&sections[0].0, &stations, false)
670}