Skip to main content

geop_core_sketch/
profile.rs

1//! Turning a solved sketch into profiles: closed loops of curves grouped into
2//! regions (an outer loop with its holes), and those loops into the NURBS
3//! curves that extrude and revolve consume.
4//!
5//! **Connectivity is structural.** Two curves are joined exactly when they
6//! share an endpoint (the same point, or points joined by
7//! [`crate::Constraint::Coincident`]) — never because their ends happen to
8//! be close. Curves hanging off a loop (open chains, e.g. a helper line) are
9//! ignored; curves that branch (three or more meeting at a point) are an
10//! error, since the regions they bound are ambiguous.
11//!
12//! **Nesting and orientation are answered on the real curves.** Which loop
13//! lies inside which comes from casting a ray and counting crossings with
14//! the kernel's own [`curve_curve_intersect`] — the same thing
15//! `geop_core_topology::contains::face::loops_contain` does for a face's
16//! trim, on the same NURBS curves the profile is made of — and which way a
17//! loop winds from its signed area, integrated from those curves
18//! ([`loop_signed_area`]). Polylines are sampled from them too, for
19//! drawing.
20
21use crate::sketch::{CurveId, CurveKind, Enclosure, PointId, Sketch, SplineShape};
22use crate::text;
23use geop_core_geometry::{
24    aabb::union_aabb,
25    intersection::curve_curve_intersect,
26    nurb_curve::{NurbCurve, NurbCurve2D, loop_signed_area},
27};
28use geop_core_math::{
29    geop_error::{GeopError, GeopResult, WithContext},
30    scalars::Scalar,
31    solvers::{
32        probe::{Probe, attempts},
33        repeat,
34        walk::walk_by,
35    },
36    vector::Vector2,
37    with_context,
38};
39use std::collections::{BTreeMap, BTreeSet};
40
41/// Budgets for the ray casts in [`loop_contains`]: how many crossings one
42/// ray may find with one curve, how hard a single search tries, and the
43/// subdivision size it stops isolating at — one part in `MIN_SUBDIVISION`
44/// of a curve's domain.
45const MAX_CROSSINGS: usize = 16;
46const MAX_NODES: usize = 4000;
47const MIN_SUBDIVISION: i64 = 1_000_000;
48/// How many directions a ray cast tries before giving up on a probe point.
49const MAX_RAY_ATTEMPTS: usize = 32;
50
51/// One curve of a loop, traversed forwards or backwards — of a text, one
52/// of its contours.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub struct ProfileEdge {
55    pub curve: CurveId,
56    pub reversed: bool,
57    /// Which of the curve's closed contours: of a text, one of its glyphs'
58    /// (see [`text::outlines`]); of any other curve, its only one, `0`.
59    pub contour: usize,
60}
61
62/// A closed chain of curves, each ending where the next starts.
63#[derive(Clone, Debug, PartialEq, Eq)]
64pub struct ProfileLoop {
65    pub edges: Vec<ProfileEdge>,
66}
67
68/// Curves joined end to end, in order (see [`Sketch::chain`]).
69#[derive(Clone, Debug, PartialEq, Eq)]
70pub struct Chain {
71    pub edges: Vec<ProfileEdge>,
72    /// Its last curve ends where its first starts — or it is a circle.
73    pub closed: bool,
74}
75
76/// An area of the sketch: an outer loop (counter-clockwise) and the holes in
77/// it (clockwise).
78#[derive(Clone, Debug, PartialEq, Eq)]
79pub struct Region {
80    pub outer: ProfileLoop,
81    pub holes: Vec<ProfileLoop>,
82}
83
84/// What a sketch draws to sweep: the areas its curves enclose — one, or
85/// several apart, as the letters of a text are — or, for a sweep into a
86/// sheet only, the one open chain they form, enclosing nothing.
87#[derive(Clone, Debug, PartialEq, Eq)]
88pub enum Shape {
89    Regions(Vec<Region>),
90    Chain(ProfileLoop),
91}
92
93/// Samples per span of a curved piece, for drawing.
94const SAMPLES: usize = 16;
95
96impl Sketch {
97    /// What the sketch draws to sweep (see [`Shape`]): its areas, or, if
98    /// its curves enclose nothing, the one chain they form.
99    pub fn shape(&self) -> GeopResult<Shape> {
100        self.validate()?;
101        if self.loops()?.is_empty() {
102            Ok(Shape::Chain(self.sweep_chain()?))
103        } else {
104            Ok(Shape::Regions(self.regions()?))
105        }
106    }
107
108    /// The one open chain the sketch's non-construction curves form (see
109    /// [`Sketch::chain`]). Fails unless they form exactly one, unbranched.
110    fn sweep_chain(&self) -> GeopResult<ProfileLoop> {
111        let curves: Vec<CurveId> = self
112            .curves
113            .iter()
114            .filter(|(_, c)| !c.construction && c.endpoints().is_some())
115            .map(|(&id, _)| id)
116            .collect();
117        if curves.is_empty() {
118            return Err(GeopError::new("sketch has no curves to sweep"));
119        }
120        let ctx = with_context!(
121            "one sketch is one profile to sweep: draw each chain in a sketch of its own"
122        );
123        let chain = self.chain(&curves).with_context(ctx)?;
124        Ok(ProfileLoop { edges: chain.edges })
125    }
126
127    /// `curves` in order as one chain, each joined to the next at an end
128    /// point, none branching: an open chain walked from its end at the
129    /// lower point id, a closed one from its first curve given — or a lone
130    /// circle.
131    pub fn chain(&self, curves: &[CurveId]) -> GeopResult<Chain> {
132        let mut ids: Vec<CurveId> = Vec::new();
133        for &c in curves {
134            self.curve(c)?;
135            if !ids.contains(&c) {
136                ids.push(c);
137            }
138        }
139        let Some(&first) = ids.first() else {
140            return Err(GeopError::new("no curves to make a chain of"));
141        };
142        if let Some(&circle) = ids
143            .iter()
144            .find(|c| matches!(self.curves[c].kind, CurveKind::Circle { .. }))
145        {
146            if ids.len() > 1 {
147                return Err(GeopError::new(format!(
148                    "circle {circle} is a chain of its own, apart from the other curves"
149                )));
150            }
151            return Ok(Chain {
152                edges: vec![ProfileEdge {
153                    curve: circle,
154                    reversed: false,
155                    contour: 0,
156                }],
157                closed: true,
158            });
159        }
160        let class = self.point_classes();
161        let ends = |c: CurveId| {
162            let (s, e) = self.curves[&c].endpoints().expect("no circle");
163            (class[&s], class[&e])
164        };
165        let mut at: BTreeMap<PointId, Vec<CurveId>> = BTreeMap::new();
166        for &c in &ids {
167            let (s, e) = ends(c);
168            at.entry(s).or_default().push(c);
169            at.entry(e).or_default().push(c);
170        }
171        if let Some((p, cs)) = at.iter().find(|(_, cs)| cs.len() > 2) {
172            return Err(GeopError::new(format!(
173                "curves branch at point {p}: {} curves meet there",
174                cs.len()
175            )));
176        }
177        // An open chain starts at its end at the lower point id.
178        let start = at.iter().find(|(_, cs)| cs.len() == 1).map(|(&p, cs)| {
179            let c = cs[0];
180            (c, ends(c).0 != p)
181        });
182        let closed = start.is_none();
183        let (c, reversed) = start.unwrap_or((first, false));
184        // On from each curve's end to the other curve there, until there is
185        // none, or it is one already in the chain.
186        let edges = walk_by(
187            ProfileEdge {
188                curve: c,
189                reversed,
190                contour: 0,
191            },
192            |edge| edge.curve,
193            |edge| {
194                let (s, e) = ends(edge.curve);
195                let end = if edge.reversed { s } else { e };
196                Ok::<_, GeopError>(at[&end].iter().find(|&&k| k != edge.curve).map(|&next| {
197                    ProfileEdge {
198                        curve: next,
199                        reversed: ends(next).1 == end,
200                        contour: 0,
201                    }
202                }))
203            },
204        )?
205        .nodes;
206        if let Some(apart) = ids
207            .iter()
208            .find(|&&k| !edges.iter().any(|edge| edge.curve == k))
209        {
210            return Err(GeopError::new(format!(
211                "the curves form separate chains: {apart} is not joined to {}",
212                edges[0].curve
213            )));
214        }
215        Ok(Chain { edges, closed })
216    }
217
218    /// Every run of the sketch's non-construction curves, in the order of
219    /// their ids: each closed curve, each contour of a text, and each stretch
220    /// of curves joined end to end, broken where three or more meet. Unlike
221    /// [`Sketch::shape`], which is what the sketch sweeps as one profile,
222    /// nothing is left out and nothing has to enclose anything: every line of
223    /// the sketch is in one run.
224    pub fn runs(&self) -> GeopResult<Vec<Chain>> {
225        self.validate()?;
226        let class = self.point_classes();
227        let lone = |curve, contour| Chain {
228            edges: vec![ProfileEdge {
229                curve,
230                reversed: false,
231                contour,
232            }],
233            closed: true,
234        };
235        let mut runs = Vec::new();
236        // The curves between two points: the edges of a graph on the point
237        // classes. A curve ending where it starts is a run by itself.
238        let mut open: BTreeMap<CurveId, (PointId, PointId)> = BTreeMap::new();
239        let mut at: BTreeMap<PointId, Vec<CurveId>> = BTreeMap::new();
240        for (&id, curve) in self.curves.iter().filter(|(_, c)| !c.construction) {
241            match (curve.endpoints(), &curve.kind) {
242                (None, CurveKind::Text { text, .. }) => {
243                    runs.extend((0..text::outlines(text)?.len()).map(|k| lone(id, k)));
244                }
245                (Some((s, e)), _) if class[&s] != class[&e] => {
246                    open.insert(id, (class[&s], class[&e]));
247                    at.entry(class[&s]).or_default().push(id);
248                    at.entry(class[&e]).or_default().push(id);
249                }
250                (Some(_), kind) if !matches!(kind, CurveKind::Spline { .. }) => {
251                    return Err(GeopError::new(format!(
252                        "curve {id} starts and ends at the same point"
253                    )));
254                }
255                _ => runs.push(lone(id, 0)),
256            }
257        }
258        // From `start`, along `first`, on through each point where only two
259        // curves meet, as far as a curve not taken yet goes on.
260        let mut left: BTreeSet<CurveId> = open.keys().copied().collect();
261        let mut walk = |start: PointId, first: CurveId| {
262            let (mut point, mut next, mut edges) = (start, Some(first), Vec::new());
263            while let Some(curve) = next.filter(|c| left.remove(c)) {
264                let (s, e) = open[&curve];
265                edges.push(ProfileEdge {
266                    curve,
267                    reversed: s != point,
268                    contour: 0,
269                });
270                point = if s == point { e } else { s };
271                next = match &at[&point][..] {
272                    [a, b] => Some(if *a == curve { *b } else { *a }),
273                    _ => None,
274                };
275            }
276            Chain {
277                closed: !edges.is_empty() && point == start,
278                edges,
279            }
280        };
281        // Out from the points that end a run, then round what is left: loops.
282        for (&point, curves) in at.iter().filter(|(_, curves)| curves.len() != 2) {
283            for &curve in curves {
284                runs.push(walk(point, curve));
285            }
286        }
287        for &curve in open.keys() {
288            runs.push(walk(open[&curve].0, curve));
289        }
290        runs.retain(|run| !run.edges.is_empty());
291        Ok(runs)
292    }
293
294    /// The chain through `curve`: it, and on from each of its ends the
295    /// line, arc or spline joined there — as long as exactly one other of
296    /// its kind (profile or construction) is.
297    pub fn chain_through(&self, curve: CurveId) -> GeopResult<Vec<CurveId>> {
298        let construction = self.curve(curve)?.construction;
299        let Some((s, e)) = self.curves[&curve].endpoints() else {
300            return Ok(vec![curve]);
301        };
302        let class = self.point_classes();
303        let kin: Vec<(CurveId, PointId, PointId)> = self
304            .curves
305            .iter()
306            .filter(|(_, c)| c.construction == construction)
307            .filter_map(|(&id, c)| c.endpoints().map(|(s, e)| (id, class[&s], class[&e])))
308            .collect();
309        let others_at = |p: PointId, not: CurveId| -> Vec<(CurveId, PointId)> {
310            kin.iter()
311                .filter(|&&(id, s, e)| id != not && (s == p || e == p))
312                .map(|&(id, s, e)| (id, if s == p { e } else { s }))
313                .collect()
314        };
315        let mut chain = vec![curve];
316        for from in [class[&e], class[&s]] {
317            // On from each curve to the one other joined at its far end, as
318            // far as the chain has not come already.
319            let on = walk_by(
320                (curve, from),
321                |&(c, _)| c,
322                |&(c, p)| {
323                    Ok::<_, GeopError>(match others_at(p, c)[..] {
324                        [(next, far)] => Some((next, far)),
325                        _ => None,
326                    })
327                },
328            )?;
329            let new: Vec<CurveId> = (on.nodes[1..].iter())
330                .map(|&(c, _)| c)
331                .take_while(|c| !chain.contains(c))
332                .collect();
333            chain.extend(new);
334        }
335        Ok(chain)
336    }
337
338    /// Every region bounded by the sketch's non-construction curves.
339    pub fn regions(&self) -> GeopResult<Vec<Region>> {
340        self.validate()?;
341        let loops = self.loops()?;
342        if loops.is_empty() {
343            return Err(GeopError::new(
344                "sketch has no closed profile: its curves do not form a loop",
345            ));
346        }
347        // Nesting and winding are questions about the sketch as drawn.
348        let drawn = Enclosure::as_drawn(self);
349        let curves: Vec<Vec<NurbCurve2D>> = loops
350            .iter()
351            .map(|l| {
352                Ok(l.to_nurbs(self, &drawn)?
353                    .into_iter()
354                    .map(|p| p.curve)
355                    .collect())
356            })
357            .collect::<GeopResult<_>>()?;
358        let extent = extent_of(&curves)?;
359        let counter_clockwise: Vec<bool> = (loops.iter().zip(&curves))
360            .map(|(l, c)| winds_counter_clockwise(l, c))
361            .collect::<GeopResult<_>>()?;
362        let bounds: Vec<Option<[Scalar; 3]>> = (curves.iter())
363            .map(|c| union_aabb(c.iter().map(NurbCurve::aabb)))
364            .collect();
365
366        // containers[i]: the loops loop `i` lies inside. A point of loop `i`
367        // serves as the probe — loops of one profile never cross, so a point
368        // on one is either inside another or outside it — and one outside
369        // the box round another loop's control points is outside that loop.
370        let mut containers: Vec<Vec<usize>> = Vec::with_capacity(loops.len());
371        for i in 0..loops.len() {
372            let probe = midpoint(&curves[i][0])?;
373            let mut inside = Vec::new();
374            for (j, other) in curves.iter().enumerate() {
375                let boxed =
376                    bounds[j].is_some_and(|b| (0..2).all(|k| b[k].could_be_equal(probe[k])));
377                if j != i && boxed && loop_contains(other, &probe, extent)? {
378                    inside.push(j);
379                }
380            }
381            containers.push(inside);
382        }
383
384        let mut regions: Vec<(usize, Region)> = Vec::new();
385        for i in (0..loops.len()).filter(|&i| containers[i].len().is_multiple_of(2)) {
386            let outer = if counter_clockwise[i] {
387                loops[i].clone()
388            } else {
389                loops[i].reversed()
390            };
391            regions.push((
392                i,
393                Region {
394                    outer,
395                    holes: Vec::new(),
396                },
397            ));
398        }
399        for i in (0..loops.len()).filter(|&i| !containers[i].len().is_multiple_of(2)) {
400            let depth = containers[i].len();
401            let parent = *containers[i]
402                .iter()
403                .find(|&&j| containers[j].len() == depth - 1)
404                .expect("an odd-depth loop lies directly inside an even-depth one");
405            let hole = if counter_clockwise[i] {
406                loops[i].reversed()
407            } else {
408                loops[i].clone()
409            };
410            regions
411                .iter_mut()
412                .find(|(j, _)| *j == parent)
413                .unwrap()
414                .1
415                .holes
416                .push(hole);
417        }
418        Ok(regions.into_iter().map(|(_, r)| r).collect())
419    }
420
421    /// Every closed loop of non-construction curves.
422    fn loops(&self) -> GeopResult<Vec<ProfileLoop>> {
423        let class = self.point_classes();
424        let mut loops = Vec::new();
425        // Open curves between two distinct points: the edges of a graph on
426        // the point classes.
427        let mut edges: Vec<(CurveId, PointId, PointId)> = Vec::new();
428        for (&id, curve) in &self.curves {
429            if curve.construction {
430                continue;
431            }
432            match curve.endpoints() {
433                Some((s, e)) if class[&s] != class[&e] => edges.push((id, class[&s], class[&e])),
434                Some(_) if !matches!(curve.kind, CurveKind::Spline { .. }) => {
435                    return Err(GeopError::new(format!(
436                        "curve {id} starts and ends at the same point"
437                    )));
438                }
439                // A text is a loop for each contour of its glyphs.
440                None if let CurveKind::Text { text, .. } = &curve.kind => {
441                    let contours = text::outlines(text)?.len();
442                    loops.extend((0..contours).map(|contour| ProfileLoop {
443                        edges: vec![ProfileEdge {
444                            curve: id,
445                            reversed: false,
446                            contour,
447                        }],
448                    }));
449                }
450                // A closed curve is a loop by itself.
451                _ => loops.push(ProfileLoop {
452                    edges: vec![ProfileEdge {
453                        curve: id,
454                        reversed: false,
455                        contour: 0,
456                    }],
457                }),
458            }
459        }
460
461        // Drop open chains: repeatedly remove edges at points of degree 1.
462        let mut alive = vec![true; edges.len()];
463        let mut degree: BTreeMap<PointId, usize> = BTreeMap::new();
464        for &(_, a, b) in &edges {
465            *degree.entry(a).or_default() += 1;
466            *degree.entry(b).or_default() += 1;
467        }
468        let _ = repeat::resolve((0..edges.len()).collect(), |&k| {
469            let (_, a, b) = edges[k];
470            if degree[&a] != 1 && degree[&b] != 1 {
471                return Err(());
472            }
473            alive[k] = false;
474            *degree.get_mut(&a).unwrap() -= 1;
475            *degree.get_mut(&b).unwrap() -= 1;
476            Ok(())
477        });
478        if let Some((p, d)) = degree.iter().find(|(_, d)| **d > 2) {
479            return Err(GeopError::new(format!(
480                "profile curves branch at point {p}: {d} curves meet there"
481            )));
482        }
483
484        // Every remaining point has degree 2: walk the cycles.
485        let mut used = vec![false; edges.len()];
486        for start in 0..edges.len() {
487            if !alive[start] || used[start] {
488                continue;
489            }
490            // On to an edge not walked yet at each edge's far end.
491            let around = walk_by(
492                (start, false),
493                |&(k, _)| k,
494                |&(k, at_end)| {
495                    used[k] = true;
496                    let (_, a, b) = edges[k];
497                    let next_point = if at_end { a } else { b };
498                    Ok::<_, GeopError>(
499                        (0..edges.len())
500                            .find(|&j| {
501                                alive[j]
502                                    && !used[j]
503                                    && (edges[j].1 == next_point || edges[j].2 == next_point)
504                            })
505                            .map(|next| (next, edges[next].2 == next_point)),
506                    )
507                },
508            )?;
509            let lp = (around.nodes.iter())
510                .map(|&(k, at_end)| ProfileEdge {
511                    curve: edges[k].0,
512                    reversed: at_end,
513                    contour: 0,
514                })
515                .collect();
516            loops.push(ProfileLoop { edges: lp });
517        }
518        Ok(loops)
519    }
520}
521
522/// Where a joint of a profile comes from in the sketch: a sketch point, or a
523/// point a sketch curve had to be split at to become NURBS pieces (see
524/// [`ProfileLoop::to_nurbs`]) — `Split { curve, index }` is where piece
525/// `index` of `curve` starts, counted in the curve's own direction. A circle
526/// has no points of its own, so all of its joints are splits, `index = 0`
527/// included.
528///
529/// This is design data, stable across edits of the sketch that keep the
530/// curve — what a topological name of anything built from the joint is made
531/// from.
532#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
533pub enum ProfileJoint {
534    Point(PointId),
535    Split { curve: CurveId, index: usize },
536}
537
538/// `p3` for a sketch point, `c5@1` for where piece 1 of curve 5 starts.
539impl std::fmt::Display for ProfileJoint {
540    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541        match self {
542            ProfileJoint::Point(p) => write!(f, "{p}"),
543            ProfileJoint::Split { curve, index } => write!(f, "{curve}@{index}"),
544        }
545    }
546}
547
548/// One NURBS piece `curve` of a chain of a sketch's curves — a profile
549/// loop, or a 3-D sketch's path — and where it comes from in the sketch:
550/// piece `index` of sketch curve `source` (counted in the curve's own
551/// direction), running from joint `start` to joint `end` in the chain's
552/// direction.
553#[derive(Clone, Debug)]
554pub struct Piece<C> {
555    pub curve: C,
556    pub source: CurveId,
557    pub index: usize,
558    pub start: ProfileJoint,
559    pub end: ProfileJoint,
560}
561
562/// A piece of a planar sketch's profile loop.
563pub type ProfilePiece = Piece<NurbCurve2D>;
564
565impl<C> Piece<C> {
566    /// The piece's stable name within its sketch: `c5` for a curve's first
567    /// (often only) piece, `c5#1` for the next.
568    pub fn name(&self) -> String {
569        if self.index == 0 {
570            format!("{}", self.source)
571        } else {
572            format!("{}#{}", self.source, self.index)
573        }
574    }
575}
576
577impl ProfileLoop {
578    /// The same loop traversed the other way.
579    pub fn reversed(&self) -> ProfileLoop {
580        ProfileLoop {
581            edges: self
582                .edges
583                .iter()
584                .rev()
585                .map(|e| ProfileEdge {
586                    reversed: !e.reversed,
587                    ..*e
588                })
589                .collect(),
590        }
591    }
592
593    /// The loop as NURBS pieces, each on the domain `[0, 1]`, each starting
594    /// exactly where the previous one ends, with the sketch's points placed
595    /// at `positions` (the sketch's own positions, or a rigid,
596    /// orientation-preserving motion of them).
597    ///
598    /// Arcs are split into pieces of at most a quarter turn — a rational
599    /// quadratic's middle weight `cos(sweep / 2)` must stay positive — and
600    /// circles into four quarters. A loop of a single piece (a closed spline)
601    /// is split in two, so every loop has at least two joints; an open chain
602    /// of one piece stays one. Each piece
603    /// records which sketch curve it is part of and which joints it runs
604    /// between (see [`ProfilePiece`]).
605    ///
606    /// Also used for an open chain (a revolve profile): the pieces then
607    /// simply don't close up, and the last one's `end` is the chain's end.
608    pub fn to_nurbs(&self, sketch: &Sketch, geometry: &Enclosure) -> GeopResult<Vec<ProfilePiece>> {
609        let mut out = Vec::new();
610        for edge in &self.edges {
611            let curve = edge.curve;
612            let ctx = with_context!("converting sketch curve {curve} to NURBS");
613            // The pieces, the index of the first among the curve's, and the
614            // joints at its ends: a text's contour starts and ends where its
615            // first piece starts.
616            let (pieces, offset, (first, last)) = match &sketch.curve(curve)?.kind {
617                CurveKind::Text { .. } => {
618                    let contours = text_contours(sketch, geometry, curve).with_context(ctx)?;
619                    let offset = contours.iter().take(edge.contour).map(Vec::len).sum();
620                    let pieces = (contours.into_iter().nth(edge.contour)).ok_or_else(|| {
621                        GeopError::new(format!("text {curve} has no contour {}", edge.contour))
622                    })?;
623                    let seam = ProfileJoint::Split {
624                        curve,
625                        index: offset,
626                    };
627                    (pieces, offset, (seam, seam))
628                }
629                _ => {
630                    let pieces = curve_nurbs(sketch, geometry, curve).with_context(ctx)?;
631                    (pieces, 0, curve_joints(sketch, curve).with_context(ctx)?)
632                }
633            };
634            let n = pieces.len();
635            // Joint `j`, in the curve's own direction: where piece `j` starts
636            // (`j = n` is the curve's end).
637            let joint = |j: usize| match j {
638                0 => first,
639                j if j == n => last,
640                j => ProfileJoint::Split {
641                    curve,
642                    index: offset + j,
643                },
644            };
645            let pieces = pieces.into_iter().enumerate().map(|(j, c)| ProfilePiece {
646                curve: c,
647                source: curve,
648                index: offset + j,
649                start: joint(j),
650                end: joint(j + 1),
651            });
652            if edge.reversed {
653                out.extend(pieces.rev().map(|p| ProfilePiece {
654                    curve: p.curve.reverse(),
655                    start: p.end,
656                    end: p.start,
657                    ..p
658                }));
659            } else {
660                out.extend(pieces);
661            }
662        }
663        if let [only] = &out[..]
664            && only.start == only.end
665        {
666            // A closed curve of a single piece: split it at its middle, which
667            // becomes the curve's joint 1 whichever way the loop runs.
668            let (a, b) = only.curve.split(Scalar::ONE.div(Scalar::TWO)?)?;
669            let middle = ProfileJoint::Split {
670                curve: only.source,
671                index: 1,
672            };
673            // Indices count in the curve's own direction, so a reversed loop
674            // meets the curve's second half first.
675            let (first, second) = if self.edges[0].reversed {
676                (1, 0)
677            } else {
678                (0, 1)
679            };
680            out = vec![
681                ProfilePiece {
682                    curve: a.with_unit_domain()?,
683                    source: only.source,
684                    index: first,
685                    start: only.start,
686                    end: middle,
687                },
688                ProfilePiece {
689                    curve: b.with_unit_domain()?,
690                    source: only.source,
691                    index: second,
692                    start: middle,
693                    end: only.end,
694                },
695            ];
696        }
697        Ok(out)
698    }
699
700    /// A dense polyline along the loop, as drawn — for drawing.
701    pub fn polyline(&self, sketch: &Sketch) -> GeopResult<Vec<Vector2>> {
702        let mut out = Vec::new();
703        for edge in &self.edges {
704            let mut pts = match &sketch.curve(edge.curve)?.kind {
705                CurveKind::Text { .. } => {
706                    let mut contours = curve_polylines(sketch, edge.curve)?;
707                    if edge.contour >= contours.len() {
708                        return Err(GeopError::new(format!(
709                            "text {} has no contour {}",
710                            edge.curve, edge.contour
711                        )));
712                    }
713                    contours.swap_remove(edge.contour)
714                }
715                _ => curve_polyline(sketch, edge.curve)?,
716            };
717            if edge.reversed {
718                pts.reverse();
719            }
720            // Each edge's last sample is the next edge's first.
721            pts.pop();
722            out.extend(pts);
723        }
724        Ok(out)
725    }
726}
727
728/// The joints at a sketch curve's start and end: its end points, or for a
729/// circle, which has none, its seam (joint 0 of its own split points).
730fn curve_joints(sketch: &Sketch, curve: CurveId) -> GeopResult<(ProfileJoint, ProfileJoint)> {
731    Ok(match sketch.curve(curve)?.endpoints() {
732        Some((s, e)) => (ProfileJoint::Point(s), ProfileJoint::Point(e)),
733        None => {
734            let seam = ProfileJoint::Split { curve, index: 0 };
735            (seam, seam)
736        }
737    })
738}
739
740/// Points along a curve as drawn, from its start to its end (a circle
741/// starts and ends at angle 0) — sampled from its NURBS pieces, dense enough
742/// for drawing. A text is no one line: it is drawn contour by contour (see
743/// [`curve_polylines`]).
744pub fn curve_polyline(sketch: &Sketch, curve: CurveId) -> GeopResult<Vec<Vector2>> {
745    if let CurveKind::Text { .. } = sketch.curve(curve)?.kind {
746        return Err(GeopError::new(format!(
747            "text {curve} is drawn contour by contour, not as one line"
748        )));
749    }
750    let pieces = curve_nurbs(sketch, &Enclosure::as_drawn(sketch), curve)?;
751    polyline(&pieces, NurbCurve2D::evaluate)
752}
753
754/// The lines a curve is drawn as: a text one for each contour of its
755/// glyphs, closed, any other curve its one line (see [`curve_polyline`]).
756pub fn curve_polylines(sketch: &Sketch, curve: CurveId) -> GeopResult<Vec<Vec<Vector2>>> {
757    match sketch.curve(curve)?.kind {
758        CurveKind::Text { .. } => (text_contours(sketch, &Enclosure::as_drawn(sketch), curve)?)
759            .iter()
760            .map(|contour| polyline(contour, NurbCurve2D::evaluate))
761            .collect(),
762        _ => Ok(vec![curve_polyline(sketch, curve)?]),
763    }
764}
765
766/// The contours of the text `curve`, built from `geometry`: each a loop of
767/// NURBS pieces (see [`text::place`]).
768pub fn text_contours(
769    sketch: &Sketch,
770    geometry: &Enclosure,
771    curve: CurveId,
772) -> GeopResult<Vec<Vec<NurbCurve2D>>> {
773    let CurveKind::Text {
774        anchor,
775        text,
776        height,
777        angle,
778    } = &sketch.curve(curve)?.kind
779    else {
780        return Err(GeopError::new(format!("curve {curve} is no text")));
781    };
782    text::place(
783        &text::outlines(text)?,
784        geometry.points[anchor],
785        *height,
786        *angle,
787    )
788}
789
790/// Points along `pieces`, a chain of curves on `[0, 1]` each starting where
791/// the one before ends, as `evaluate` gives them: a straight piece by its
792/// ends, any other by `SAMPLES` per span of its control points — dense
793/// enough to draw it.
794pub(crate) fn polyline<const D: usize, P>(
795    pieces: &[NurbCurve<D>],
796    evaluate: impl Fn(&NurbCurve<D>, Scalar) -> GeopResult<P>,
797) -> GeopResult<Vec<P>> {
798    let mut out = Vec::new();
799    for piece in pieces {
800        let spans = if piece.degree == 1 {
801            1
802        } else {
803            SAMPLES * (piece.control_points.len() - 1)
804        };
805        // Each piece's last sample is the next piece's first.
806        out.pop();
807        for i in 0..=spans {
808            out.push(evaluate(
809                piece,
810                Scalar::from_ratio(i as i64, spans as i64)?,
811            )?);
812        }
813    }
814    Ok(out)
815}
816
817/// Degree of a spline with `n` control points.
818fn spline_degree(n: usize) -> usize {
819    3.min(n - 1)
820}
821
822/// Clamped uniform knot vector on `[0, 1]`.
823pub(crate) fn spline_knots(n: usize, degree: usize) -> GeopResult<Vec<Scalar>> {
824    let spans = n - degree;
825    let mut knots = vec![Scalar::ZERO; degree + 1];
826    for i in 1..spans {
827        knots.push(Scalar::from_ratio(i as i64, spans as i64)?);
828    }
829    knots.extend(std::iter::repeat_n(Scalar::ONE, degree + 1));
830    Ok(knots)
831}
832
833/// One sketch curve as NURBS pieces from its start to its end, built from
834/// `geometry` — how many pieces, from the sketch as drawn, which is design
835/// data, so the pieces and their names do not depend on how precisely the
836/// geometry is known. A text's are its contours' pieces, one contour after
837/// the other (see [`text_contours`]).
838pub fn curve_nurbs(
839    sketch: &Sketch,
840    geometry: &Enclosure,
841    curve: CurveId,
842) -> GeopResult<Vec<NurbCurve2D>> {
843    let at = |p: &PointId| geometry.points[p];
844    let param = || geometry.params[&curve];
845    let one_half = Scalar::ONE.div(Scalar::TWO)?;
846    match &sketch.curve(curve)?.kind {
847        CurveKind::Line { start, end } => Ok(vec![NurbCurve2D::line(at(start), at(end))?]),
848        CurveKind::Arc { start, end, sweep } => {
849            let (s, e) = (at(start), at(end));
850            if sweep.is_sharp() && sweep.could_be_equal(Scalar::ZERO) {
851                return Ok(vec![NurbCurve2D::line(s, e)?]);
852            }
853            // As many pieces as quarter turns the sweep spans, one that could
854            // be exactly `k` of them spanning `k`: counted on the sweep as
855            // drawn, which is design data, so the pieces' names do not
856            // depend on how precisely it is known.
857            let quarter = Scalar::PI.div(Scalar::TWO)?;
858            let pieces = (1..=4)
859                .find(|&k| {
860                    !sweep
861                        .abs()
862                        .definitely_greater(quarter.mul(Scalar::from_i64(k as i64)))
863                })
864                .unwrap_or(4);
865            let delta = param().div(Scalar::from_i64(pieces as i64))?;
866            let half = delta.div(Scalar::TWO)?;
867            // Piece boundaries: the start turned about the center by
868            // multiples of `delta`. Only needed for more than one piece,
869            // where the arc turns by more than a quarter and so has a center
870            // at a moderate distance.
871            let mut ends = vec![s];
872            if pieces > 1 {
873                let whole = param().div(Scalar::TWO)?;
874                let chord = e.sub(&s);
875                let length = chord.norm();
876                let left = chord.normalize()?.perp();
877                let mid = s.add(&e).prod_scalar(one_half);
878                let offset = length.mul(one_half).mul(whole.cos()).div(whole.sin())?;
879                let center = mid.add(&left.prod_scalar(offset));
880                ends.extend((1..pieces).map(|j| {
881                    let angle = delta.mul(Scalar::from_i64(j as i64));
882                    center.add(&s.sub(&center).rotate(angle.cos(), angle.sin()))
883                }));
884            }
885            ends.push(e);
886            ends.windows(2)
887                .map(|w| {
888                    // Tangent intersection from the chord alone: `(L/2)
889                    // tan(δ/2)` to the chord's right (its left for a
890                    // clockwise arc). Exact for any radius, including a
891                    // nearly straight arc whose center is far away.
892                    let chord = w[1].sub(&w[0]);
893                    let length = chord.norm();
894                    let left = chord.normalize()?.perp();
895                    let bulge = length.mul(one_half).mul(half.sin()).div(half.cos())?;
896                    let mid = w[0].add(&w[1]).prod_scalar(one_half);
897                    let m = mid.sub(&left.prod_scalar(bulge));
898                    NurbCurve2D::arc(w[0], m, w[1], half.cos())
899                })
900                .collect()
901        }
902        CurveKind::Circle { center, .. } => {
903            let c = at(center);
904            let r = param();
905            let point = |x: Scalar, y: Scalar| c.add(&Vector2::from_array([x, y]));
906            let q = [
907                point(r, Scalar::ZERO),
908                point(Scalar::ZERO, r),
909                point(r.neg(), Scalar::ZERO),
910                point(Scalar::ZERO, r.neg()),
911            ];
912            let corners = [
913                point(r, r),
914                point(r.neg(), r),
915                point(r.neg(), r.neg()),
916                point(r, r.neg()),
917            ];
918            // cos(45°), honestly: √2 / 2 is irrational.
919            let w = one_half.sqrt()?;
920            (0..4)
921                .map(|j| NurbCurve2D::arc(q[j], corners[j], q[(j + 1) % 4], w))
922                .collect()
923        }
924        CurveKind::Spline {
925            control_points,
926            shape,
927        } => {
928            let points: Vec<Vector2> = control_points.iter().map(at).collect();
929            Ok(vec![spline_nurbs(&points, shape.as_ref())?])
930        }
931        CurveKind::Text { .. } => Ok(text_contours(sketch, geometry, curve)?
932            .into_iter()
933            .flatten()
934            .collect()),
935    }
936}
937
938/// The point at `t` of a spline of `n` control points and the shape
939/// `shape` — and its derivative there, if `derivative` — as a sum of its
940/// control points: each one whose basis function's support reaches `t`,
941/// by its index, with the rational basis function's value and derivative
942/// at `t`. Constant numbers, for the weights and the knots are design data:
943/// a point of a spline is linear in its control points. Read off the
944/// spline of each control point alone at one, the rest at zero.
945pub(crate) fn basis_at(
946    n: usize,
947    shape: Option<&SplineShape>,
948    t: Scalar,
949    derivative: bool,
950) -> GeopResult<Vec<(usize, Scalar, Scalar)>> {
951    let one_hot = |j: usize| -> Vec<Vector2> {
952        (0..n)
953            .map(|k| {
954                let one = if k == j { Scalar::ONE } else { Scalar::ZERO };
955                Vector2::from_array([one, Scalar::ZERO])
956            })
957            .collect()
958    };
959    let whole = spline_nurbs(&one_hot(0), shape)?;
960    let (degree, knots) = (whole.degree, &whole.knot_vector);
961    let mut out = Vec::new();
962    for j in 0..n {
963        if t.definitely_less(knots[j]) || t.definitely_greater(knots[j + degree + 1]) {
964            continue;
965        }
966        let spline = spline_nurbs(&one_hot(j), shape)?;
967        let value = spline.evaluate(t)?[0];
968        let slope = if derivative {
969            spline.tangent(t)?[0]
970        } else {
971            Scalar::ZERO
972        };
973        out.push((j, value, slope));
974    }
975    Ok(out)
976}
977
978/// The spline of the control points `points` and the shape `shape` (see
979/// [`CurveKind::Spline`]), on `[0, 1]`.
980pub(crate) fn spline_nurbs(
981    points: &[Vector2],
982    shape: Option<&SplineShape>,
983) -> GeopResult<NurbCurve2D> {
984    let n = points.len();
985    let Some(shape) = shape else {
986        let degree = spline_degree(n);
987        return NurbCurve::try_new(
988            degree,
989            points.iter().map(|p| p.homogeneous(Scalar::ONE)).collect(),
990            spline_knots(n, degree)?,
991        );
992    };
993    // Taken from elsewhere, on knots of its own: brought onto `[0, 1]`,
994    // like every other piece.
995    let curve = NurbCurve::try_new(
996        shape.degree,
997        points
998            .iter()
999            .zip(&shape.weights)
1000            .map(|(p, &w)| p.homogeneous(w))
1001            .collect(),
1002        shape.knots.clone(),
1003    )?;
1004    curve.with_unit_domain()
1005}
1006
1007// ── containment, on the curves themselves ───────────────────────────────────
1008
1009/// How far `loops`' control points spread: the diagonal of their bounding
1010/// box — how long a ray has to be to leave every loop behind, and the
1011/// length the probe offsets below are measured against.
1012fn extent_of(loops: &[Vec<NurbCurve2D>]) -> GeopResult<Scalar> {
1013    let bounds = union_aabb(loops.iter().flatten().map(NurbCurve::aabb));
1014    let span = |k: usize| bounds.map_or(Scalar::ZERO, |b| b[k].upper().sub(b[k].lower()));
1015    let extent = span(0).mul(span(0)).add(span(1).mul(span(1))).sqrt()?;
1016    if !extent.definitely_greater(Scalar::ZERO) {
1017        return Err(GeopError::new("the profile's loops have no extent"));
1018    }
1019    Ok(extent)
1020}
1021
1022/// The point halfway along `curve` — a probe, chosen freely, so sharp.
1023fn midpoint(curve: &NurbCurve2D) -> GeopResult<Vector2> {
1024    Ok(curve.evaluate(curve.mid_parameter()?)?.sharpen())
1025}
1026
1027/// A segment from `from` in direction `dir`, long enough to leave a profile
1028/// of size `extent` behind.
1029fn ray(from: &Vector2, dir: &Vector2, extent: Scalar) -> GeopResult<NurbCurve2D> {
1030    let to = from
1031        .add(&dir.prod_scalar(Scalar::from_i64(3).mul(extent)))
1032        .sharpen();
1033    NurbCurve2D::line(*from, to)
1034}
1035
1036/// Is `probe` inside the closed loop `curves`?
1037///
1038/// Ray casting, counting crossings with [`curve_curve_intersect`] — the
1039/// kernel's own search on the loop's own curves, so a hole's boundary is
1040/// followed exactly rather than through a polygon standing in for it. A ray
1041/// that grazes a curve's endpoint (a crossing shared by two curves, so
1042/// ambiguous to count), runs along a curve, or exhausts a search's budget
1043/// says nothing reliable, and the next direction is tried instead; the
1044/// directions walk the golden angle, so a handful of them are spread evenly
1045/// around the circle without ever repeating. A direction is a free choice,
1046/// so it is sharp.
1047fn loop_contains(curves: &[NurbCurve2D], probe: &Vector2, extent: Scalar) -> GeopResult<bool> {
1048    let min_subdivision = Scalar::from_ratio(1, MIN_SUBDIVISION)?;
1049    // The golden angle, `π (3 - √5)`.
1050    let golden = Scalar::PI.mul(Scalar::from_i64(3).sub(Scalar::from_i64(5).sqrt()?));
1051    let mut last_rejection = String::new();
1052    // Directions tried in turn until one is clear.
1053    let found = attempts(MAX_RAY_ATTEMPTS, |&k| -> GeopResult<_> {
1054        let angle = golden.mul(Scalar::from_i64(k as i64));
1055        let dir = Vector2::from_array([angle.cos(), angle.sin()]).sharpen();
1056        let ray = ray(probe, &dir, extent)?;
1057        let mut crossings = 0usize;
1058        for curve in curves {
1059            let hits =
1060                match curve_curve_intersect(&ray, curve, MAX_CROSSINGS, MAX_NODES, min_subdivision)
1061                {
1062                    Ok(hits) if !hits.is_coincident() => hits.into_vec(),
1063                    Ok(_) => {
1064                        last_rejection = format!("the ray runs along {curve:?}");
1065                        return Ok(Probe::Next);
1066                    }
1067                    Err(e) => {
1068                        last_rejection =
1069                            format!("the ray against {curve:?} did not converge: {e:?}");
1070                        return Ok(Probe::Next);
1071                    }
1072                };
1073            let (t0, t1) = curve.domain();
1074            for (along_ray, along_curve) in hits {
1075                if !along_ray.definitely_greater(Scalar::ZERO) {
1076                    // At the probe itself: it lies on this loop, which the
1077                    // caller already knows (it picked a point of another
1078                    // one), so it is not a crossing of anything.
1079                    continue;
1080                }
1081                let from_start = along_curve.sub(t0).abs();
1082                let from_end = along_curve.sub(t1).abs();
1083                if !from_start.definitely_greater(min_subdivision)
1084                    || !from_end.definitely_greater(min_subdivision)
1085                {
1086                    last_rejection =
1087                        format!("the ray grazes an end of {curve:?} at {along_curve:?}");
1088                    return Ok(Probe::Next);
1089                }
1090                crossings += 1;
1091            }
1092        }
1093        Ok(Probe::Found(!crossings.is_multiple_of(2)))
1094    })?;
1095    if let Some(inside) = found {
1096        return Ok(inside);
1097    }
1098    Err(GeopError::new(format!(
1099        "could not classify {probe:?} against a loop: every ray direction was ambiguous, \
1100         the last because {last_rejection}"
1101    )))
1102}
1103
1104/// Does the loop `lp`, whose pieces are `curves`, wind counter-clockwise
1105/// (material on its left)? Its signed area says, integrated from the
1106/// curves themselves ([`loop_signed_area`]).
1107fn winds_counter_clockwise(lp: &ProfileLoop, curves: &[NurbCurve2D]) -> GeopResult<bool> {
1108    let area = loop_signed_area(curves)?;
1109    if area.definitely_greater(Scalar::ZERO) {
1110        Ok(true)
1111    } else if area.definitely_less(Scalar::ZERO) {
1112        Ok(false)
1113    } else {
1114        Err(GeopError::new(format!(
1115            "could not tell which way the loop {lp:?} winds: its area {area:?} could be zero"
1116        )))
1117    }
1118}