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