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