Skip to main content

geop_lib_sweep/
sweep.rs

1//! Sweeping planar profiles along a [`Path`] — straight, for an extrude,
2//! around an axis, for a revolve, along a chain of curves, for a sweep (see
3//! [`crate::path_sweep`]) — into a solid or a sheet, described whole as a
4//! [`BodySpec`] and built in one go (see [`geop_core_topology::build`]).
5//! [`skin`] builds the same grid through a different section at every
6//! station, as a loft does (see [`crate::loft`]). The grid itself — its
7//! vertices, edges, walls and caps, and their names — is built by
8//! [`crate::skin`], as a blend's tool's is; what is the
9//! sweep's own is where its sections are: placed by the path's frames.
10//!
11//! A sweep is a grid: every profile joint sits at every station of the path
12//! (a vertex), every profile curve lies at every station (an edge), every
13//! joint travels along every span between two stations (a *lateral* edge),
14//! and every curve sweeps a *wall* face through every span. A solid swept
15//! along an open path is closed off by flat *caps*, at the first and last
16//! station; along a closed path — a full turn — it needs none. The loops
17//! swept are areas one after the other, each an outer loop and then its
18//! holes, which wind the other way — the letters of a text several — and
19//! each area is closed off by caps of its own, a lump of the solid (see
20//! [`areas`]).
21//!
22//! A revolve's axis makes two things degenerate. A joint on the axis is a
23//! *pole*: it stays put, so it is a single vertex at every station, with no
24//! lateral edges — the walls next to it close over it with a degenerate
25//! coedge sitting at the pole (see [`CoedgeOn::Vertex`]). A curve lying on
26//! the axis sweeps no wall at all; with caps, it is the one edge both caps
27//! share.
28//!
29//! **Parametrization.** A wall is the tensor product of its span (`u`, from
30//! one station to the next: degree 1 for a line, a rational quadratic for an
31//! arc, a B-spline for any other curve) and its curve (`v`, the curve's own
32//! degree and knots). So its normal `∂u × ∂v` points out of the material
33//! exactly when the profile runs counter-clockwise in its stations' `(e1,
34//! e2)` and the path runs along `-(e1 × e2)` — or clockwise, along `e1 ×
35//! e2` — see [`Path::along_normal`].
36//! Orienting the profile accordingly is the caller's part; [`sweep`] builds
37//! the caps to match.
38
39use std::collections::BTreeMap;
40
41use geop_core_geometry::{
42    nurb_curve::{NurbCurve, NurbCurve2D, NurbCurve3D, embed_point, loop_signed_area},
43    nurb_surface::{FlatPatch, NurbSurface, NurbSurface3D},
44};
45use geop_core_math::{
46    geop_error::{GeopError, GeopResult, WithContext},
47    scalars::Scalar,
48    union_find::UnionFind,
49    vector::{Vector2, Vector3, Vector4},
50    with_context,
51};
52use geop_core_topology::build::{BodySpec, BuiltBody, CoedgeOn};
53use geop_ops_model::{BodyNames, NamedModel, Namer, Profile};
54
55use crate::skin::{self, Strip, StripCap, StripGeometry};
56
57/// Where the profile's plane is at one station of a path: a profile point
58/// `(x, y)` lies at `origin + x e1 + y e2`.
59#[derive(Clone, Debug)]
60pub struct Frame {
61    pub origin: Vector3,
62    pub e1: Vector3,
63    pub e2: Vector3,
64}
65
66impl Frame {
67    /// The profile point `p` at this station.
68    pub fn point(&self, p: &Vector2) -> Vector3 {
69        self.origin
70            .add(&self.e1.prod_scalar(p[0]))
71            .add(&self.e2.prod_scalar(p[1]))
72    }
73
74    /// The profile curve `curve` at this station. An affine map of its
75    /// control points, so exact for any NURBS.
76    pub fn curve(&self, curve: &NurbCurve2D) -> GeopResult<NurbCurve3D> {
77        curve.embed(&self.origin, &self.e1, &self.e2)
78    }
79}
80
81/// How the profile travels from one station to the next.
82#[derive(Clone, Debug)]
83pub enum Span {
84    /// In a straight line: degree 1.
85    Line,
86    /// Along a NURBS of `degree` on the clamped `knots` over `[0, 1]`, whose
87    /// inner control rows are the span's first section placed by each of
88    /// `middle`, weighted by its weight (none: one): along circular arcs —
89    /// a rational quadratic (see [`Span::arc`]) — translated along a curve,
90    /// or along any other path, approximated by sections of the profile
91    /// skinned together (see [`crate::path_sweep`]).
92    Curve {
93        degree: usize,
94        knots: Vec<Scalar>,
95        middle: Vec<(Frame, Option<Scalar>)>,
96    },
97    /// From one section to another of a different shape, along a NURBS of
98    /// `degree` on the clamped `knots` over `[0, 1]`, each inner control row
99    /// the sum of the span's first section placed by the first frame and
100    /// its last section placed by the second, each times its weight: the
101    /// interior of a loft shaped by guide curves (see [`crate::loft`]).
102    Blend {
103        degree: usize,
104        knots: Vec<Scalar>,
105        middle: Vec<[(Frame, Scalar); 2]>,
106    },
107    /// Along a NURBS of `degree` on the clamped `knots` over `[0, 1]`, each
108    /// inner control row a sum of the sections at any stations — each
109    /// placed by its own station's frame, times its coefficient: a loft
110    /// running smoothly through its sections, its tangent at each taken
111    /// from its neighbours (see [`crate::loft`]).
112    Through {
113        degree: usize,
114        knots: Vec<Scalar>,
115        middle: Vec<Vec<(usize, Scalar)>>,
116    },
117}
118
119impl Span {
120    /// Along circular arcs: a rational quadratic whose middle control row is
121    /// the profile at `middle`, weighted `weight`.
122    pub fn arc(middle: Frame, weight: Scalar) -> Self {
123        Span::Curve {
124            degree: 2,
125            knots: vec![
126                Scalar::ZERO,
127                Scalar::ZERO,
128                Scalar::ZERO,
129                Scalar::ONE,
130                Scalar::ONE,
131                Scalar::ONE,
132            ],
133            middle: vec![(middle, Some(weight))],
134        }
135    }
136}
137
138/// One control row of a span (see [`Path::rows`]).
139enum Row<'a> {
140    /// The span's first section, placed by a frame, its weight multiplied
141    /// by the weight given, if any.
142    First(&'a Frame, Option<Scalar>),
143    /// The span's last section, placed by a frame.
144    Last(&'a Frame),
145    /// The sum of the first section placed by one frame and the last placed
146    /// by another, each times its weight.
147    Both(&'a [(Frame, Scalar); 2]),
148    /// The sum of the sections at the stations given, each placed by its
149    /// own station's frame, times its coefficient.
150    Stations(&'a [(usize, Scalar)]),
151}
152
153impl Row<'_> {
154    /// The row's control point for the homogeneous profile point `at[s]`
155    /// of the section at every station `s` of `stations`, in span `(a, b)`:
156    /// from station `a` to station `b`.
157    fn point(&self, stations: &[Frame], (a, b): (usize, usize), at: &[Vector3]) -> Vector4 {
158        let place =
159            |p: &Vector3, frame: &Frame| embed_point(p, &frame.origin, &frame.e1, &frame.e2);
160        match self {
161            Row::First(frame, weight) => weighted(place(&at[a], frame), *weight),
162            Row::Last(frame) => place(&at[b], frame),
163            Row::Both([(fa, wa), (fb, wb)]) => {
164                let (pa, pb) = (place(&at[a], fa), place(&at[b], fb));
165                Vector4::from_array(std::array::from_fn(|k| pa[k].mul(*wa).add(pb[k].mul(*wb))))
166            }
167            Row::Stations(terms) => {
168                // A coefficient of one multiplies nothing, and the sum starts
169                // at the first term, not at zero: rounding by either would
170                // only widen the row.
171                let term = |&(s, coefficient): &(usize, Scalar)| -> Vector4 {
172                    let p = place(&at[s], &stations[s]);
173                    if coefficient.is_exactly(Scalar::ONE) {
174                        p
175                    } else {
176                        Vector4::from_array(std::array::from_fn(|k| p[k].mul(coefficient)))
177                    }
178                };
179                let mut terms = terms.iter().map(term);
180                let first = terms.next().expect("a row of at least one section");
181                terms.fold(first, |sum, t| {
182                    Vector4::from_array(std::array::from_fn(|k| sum[k].add(t[k])))
183                })
184            }
185        }
186    }
187}
188
189/// What a profile is swept along: its stations, and the spans between
190/// consecutive ones — around to the first again for a `closed` path (a full
191/// turn), which has as many spans as stations; an open one has one fewer.
192#[derive(Clone, Debug)]
193pub struct Path {
194    pub stations: Vec<Frame>,
195    pub spans: Vec<Span>,
196    pub closed: bool,
197    /// Whether the path runs along `e1 × e2` of its stations rather than
198    /// against it — which way the profile must wind (see the module docs).
199    pub along_normal: bool,
200    /// What each station is called in the names of what lies on it: `start`
201    /// and `end`, or `a0`, `a1`, ...
202    pub station_names: Vec<String>,
203    /// What each span is called in the names of what it sweeps — `q0`, `q1`,
204    /// ... — or nothing, for a path of one span that needs no telling apart.
205    pub span_names: Vec<Option<String>>,
206}
207
208impl Path {
209    /// Station `j`, counting on around a closed path.
210    fn station(&self, j: usize) -> usize {
211        if self.closed {
212            j % self.stations.len()
213        } else {
214            j
215        }
216    }
217
218    /// The control rows of span `j`: its first station's, its inner rows,
219    /// its last station's. A station's row has no weight: multiplying by
220    /// one would only widen every enclosure by rounding.
221    fn rows(&self, j: usize) -> Vec<Row<'_>> {
222        let (a, b) = (&self.stations[j], &self.stations[self.station(j + 1)]);
223        let middle: Vec<Row<'_>> = match &self.spans[j] {
224            Span::Line => Vec::new(),
225            Span::Curve { middle, .. } => middle.iter().map(|(m, w)| Row::First(m, *w)).collect(),
226            Span::Blend { middle, .. } => middle.iter().map(Row::Both).collect(),
227            Span::Through { middle, .. } => middle.iter().map(|m| Row::Stations(m)).collect(),
228        };
229        std::iter::once(Row::First(a, None))
230            .chain(middle)
231            .chain(std::iter::once(Row::Last(b)))
232            .collect()
233    }
234
235    fn degree(&self, j: usize) -> usize {
236        match &self.spans[j] {
237            Span::Line => 1,
238            Span::Curve { degree, .. }
239            | Span::Blend { degree, .. }
240            | Span::Through { degree, .. } => *degree,
241        }
242    }
243
244    fn knots(&self, j: usize) -> Vec<Scalar> {
245        match &self.spans[j] {
246            Span::Line => vec![Scalar::ZERO, Scalar::ZERO, Scalar::ONE, Scalar::ONE],
247            Span::Curve { knots, .. } | Span::Blend { knots, .. } | Span::Through { knots, .. } => {
248                knots.clone()
249            }
250        }
251    }
252
253    /// The stations span `j` runs between.
254    fn ends(&self, j: usize) -> (usize, usize) {
255        (j, self.station(j + 1))
256    }
257
258    /// The path along span `j` of a profile point, `points[s]` in the
259    /// section at station `s` (see [`skin`]).
260    fn lateral(&self, j: usize, points: &[Vector2]) -> GeopResult<NurbCurve3D> {
261        let at: Vec<Vector3> = points
262            .iter()
263            .map(|p| Vector3::from_array([p[0], p[1], Scalar::ONE]))
264            .collect();
265        let control_points = self
266            .rows(j)
267            .iter()
268            .map(|row| row.point(&self.stations, self.ends(j), &at))
269            .collect();
270        NurbCurve::try_new(self.degree(j), control_points, self.knots(j))
271    }
272
273    /// The wall a profile curve sweeps through span `j`, `curves[s]` in the
274    /// section at station `s` — compatible curves: `u` along the span, `v`
275    /// along the curve.
276    fn wall(&self, j: usize, curves: &[&NurbCurve2D]) -> GeopResult<NurbSurface3D> {
277        let a = curves[j];
278        let rows = self.rows(j);
279        let mut control_points = Vec::with_capacity(rows.len() * a.control_points.len());
280        for row in &rows {
281            for k in 0..a.control_points.len() {
282                let at: Vec<Vector3> = curves.iter().map(|c| c.control_points[k]).collect();
283                control_points.push(row.point(&self.stations, self.ends(j), &at));
284            }
285        }
286        NurbSurface::try_new(
287            self.degree(j),
288            a.degree,
289            control_points,
290            self.knots(j),
291            a.knot_vector.clone(),
292        )
293    }
294
295    fn check(&self) -> GeopResult<()> {
296        let stations = self.stations.len();
297        let spans = self.spans.len();
298        let expected = if self.closed { stations } else { stations - 1 };
299        if spans == 0 || spans != expected {
300            return Err(GeopError::new(format!(
301                "sweep: a {} path of {stations} stations cannot have {spans} spans",
302                if self.closed { "closed" } else { "open" }
303            )));
304        }
305        if self.station_names.len() != stations || self.span_names.len() != spans {
306            return Err(GeopError::new("sweep: a name for every station and span"));
307        }
308        Ok(())
309    }
310}
311
312/// The homogeneous point `p` with its weight multiplied by `weight`, if any.
313fn weighted(p: Vector4, weight: Option<Scalar>) -> Vector4 {
314    match weight {
315        None => p,
316        Some(w) => Vector4::from_array([p[0].mul(w), p[1].mul(w), p[2].mul(w), p[3].mul(w)]),
317    }
318}
319
320/// One profile loop or chain to sweep, and which of its joints are poles
321/// and which of its curves lie on the axis (see the module docs). A curve
322/// on the axis runs between two poles.
323#[derive(Clone, Debug)]
324pub struct SweepLoop {
325    pub profile: Profile,
326    /// Per joint: whether it lies on the axis.
327    pub poles: Vec<bool>,
328    /// Per curve: whether it lies along the axis.
329    pub on_axis: Vec<bool>,
330    /// Whether the loop is collapsed to one point, every curve degenerate
331    /// there: the apex a loft to a point comes to (see [`skin`]).
332    pub apex: bool,
333}
334
335impl SweepLoop {
336    /// `profile`, clear of any axis.
337    pub fn plain(profile: Profile) -> Self {
338        Self {
339            poles: vec![false; profile.joint_names.len()],
340            on_axis: vec![false; profile.curves.len()],
341            profile,
342            apex: false,
343        }
344    }
345
346    /// The same loop traversed the other way; every flag stays with its
347    /// curve or joint (see [`Profile::reversed`]).
348    pub fn reversed(&self) -> Self {
349        let n = self.profile.curves.len();
350        let poles = if self.profile.is_closed() {
351            (0..n).map(|m| self.poles[(n - m) % n]).collect()
352        } else {
353            self.poles.iter().rev().copied().collect()
354        };
355        Self {
356            profile: self.profile.reversed(),
357            poles,
358            on_axis: self.on_axis.iter().rev().copied().collect(),
359            apex: self.apex,
360        }
361    }
362
363    /// The same loop with every curve cut at its corners: the knots inside
364    /// it where the curve is only C0 and turns, its tangent coming in
365    /// definitely not along the one going out — a polyline given as one
366    /// curve, a curve a STEP file gives with a kink. Swept whole, such a
367    /// curve makes a wall creased inside one face, which every question
368    /// about a face's normal — an offset, a blend, a silhouette — takes
369    /// for one smooth surface. Cut, the crease is an edge. The pieces of a
370    /// curve `X` are `X`, `X#k1`, `X#k2`, ..., the joints between them
371    /// `X@k1`, `X@k2`, ...; each piece is an exact piece of the curve, on
372    /// `[0, 1]`. A joint inside a curve on the axis is a pole.
373    pub fn at_corners(&self) -> GeopResult<Self> {
374        let mut out = self.clone();
375        // From the last curve back, so the indices still to come stay.
376        for i in (0..self.profile.curves.len()).rev() {
377            let curve = &self.profile.curves[i];
378            let mut corners = Vec::new();
379            let mut knots = curve.knot_vector
380                [curve.degree + 1..curve.knot_vector.len() - curve.degree - 1]
381                .iter()
382                .peekable();
383            while let Some(&k) = knots.next() {
384                let mut multiplicity = 1;
385                while knots.next_if(|&&n| n.is_exactly(k)).is_some() {
386                    multiplicity += 1;
387                }
388                if multiplicity < curve.degree {
389                    continue;
390                }
391                let (before, after) = curve.split(k)?;
392                let (incoming, outgoing) = (
393                    before.tangent(before.domain().1)?,
394                    after.tangent(after.domain().0)?,
395                );
396                let turns = !incoming.prod_cross(&outgoing).could_be_equal(Scalar::ZERO)
397                    || incoming.prod_dot(&outgoing).definitely_less(Scalar::ZERO);
398                if turns {
399                    corners.push(k);
400                }
401            }
402            if corners.is_empty() {
403                continue;
404            }
405            let (t0, t1) = curve.domain();
406            let bounds: Vec<Scalar> = std::iter::once(t0).chain(corners).chain([t1]).collect();
407            let pieces = bounds
408                .windows(2)
409                .map(|w| {
410                    curve
411                        .sub_curve(w[0], w[1])?
412                        .with_unit_domain()?
413                        .with_unit_end_weights()
414                })
415                .collect::<GeopResult<Vec<_>>>()?;
416            let name = self.profile.curve_names[i].clone();
417            let cuts = pieces.len() - 1;
418            out.profile.curves.splice(i..=i, pieces);
419            out.profile
420                .curve_names
421                .splice(i + 1..i + 1, (1..=cuts).map(|k| format!("{name}#k{k}")));
422            out.profile
423                .joint_names
424                .splice(i + 1..i + 1, (1..=cuts).map(|k| format!("{name}@k{k}")));
425            let on_axis = self.on_axis[i];
426            out.on_axis.splice(i..=i, vec![on_axis; cuts + 1]);
427            out.poles.splice(i + 1..i + 1, vec![on_axis; cuts]);
428        }
429        Ok(out)
430    }
431
432    fn joints(&self) -> usize {
433        self.profile.joint_names.len()
434    }
435
436    /// The joint curve `i` ends at.
437    fn next(&self, i: usize) -> usize {
438        (i + 1) % self.joints()
439    }
440
441    /// Checks the profile is a chain of clamped curves on `[0, 1]`, each
442    /// starting where the previous one ends — a closed one of at least two,
443    /// since every vertex sits at a joint — with a flag for every joint and
444    /// curve, and every curve on the axis between two poles.
445    fn check(&self) -> GeopResult<()> {
446        let profile = &self.profile;
447        profile.check_names()?;
448        let curves = &profile.curves;
449        let n = curves.len();
450        if n == 0 || (profile.is_closed() && n < 2) {
451            return Err(GeopError::new(format!(
452                "sweep: a profile of {n} curve(s) is too short"
453            )));
454        }
455        if self.poles.len() != self.joints() || self.on_axis.len() != n {
456            return Err(GeopError::new("sweep: a flag for every joint and curve"));
457        }
458        for (i, curve) in curves.iter().enumerate() {
459            let (t0, t1) = curve.domain();
460            if !(t0.could_be_equal(Scalar::ZERO) && t1.could_be_equal(Scalar::ONE)) {
461                return Err(GeopError::new(format!(
462                    "sweep: curve {} has domain ({t0:?}, {t1:?}), not [0, 1]",
463                    profile.curve_names[i]
464                )));
465            }
466            if i + 1 < n || profile.is_closed() {
467                let next = &curves[(i + 1) % n];
468                let (end, start) = (curve.end()?, next.start()?);
469                if !end.could_be_equal(&start) {
470                    return Err(GeopError::new(format!(
471                        "sweep: curve {} ends at {end:?}, but the next one starts at {start:?}",
472                        profile.curve_names[i]
473                    )));
474                }
475            }
476            if self.on_axis[i] && !(self.poles[i] && self.poles[self.next(i)]) {
477                return Err(GeopError::new(format!(
478                    "sweep: curve {} lies on the axis, but its ends are not poles",
479                    profile.curve_names[i]
480                )));
481            }
482        }
483        Ok(())
484    }
485}
486
487/// Sweeps `loops` along `path`: into a solid named `solid`, or, without
488/// one, into sheets — a shell of walls per loop, with no caps.
489///
490/// A solid's loops are closed, the first its outer boundary and the rest
491/// holes, each oriented as the module docs say; along an open path it gets
492/// a cap at either end, `N(start)` and `N(end)`. The rest is named after the
493/// profile's curves `X` and joints `P` (see [`Profile`]) and the path's
494/// station names `s` and span names `q`:
495///
496/// | entity | name |
497/// |---|---|
498/// | wall swept by `X` through span `q` | `N(X,q)`, or `N(X)` for an unnamed span |
499/// | `X` at station `s` | `N(X,s)`; `N(X)` for a curve on the axis |
500/// | lateral edge swept by `P` through `q` | `N(P,q)`, or `N(P)` |
501/// | vertex of `P` at `s` | `N(P,s)`; `N(P)` for a pole |
502///
503/// The solid's shells are the connected sets of faces: one, unless a full
504/// turn leaves a hole — or a pocket closed off by the axis — sweeping a void
505/// of its own.
506pub fn sweep(
507    model: &mut NamedModel,
508    namer: &Namer,
509    path: &Path,
510    loops: &[SweepLoop],
511    solid: Option<&str>,
512) -> GeopResult<BuiltBody> {
513    let loops = (loops.iter())
514        .map(SweepLoop::at_corners)
515        .collect::<GeopResult<Vec<_>>>()?;
516    let sections = vec![loops; path.stations.len()];
517    skin(model, namer, path, &sections, solid)
518}
519
520/// [`sweep`]s `loops` as they are drawn — the outer loop counter-clockwise
521/// in the stations' `(e1, e2)`, the holes clockwise — each run the other
522/// way first if the path runs along `e1 × e2`, where a counter-clockwise
523/// outer loop would sweep walls facing inwards.
524pub fn sweep_drawn(
525    model: &mut NamedModel,
526    namer: &Namer,
527    path: &Path,
528    loops: &[SweepLoop],
529    solid: Option<&str>,
530) -> GeopResult<BuiltBody> {
531    if path.along_normal {
532        let reversed: Vec<SweepLoop> = loops.iter().map(SweepLoop::reversed).collect();
533        sweep(model, namer, path, &reversed, solid)
534    } else {
535        sweep(model, namer, path, loops, solid)
536    }
537}
538
539/// Like [`sweep`], but with a section of its own at every station of
540/// `path`: `sections[s]` is what lies at station `s`, in its frame — as a
541/// loft passes through profiles of different shapes. Each section has the
542/// same loops, each loop the same number of curves and the same flags, and
543/// the `i`-th curves of all of them are compatible: of one degree, on one
544/// knot vector (see `NurbCurve::compatible`). A wall then runs from one
545/// section's curve to the next one's; a span's inner rows carry its first
546/// station's section on.
547///
548/// Everything is named after the first section's curves and joints, as
549/// [`sweep`] names it.
550pub fn skin(
551    model: &mut NamedModel,
552    namer: &Namer,
553    path: &Path,
554    sections: &[Vec<SweepLoop>],
555    solid: Option<&str>,
556) -> GeopResult<BuiltBody> {
557    let (spec, names) = skinned(namer, path, sections, solid)?;
558    model.build_body(spec, names).with_context(with_context!(
559        "skin({}, solid={solid:?}): building it",
560        namer.root()
561    ))
562}
563
564/// What [`skin`] builds, described: the body and its names.
565pub(crate) fn skinned(
566    namer: &Namer,
567    path: &Path,
568    sections: &[Vec<SweepLoop>],
569    solid: Option<&str>,
570) -> GeopResult<(BodySpec, BodyNames)> {
571    let ctx = with_context!(
572        "skin({}, {} section(s) of {} loop(s), solid={solid:?})",
573        namer.root(),
574        sections.len(),
575        sections.first().map_or(0, Vec::len)
576    );
577    path.check().with_context(ctx)?;
578    check_sections(path, sections, solid.is_some()).with_context(ctx)?;
579    let loops = &sections[0];
580    let geometry: Vec<Placed<'_>> = (0..loops.len())
581        .map(|l| Placed { path, sections, l })
582        .collect();
583    let apexes: Vec<Vec<bool>> = (0..loops.len())
584        .map(|l| sections.iter().map(|section| section[l].apex).collect())
585        .collect();
586    let strips: Vec<Strip<'_>> = loops
587        .iter()
588        .zip(&geometry)
589        .zip(&apexes)
590        .map(|((lp, geometry), apex)| Strip {
591            namer,
592            joint_names: &lp.profile.joint_names,
593            curve_names: &lp.profile.curve_names,
594            station_names: &path.station_names,
595            span_names: &path.span_names,
596            closed: path.closed,
597            poles: &lp.poles,
598            on_axis: &lp.on_axis,
599            apex,
600            glued: Vec::new(),
601            geometry,
602        })
603        .collect();
604    let mut caps = Vec::new();
605    if solid.is_some() && !path.closed {
606        // Each parametrized so that it runs the outer loop counter-clockwise.
607        for (s, forward, name) in [(0, true, "start"), (path.stations.len() - 1, false, "end")] {
608            let (frame, section) = (&path.stations[s], &sections[s]);
609            // A section collapsed to its apex needs no cap.
610            if section.iter().any(|lp| lp.apex) {
611                if section.len() > 1 {
612                    return Err(GeopError::new(format!(
613                        "skin: the section at {} comes to a point, and has holes",
614                        path.station_names[s]
615                    )))
616                    .with_context(ctx);
617                }
618                continue;
619            }
620            // A cap for each area: the first one's named for the station,
621            // every other's for the station and the least name of its outer
622            // loop's curves.
623            for (a, area) in areas(section).with_context(ctx)?.into_iter().enumerate() {
624                let patch = FlatPatch::around(
625                    frame.origin,
626                    frame.e1,
627                    frame.e2,
628                    area.iter().flat_map(|&l| &section[l].profile.curves),
629                    path.along_normal == forward,
630                )?;
631                let loops = (area.iter())
632                    .map(|&l| {
633                        let pcurves = (section[l].profile.curves.iter())
634                            .map(|c| patch.pcurve(c))
635                            .collect::<GeopResult<_>>()?;
636                        Ok((l, pcurves))
637                    })
638                    .collect::<GeopResult<_>>()?;
639                let name = match a {
640                    0 => namer.name(&[name]),
641                    _ => {
642                        let outline = &section[area[0]].profile.curve_names;
643                        let least = outline.iter().min().map_or("", String::as_str);
644                        namer.name(&[name, least])
645                    }
646                };
647                caps.push(StripCap {
648                    name,
649                    station: s,
650                    forward,
651                    surface: patch.surface()?,
652                    loops,
653                });
654            }
655        }
656    }
657
658    let mut spec = BodySpec::new(solid.is_some());
659    let mut names = BodyNames {
660        solid: solid.map(str::to_string),
661        ..BodyNames::default()
662    };
663    skin::skin(&mut spec, &mut names, &strips, &caps).with_context(ctx)?;
664    spec.shells = connected_faces(&spec);
665    Ok((spec, names))
666}
667
668/// The areas `loops` are, as the indices of their loops: a loop winding the
669/// way the first one does — by its signed area, integrated from its curves
670/// ([`loop_signed_area`]) — is the outer loop of an area, and the loops
671/// after it winding the other way are its holes. One loop is one area.
672pub(crate) fn areas(loops: &[SweepLoop]) -> GeopResult<Vec<Vec<usize>>> {
673    if loops.len() < 2 {
674        return Ok(vec![(0..loops.len()).collect()]);
675    }
676    let winds = |l: usize| -> GeopResult<bool> {
677        let area = loop_signed_area(&loops[l].profile.curves)?;
678        if area.definitely_greater(Scalar::ZERO) {
679            Ok(true)
680        } else if area.definitely_less(Scalar::ZERO) {
681            Ok(false)
682        } else {
683            Err(GeopError::new(format!(
684                "loop {l} of the profile encloses an area {area:?} that could be zero: which way it winds, and so whether it is a hole, is unknown"
685            )))
686        }
687    };
688    let outer = winds(0)?;
689    let mut areas: Vec<Vec<usize>> = Vec::new();
690    for l in 0..loops.len() {
691        match areas.last_mut() {
692            Some(area) if winds(l)? != outer => area.push(l),
693            _ => areas.push(vec![l]),
694        }
695    }
696    Ok(areas)
697}
698
699/// The wall the curve `i` of loop `l` of `loops` sweeps through span `j`
700/// of `path`, as [`sweep`] builds it.
701pub(crate) fn wall(
702    path: &Path,
703    loops: &[SweepLoop],
704    (l, j, i): (usize, usize, usize),
705) -> GeopResult<NurbSurface3D> {
706    let sections = vec![loops.to_vec(); path.stations.len()];
707    Placed {
708        path,
709        sections: &sections,
710        l,
711    }
712    .wall(j, i)
713}
714
715/// Loop `l` of `sections`, each placed by its station's frame of `path`:
716/// what [`skin`] builds.
717struct Placed<'a> {
718    path: &'a Path,
719    sections: &'a [Vec<SweepLoop>],
720    l: usize,
721}
722
723impl Placed<'_> {
724    fn profile(&self, s: usize) -> &Profile {
725        &self.sections[s][self.l].profile
726    }
727}
728
729impl StripGeometry for Placed<'_> {
730    fn joint(&self, s: usize, i: usize) -> GeopResult<Vector3> {
731        Ok(self.path.stations[s].point(&self.profile(s).joint(i)?))
732    }
733
734    fn curve(&self, s: usize, i: usize) -> GeopResult<NurbCurve3D> {
735        self.path.stations[s].curve(&self.profile(s).curves[i])
736    }
737
738    fn lateral(&self, j: usize, i: usize) -> GeopResult<NurbCurve3D> {
739        let points = (0..self.sections.len())
740            .map(|s| self.profile(s).joint(i))
741            .collect::<GeopResult<Vec<_>>>()?;
742        self.path.lateral(j, &points)
743    }
744
745    fn wall(&self, j: usize, i: usize) -> GeopResult<NurbSurface3D> {
746        let curves: Vec<&NurbCurve2D> = (0..self.sections.len())
747            .map(|s| &self.profile(s).curves[i])
748            .collect();
749        self.path.wall(j, &curves)
750    }
751}
752
753/// Checks that `sections` fit `path` and each other, as [`skin`] needs:
754/// one per station, each a valid set of loops — closed ones, for a solid —
755/// all with the same loops, curves and flags, the `i`-th curves of all of
756/// them on one degree and one knot vector.
757fn check_sections(path: &Path, sections: &[Vec<SweepLoop>], solid: bool) -> GeopResult<()> {
758    if sections.len() != path.stations.len() {
759        return Err(GeopError::new(format!(
760            "skin: {} sections for {} stations",
761            sections.len(),
762            path.stations.len()
763        )));
764    }
765    let first = &sections[0];
766    if first.is_empty() {
767        return Err(GeopError::new("sweep: nothing to sweep"));
768    }
769    let same = |a: &Scalar, b: &Scalar| a.is_subset_of(*b) && b.is_subset_of(*a);
770    for (s, section) in sections.iter().enumerate() {
771        if section.len() != first.len() {
772            return Err(GeopError::new(format!(
773                "skin: section {s} has {} loops, the first {}",
774                section.len(),
775                first.len()
776            )));
777        }
778        for (l, (lp, lp0)) in section.iter().zip(first).enumerate() {
779            lp.check()?;
780            if solid && !lp.profile.is_closed() {
781                return Err(GeopError::new(
782                    "sweep: a solid is swept from closed loops only",
783                ));
784            }
785            if lp.profile.curves.len() != lp0.profile.curves.len()
786                || lp.profile.is_closed() != lp0.profile.is_closed()
787                || lp.poles != lp0.poles
788                || lp.on_axis != lp0.on_axis
789            {
790                return Err(GeopError::new(format!(
791                    "skin: loop {l} of section {s} does not match the first section's"
792                )));
793            }
794            for (i, (c, c0)) in lp
795                .profile
796                .curves
797                .iter()
798                .zip(&lp0.profile.curves)
799                .enumerate()
800            {
801                if c.degree != c0.degree
802                    || c.knot_vector.len() != c0.knot_vector.len()
803                    || !c
804                        .knot_vector
805                        .iter()
806                        .zip(&c0.knot_vector)
807                        .all(|(a, b)| same(a, b))
808                {
809                    return Err(GeopError::new(format!(
810                        "skin: curve {i} of loop {l} of section {s} (degree {}, knots {:?}) is not compatible with the first section's (degree {}, knots {:?})",
811                        c.degree, c.knot_vector, c0.degree, c0.knot_vector
812                    )));
813                }
814            }
815        }
816    }
817    Ok(())
818}
819
820/// The faces of `spec` grouped into connected sets — faces sharing an edge
821/// are connected — each in the order of its faces' indices, and the sets in
822/// the order of their first face.
823fn connected_faces(spec: &BodySpec) -> Vec<Vec<usize>> {
824    let mut sets = UnionFind::new(spec.faces.len());
825    let mut user_of_edge: BTreeMap<usize, usize> = BTreeMap::new();
826    for (f, face) in spec.faces.iter().enumerate() {
827        for coedge in std::iter::once(&face.outer).chain(&face.holes).flatten() {
828            if let CoedgeOn::Edge(e, _) = coedge.on {
829                match user_of_edge.get(&e) {
830                    Some(&other) => sets.union(f, other),
831                    None => {
832                        user_of_edge.insert(e, f);
833                    }
834                }
835            }
836        }
837    }
838    sets.groups()
839}