Skip to main content

geop_lib_sweep/
loft.rs

1//! Lofting: a body through two or more planar profiles — sections — each in
2//! a plane of its own, by skinning them (see [`crate::sweep::skin`]): a
3//! square on one plane and a circle on another give a solid running from
4//! one to the other. Two sections are joined by ruled walls, straight lines
5//! between corresponding points of the two; three or more by walls running
6//! smoothly through all of them, tangent across every section between (see
7//! [`through`]).
8//!
9//! To be skinned, the sections are made to correspond:
10//!
11//! - **winding**: each is turned, if need be, to run the same way round
12//!   seen along the loft — counter-clockwise looking back from the next
13//!   section — mirroring its frame where its plane faces the other way;
14//! - **matched points**: points the designer matched across the closed
15//!   sections — the first of each section with each other, the second with
16//!   each other, and so on — are lofted into each other: that is how one
17//!   says which part of one profile goes where on the next. Every section
18//!   matched has as many, running round it in the same order; it starts at
19//!   its first. Between two matched points, the sections' corners are
20//!   matched too, wherever every matched section has as many there;
21//! - **corners**: with no points matched, the sections' corners — the
22//!   joints where the profile's tangent turns, which the designer sees;
23//!   not the kernel's own split of an arc at a quarter turn, nor a sketch
24//!   point where two curves meet tangentially (see `Profile::corners`) —
25//!   are the matched points, in cyclic order, each section turned to line
26//!   its corners up best with the previous section's that has any. Every
27//!   section with corners has as many, or the loft is refused by name: a
28//!   D of two corners into a triangle needs matched points or guides to
29//!   say what goes where. A section without corners — a circle — is lined
30//!   up as a whole, below;
31//! - **runs**: between two matched points, the smooth run of each matched
32//!   section is cut where any of the others has a joint, at the same
33//!   fraction of the run's length (see `RunCuts::merged`): the flat bottom of a
34//!   D meets the flat bottom of the next, whatever pieces their arcs come
35//!   in. A section not matched gets as many curves as the others have, by
36//!   halving its longest ones; where it has more, the others' longest
37//!   stretches are cut in the middle;
38//! - **start**: a closed section that is not matched starts at whichever
39//!   joint lines its joints up best with its neighbour towards the first
40//!   matched section (or the section before, with none) — its shape about
41//!   its centre — and an open one runs whichever way round lines its ends
42//!   up;
43//! - **curves**: the `i`-th curves of all sections are made compatible, one
44//!   degree and one knot vector (see `NurbCurve::compatible`).
45//!
46//! Open chains loft into sheets: between two curves, the ruled surface
47//! joining them.
48//!
49//! **Guide curves** shape the walls between the sections instead of letting
50//! them run straight. A guide runs from a point of the first section through
51//! every other to the last; where it meets each section is a matched point
52//! (so guides and matching points do not mix). Between two sections, the
53//! walls are the ruled surface plus a displacement that is affine across the
54//! section and takes each guide's points on the sections along the guide:
55//! the walls' edges there are the guides themselves (see `guided_span`).
56//! One guide moves the sections along with it; two also turn and stretch
57//! them; three, at most, fix any affine change of them.
58
59use geop_core_geometry::{
60    contains::curve::curve_could_contain,
61    intersection::{curve_surface_intersect, refine_crossing},
62    nurb_curve::{NurbCurve, NurbCurve2D, NurbCurve3D},
63    nurb_surface::NurbSurface3D,
64    search::{MAX_NODES, min_subdivision_size},
65};
66use geop_core_math::{
67    geop_error::{GeopError, GeopResult, WithContext},
68    plain::{self, P3, add, distance, dot, inverse2, scale, sub},
69    primitives::CoordinateSystem,
70    scalars::Scalar,
71    vector::{Vector2, Vector3},
72    with_context,
73};
74use geop_core_topology::build::BuiltBody;
75use geop_ops_model::{Chain, NamedModel, Namer, Profile};
76
77use crate::{
78    plain::Plain,
79    sweep::{Frame, Path, Span, SweepLoop, skin},
80};
81
82/// One profile to loft through: a closed loop or an open chain, drawn in
83/// `plane`'s `(u, v)` — a closed one counter-clockwise, as a sketch's outer
84/// loop runs — and what its station is called. `matched`: the joints of a
85/// closed loop matched with the other sections', in the order they are
86/// matched (see [`mark`] and the module docs).
87#[derive(Clone, Debug)]
88pub struct Section {
89    pub plane: CoordinateSystem,
90    pub profile: Profile,
91    pub name: String,
92    pub matched: Vec<String>,
93}
94
95/// A section as it is skinned: its frame and its profile in it.
96#[derive(Clone, Debug)]
97struct Placed {
98    frame: Frame,
99    profile: Profile,
100}
101
102impl Placed {
103    /// The joints, in space, as plain numbers.
104    fn joints(&self) -> GeopResult<Vec<[f64; 3]>> {
105        let n = self.profile.joint_names.len();
106        (0..n)
107            .map(|i| {
108                let q = self.frame.point(&self.profile.joint(i)?);
109                Ok(q.to_f64_array())
110            })
111            .collect()
112    }
113
114    /// The same section with its frame's `e2` turned round and its curves
115    /// mirrored to match: the same curves in space, winding the other way
116    /// in the frame.
117    fn mirrored(&self) -> Self {
118        let mirror = |c: &NurbCurve2D| {
119            let mut c = c.clone();
120            for cp in &mut c.control_points {
121                cp[1] = cp[1].neg();
122            }
123            c.recompute_aabb();
124            c
125        };
126        Self {
127            frame: Frame {
128                e2: self.frame.e2.neg(),
129                ..self.frame.clone()
130            },
131            profile: self.profile.map_curves(mirror),
132        }
133    }
134}
135
136/// The mean of `points`: where a section or a profile is, to measure how
137/// far apart sections are or which end of a path is nearer — free choices.
138pub(crate) fn centre(points: &[[f64; 3]]) -> [f64; 3] {
139    let n = points.len() as f64;
140    let mut c = [0.0; 3];
141    for p in points {
142        for k in 0..3 {
143            c[k] += p[k] / n;
144        }
145    }
146    c
147}
148
149/// The control polygon's length of `curve`: how long it is, near enough to
150/// pick the longest curve to halve — a free choice.
151fn polygon_length(curve: &NurbCurve2D) -> f64 {
152    let points: Vec<[f64; 2]> = curve
153        .control_points
154        .iter()
155        .map(|cp| {
156            let w = cp[2].to_f64();
157            [cp[0].to_f64() / w, cp[1].to_f64() / w]
158        })
159        .collect();
160    points.windows(2).map(|w| distance(w[1], w[0])).sum()
161}
162
163/// `profile` with its curve `X = curves[i]` cut at the fractions `at` of
164/// its length, increasing and strictly inside `(0, 1)`: the pieces `X`,
165/// `X#cut1`, `X#cut2`, ... and the joints between them `X@cut1`, `X@cut2`,
166/// ... (a sketch names an arc's own quarter-turn pieces `X#1` and `X@1`).
167/// Where along the curve is a free choice (see [`correspond`]), so the
168/// cuts are sharp (see `NurbCurve::parameter_at_length_fraction`); the
169/// pieces are exact pieces of it, each on `[0, 1]` with its end weights
170/// one.
171fn cut(profile: &Profile, i: usize, at: &[f64]) -> GeopResult<Profile> {
172    let curve = &profile.curves[i];
173    let (t0, t1) = curve.domain();
174    let mut bounds = vec![t0];
175    for &f in at {
176        bounds.push(curve.parameter_at_length_fraction(f)?);
177    }
178    bounds.push(t1);
179    let pieces = bounds
180        .windows(2)
181        .map(|w| {
182            curve
183                .sub_curve(w[0], w[1])?
184                .with_unit_domain()?
185                .with_unit_end_weights()
186        })
187        .collect::<GeopResult<Vec<_>>>()?;
188    let mut out = profile.clone();
189    let name = profile.curve_names[i].clone();
190    let count = pieces.len();
191    out.curves.splice(i..=i, pieces);
192    out.curve_names
193        .splice(i + 1..i + 1, (1..count).map(|k| format!("{name}#cut{k}")));
194    out.joint_names
195        .splice(i + 1..i + 1, (1..count).map(|k| format!("{name}@cut{k}")));
196    Ok(out)
197}
198
199/// The closed `profile` with a joint at `point`, and that joint's name:
200/// the joint already there, or — where `point` lies along a curve — a new
201/// one named `joint`, the curve split there, its second half `X#joint`.
202/// Where exactly along the curve is a free choice within where `point`
203/// could be: the halves are exact pieces of it either way. An error if
204/// `point` is on no curve of the profile.
205pub fn mark(profile: &Profile, point: &Vector2, joint: &str) -> GeopResult<(Profile, String)> {
206    let size = min_subdivision_size();
207    let n = profile.curves.len();
208    for (i, curve) in profile.curves.iter().enumerate() {
209        let (t0, t1) = curve.domain();
210        if curve.evaluate(t0)?.could_be_equal(point) {
211            return Ok((profile.clone(), profile.joint_names[i].clone()));
212        }
213        let Some(t) = curve_could_contain(curve, point, MAX_NODES, size)? else {
214            continue;
215        };
216        if t.could_be_equal(t1) {
217            return Ok((profile.clone(), profile.joint_names[(i + 1) % n].clone()));
218        }
219        let (a, b) = curve.split(t.sharpen())?;
220        let mut out = profile.clone();
221        let name = profile.curve_names[i].clone();
222        out.curves
223            .splice(i..=i, [a.with_unit_domain()?, b.with_unit_domain()?]);
224        out.curve_names.insert(i + 1, format!("{name}#{joint}"));
225        out.joint_names.insert(i + 1, joint.to_string());
226        return Ok((out, joint.to_string()));
227    }
228    Err(GeopError::new(format!(
229        "the point at {point:?} is on none of the profile's curves"
230    )))
231}
232
233/// Where the joints `matched` are along `profile`, which starts at the
234/// first: their indices, increasing — an error if they do not run round it
235/// in that order.
236fn matched_indices(profile: &Profile, matched: &[String], name: &str) -> GeopResult<Vec<usize>> {
237    let indices = matched
238        .iter()
239        .map(|joint| {
240            profile
241                .joint_names
242                .iter()
243                .position(|j| j == joint)
244                .ok_or_else(|| GeopError::new(format!("loft: no joint {joint} on {name}")))
245        })
246        .collect::<GeopResult<Vec<_>>>()?;
247    if indices.windows(2).any(|w| w[0] >= w[1]) {
248        return Err(GeopError::new(format!(
249            "loft: the matching points of {name} do not run round it in the order they are matched with the other profiles'"
250        )));
251    }
252    Ok(indices)
253}
254
255/// The curves of `profile` in each run between two consecutive joints of
256/// `indices` (see [`matched_indices`]), the last run back to the start:
257/// each run's range of curve indices.
258fn runs(profile: &Profile, indices: &[usize]) -> Vec<std::ops::Range<usize>> {
259    let n = profile.curves.len();
260    (0..indices.len())
261        .map(|j| indices[j]..indices.get(j + 1).copied().unwrap_or(n))
262        .collect()
263}
264
265/// Where the joints inside the run `run` of `profile` are along it, as
266/// fractions of the run's length — the curves' lengths, enclosed — and
267/// that length: `k - 1` fractions for a run of `k` curves, increasing from
268/// the run's start at 0 to its end at 1.
269fn run_fractions(
270    profile: &Profile,
271    run: std::ops::Range<usize>,
272) -> GeopResult<(Vec<Scalar>, Scalar)> {
273    let lengths = run
274        .map(|i| Ok(profile.curves[i].length()?.0))
275        .collect::<GeopResult<Vec<Scalar>>>()?;
276    let total = lengths.iter().fold(Scalar::ZERO, |sum, &l| sum.add(l));
277    let mut fractions = Vec::with_capacity(lengths.len() - 1);
278    let mut sum = Scalar::ZERO;
279    for &l in &lengths[..lengths.len() - 1] {
280        sum = sum.add(l);
281        fractions.push(sum.div(total)?);
282    }
283    Ok((fractions, total))
284}
285
286/// The cuts of one run of several matched sections (see [`RunCuts::merged`]).
287struct RunCuts {
288    /// Where the cuts are, as fractions of the run's length: increasing,
289    /// strictly inside `(0, 1)`, sharp.
290    at: Vec<f64>,
291    /// For each section, which of the cuts are its own joints, in order.
292    own: Vec<Vec<usize>>,
293}
294
295impl RunCuts {
296    /// The joints of corresponding runs of several sections, `fractions[s]`
297    /// the run's of section `s` (see [`run_fractions`]), merged into one
298    /// list of cuts along every one of them: joints of different sections
299    /// that could lie at one fraction are one cut, which each of them
300    /// keeps; every other joint is a cut the other sections get. Where
301    /// exactly a cut lies is a free choice within what the joints enclose,
302    /// so the cuts are sharp. `name(s)` names section `s` for an error: two
303    /// joints of one section at one place along its run.
304    fn merged(fractions: &[Vec<Scalar>], name: impl Fn(usize) -> String) -> GeopResult<Self> {
305        let mut merged: Vec<Scalar> = Vec::new();
306        let mut own: Vec<Vec<usize>> = vec![Vec::new(); fractions.len()];
307        for (s, fractions) in fractions.iter().enumerate() {
308            for &f in fractions {
309                // After the cuts this section has claimed so far.
310                let claimed = own[s].last().map_or(0, |&k| k + 1);
311                let k = match merged[claimed..].iter().position(|m| m.could_be_equal(f)) {
312                    Some(k) => {
313                        merged[claimed + k] = merged[claimed + k].union(f);
314                        claimed + k
315                    }
316                    None => {
317                        let at = merged
318                            .iter()
319                            .position(|m| !m.definitely_less(f))
320                            .unwrap_or(merged.len());
321                        if at < claimed {
322                            return Err(GeopError::new(format!(
323                                "loft: two joints of the profile {} could lie at one place along it, {:?} of the way along the run between its matched points",
324                                name(s),
325                                f
326                            )));
327                        }
328                        merged.insert(at, f);
329                        for k in own.iter_mut().flatten().filter(|k| **k >= at) {
330                            *k += 1;
331                        }
332                        at
333                    }
334                };
335                own[s].push(k);
336            }
337        }
338        let at: Vec<f64> = merged.iter().map(|m| m.to_f64()).collect();
339        if !at.windows(2).all(|w| w[0] < w[1]) || at.iter().any(|&c| !(0.0 < c && c < 1.0)) {
340            return Err(GeopError::new(format!(
341                "loft: the joints along corresponding runs of the profiles cannot be told apart: {merged:?}"
342            )));
343        }
344        Ok(Self { at, own })
345    }
346
347    /// One more cut, in the middle of the stretch between the cut `k - 1`
348    /// and the cut `k` (the run's ends for the first and the last): no
349    /// section's joint, a free choice.
350    fn halve(&mut self, k: usize) {
351        let bound = |i: usize| -> f64 {
352            if i == 0 {
353                0.0
354            } else {
355                self.at.get(i - 1).copied().unwrap_or(1.0)
356            }
357        };
358        let middle = (bound(k) + bound(k + 1)) / 2.0;
359        self.at.insert(k, middle);
360        for i in self.own.iter_mut().flatten().filter(|i| **i >= k) {
361            *i += 1;
362        }
363    }
364
365    /// How many curves the run has once cut.
366    fn pieces(&self) -> usize {
367        self.at.len() + 1
368    }
369
370    /// The length of the stretch between the cut `k - 1` and the cut `k`,
371    /// as a fraction of the run's.
372    fn stretch(&self, k: usize) -> f64 {
373        let bounds: Vec<f64> = [0.0]
374            .into_iter()
375            .chain(self.at.iter().copied())
376            .chain([1.0])
377            .collect();
378        bounds[k + 1] - bounds[k]
379    }
380}
381
382/// `profile`, each of whose runs `runs[j]` (see [`runs`]) is to be cut as
383/// `cuts[j]` says (see [`RunCuts`]), with every cut that is not a joint of
384/// section `s` made: the curve it falls in cut there, at the same fraction
385/// of the curve's length as of its stretch between the cuts either side
386/// (see [`cut`]).
387fn cut_runs(
388    profile: &Profile,
389    runs: &[std::ops::Range<usize>],
390    cuts: &[RunCuts],
391    s: usize,
392) -> GeopResult<Profile> {
393    let mut out = profile.clone();
394    // Runs from the last, so that cutting one leaves the indices of those
395    // before it as they are.
396    for j in (0..runs.len()).rev() {
397        let run = &runs[j];
398        let mut joints = cuts[j].own[s].iter().peekable();
399        // For each curve of the run, the cuts within it, as fractions of
400        // the curve's stretch between the cuts at its ends.
401        let mut per_curve: Vec<Vec<f64>> = vec![Vec::new(); run.len()];
402        let mut curve = 0;
403        let mut start = 0.0;
404        for (k, &c) in cuts[j].at.iter().enumerate() {
405            if joints.next_if_eq(&&k).is_some() {
406                // The joint after `curve`: the next curve starts here.
407                for f in &mut per_curve[curve] {
408                    *f = (*f - start) / (c - start);
409                }
410                curve += 1;
411                start = c;
412            } else {
413                per_curve[curve].push(c);
414            }
415        }
416        for f in &mut per_curve[curve] {
417            *f = (*f - start) / (1.0 - start);
418        }
419        for (k, at) in per_curve.iter().enumerate().rev() {
420            if !at.is_empty() {
421                out = cut(&out, run.start + k, at)?;
422            }
423        }
424    }
425    Ok(out)
426}
427
428/// The corners of the closed sections `placed` as their matched points
429/// (see [`correspond`]): every section with corners has as many, and
430/// their corners are matched in cyclic order, each section's turned to line
431/// its corners up best with the previous cornered section's, both about
432/// their centres. A section without corners — a circle — gets none: it is
433/// lined up with its neighbour as a whole. An error, naming the sections
434/// and their counts, where the counts differ.
435fn corner_anchors(
436    sections: &[Section],
437    placed: &[Placed],
438    centres: &[[f64; 3]],
439) -> GeopResult<Vec<Vec<String>>> {
440    let corners = placed
441        .iter()
442        .map(|p| p.profile.corners())
443        .collect::<GeopResult<Vec<Vec<usize>>>>()?;
444    let mut points = vec![Vec::new(); placed.len()];
445    let cornered: Vec<usize> = (0..placed.len())
446        .filter(|&s| !corners[s].is_empty())
447        .collect();
448    let Some(&reference) = cornered.first() else {
449        return Ok(points);
450    };
451    let count = corners[reference].len();
452    if let Some(&s) = cornered.iter().find(|&&s| corners[s].len() != count) {
453        return Err(GeopError::new(format!(
454            "loft: the profile {} has {} corners, the profile {} has {count}: lofted as they are, the profiles' corners are matched one to one, so every profile with corners needs as many — match points, or add guides, to say which go where",
455            sections[s].name,
456            corners[s].len(),
457            sections[reference].name
458        )));
459    }
460    let about = |s: usize, k: usize| -> GeopResult<[f64; 3]> {
461        let joint = placed[s]
462            .frame
463            .point(&placed[s].profile.joint(corners[s][k])?);
464        Ok(sub(joint.to_f64_array(), centres[s]))
465    };
466    let mut shifts = vec![0; placed.len()];
467    for w in cornered.windows(2) {
468        let (previous, s) = (w[0], w[1]);
469        let before = (0..count)
470            .map(|k| about(previous, (k + shifts[previous]) % count))
471            .collect::<GeopResult<Vec<_>>>()?;
472        let mut best: Option<(f64, usize)> = None;
473        for shift in 0..count {
474            let mut cost = 0.0;
475            for (k, &b) in before.iter().enumerate() {
476                let d = sub(about(s, (k + shift) % count)?, b);
477                cost += dot(d, d);
478            }
479            if best.is_none_or(|(c, _)| cost < c) {
480                best = Some((cost, shift));
481            }
482        }
483        shifts[s] = best.expect("a corner to start at").1;
484    }
485    for &s in &cornered {
486        points[s] = (0..count)
487            .map(|k| placed[s].profile.joint_names[corners[s][(k + shifts[s]) % count]].clone())
488            .collect();
489    }
490    Ok(points)
491}
492
493/// The section `next` to an apex at `point`, collapsed into it: a frame at
494/// the point, square as `next`'s, and every curve of `next` with all its
495/// control points there — its degree, knots and weights kept, so that it
496/// is compatible with `next`'s and the walls between are ruled into the
497/// point. Named as `next` is.
498fn collapsed(next: &Placed, point: &Vector3) -> GeopResult<Placed> {
499    let profile = next.profile.map_curves(|c| {
500        let mut c = c.clone();
501        for cp in &mut c.control_points {
502            cp[0] = Scalar::ZERO;
503            cp[1] = Scalar::ZERO;
504        }
505        c.recompute_aabb();
506        c
507    });
508    Ok(Placed {
509        frame: Frame {
510            origin: *point,
511            ..next.frame.clone()
512        },
513        profile,
514    })
515}
516
517/// The sections made to correspond, as the module docs say — closed ones
518/// winding so that the loft runs against each frame's `e1 x e2`, which
519/// makes the skinned walls face out. `beyond` are points the loft runs to
520/// before the first section and after the last, if any: which way it runs
521/// through a section next to one is towards it, or from it.
522fn correspond(sections: &[Section], beyond: [Option<[f64; 3]>; 2]) -> GeopResult<Vec<Placed>> {
523    let closed = sections[0].profile.is_closed();
524    if sections.iter().any(|s| s.profile.is_closed() != closed) {
525        return Err(GeopError::new(
526            "loft: the profiles are either all closed or all open",
527        ));
528    }
529    let mut placed: Vec<Placed> = sections
530        .iter()
531        .map(|s| Placed {
532            frame: Frame {
533                origin: *s.plane.origin(),
534                e1: *s.plane.u(),
535                e2: *s.plane.v(),
536            },
537            profile: s.profile.clone(),
538        })
539        .collect();
540    let centres = placed
541        .iter()
542        .map(|p| Ok(centre(&p.joints()?)))
543        .collect::<GeopResult<Vec<_>>>()?;
544
545    // Winding: every closed section counter-clockwise in its frame, the
546    // loft running against the frame's `e1 x e2`.
547    if closed {
548        let n = placed.len();
549        let v = |a: [f64; 3]| Vector3::from_array(a.map(Scalar::from_f64));
550        for s in 0..n {
551            let (from, to) = match (s + 1 < n, s > 0, beyond) {
552                (true, _, _) => (centres[s], centres[s + 1]),
553                (false, _, [_, Some(after)]) => (centres[s], after),
554                (false, true, _) => (centres[s - 1], centres[s]),
555                (false, false, [Some(before), None]) => (before, centres[s]),
556                (false, false, [None, None]) => {
557                    return Err(GeopError::new("loft: one profile, and nothing to loft to"));
558                }
559            };
560            let along = v(to).sub(&v(from));
561            let frame = &placed[s].frame;
562            let facing = frame.e1.prod_cross(&frame.e2).prod_dot(&along);
563            if facing.definitely_greater(Scalar::ZERO) {
564                let reversed = placed[s].mirrored();
565                placed[s] = Placed {
566                    profile: reversed.profile.reversed(),
567                    ..reversed
568                };
569            } else if !facing.definitely_less(Scalar::ZERO) {
570                return Err(GeopError::new(format!(
571                    "loft: the loft could run along the plane of {}",
572                    sections[s].name
573                )));
574            }
575        }
576    }
577
578    // Matched points: the designer's, in the order they run round the first
579    // matched section as it now winds — every section's in the same order,
580    // so that the `k`-th still meets the `k`-th — whatever order they were
581    // given in; or, with none given, the sections' corners.
582    let mut points: Vec<Vec<String>> = sections.iter().map(|s| s.matched.clone()).collect();
583    let explicit = points.iter().any(|p| !p.is_empty());
584    if let Some(reference) = (0..sections.len()).find(|&s| !points[s].is_empty()) {
585        let joints = &placed[reference].profile.joint_names;
586        let mut order: Vec<usize> = (0..points[reference].len()).collect();
587        order.sort_by_key(|&k| joints.iter().position(|j| *j == points[reference][k]));
588        for list in points.iter_mut().filter(|l| l.len() == order.len()) {
589            *list = order.iter().map(|&k| list[k].clone()).collect();
590        }
591    }
592    if !explicit && closed {
593        points = corner_anchors(sections, &placed, &centres)?;
594    }
595
596    // Each matched section starting at its first matched point, and the
597    // runs between consecutive matched points cut alike in all of them.
598    let matched: Vec<usize> = (0..sections.len())
599        .filter(|&s| !points[s].is_empty())
600        .collect();
601    let mut most = placed
602        .iter()
603        .map(|p| p.profile.curves.len())
604        .max()
605        .unwrap_or(0);
606    if let Some(&first) = matched.first() {
607        if !closed {
608            return Err(GeopError::new(
609                "loft: matching points are for closed profiles: open chains are lofted end to end",
610            ));
611        }
612        let count = points[first].len();
613        if let Some(&s) = matched.iter().find(|&&s| points[s].len() != count) {
614            return Err(GeopError::new(format!(
615                "loft: {} has {} matching points, {} has {}: every profile matched has as many",
616                sections[s].name,
617                points[s].len(),
618                sections[first].name,
619                count
620            )));
621        }
622        for &s in &matched {
623            let start = placed[s]
624                .profile
625                .joint_names
626                .iter()
627                .position(|j| *j == points[s][0])
628                .ok_or_else(|| GeopError::new("loft: the first matching point is no joint"))?;
629            placed[s].profile = placed[s].profile.starting_at(start);
630        }
631        let indices = |points: &[Vec<String>]| -> GeopResult<Vec<Vec<usize>>> {
632            matched
633                .iter()
634                .map(|&s| matched_indices(&placed[s].profile, &points[s], &sections[s].name))
635                .collect()
636        };
637
638        // Corners between the designer's matched points are matched too,
639        // between each two points every matched section has as many
640        // corners between.
641        if explicit {
642            let inner: Vec<Vec<Vec<String>>> = matched
643                .iter()
644                .zip(indices(&points)?)
645                .map(|(&s, indices)| {
646                    let profile = &placed[s].profile;
647                    let corners = profile.corners()?;
648                    Ok(runs(profile, &indices)
649                        .into_iter()
650                        .map(|run| {
651                            corners
652                                .iter()
653                                .filter(|&&c| run.start < c && c < run.end)
654                                .map(|&c| profile.joint_names[c].clone())
655                                .collect()
656                        })
657                        .collect())
658                })
659                .collect::<GeopResult<_>>()?;
660            for (m, &s) in matched.iter().enumerate() {
661                let mut all = Vec::new();
662                for j in 0..count {
663                    all.push(points[s][j].clone());
664                    if inner.iter().all(|i| i[j].len() == inner[m][j].len()) {
665                        all.extend(inner[m][j].iter().cloned());
666                    }
667                }
668                points[s] = all;
669            }
670        }
671
672        // Runs: every joint of any matched section's run is a cut of the
673        // corresponding run of every other, at the same fraction of the
674        // run's length (see `merged_cuts`).
675        let run_list: Vec<Vec<std::ops::Range<usize>>> = matched
676            .iter()
677            .zip(indices(&points)?)
678            .map(|(&s, indices)| runs(&placed[s].profile, &indices))
679            .collect();
680        let fractions: Vec<Vec<(Vec<Scalar>, Scalar)>> = matched
681            .iter()
682            .zip(&run_list)
683            .map(|(&s, runs)| {
684                runs.iter()
685                    .map(|run| run_fractions(&placed[s].profile, run.clone()))
686                    .collect()
687            })
688            .collect::<GeopResult<_>>()?;
689        let count = run_list[0].len();
690        let mut cuts: Vec<RunCuts> = (0..count)
691            .map(|j| {
692                let along: Vec<Vec<Scalar>> = fractions.iter().map(|f| f[j].0.clone()).collect();
693                RunCuts::merged(&along, |m| sections[matched[m]].name.clone())
694            })
695            .collect::<GeopResult<_>>()?;
696        // A section not matched with more curves than the runs add up to:
697        // the longest stretch between two cuts of the first matched section
698        // is cut in the middle, until the runs add up to as many — a free
699        // choice, where no joint says where to cut.
700        let total = |cuts: &[RunCuts]| cuts.iter().map(RunCuts::pieces).sum::<usize>();
701        while total(&cuts) < most {
702            let mut longest: Option<(f64, usize, usize)> = None;
703            for (j, run) in cuts.iter().enumerate() {
704                let length = fractions[0][j].1.to_f64();
705                for k in 0..run.pieces() {
706                    let stretch = run.stretch(k) * length;
707                    if longest.is_none_or(|(l, _, _)| stretch > l) {
708                        longest = Some((stretch, j, k));
709                    }
710                }
711            }
712            let (_, j, k) = longest.expect("a run has a stretch");
713            cuts[j].halve(k);
714        }
715        most = total(&cuts);
716        for (m, &s) in matched.iter().enumerate() {
717            placed[s].profile = cut_runs(&placed[s].profile, &run_list[m], &cuts, m)
718                .with_context(with_context!("cutting the profile {}", sections[s].name))?;
719        }
720    }
721
722    // A section not matched: as many curves as the others, each halving its
723    // longest one — a free choice, as no corner says where.
724    for (s, p) in placed.iter_mut().enumerate() {
725        if points[s].is_empty() {
726            for _ in p.profile.curves.len()..most {
727                let longest = (0..p.profile.curves.len())
728                    .max_by(|&a, &b| {
729                        polygon_length(&p.profile.curves[a])
730                            .total_cmp(&polygon_length(&p.profile.curves[b]))
731                    })
732                    .expect("a profile has curves");
733                p.profile = cut(&p.profile, longest, &[0.5])?;
734            }
735        }
736    }
737
738    // Start: a matched section where it is matched; the others lined up
739    // with their neighbour towards the first matched one.
740    let reference = matched.first().copied().unwrap_or(0);
741    let order: Vec<(usize, usize)> = (reference + 1..placed.len())
742        .map(|s| (s, s - 1))
743        .chain((0..reference).rev().map(|s| (s, s + 1)))
744        .collect();
745    for (s, neighbour) in order {
746        if !points[s].is_empty() {
747            continue;
748        }
749        let before = placed[neighbour].joints()?;
750        let candidates: Vec<Profile> = if closed {
751            (0..most)
752                .map(|k| placed[s].profile.starting_at(k))
753                .collect()
754        } else {
755            vec![placed[s].profile.clone(), placed[s].profile.reversed()]
756        };
757        let mut best: Option<(f64, Profile)> = None;
758        for candidate in candidates {
759            let joints = Placed {
760                frame: placed[s].frame.clone(),
761                profile: candidate.clone(),
762            }
763            .joints()?;
764            // Closed: the shapes about their centres; open: where they are.
765            let (ca, cb) = if closed {
766                (centres[s], centres[neighbour])
767            } else {
768                ([0.0; 3], [0.0; 3])
769            };
770            let cost: f64 = joints
771                .iter()
772                .zip(&before)
773                .map(|(&a, &b)| {
774                    let d = sub(sub(a, ca), sub(b, cb));
775                    dot(d, d)
776                })
777                .sum();
778            if best.as_ref().is_none_or(|(c, _)| cost < *c) {
779                best = Some((cost, candidate));
780            }
781        }
782        placed[s].profile = best.expect("at least one candidate").1;
783    }
784
785    // Curves: compatible across the sections.
786    for i in 0..most {
787        let curves: Vec<NurbCurve2D> = placed
788            .iter()
789            .map(|p| p.profile.curves[i].with_unit_end_weights())
790            .collect::<GeopResult<_>>()?;
791        for (p, curve) in placed.iter_mut().zip(NurbCurve::compatible(&curves)?) {
792            p.profile.curves[i] = curve;
793        }
794    }
795    Ok(placed)
796}
797
798// ── guide curves ────────────────────────────────────────────────────────────
799
800/// A loft's guide, run from the first section to the last, and where it
801/// crosses each section: a curve of its chain and a parameter along it.
802struct Crossed {
803    chain: Chain<NurbCurve3D>,
804    at: Vec<(usize, Scalar)>,
805}
806
807/// Where `guide` crosses each of `sections` (see [`Crossed`]): starting on
808/// the first one's plane — run the other way if it ends there — ending on
809/// the last one's, and crossing every plane between exactly once. An error,
810/// naming the guide and the section, otherwise.
811fn crossings(sections: &[Section], guide: &Chain<NurbCurve3D>) -> GeopResult<Crossed> {
812    let name = &guide.name;
813    if guide.is_closed() {
814        return Err(GeopError::new(format!(
815            "loft: the guide {name} is a closed loop: a guide runs from the first profile to the last"
816        )));
817    }
818    let (first, last) = (&sections[0], &sections[sections.len() - 1]);
819    let mut chain = guide.starting_on(&first.plane)?;
820    if let Some(c) = &chain
821        && !last.plane.to_uvw(&c.joint(c.curves.len())?)[2].could_be_equal(Scalar::ZERO)
822    {
823        chain = None;
824    }
825    let Some(chain) = chain else {
826        return Err(GeopError::new(format!(
827            "loft: the guide {name} has to run from the profile {} to the profile {}, starting and ending on them",
828            first.name, last.name
829        )));
830    };
831    let n = chain.curves.len();
832    let mut at = vec![(0, Scalar::ZERO)];
833    for section in &sections[1..sections.len() - 1] {
834        let ctx = with_context!(
835            "where the guide {name} crosses the profile {}",
836            section.name
837        );
838        let patch = plane_patch(&section.plane, &chain)?;
839        let mut hits = Vec::new();
840        for (i, curve) in chain.curves.iter().enumerate() {
841            let found =
842                curve_surface_intersect(curve, &patch, 4, MAX_NODES, min_subdivision_size())
843                    .with_context(ctx)?;
844            if found.is_coincident() {
845                return Err(GeopError::new(format!(
846                    "loft: the guide {name} runs along the plane of the profile {}",
847                    section.name
848                )));
849            }
850            for (t, uv) in found.into_vec() {
851                // A crossing at a joint of the guide, found again at the
852                // start of the curve after it.
853                if i > 0 && t.could_be_equal(Scalar::ZERO) {
854                    continue;
855                }
856                let (t, _) = refine_crossing(curve, &patch, t, uv);
857                hits.push((i, t));
858            }
859        }
860        match hits.as_slice() {
861            [hit] => at.push(*hit),
862            _ => {
863                return Err(GeopError::new(format!(
864                    "loft: the guide {name} crosses the plane of the profile {} {} times, not once",
865                    section.name,
866                    hits.len()
867                )));
868            }
869        }
870    }
871    at.push((n - 1, Scalar::ONE));
872    Ok(Crossed { chain, at })
873}
874
875/// A flat patch of `plane` that every crossing of `chain` with it lies on:
876/// the box of the chain's control points seen in the plane, as wide again
877/// on every side — a search domain, nothing more.
878fn plane_patch(plane: &CoordinateSystem, chain: &Chain<NurbCurve3D>) -> GeopResult<NurbSurface3D> {
879    let uvs = chain
880        .curves
881        .iter()
882        .flat_map(|c| &c.control_points)
883        .map(|cp| Ok(plane.to_uv(&cp.dehomogenized()?).to_f64_array()))
884        .collect::<GeopResult<Vec<_>>>()?;
885    let (lo, hi) =
886        plain::bounds(uvs).ok_or_else(|| GeopError::new("the path has no control points"))?;
887    let margin = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1.0);
888    let corner = |u: f64, v: f64| {
889        plane.uv_to_xyz(&Vector2::from_array([
890            Scalar::from_f64(u),
891            Scalar::from_f64(v),
892        ]))
893    };
894    let (u0, u1, v0, v1) = (
895        lo[0] - margin,
896        hi[0] + margin,
897        lo[1] - margin,
898        hi[1] + margin,
899    );
900    NurbSurface3D::bilinear(
901        corner(u0, v0),
902        corner(u1, v0),
903        corner(u1, v1),
904        corner(u0, v1),
905    )
906}
907
908/// Sections with the guides' joints, and the guides as crossed (see [`guided`]).
909type Guided = (Vec<Section>, Vec<Crossed>);
910
911/// `sections` with a joint wherever a guide crosses them, matched across
912/// them in the order of the guides — the joint named `K,G` for the section
913/// `K` and the guide `G`, unless one is there already — and the guides as
914/// crossed. Guides take the place of matching points: the two do not mix.
915fn guided(sections: &[Section], guides: &[Chain<NurbCurve3D>]) -> GeopResult<Guided> {
916    if guides.len() > MAX_GUIDES {
917        return Err(GeopError::new(format!(
918            "loft: {} guides given, but a loft follows at most {MAX_GUIDES}",
919            guides.len()
920        )));
921    }
922    if let Some(s) = sections.iter().find(|s| !s.matched.is_empty()) {
923        return Err(GeopError::new(format!(
924            "loft: the profile {} has matching points, but the guides say which points match: use one or the other",
925            s.name
926        )));
927    }
928    let crossed = guides
929        .iter()
930        .map(|g| crossings(sections, g))
931        .collect::<GeopResult<Vec<_>>>()?;
932    let mut out = sections.to_vec();
933    for (j, section) in out.iter_mut().enumerate() {
934        for (guide, crossed) in guides.iter().zip(&crossed) {
935            let (i, t) = crossed.at[j];
936            let point = crossed.chain.curves[i].evaluate(t)?;
937            let (profile, joint) = mark(
938                &section.profile,
939                &section.plane.to_uv(&point),
940                &format!("{},{}", section.name, guide.name),
941            )
942            .with_context(with_context!(
943                "the guide {} on the profile {}",
944                guide.name,
945                section.name
946            ))?;
947            section.profile = profile;
948            section.matched.push(joint);
949        }
950    }
951    // Three guides that meet the first profile in a line cannot say how to
952    // shape the sections across it.
953    if guides.len() == 3 {
954        let joint = |name: &String| -> GeopResult<Vector2> {
955            let profile = &out[0].profile;
956            let i = profile
957                .joint_names
958                .iter()
959                .position(|j| j == name)
960                .expect("a guide's joint is on its profile");
961            profile.joint(i)
962        };
963        let q = out[0]
964            .matched
965            .iter()
966            .map(joint)
967            .collect::<GeopResult<Vec<_>>>()?;
968        let (a, b) = (q[1].sub(&q[0]), q[2].sub(&q[0]));
969        if a[0]
970            .mul(b[1])
971            .sub(a[1].mul(b[0]))
972            .could_be_equal(Scalar::ZERO)
973        {
974            return Err(GeopError::new(format!(
975                "loft: the guides {} could meet the profile {} in a line: they cannot shape it across that line",
976                guides
977                    .iter()
978                    .map(|g| g.name.as_str())
979                    .collect::<Vec<_>>()
980                    .join(", "),
981                out[0].name
982            )));
983        }
984    }
985    Ok((out, crossed))
986}
987
988/// At most this many guides: an affine map of each section is shaped by
989/// them, which three points fix.
990const MAX_GUIDES: usize = 3;
991
992/// The piece of `crossed` from where it crosses section `j` to where it
993/// crosses the next, as one curve on `[0, 1]`. Where it is cut is a free
994/// choice within where it crosses — the section's joint is what the walls
995/// meet — so the cuts are sharpened.
996fn guide_piece(crossed: &Crossed, j: usize) -> GeopResult<NurbCurve3D> {
997    let ((c0, t0), (c1, t1)) = (crossed.at[j], crossed.at[j + 1]);
998    let (t0, t1) = (t0.sharpen(), t1.sharpen());
999    let mut curves = Vec::new();
1000    for i in c0..=c1 {
1001        let curve = &crossed.chain.curves[i];
1002        let from = if i == c0 { t0 } else { Scalar::ZERO };
1003        let to = if i == c1 { t1 } else { Scalar::ONE };
1004        if from.could_be_equal(to) {
1005            continue;
1006        }
1007        curves.push(if i == c0 || i == c1 {
1008            curve.sub_curve(from, to)?
1009        } else {
1010            curve.clone()
1011        });
1012    }
1013    NurbCurve::join(&curves)
1014}
1015
1016/// The span from the section `a` to the next, `b`, shaped by guides: one
1017/// piece of each (see [`guide_piece`]) and its joints `joints[k]` — the
1018/// names of the joint on `a` and on `b`.
1019///
1020/// The span takes the guides' degree, knots and weights, made compatible;
1021/// each inner row is the ruled surface between the two sections at the
1022/// row's Greville abscissa `ξ`, plus a displacement affine across the
1023/// section — an affine map of the blended profile point — that takes each
1024/// guide's joint to the guide's control point: the walls' edges along the
1025/// guides are the guides, and everything between is moved along with them.
1026/// Three guides fix the map; one moves the sections, two also turn and
1027/// stretch them along the line between their joints. The displacement is
1028/// worked out in plain numbers: a free choice, taken as sharp, the walls
1029/// all built from it.
1030fn guided_span(
1031    a: &Placed,
1032    b: &Placed,
1033    joints: &[(String, String)],
1034    pieces: &[NurbCurve3D],
1035) -> GeopResult<Span> {
1036    let pieces = NurbCurve::compatible(pieces)?;
1037    let first = &pieces[0];
1038    let (degree, knots) = (first.degree, first.knot_vector.clone());
1039    let rows = first.control_points.len();
1040    for (k, piece) in pieces.iter().enumerate().skip(1) {
1041        if !(0..rows).all(|r| piece.control_points[r][3].could_be_equal(first.control_points[r][3]))
1042        {
1043            return Err(GeopError::new(format!(
1044                "loft: the guides through {} and {} are rational curves of different weights: draw them alike, or as splines",
1045                joints[0].0, joints[k].0
1046            )));
1047        }
1048    }
1049    let joint_at = |placed: &Placed, name: &str| -> GeopResult<[f64; 2]> {
1050        let profile = &placed.profile;
1051        let i = profile
1052            .joint_names
1053            .iter()
1054            .position(|j| j == name)
1055            .ok_or_else(|| GeopError::new(format!("loft: no joint {name} for a guide")))?;
1056        let p = profile.joint(i)?;
1057        Ok([p[0].to_f64(), p[1].to_f64()])
1058    };
1059    let ends: Vec<([f64; 2], [f64; 2])> = joints
1060        .iter()
1061        .map(|(ja, jb)| Ok((joint_at(a, ja)?, joint_at(b, jb)?)))
1062        .collect::<GeopResult<_>>()?;
1063    let (fa, fb) = (Plain::of(&a.frame), Plain::of(&b.frame));
1064    let place = |f: &Plain, p: [f64; 2]| add(f.origin, add(scale(f.e1, p[0]), scale(f.e2, p[1])));
1065    let mut middle = Vec::with_capacity(rows.saturating_sub(2));
1066    for r in 1..rows - 1 {
1067        let xi = knots[r + 1..=r + degree]
1068            .iter()
1069            .map(|k| k.to_f64())
1070            .sum::<f64>()
1071            / degree as f64;
1072        let weight = first.control_points[r][3];
1073        // Each guide: where its joint lies blended across the section, and
1074        // how far the ruled surface there is from the guide's control point.
1075        let (q, d): (Vec<[f64; 2]>, Vec<P3>) = ends
1076            .iter()
1077            .zip(&pieces)
1078            .map(|(&(ja, jb), piece)| {
1079                let q = [
1080                    (1.0 - xi) * ja[0] + xi * jb[0],
1081                    (1.0 - xi) * ja[1] + xi * jb[1],
1082                ];
1083                let ruled = add(scale(place(&fa, ja), 1.0 - xi), scale(place(&fb, jb), xi));
1084                let cp = piece.control_points[r];
1085                let w = cp[3].to_f64();
1086                let g = [cp[0].to_f64() / w, cp[1].to_f64() / w, cp[2].to_f64() / w];
1087                (q, sub(g, ruled))
1088            })
1089            .unzip();
1090        let (t, m) = affine_through(&q, &d);
1091        let moved = |f: &Plain| Plain {
1092            origin: add(f.origin, t),
1093            e1: add(f.e1, m[0]),
1094            e2: add(f.e2, m[1]),
1095        };
1096        middle.push([
1097            (moved(&fa).frame(), weight.mul(Scalar::from_f64(1.0 - xi))),
1098            (moved(&fb).frame(), weight.mul(Scalar::from_f64(xi))),
1099        ]);
1100    }
1101    Ok(Span::Blend {
1102        degree,
1103        knots,
1104        middle,
1105    })
1106}
1107
1108/// The affine map `p -> t + m[0] p.x + m[1] p.y` taking each `q[k]` to
1109/// `d[k]`, for one to three points: a shift for one, the least stretch
1110/// along the line between them for two, exact for three not in a line.
1111fn affine_through(q: &[[f64; 2]], d: &[P3]) -> (P3, [P3; 2]) {
1112    let m = match q.len() {
1113        1 => [[0.0; 3]; 2],
1114        2 => {
1115            let (dq, dd) = (sub(q[1], q[0]), sub(d[1], d[0]));
1116            let n = dot(dq, dq);
1117            [scale(dd, dq[0] / n), scale(dd, dq[1] / n)]
1118        }
1119        _ => {
1120            let (q1, q2) = (sub(q[1], q[0]), sub(q[2], q[0]));
1121            let (d1, d2) = (sub(d[1], d[0]), sub(d[2], d[0]));
1122            // [d1 d2] [q1 q2]^-1, the points as columns.
1123            let inv = inverse2([[q1[0], q2[0]], [q1[1], q2[1]]]);
1124            [
1125                add(scale(d1, inv[0][0]), scale(d2, inv[1][0])),
1126                add(scale(d1, inv[0][1]), scale(d2, inv[1][1])),
1127            ]
1128        }
1129    };
1130    let t = sub(d[0], add(scale(m[0], q[0][0]), scale(m[1], q[0][1])));
1131    (t, m)
1132}
1133
1134/// The spans of a loft running smoothly through the sections `placed`, one
1135/// cubic between each two: its inner control rows each a section and a
1136/// third of its tangent there, so that the walls on either side of a
1137/// section meet it with one tangent plane.
1138///
1139/// The tangent at a section is Bessel's, from the sections either side,
1140/// each parametrized by how far apart their centres are; at the first and
1141/// the last, the parabola's through the three there. Where the sections
1142/// are spaced is a free choice of the loft's shape, as any spline through
1143/// them is; spacing by their distances keeps a section far from the others
1144/// from pulling the walls past it. Sections with their centres in one
1145/// place are spaced evenly.
1146fn through(placed: &[Placed], closed: bool) -> GeopResult<Vec<Span>> {
1147    // Spans; stations `0..=n`, the last the first again when `closed`.
1148    let n = if closed {
1149        placed.len()
1150    } else {
1151        placed.len() - 1
1152    };
1153    let station = |s: usize| s % placed.len();
1154    let centres = placed
1155        .iter()
1156        .map(|p| Ok(centre(&p.joints()?)))
1157        .collect::<GeopResult<Vec<_>>>()?;
1158    let mut d: Vec<f64> = (0..n)
1159        .map(|j| distance(centres[j], centres[station(j + 1)]))
1160        .collect();
1161    if d.iter().any(|&d| d <= 0.0) {
1162        d = vec![1.0; n];
1163    }
1164    // Rows as coefficients of the sections: `unit(s)` is section `s`.
1165    let unit = |s: usize| -> Vec<f64> {
1166        (0..placed.len())
1167            .map(|k| if k == station(s) { 1.0 } else { 0.0 })
1168            .collect()
1169    };
1170    let combine = |a: &[f64], x: f64, b: &[f64], y: f64| -> Vec<f64> {
1171        a.iter().zip(b).map(|(a, b)| x * a + y * b).collect()
1172    };
1173    // The chord of span `j`, per unit of its length.
1174    let chord = |j: usize| combine(&unit(j + 1), 1.0 / d[j], &unit(j), -1.0 / d[j]);
1175    let mut tangent: Vec<Vec<f64>> = vec![Vec::new(); n + 1];
1176    for j in 1..n {
1177        let w = d[j - 1] + d[j];
1178        tangent[j] = combine(&chord(j - 1), d[j] / w, &chord(j), d[j - 1] / w);
1179    }
1180    if closed {
1181        // Round the seam: the first section's tangent from the last span
1182        // and the first, as at any other.
1183        let w = d[n - 1] + d[0];
1184        tangent[0] = combine(&chord(n - 1), d[0] / w, &chord(0), d[n - 1] / w);
1185        tangent[n] = tangent[0].clone();
1186    } else {
1187        tangent[0] = combine(&chord(0), 2.0, &tangent[1], -1.0);
1188        tangent[n] = combine(&chord(n - 1), 2.0, &tangent[n - 1], -1.0);
1189    }
1190    let row = |coefficients: Vec<f64>| -> Vec<(usize, Scalar)> {
1191        coefficients
1192            .into_iter()
1193            .enumerate()
1194            .filter(|&(_, c)| c != 0.0)
1195            .map(|(s, c)| (s, Scalar::from_f64(c)))
1196            .collect()
1197    };
1198    let (zero, one) = (Scalar::ZERO, Scalar::ONE);
1199    Ok((0..n)
1200        .map(|j| Span::Through {
1201            degree: 3,
1202            knots: vec![zero, zero, zero, zero, one, one, one, one],
1203            middle: vec![
1204                row(combine(&unit(j), 1.0, &tangent[j], d[j] / 3.0)),
1205                row(combine(&unit(j + 1), 1.0, &tangent[j + 1], -d[j] / 3.0)),
1206            ],
1207        })
1208        .collect())
1209}
1210
1211/// A point a loft comes to before its first section or after its last:
1212/// every curve of the profile next to it runs into it, as a cone into its
1213/// apex. Its station is named `name`.
1214#[derive(Clone, Debug)]
1215pub struct Apex {
1216    pub point: Vector3,
1217    pub name: String,
1218}
1219
1220/// How a loft ends: before its first section and after its last, a cap
1221/// or a point it comes to (see [`Apex`]) — or nowhere, closing on itself
1222/// from its last section round to its first.
1223#[derive(Clone, Debug)]
1224pub enum Ends {
1225    Open([Option<Apex>; 2]),
1226    Closed,
1227}
1228
1229impl Ends {
1230    /// Capped by the first and last sections.
1231    pub const CAPPED: Ends = Ends::Open([None, None]);
1232}
1233
1234/// Lofts through `sections` (see the module docs): closed loops into a solid
1235/// named `solid` — capped by the first and last sections — or, without one,
1236/// into sheets; open chains into sheets only. Its `ends` may instead be
1237/// points the loft comes to before its first section and after its last:
1238/// there it closes in a point instead of a cap (see [`Apex`]). The tangent
1239/// of a smooth loft into an apex is a free choice, as at any end section:
1240/// from its neighbours (see [`through`]). Or the loft closes on itself
1241/// ([`Ends::Closed`]), running smoothly on from its last section round to
1242/// its first, through three sections or more, with no caps: a ring.
1243///
1244/// Named after the first section's curves `X` and joints `P` and the
1245/// sections' names `K` (see [`skin`]): the walls `N(X)` — `N(X,K>L)` between
1246/// the sections `K` and `L` when there are more than two — the curves at the
1247/// sections `N(X,K)`, the edges between them `N(P)` or `N(P,K>L)`, the
1248/// vertices `N(P,K)`, the caps `N(start)` and `N(end)`; an apex `A` is the
1249/// vertex `N(A)`.
1250pub fn loft(
1251    model: &mut NamedModel,
1252    namer: &Namer,
1253    solid: Option<&str>,
1254    sections: &[Section],
1255    ends: &Ends,
1256    guides: &[Chain<NurbCurve3D>],
1257) -> GeopResult<BuiltBody> {
1258    let (apexes, closed) = match ends {
1259        Ends::Open(apexes) => (apexes, false),
1260        Ends::Closed => (&[None, None], true),
1261    };
1262    let ctx = with_context!(
1263        "loft({}, through {:?}, apexes {:?})",
1264        namer.root(),
1265        sections.iter().map(|s| &s.name).collect::<Vec<_>>(),
1266        apexes.iter().flatten().map(|a| &a.name).collect::<Vec<_>>()
1267    );
1268    let ends_count = apexes.iter().flatten().count();
1269    let ends = ends_count;
1270    if sections.is_empty() || sections.len() + ends < 2 {
1271        return Err(GeopError::new(format!(
1272            "loft: needs two profiles or more, or a profile and a point, not {} profile(s) and {ends} point(s)",
1273            sections.len()
1274        )))
1275        .with_context(ctx);
1276    }
1277    if ends > 0 && !guides.is_empty() {
1278        return Err(GeopError::new(
1279            "loft: guides run from the first profile to the last, and a loft to a point has a point at an end: loft to the point without guides",
1280        ))
1281        .with_context(ctx);
1282    }
1283    if closed && (sections.len() < 3 || !guides.is_empty()) {
1284        return Err(GeopError::new(format!(
1285            "loft: a loft closing on itself runs through three profiles or more, without guides, not {} profile(s) and {} guide(s)",
1286            sections.len(),
1287            guides.len()
1288        )))
1289        .with_context(ctx);
1290    }
1291    if ends > 0 && !sections[0].profile.is_closed() {
1292        return Err(GeopError::new(
1293            "loft: only a closed profile comes to a point",
1294        ))
1295        .with_context(ctx);
1296    }
1297    let (sections, crossed) = if guides.is_empty() {
1298        (sections.to_vec(), Vec::new())
1299    } else {
1300        guided(sections, guides).with_context(ctx)?
1301    };
1302    let beyond = [0, 1].map(|k| apexes[k].as_ref().map(|a| a.point.to_f64_array()));
1303    let mut placed = correspond(&sections, beyond).with_context(ctx)?;
1304    let mut names: Vec<String> = sections.iter().map(|s| s.name.clone()).collect();
1305    let mut apex = vec![false; sections.len()];
1306    for (k, end) in apexes.iter().enumerate() {
1307        let Some(end) = end else { continue };
1308        let next = if k == 0 { 0 } else { placed.len() - 1 };
1309        let point = collapsed(&placed[next], &end.point).with_context(ctx)?;
1310        if k == 0 {
1311            placed.insert(0, point);
1312            names.insert(0, end.name.clone());
1313            apex.insert(0, true);
1314        } else {
1315            placed.push(point);
1316            names.push(end.name.clone());
1317            apex.push(true);
1318        }
1319    }
1320    let spans = if closed {
1321        placed.len()
1322    } else {
1323        placed.len() - 1
1324    };
1325    let through = if crossed.is_empty() && spans > 1 {
1326        Some(through(&placed, closed).with_context(ctx)?)
1327    } else {
1328        None
1329    };
1330    let span_list = (0..spans)
1331        .map(|j| {
1332            if let Some(through) = &through {
1333                return Ok(through[j].clone());
1334            }
1335            if crossed.is_empty() {
1336                return Ok(Span::Line);
1337            }
1338            let joints: Vec<(String, String)> = (0..guides.len())
1339                .map(|k| {
1340                    (
1341                        sections[j].matched[k].clone(),
1342                        sections[j + 1].matched[k].clone(),
1343                    )
1344                })
1345                .collect();
1346            let pieces = crossed
1347                .iter()
1348                .map(|c| guide_piece(c, j))
1349                .collect::<GeopResult<Vec<_>>>()?;
1350            guided_span(&placed[j], &placed[j + 1], &joints, &pieces).with_context(with_context!(
1351                "between the profiles {} and {}",
1352                sections[j].name,
1353                sections[j + 1].name
1354            ))
1355        })
1356        .collect::<GeopResult<Vec<_>>>()
1357        .with_context(ctx)?;
1358    let path = Path {
1359        stations: placed.iter().map(|p| p.frame.clone()).collect(),
1360        spans: span_list,
1361        closed,
1362        along_normal: false,
1363        span_names: if spans == 1 {
1364            vec![None]
1365        } else {
1366            (0..spans)
1367                .map(|j| Some(format!("{}>{}", names[j], names[(j + 1) % names.len()])))
1368                .collect()
1369        },
1370        station_names: names,
1371    };
1372    let loops: Vec<Vec<SweepLoop>> = placed
1373        .into_iter()
1374        .zip(apex)
1375        .map(|(p, apex)| {
1376            vec![SweepLoop {
1377                apex,
1378                ..SweepLoop::plain(p.profile)
1379            }]
1380        })
1381        .collect();
1382    skin(model, namer, &path, &loops, solid).with_context(ctx)
1383}
1384
1385#[cfg(test)]
1386mod tests {
1387    use super::*;
1388    use crate::common::polygon;
1389    use crate::shapes::{circle, plane, square, v2, v3};
1390    use geop_core_geometry::nurb_curve::{NurbCurve2D, sqrt2_over_2};
1391    use geop_ops_model::testing::{assert_model_valid, assert_model_valid_sheets};
1392
1393    /// The plane `z = height`, along `x` and `y`.
1394    fn level(height: f64) -> CoordinateSystem {
1395        plane(v3(0., 0., height), v3(1., 0., 0.), v3(0., 1., 0.))
1396    }
1397
1398    /// The regular polygon of `n` corners on the circle of radius `r`,
1399    /// counter-clockwise, its first corner at angle `phase` (in turns).
1400    fn regular(n: usize, r: f64, phase: f64) -> Vec<NurbCurve2D> {
1401        let corners: Vec<Vector2> = (0..n)
1402            .map(|i| {
1403                let a = std::f64::consts::TAU * (phase + i as f64 / n as f64);
1404                v2(r * a.cos(), r * a.sin())
1405            })
1406            .collect();
1407        polygon(&corners).unwrap()
1408    }
1409
1410    fn section(name: &str, plane: CoordinateSystem, curves: Vec<NurbCurve2D>) -> Section {
1411        Section {
1412            plane,
1413            profile: Profile::closed(curves).with_prefix(&format!("{name},")),
1414            name: name.into(),
1415            matched: Vec::new(),
1416        }
1417    }
1418
1419    /// Lofts `sections` into a solid in a fresh part, and checks it is valid.
1420    fn lofted(sections: &[Section]) -> NamedModel {
1421        let mut model = NamedModel::new();
1422        let namer = Namer::new("loft", "l").unwrap();
1423        loft(
1424            &mut model,
1425            &namer,
1426            Some(&namer.root()),
1427            sections,
1428            &Ends::CAPPED,
1429            &[],
1430        )
1431        .unwrap();
1432        model.check_names().unwrap();
1433        assert_model_valid(model.topology());
1434        model
1435    }
1436
1437    /// A square below, a circle above: four ruled walls and two caps.
1438    #[test]
1439    fn loft_square_to_circle() {
1440        let model = lofted(&[
1441            section("a", level(0.0), regular(4, 1.0, 0.125)),
1442            section("b", level(2.0), circle(0.5)),
1443        ]);
1444        let topology = model.topology();
1445        assert_eq!(topology.faces.len(), 4 + 2);
1446        assert_eq!(topology.vertices.len(), 8);
1447        // Every vertex of the top section lies on its circle.
1448        for v in topology.vertices.values() {
1449            if v.point[2].could_be_equal(Scalar::from_f64(2.0)) {
1450                let (x, y) = (v.point[0], v.point[1]);
1451                assert!(
1452                    x.mul(x)
1453                        .add(y.mul(y))
1454                        .could_be_equal(Scalar::from_f64(0.25))
1455                );
1456            }
1457        }
1458    }
1459
1460    /// Lofts `sections` into a point `apex` at its start or end, as
1461    /// `at_end` says; checks the solid is valid and returns it.
1462    fn lofted_to(sections: &[Section], apex: Vector3, at_end: bool) -> NamedModel {
1463        let mut model = NamedModel::new();
1464        let namer = Namer::new("loft", "l").unwrap();
1465        let apex = Apex {
1466            point: apex,
1467            name: "tip".into(),
1468        };
1469        let ends = if at_end {
1470            Ends::Open([None, Some(apex)])
1471        } else {
1472            Ends::Open([Some(apex), None])
1473        };
1474        loft(
1475            &mut model,
1476            &namer,
1477            Some(&namer.root()),
1478            sections,
1479            &ends,
1480            &[],
1481        )
1482        .unwrap();
1483        model.check_names().unwrap();
1484        assert_model_valid(model.topology());
1485        model
1486    }
1487
1488    /// Circles in four planes round the `z` axis, lofted closed: a ring,
1489    /// smooth across every section — the seam too — with no caps.
1490    #[test]
1491    fn closed_loft_through_four_circles_is_a_ring() {
1492        let sections: Vec<Section> = (0..4)
1493            .map(|k| {
1494                let a = std::f64::consts::FRAC_PI_2 * k as f64;
1495                let (c, s) = (a.cos(), a.sin());
1496                let at = plane(v3(2.0 * c, 2.0 * s, 0.0), v3(c, s, 0.0), v3(0.0, 0.0, 1.0));
1497                section(&format!("s{k}"), at, circle(0.3))
1498            })
1499            .collect();
1500        let mut model = NamedModel::new();
1501        let namer = Namer::new("loft", "l").unwrap();
1502        loft(
1503            &mut model,
1504            &namer,
1505            Some(&namer.root()),
1506            &sections,
1507            &Ends::Closed,
1508            &[],
1509        )
1510        .unwrap();
1511        model.check_names().unwrap();
1512        assert_model_valid(model.topology());
1513        let topology = model.topology();
1514        assert_eq!(topology.faces.len(), 4 * 4);
1515        assert!(model.face_id("loft(l,start)").is_err());
1516        assert!(model.face_id("loft(l,s0,c0,s3>s0)").is_ok());
1517        // The walls are smooth across the seam: the tangent along the loft
1518        // at the seam is the same from either side.
1519        let surface = |name: &str| {
1520            let face = model.face_id(name).unwrap();
1521            topology.get_face(face).unwrap().surface.clone()
1522        };
1523        let (before, after) = (
1524            surface("loft(l,s0,c0,s3>s0)"),
1525            surface("loft(l,s0,c0,s0>s1)"),
1526        );
1527        let half = Scalar::from_f64(0.5);
1528        let du = |s: &NurbSurface3D, u: f64| {
1529            let (du, _) = s.derivatives(Scalar::from_f64(u), half).unwrap();
1530            du.to_f64_array()
1531        };
1532        let (a, b) = (du(&before, 1.0), du(&after, 0.0));
1533        let unit = |v: [f64; 3]| plain::scale(v, 1.0 / plain::dot(v, v).sqrt());
1534        let (a, b) = (unit(a), unit(b));
1535        assert!(plain::distance(a, b) < 1e-9, "{a:?} {b:?}");
1536    }
1537
1538    /// The volume of the solid `loft(l)` of `model`, from its faces.
1539    fn volume(model: &NamedModel) -> f64 {
1540        let solid = model.solid_id("loft(l)").unwrap();
1541        let mass = model.topology().mass_properties(solid).unwrap();
1542        mass.volume.to_f64()
1543    }
1544
1545    /// A square into a point above: a pyramid, its four walls ruled into
1546    /// the apex, one cap — a third of the base times the height.
1547    #[test]
1548    fn loft_square_to_a_point_is_a_pyramid() {
1549        let model = lofted_to(
1550            &[section("a", level(0.0), square(1.0))],
1551            v3(0.0, 0.0, 3.0),
1552            true,
1553        );
1554        let topology = model.topology();
1555        assert_eq!(topology.faces.len(), 4 + 1);
1556        assert_eq!(topology.vertices.len(), 5);
1557        assert!(model.vertex_id("loft(l,tip)").is_ok());
1558        assert!((volume(&model) - 4.0).abs() < 1e-9, "{}", volume(&model));
1559    }
1560
1561    /// A point below a circle, off its axis: an oblique cone, from the
1562    /// point up — a third of the base times the height all the same.
1563    #[test]
1564    fn loft_from_a_point_to_a_circle_is_a_cone() {
1565        let model = lofted_to(
1566            &[section("a", level(2.0), circle(1.0))],
1567            v3(0.5, 0.0, 0.0),
1568            false,
1569        );
1570        let expected = std::f64::consts::PI * 2.0 / 3.0;
1571        assert!(
1572            (volume(&model) - expected).abs() < 1e-9,
1573            "{}",
1574            volume(&model)
1575        );
1576    }
1577
1578    /// Through two sections into a point, smoothly: the walls are cubic
1579    /// through all three stations, the tangent at the point from its
1580    /// neighbours, as at any end section.
1581    #[test]
1582    fn loft_through_two_sections_into_a_point() {
1583        let model = lofted_to(
1584            &[
1585                section("a", level(0.0), square(1.0)),
1586                section("b", level(1.0), circle(0.8)),
1587            ],
1588            v3(0.0, 0.0, 2.0),
1589            true,
1590        );
1591        let topology = model.topology();
1592        assert_eq!(topology.faces.len(), 2 * 4 + 1);
1593    }
1594
1595    /// The upper plane tilted, and facing down: still a valid solid.
1596    #[test]
1597    fn loft_to_a_tilted_plane_facing_the_other_way() {
1598        let tilted = plane(v3(0.2, 0., 2.), v3(0.8, 0., 0.6), v3(0., -1., 0.));
1599        lofted(&[
1600            section("a", level(0.0), regular(4, 1.0, 0.0)),
1601            section("b", tilted, circle(0.5)),
1602        ]);
1603    }
1604
1605    /// Through three sections: two layers of walls, named after the spans
1606    /// between the sections.
1607    #[test]
1608    fn loft_through_three_sections() {
1609        let model = lofted(&[
1610            section("a", level(0.0), regular(4, 1.0, 0.125)),
1611            section("b", level(1.0), circle(0.5)),
1612            section("c", level(2.5), regular(4, 0.8, 0.0)),
1613        ]);
1614        assert_eq!(model.topology().faces.len(), 2 * 4 + 2);
1615        assert!(model.face_id("loft(l,a,c0,a>b)").is_ok());
1616        assert!(model.face_id("loft(l,a,c0,b>c)").is_ok());
1617        assert!(model.edge_id("loft(l,a,c0,c)").is_ok());
1618    }
1619
1620    /// Through three circles of different sizes, the walls either side of
1621    /// the middle one meet it with one tangent plane: the loft has no crease
1622    /// there, as ruled walls would.
1623    #[test]
1624    fn loft_through_three_sections_is_smooth() {
1625        let model = lofted(&[
1626            section("a", level(0.0), circle(1.0)),
1627            section("b", level(1.0), circle(1.5)),
1628            section("c", level(3.0), circle(0.8)),
1629        ]);
1630        let topology = model.topology();
1631        let surface = |name: &str| &topology.faces[&model.face_id(name).unwrap()].surface;
1632        let (below, above) = (surface("loft(l,a,c0,a>b)"), surface("loft(l,a,c0,b>c)"));
1633        for v in [0.1, 0.5, 0.9] {
1634            let normal = |surface: &geop_core_geometry::nurb_surface::NurbSurface3D, u: Scalar| {
1635                let (v0, v1) = surface.domain_v();
1636                let v = Scalar::from_f64(v0.to_f64() + (v1.to_f64() - v0.to_f64()) * v);
1637                surface.normal(u, v).unwrap()
1638            };
1639            let (below, above) = (
1640                normal(below, below.domain_u().1),
1641                normal(above, above.domain_u().0),
1642            );
1643            assert!(
1644                below.could_be_equal(&above),
1645                "the walls meet the middle circle with the normals {below:?} below and {above:?} above"
1646            );
1647        }
1648    }
1649
1650    /// A triangle and a circle: the triangle's longest side is halved to
1651    /// match the circle's four quarters.
1652    #[test]
1653    fn loft_triangle_to_circle() {
1654        let model = lofted(&[
1655            section("a", level(0.0), regular(3, 1.0, 0.0)),
1656            section("b", level(1.5), circle(0.6)),
1657        ]);
1658        assert_eq!(model.topology().faces.len(), 4 + 2);
1659        // All three sides are as long: whichever was halved.
1660        assert!((0..3).any(|k| model.vertex_id(&format!("loft(l,a,c{k}@cut1,b)")).is_ok()));
1661    }
1662
1663    /// A hexagon and a circle: the circle's quarters are halved — rational
1664    /// arcs, brought back to end weights of one.
1665    #[test]
1666    fn loft_hexagon_to_circle() {
1667        let model = lofted(&[
1668            section("a", level(0.0), regular(6, 1.0, 0.0)),
1669            section("b", level(1.5), circle(0.6)),
1670        ]);
1671        assert_eq!(model.topology().faces.len(), 6 + 2);
1672    }
1673
1674    /// Two open curves loft into the ruled sheet between them.
1675    #[test]
1676    fn loft_between_two_curves_is_a_ruled_sheet() {
1677        let line = vec![NurbCurve2D::line(v2(-1., 0.), v2(1., 0.)).unwrap()];
1678        let arc =
1679            vec![NurbCurve2D::arc(v2(1., 0.), v2(1., 1.), v2(0., 1.), sqrt2_over_2()).unwrap()];
1680        let open = |name: &str, plane, curves| Section {
1681            plane,
1682            profile: Profile::open(curves).with_prefix(&format!("{name},")),
1683            name: name.to_string(),
1684            matched: Vec::new(),
1685        };
1686        let mut model = NamedModel::new();
1687        let namer = Namer::new("loft", "l").unwrap();
1688        let built = loft(
1689            &mut model,
1690            &namer,
1691            None,
1692            &[open("a", level(0.0), line), open("b", level(1.0), arc)],
1693            &Ends::CAPPED,
1694            &[],
1695        )
1696        .unwrap();
1697        assert!(built.solid.is_none());
1698        model.check_names().unwrap();
1699        assert_model_valid_sheets(model.topology());
1700        assert_eq!(model.topology().faces.len(), 1);
1701    }
1702
1703    /// One profile is not a loft.
1704    #[test]
1705    fn loft_of_one_profile_is_refused() {
1706        let mut model = NamedModel::new();
1707        let namer = Namer::new("loft", "l").unwrap();
1708        let only = section("a", level(0.0), regular(4, 1.0, 0.0));
1709        assert!(
1710            loft(
1711                &mut model,
1712                &namer,
1713                Some("loft(l)"),
1714                &[only],
1715                &Ends::CAPPED,
1716                &[]
1717            )
1718            .is_err()
1719        );
1720    }
1721
1722    // ── guide curves ────────────────────────────────────────────────────────
1723
1724    /// The quadratic Bézier guide `name` through the control points `points`.
1725    fn guide(name: &str, points: [[f64; 3]; 3]) -> Chain<NurbCurve3D> {
1726        let f = Scalar::from_f64;
1727        let curve = NurbCurve::try_new(
1728            2,
1729            points
1730                .iter()
1731                .map(|p| {
1732                    geop_core_math::vector::Vector4::from_array([f(p[0]), f(p[1]), f(p[2]), f(1.0)])
1733                })
1734                .collect(),
1735            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
1736        )
1737        .unwrap();
1738        Chain {
1739            name: name.into(),
1740            curves: vec![curve],
1741            curve_names: vec![format!("{name},c")],
1742            joint_names: vec![format!("{name},s"), format!("{name},e")],
1743        }
1744    }
1745
1746    /// Lofts `sections` along `guides` into a solid in a fresh part, and
1747    /// checks it is valid.
1748    fn guided_loft(sections: &[Section], guides: &[Chain<NurbCurve3D>]) -> NamedModel {
1749        let mut model = NamedModel::new();
1750        let namer = Namer::new("loft", "l").unwrap();
1751        loft(
1752            &mut model,
1753            &namer,
1754            Some(&namer.root()),
1755            sections,
1756            &Ends::CAPPED,
1757            guides,
1758        )
1759        .unwrap();
1760        model.check_names().unwrap();
1761        assert_model_valid(model.topology());
1762        model
1763    }
1764
1765    /// The edge `name` of `model` runs along `guide`: one point for one at
1766    /// every parameter — the walls' edge there is the guide itself.
1767    fn assert_follows(model: &NamedModel, name: &str, guide: &Chain<NurbCurve3D>) {
1768        let edge = model.edge_id(name).unwrap();
1769        let curve = &model.topology().edges[&edge].curve;
1770        for i in 0..=8 {
1771            let t = Scalar::from_f64(i as f64 / 8.0);
1772            let (a, b) = (
1773                curve.evaluate(t).unwrap(),
1774                guide.curves[0].evaluate(t).unwrap(),
1775            );
1776            // The edge's inner control points are the guide's, but for the
1777            // rounding of the shape between, a free choice in plain numbers.
1778            for k in 0..3 {
1779                assert!(
1780                    (a[k].to_f64() - b[k].to_f64()).abs() < 1e-12,
1781                    "at {t:?}: {a:?} vs {b:?}"
1782                );
1783            }
1784        }
1785    }
1786
1787    /// Two squares, one guide bowing out from a corner of one to the same
1788    /// corner of the other: the walls bulge with it, their edge from that
1789    /// corner the guide.
1790    #[test]
1791    fn loft_with_one_guide() {
1792        let g = guide("g", [[1., 1., 0.], [2., 2., 1.], [1., 1., 2.]]);
1793        let model = guided_loft(
1794            &[
1795                section("a", level(0.0), square(1.0)),
1796                section("b", level(2.0), square(1.0)),
1797            ],
1798            std::slice::from_ref(&g),
1799        );
1800        assert_eq!(model.topology().faces.len(), 4 + 2);
1801        assert_follows(&model, "loft(l,a,p2)", &g);
1802        // The other corners move along with it: half way up, the whole
1803        // section shifted by half the guide's bulge.
1804        let edge = model.edge_id("loft(l,a,p0)").unwrap();
1805        let middle = model.topology().edges[&edge]
1806            .curve
1807            .evaluate(Scalar::from_f64(0.5))
1808            .unwrap();
1809        for (k, want) in [-0.5, -0.5, 1.0].into_iter().enumerate() {
1810            assert!((middle[k].to_f64() - want).abs() < 1e-12, "{middle:?}");
1811        }
1812    }
1813
1814    /// Two guides bowing out from opposite corners: the sections stretch
1815    /// along the diagonal between them, both edges the guides.
1816    #[test]
1817    fn loft_with_two_guides() {
1818        let g = guide("g", [[1., 1., 0.], [2., 2., 1.], [1., 1., 2.]]);
1819        let h = guide("h", [[-1., -1., 0.], [-2., -2., 1.], [-1., -1., 2.]]);
1820        let model = guided_loft(
1821            &[
1822                section("a", level(0.0), square(1.0)),
1823                section("b", level(2.0), square(1.0)),
1824            ],
1825            &[h.clone(), g.clone()],
1826        );
1827        assert_follows(&model, "loft(l,a,p2)", &g);
1828        assert_follows(&model, "loft(l,a,p0)", &h);
1829    }
1830
1831    /// A guide meeting the profiles along their sides, not at a corner:
1832    /// each side split where the guide meets it.
1833    #[test]
1834    fn guide_splits_the_sides_it_meets() {
1835        let g = guide("g", [[1., 0., 0.], [1.5, 0., 1.], [1., 0., 2.]]);
1836        let model = guided_loft(
1837            &[
1838                section("a", level(0.0), square(1.0)),
1839                section("b", level(2.0), circle(1.0)),
1840            ],
1841            std::slice::from_ref(&g),
1842        );
1843        assert_follows(&model, "loft(l,a,g)", &g);
1844    }
1845
1846    /// Through three profiles: the guide crosses the middle one at its
1847    /// corner, and is followed on either side of it.
1848    #[test]
1849    fn loft_with_a_guide_through_three_profiles() {
1850        let g = guide("g", [[1., 1., 0.], [1.4, 1.4, 1.], [1., 1., 2.]]);
1851        let model = guided_loft(
1852            &[
1853                section("a", level(0.0), square(1.0)),
1854                section("b", level(1.0), square(1.2)),
1855                section("c", level(2.0), square(1.0)),
1856            ],
1857            std::slice::from_ref(&g),
1858        );
1859        assert_eq!(model.topology().faces.len(), 2 * 4 + 2);
1860        let corner = model.vertex_id("loft(l,a,p2,b)").unwrap();
1861        let p = model.topology().get_vertex(corner).unwrap().point;
1862        assert!(p.could_be_equal(&v3(1.2, 1.2, 1.0)), "{p:?}");
1863    }
1864
1865    /// What a guided loft refuses, by name.
1866    #[test]
1867    fn guided_loft_refuses_what_it_cannot_build() {
1868        let sections = [
1869            section("a", level(0.0), square(1.0)),
1870            section("b", level(2.0), square(1.0)),
1871        ];
1872        let refused = |sections: &[Section], guides: &[Chain<NurbCurve3D>], says: &str| {
1873            let mut model = NamedModel::new();
1874            let namer = Namer::new("loft", "l").unwrap();
1875            let error = loft(
1876                &mut model,
1877                &namer,
1878                Some("loft(l)"),
1879                sections,
1880                &Ends::CAPPED,
1881                guides,
1882            )
1883            .unwrap_err();
1884            assert!(error.root_message().contains(says), "{error:?}");
1885        };
1886        // Starting off the first profile's plane.
1887        refused(
1888            &sections,
1889            &[guide("off", [[1., 1., 0.5], [2., 2., 1.], [1., 1., 2.]])],
1890            "the guide off has to run from the profile a to the profile b",
1891        );
1892        // Starting on its plane, but off the profile.
1893        refused(
1894            &sections,
1895            &[guide("out", [[3., 1., 0.], [2., 2., 1.], [1., 1., 2.]])],
1896            "is on none of the profile's curves",
1897        );
1898        // With matching points too.
1899        let mut matched = sections.to_vec();
1900        matched[0].matched = vec!["a,p0".into()];
1901        matched[1].matched = vec!["b,p0".into()];
1902        refused(
1903            &matched,
1904            &[guide("g", [[1., 1., 0.], [2., 2., 1.], [1., 1., 2.]])],
1905            "use one or the other",
1906        );
1907        // Four guides.
1908        let four: Vec<Chain<NurbCurve3D>> = (0..4)
1909            .map(|k| {
1910                let (x, y) = [(1., 1.), (-1., 1.), (-1., -1.), (1., -1.)][k];
1911                guide(
1912                    &format!("g{k}"),
1913                    [[x, y, 0.], [2. * x, 2. * y, 1.], [x, y, 2.]],
1914                )
1915            })
1916            .collect();
1917        refused(&sections, &four, "at most 3");
1918    }
1919}