Skip to main content

ogeom_geom/
curve.rs

1//! Concrete space curves.
2//!
3//! Each analytic curve is a thin parameterization over the shape descriptions
4//! in `ogeom-math`. The spline curve carries its own control points. All of them
5//! are reachable through [`Curve`], an enum rather than a boxed trait object.
6//!
7//! # Why an enum
8//!
9//! Curves are stored in their millions in a real model, compared constantly,
10//! and eventually serialized. An enum makes each of those cheap: no allocation,
11//! no vtable, `Clone` and `PartialEq` derived, and (most usefully) exhaustive
12//! matching, so adding a curve type produces a compile error at every site that
13//! needs to know rather than a silent fallthrough.
14//!
15//! Deliberately *not* `#[non_exhaustive]`. Marking it so would force every
16//! match outside this crate to carry a wildcard arm, which is exactly the
17//! silent fallthrough the enum exists to prevent: a new curve type would then
18//! compile everywhere and be mishandled everywhere. The cost is that adding a
19//! variant is a breaking change, which for a kernel this size is the right
20//! trade: a curve type nobody handles is worse than a version bump.
21//!
22//! [`CurveKind`] *is* non-exhaustive, because matching on it is for opting into
23//! an analytic shortcut and a caller that does not recognise a kind should fall
24//! back to the general path rather than fail to compile.
25//!
26//! The [`Curve3d`] trait is still the interface algorithms are written against;
27//! [`Curve`] implements it and forwards.
28
29use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
30use ogeom_math::{
31    Axis, Circle, Ellipse, Frame, Hyperbola, KnotVector, Parabola, Point, Transform, Vector,
32    Weighted, bspline, elementary,
33};
34
35use crate::traits::{Continuity, Curve3d, CurveKind, Reversible, Transformable};
36
37/// How far along a line the default domain reaches either side of its origin.
38///
39/// A line is unbounded, but every interface here works on a finite interval, so
40/// an unbounded curve needs *some* domain. This is far beyond any real model
41/// while staying well short of the range where `f64` spacing becomes coarse.
42pub const LINE_EXTENT: f64 = 1.0e9;
43
44/// A curve in space.
45#[derive(Debug, Clone, PartialEq)]
46pub enum Curve {
47    /// A straight line.
48    Line(LineCurve),
49    /// A circle or arc.
50    Circle(CircleCurve),
51    /// An ellipse or arc.
52    Ellipse(EllipseCurve),
53    /// One branch of a hyperbola.
54    Hyperbola(HyperbolaCurve),
55    /// A parabola.
56    Parabola(ParabolaCurve),
57    /// A polynomial or rational B-spline.
58    BSpline(BSplineCurve),
59    /// A helix about an axis, the one transcendental the vocabulary keeps.
60    Helix(HelixCurve),
61    /// Another curve restricted to a sub-interval.
62    Trimmed(Box<TrimmedCurve>),
63    /// A curve at a constant distance from another, offset in the plane
64    /// perpendicular to a reference direction.
65    Offset(Box<OffsetCurve>),
66    /// A surface curve: a pcurve composed with the surface it is drawn on.
67    OnSurface(Box<CurveOnSurface>),
68}
69
70/// A straight line, parameterized by length from its origin.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct LineCurve {
73    axis: Axis,
74    domain: (f64, f64),
75}
76
77/// A circle, parameterized by angle.
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct CircleCurve {
80    circle: Circle,
81    reversed: bool,
82}
83
84/// An ellipse, parameterized by eccentric angle.
85#[derive(Debug, Clone, Copy, PartialEq)]
86pub struct EllipseCurve {
87    ellipse: Ellipse,
88    reversed: bool,
89}
90
91/// One branch of a hyperbola.
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct HyperbolaCurve {
94    hyperbola: Hyperbola,
95    domain: (f64, f64),
96    reversed: bool,
97}
98
99/// A parabola.
100#[derive(Debug, Clone, Copy, PartialEq)]
101pub struct ParabolaCurve {
102    parabola: Parabola,
103    domain: (f64, f64),
104    reversed: bool,
105}
106
107/// A helix about its frame's `z`, parameterized by turn angle.
108///
109/// The point at `t` sits at angle `t` around the axis, radius out along the
110/// turned `x`, risen by `pitch·t/2π` along `z`, so one full turn advances
111/// exactly one pitch, and a negative pitch winds the other hand. A non-zero
112/// `taper` advances the radius the same way and winds a cone instead. A
113/// helix is transcendental: no rational B-spline states it exactly, which
114/// is why it is its own type rather than a conversion.
115#[derive(Debug, Clone, Copy, PartialEq)]
116pub struct HelixCurve {
117    frame: Frame,
118    radius: f64,
119    pitch: f64,
120    /// The radial advance per full turn: zero is the cylindrical helix,
121    /// anything else winds a cone whose half-angle satisfies
122    /// `tan(angle) = taper / pitch`.
123    taper: f64,
124    domain: (f64, f64),
125    reversed: bool,
126}
127
128/// A B-spline curve, polynomial or rational.
129#[derive(Debug, Clone, PartialEq)]
130pub struct BSplineCurve {
131    knots: KnotVector,
132    control: Vec<Weighted<Point>>,
133    rational: bool,
134    periodic: bool,
135}
136
137/// Another curve restricted to a sub-interval of its domain.
138#[derive(Debug, Clone, PartialEq)]
139pub struct TrimmedCurve {
140    basis: Curve,
141    domain: (f64, f64),
142    reversed: bool,
143}
144
145impl Curve {
146    /// This curve over the wider parameter range `range`, tracing the same
147    /// points at the same parameters over its own domain and running on as
148    /// its own formula past it: a line, and a parabola, a hyperbola branch
149    /// or a helix running forward, over any range; a forward trim over any
150    /// range its basis holds. `None` for a curve whose formula does not
151    /// reach past its domain (a B-spline, an offset or surface curve), one
152    /// that repeats (a whole circle or ellipse has no end), one whose
153    /// reversal mirrors its parameters across its domain, or a range that
154    /// does not hold the domain.
155    #[must_use]
156    pub fn widened(&self, (lo, hi): (f64, f64)) -> Option<Self> {
157        let (a, b) = self.domain();
158        if !lo.is_finite() || !hi.is_finite() || lo > a || hi < b {
159            return None;
160        }
161        match self {
162            Self::Line(l) => Some(Self::Line(LineCurve {
163                domain: (lo, hi),
164                ..*l
165            })),
166            Self::Parabola(p) if !p.reversed => Some(Self::Parabola(ParabolaCurve {
167                domain: (lo, hi),
168                ..*p
169            })),
170            Self::Hyperbola(h) if !h.reversed => Some(Self::Hyperbola(HyperbolaCurve {
171                domain: (lo, hi),
172                ..*h
173            })),
174            Self::Helix(h) if !h.reversed => Some(Self::Helix(HelixCurve {
175                domain: (lo, hi),
176                ..*h
177            })),
178            Self::Trimmed(t) if !t.reversed => {
179                let (lo, hi) = if t.basis.is_periodic() {
180                    (lo, hi)
181                } else {
182                    let (ba, bb) = t.basis.domain();
183                    (lo.max(ba), hi.min(bb))
184                };
185                (lo < a || hi > b).then(|| {
186                    Self::Trimmed(Box::new(TrimmedCurve {
187                        basis: t.basis.clone(),
188                        domain: (lo, hi),
189                        reversed: false,
190                    }))
191                })
192            }
193            _ => None,
194        }
195    }
196}
197
198impl LineCurve {
199    /// A line along `axis`, spanning [`LINE_EXTENT`] either side of its origin.
200    #[must_use]
201    pub const fn new(axis: Axis) -> Self {
202        Self {
203            axis,
204            domain: (-LINE_EXTENT, LINE_EXTENT),
205        }
206    }
207
208    /// A line segment between two distinct points.
209    ///
210    /// The domain runs from zero to the distance between them, so the parameter
211    /// is arc length, which makes every length query along the segment exact.
212    ///
213    /// # Errors
214    ///
215    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the points
216    /// coincide.
217    pub fn segment(from: Point, to: Point, tol: Tolerances) -> OgeomResult<Self> {
218        let axis = Axis::through(from, to, tol)?;
219        Ok(Self {
220            axis,
221            domain: (0.0, from.distance(to)),
222        })
223    }
224
225    /// A line over an explicit parameter range.
226    ///
227    /// # Errors
228    ///
229    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the range
230    /// is empty or non-finite.
231    pub fn over(axis: Axis, start: f64, end: f64) -> OgeomResult<Self> {
232        if !start.is_finite() || !end.is_finite() || end <= start {
233            ogeom_bail!(Construction, "line range [{start}, {end}] is empty");
234        }
235        Ok(Self {
236            axis,
237            domain: (start, end),
238        })
239    }
240
241    /// The underlying axis.
242    #[must_use]
243    pub const fn axis(&self) -> Axis {
244        self.axis
245    }
246}
247
248impl CircleCurve {
249    /// A full circle, running counter-clockwise about its frame's `z`.
250    #[must_use]
251    pub const fn new(circle: Circle) -> Self {
252        Self {
253            circle,
254            reversed: false,
255        }
256    }
257
258    /// The underlying circle.
259    #[must_use]
260    pub const fn circle(&self) -> Circle {
261        self.circle
262    }
263
264    /// Whether the curve runs backwards along its underlying circle.
265    ///
266    /// Part of the curve's state and not derivable from its circle, so
267    /// anything that has to reproduce this curve exactly (the native format
268    /// above all) needs to be able to read it.
269    #[must_use]
270    pub const fn is_reversed(&self) -> bool {
271        self.reversed
272    }
273
274    /// The same curve, the same point at every parameter, running forward:
275    /// a reversed circle is its circle's frame turned half a turn about its
276    /// `x` axis, the angle measured from the same `x` towards `-y`.
277    ///
278    /// # Errors
279    ///
280    /// None in practice: the turned frame is the given one's axes negated.
281    pub fn forward(&self, tol: Tolerances) -> OgeomResult<Self> {
282        if !self.reversed {
283            return Ok(*self);
284        }
285        let frame = turned_about_x(self.circle.frame(), tol)?;
286        Ok(Self::new(Circle::new(frame, self.circle.radius(), tol)?))
287    }
288}
289
290/// A frame turned half a turn about its own `x` axis, of the same
291/// handedness.
292fn turned_about_x(frame: Frame, tol: Tolerances) -> OgeomResult<Frame> {
293    Frame::from_axes(frame.origin(), frame.x(), -frame.y(), -frame.z(), tol)
294}
295
296impl EllipseCurve {
297    /// A full ellipse.
298    #[must_use]
299    pub const fn new(ellipse: Ellipse) -> Self {
300        Self {
301            ellipse,
302            reversed: false,
303        }
304    }
305
306    /// The underlying ellipse.
307    #[must_use]
308    pub const fn ellipse(&self) -> Ellipse {
309        self.ellipse
310    }
311
312    /// Whether the curve runs backwards along its underlying ellipse.
313    ///
314    /// Part of the curve's state and not derivable from its ellipse, so
315    /// anything that has to reproduce this curve exactly (the native format
316    /// above all) needs to be able to read it.
317    #[must_use]
318    pub const fn is_reversed(&self) -> bool {
319        self.reversed
320    }
321
322    /// The same curve, the same point at every parameter, running forward,
323    /// as [`CircleCurve::forward`].
324    ///
325    /// # Errors
326    ///
327    /// None in practice: the turned frame is the given one's axes negated.
328    pub fn forward(&self, tol: Tolerances) -> OgeomResult<Self> {
329        if !self.reversed {
330            return Ok(*self);
331        }
332        let e = self.ellipse;
333        let frame = turned_about_x(e.frame(), tol)?;
334        Ok(Self::new(Ellipse::new(
335            frame,
336            e.major_radius(),
337            e.minor_radius(),
338            tol,
339        )?))
340    }
341}
342
343impl HyperbolaCurve {
344    /// A hyperbola branch over `[-extent, extent]` in its natural parameter.
345    ///
346    /// # Errors
347    ///
348    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `extent` is
349    /// not finite and positive.
350    pub fn new(hyperbola: Hyperbola, extent: f64) -> OgeomResult<Self> {
351        if !extent.is_finite() || extent <= 0.0 {
352            ogeom_bail!(
353                Construction,
354                "hyperbola extent {extent} must be finite and positive"
355            );
356        }
357        Ok(Self {
358            hyperbola,
359            domain: (-extent, extent),
360            reversed: false,
361        })
362    }
363
364    /// A hyperbola branch over an arbitrary `[start, end]` in its natural
365    /// parameter.
366    ///
367    /// # Errors
368    ///
369    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
370    /// interval is not finite and increasing.
371    pub fn over(hyperbola: Hyperbola, start: f64, end: f64) -> OgeomResult<Self> {
372        if !start.is_finite() || !end.is_finite() || start >= end {
373            ogeom_bail!(
374                Construction,
375                "hyperbola domain [{start}, {end}] must be finite and increasing"
376            );
377        }
378        Ok(Self {
379            hyperbola,
380            domain: (start, end),
381            reversed: false,
382        })
383    }
384
385    /// The underlying hyperbola.
386    #[must_use]
387    pub const fn hyperbola(&self) -> Hyperbola {
388        self.hyperbola
389    }
390
391    /// Whether the curve runs backwards along its underlying hyperbola.
392    ///
393    /// Part of the curve's state and not derivable from its hyperbola, so
394    /// anything that has to reproduce this curve exactly (the native format
395    /// above all) needs to be able to read it.
396    #[must_use]
397    pub const fn is_reversed(&self) -> bool {
398        self.reversed
399    }
400}
401
402impl ParabolaCurve {
403    /// A parabola over `[-extent, extent]`.
404    ///
405    /// # Errors
406    ///
407    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `extent` is
408    /// not finite and positive.
409    pub fn new(parabola: Parabola, extent: f64) -> OgeomResult<Self> {
410        if !extent.is_finite() || extent <= 0.0 {
411            ogeom_bail!(
412                Construction,
413                "parabola extent {extent} must be finite and positive"
414            );
415        }
416        Ok(Self {
417            parabola,
418            domain: (-extent, extent),
419            reversed: false,
420        })
421    }
422
423    /// A parabola over an arbitrary `[start, end]`.
424    ///
425    /// # Errors
426    ///
427    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
428    /// interval is not finite and increasing.
429    pub fn over(parabola: Parabola, start: f64, end: f64) -> OgeomResult<Self> {
430        if !start.is_finite() || !end.is_finite() || start >= end {
431            ogeom_bail!(
432                Construction,
433                "parabola domain [{start}, {end}] must be finite and increasing"
434            );
435        }
436        Ok(Self {
437            parabola,
438            domain: (start, end),
439            reversed: false,
440        })
441    }
442
443    /// The underlying parabola.
444    #[must_use]
445    pub const fn parabola(&self) -> Parabola {
446        self.parabola
447    }
448
449    /// Whether the curve runs backwards along its underlying parabola.
450    ///
451    /// Part of the curve's state and not derivable from its parabola, so
452    /// anything that has to reproduce this curve exactly (the native format
453    /// above all) needs to be able to read it.
454    #[must_use]
455    pub const fn is_reversed(&self) -> bool {
456        self.reversed
457    }
458}
459
460impl HelixCurve {
461    /// A helix over `turns` full revolutions from angle zero.
462    ///
463    /// # Errors
464    ///
465    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
466    /// the radius is not finite and positive, the pitch is not finite and
467    /// non-zero (a zero pitch is a circle, and there is a type for that),
468    /// or `turns` is not finite and positive.
469    pub fn new(frame: Frame, radius: f64, pitch: f64, turns: f64) -> OgeomResult<Self> {
470        if !turns.is_finite() || turns <= 0.0 {
471            ogeom_bail!(Construction, "a helix over {turns} turns is not a curve");
472        }
473        Self::over(frame, radius, pitch, 0.0, core::f64::consts::TAU * turns)
474    }
475
476    /// A helix over an arbitrary increasing angle interval.
477    ///
478    /// # Errors
479    ///
480    /// As [`HelixCurve::new`], with the interval checked instead of the turn
481    /// count.
482    pub fn over(frame: Frame, radius: f64, pitch: f64, start: f64, end: f64) -> OgeomResult<Self> {
483        if !radius.is_finite() || radius <= 0.0 {
484            ogeom_bail!(
485                Construction,
486                "helix radius {radius} must be finite and positive"
487            );
488        }
489        if !pitch.is_finite() || pitch == 0.0 {
490            ogeom_bail!(
491                Construction,
492                "helix pitch {pitch} must be finite and non-zero; a zero pitch is a circle"
493            );
494        }
495        if !start.is_finite() || !end.is_finite() || start >= end {
496            ogeom_bail!(
497                Construction,
498                "helix domain [{start}, {end}] must be finite and increasing"
499            );
500        }
501        Ok(Self {
502            frame,
503            radius,
504            pitch,
505            taper: 0.0,
506            domain: (start, end),
507            reversed: false,
508        })
509    }
510
511    /// A conical helix: the radius advances by `taper` per full turn while
512    /// the point rises by `pitch`, winding the cone whose half-angle
513    /// satisfies `tan(angle) = taper / pitch`. `radius` is the radius at
514    /// angle zero, whether or not the interval holds it.
515    ///
516    /// # Errors
517    ///
518    /// As [`HelixCurve::over`], and additionally if `taper` is not finite
519    /// or the radius runs non-positive anywhere on the interval. Past the
520    /// apex there is no cone to wind.
521    pub fn conical(
522        frame: Frame,
523        radius: f64,
524        pitch: f64,
525        taper: f64,
526        start: f64,
527        end: f64,
528    ) -> OgeomResult<Self> {
529        let mut helix = Self::over(frame, radius, pitch, start, end)?;
530        if !taper.is_finite() {
531            ogeom_bail!(Construction, "helix taper {taper} must be finite");
532        }
533        let slope = taper / core::f64::consts::TAU;
534        let (ra, rb) = (slope.mul_add(start, radius), slope.mul_add(end, radius));
535        if ra <= 0.0 || rb <= 0.0 {
536            ogeom_bail!(
537                Construction,
538                "the helix radius runs non-positive on [{start}, {end}]; \
539                 past the apex there is no cone to wind"
540            );
541        }
542        helix.taper = taper;
543        Ok(helix)
544    }
545
546    /// The frame the helix turns about.
547    #[must_use]
548    pub const fn frame(&self) -> &Frame {
549        &self.frame
550    }
551
552    /// The radius.
553    #[must_use]
554    pub const fn radius(&self) -> f64 {
555        self.radius
556    }
557
558    /// The advance along the axis per full turn. Negative winds left-handed.
559    #[must_use]
560    pub const fn pitch(&self) -> f64 {
561        self.pitch
562    }
563
564    /// The radial advance per full turn. Zero is the cylindrical helix.
565    #[must_use]
566    pub const fn taper(&self) -> f64 {
567        self.taper
568    }
569
570    /// Whether evaluation runs the domain backwards.
571    #[must_use]
572    pub const fn is_reversed(&self) -> bool {
573        self.reversed
574    }
575
576    /// The exact arc length between two parameters.
577    ///
578    /// The speed at angle `t` is `√(r(t)² + k²)`, where the radius
579    /// `r(t) = radius + s·t` advances by `s = taper/2π` a radian and
580    /// `k² = s² + (pitch/2π)²`. On a cylinder (`s` zero) that is constant, and
581    /// the length is the speed times the swept angle. On a cone it is
582    /// `∫√(r² + k²) dr / s`, in closed form.
583    #[must_use]
584    pub fn arc_length(&self, from: f64, to: f64) -> f64 {
585        let tau = core::f64::consts::TAU;
586        let (rise, slope) = (self.pitch / tau, self.taper / tau);
587        if slope == 0.0 {
588            return (to - from).abs() * self.radius.hypot(rise);
589        }
590        let k = slope.hypot(rise);
591        let primitive = |r: f64| f64::midpoint(r * r.hypot(k), k * k * (r / k).asinh());
592        let (r0, r1) = (
593            slope.mul_add(from, self.radius),
594            slope.mul_add(to, self.radius),
595        );
596        ((primitive(r1) - primitive(r0)) / slope).abs()
597    }
598
599    /// Point and first three derivatives at the raw (unreversed) angle.
600    fn at(&self, t: f64) -> (Point, Vector, Vector, Vector) {
601        let (sin, cos) = t.sin_cos();
602        let x = self.frame.x().vector();
603        let y = self.frame.y().vector();
604        let z = self.frame.z().vector();
605        let rise = self.pitch / core::f64::consts::TAU;
606        let slope = self.taper / core::f64::consts::TAU;
607        let r = slope.mul_add(t, self.radius);
608        let point = self.frame.origin() + x * (r * cos) + y * (r * sin) + z * (rise * t);
609        let d1 = x * slope.mul_add(cos, -(r * sin)) + y * slope.mul_add(sin, r * cos) + z * rise;
610        let d2 = x * (2.0 * slope).mul_add(-sin, -(r * cos))
611            + y * (2.0 * slope).mul_add(cos, -(r * sin));
612        let d3 =
613            x * (3.0 * slope).mul_add(-cos, r * sin) + y * (3.0 * slope).mul_add(-sin, -(r * cos));
614        (point, d1, d2, d3)
615    }
616}
617
618impl Curve3d for HelixCurve {
619    fn domain(&self) -> (f64, f64) {
620        self.domain
621    }
622
623    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
624        let u = self.normalize_parameter(u, tol)?;
625        let t = if self.reversed {
626            mirror(u, self.domain.0, self.domain.1)
627        } else {
628            u
629        };
630        Ok(self.at(t).0)
631    }
632
633    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
634        Ok(self.derivatives_at(u, 1, tol)?[1])
635    }
636
637    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
638        let u = self.normalize_parameter(u, tol)?;
639        let t = if self.reversed {
640            mirror(u, self.domain.0, self.domain.1)
641        } else {
642            u
643        };
644        let (point, _, _, _) = self.at(t);
645        // The radius runs linearly in the angle, so the `k`th derivative of
646        // `r cos t` is `r cos⁽ᵏ⁾ t + k s cos⁽ᵏ⁻¹⁾ t`, and alike for the sine;
647        // the rise survives only to first order. The chain rule for the
648        // reversal flips odd orders.
649        let (sin, cos) = t.sin_cos();
650        let x = self.frame.x().vector();
651        let y = self.frame.y().vector();
652        let z = self.frame.z().vector();
653        let rise = self.pitch / core::f64::consts::TAU;
654        let slope = self.taper / core::f64::consts::TAU;
655        let r = slope.mul_add(t, self.radius);
656        let cos_k = |k: usize| [cos, -sin, -cos, sin][k % 4];
657        let sin_k = |k: usize| [sin, cos, -sin, -cos][k % 4];
658        let sign = if self.reversed { -1.0 } else { 1.0 };
659        let mut out = vec![point.to_vector()];
660        let mut factor = 1.0;
661        for k in 1..=n {
662            factor *= sign;
663            #[allow(clippy::cast_precision_loss)]
664            let ks = k as f64 * slope;
665            let mut d = x * ks.mul_add(cos_k(k - 1), r * cos_k(k))
666                + y * ks.mul_add(sin_k(k - 1), r * sin_k(k));
667            if k == 1 {
668                d += z * rise;
669            }
670            out.push(d * factor);
671        }
672        Ok(out)
673    }
674
675    fn kind(&self) -> CurveKind {
676        CurveKind::Helix
677    }
678
679    fn continuity(&self) -> Continuity {
680        Continuity::CInfinity
681    }
682
683    fn is_closed(&self, _tol: Tolerances) -> bool {
684        false
685    }
686
687    fn is_periodic(&self) -> bool {
688        false
689    }
690}
691
692/// A space curve displaced a constant signed distance perpendicular to a
693/// reference direction: the offset direction at `t` is the unit vector of
694/// `tangent x reference`, the classical spelling.
695///
696/// Point and first derivative are exact from the basis's first two
697/// derivatives. The second would need the basis's third, which the
698/// vocabulary does not carry, so `derivatives_at` beyond order one refuses
699/// by name. Where the tangent runs along the reference the offset direction
700/// is undefined and evaluation refuses.
701#[derive(Debug, Clone, PartialEq)]
702pub struct OffsetCurve {
703    basis: Curve,
704    distance: f64,
705    reference: ogeom_math::Direction,
706}
707
708impl OffsetCurve {
709    /// Offset `basis` by `distance` along `tangent x reference`.
710    ///
711    /// # Errors
712    ///
713    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
714    /// distance is not finite and non-zero.
715    pub fn new(basis: Curve, distance: f64, reference: ogeom_math::Direction) -> OgeomResult<Self> {
716        if !distance.is_finite() || distance == 0.0 {
717            ogeom_bail!(
718                Construction,
719                "an offset of {distance} is not a displacement"
720            );
721        }
722        Ok(Self {
723            basis,
724            distance,
725            reference,
726        })
727    }
728
729    /// The curve being offset.
730    #[must_use]
731    pub const fn basis(&self) -> &Curve {
732        &self.basis
733    }
734
735    /// The signed displacement.
736    #[must_use]
737    pub const fn distance(&self) -> f64 {
738        self.distance
739    }
740
741    /// The direction the offset plane is perpendicular to.
742    #[must_use]
743    pub const fn reference(&self) -> ogeom_math::Direction {
744        self.reference
745    }
746
747    /// The unit offset direction and its derivative at `t`.
748    fn direction_and_slope(&self, t: f64, tol: Tolerances) -> OgeomResult<(Vector, Vector)> {
749        let d = self.basis.derivatives_at(t, 2, tol)?;
750        let v = self.reference.vector();
751        let w = d[1].cross(v);
752        let m = w.magnitude();
753        if m <= tol.confusion() * d[1].magnitude().max(1.0) {
754            ogeom_bail!(
755                Construction,
756                "the tangent at {t} runs along the reference; the offset direction is undefined"
757            );
758        }
759        let n = w / m;
760        let wp = d[2].cross(v);
761        let np = (wp - n * n.dot(wp)) / m;
762        Ok((n, np))
763    }
764}
765
766impl Curve3d for OffsetCurve {
767    fn domain(&self) -> (f64, f64) {
768        self.basis.domain()
769    }
770
771    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
772        let base = self.basis.point_at(u, tol)?;
773        let (n, _) = self.direction_and_slope(u, tol)?;
774        Ok(base + n * self.distance)
775    }
776
777    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
778        let d1 = self.basis.d1_at(u, tol)?;
779        let (_, np) = self.direction_and_slope(u, tol)?;
780        Ok(d1 + np * self.distance)
781    }
782
783    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
784        if n >= 2 {
785            ogeom_bail!(
786                Construction,
787                "an offset curve's second derivative needs its basis's third, which the \
788                 vocabulary does not carry"
789            );
790        }
791        let mut out = vec![self.point_at(u, tol)?.to_vector()];
792        if n >= 1 {
793            out.push(self.d1_at(u, tol)?);
794        }
795        Ok(out)
796    }
797
798    fn kind(&self) -> CurveKind {
799        CurveKind::Offset
800    }
801
802    fn continuity(&self) -> Continuity {
803        match self.basis.continuity() {
804            Continuity::CInfinity => Continuity::CInfinity,
805            Continuity::C2 | Continuity::G2 => Continuity::C1,
806            Continuity::C1 | Continuity::G1 | Continuity::C0 => Continuity::C0,
807        }
808    }
809
810    fn is_closed(&self, tol: Tolerances) -> bool {
811        self.basis.is_closed(tol)
812    }
813
814    fn is_periodic(&self) -> bool {
815        self.basis.is_periodic()
816    }
817}
818
819/// A pcurve composed with the surface it is drawn on: the space curve a
820/// trimming boundary actually traces.
821///
822/// Everything is exact: the chain rule composes the pcurve's derivatives
823/// with the surface's, both of which the vocabulary carries to second
824/// order.
825#[derive(Debug, Clone, PartialEq)]
826pub struct CurveOnSurface {
827    pcurve: crate::curve2d::PlanarCurve,
828    surface: crate::surface::SurfaceGeometry,
829}
830
831impl CurveOnSurface {
832    /// The composition of `pcurve` with `surface`.
833    #[must_use]
834    pub const fn new(
835        pcurve: crate::curve2d::PlanarCurve,
836        surface: crate::surface::SurfaceGeometry,
837    ) -> Self {
838        Self { pcurve, surface }
839    }
840
841    /// The curve in parameter space.
842    #[must_use]
843    pub const fn pcurve(&self) -> &crate::curve2d::PlanarCurve {
844        &self.pcurve
845    }
846
847    /// The surface the pcurve is drawn on.
848    #[must_use]
849    pub const fn surface(&self) -> &crate::surface::SurfaceGeometry {
850        &self.surface
851    }
852}
853
854impl Curve3d for CurveOnSurface {
855    fn domain(&self) -> (f64, f64) {
856        use crate::traits::Curve2d as _;
857        self.pcurve.domain()
858    }
859
860    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
861        use crate::traits::{Curve2d as _, Surface as _};
862        let p = self.pcurve.point_at(u, tol)?;
863        self.surface.point_at(p.x, p.y, tol)
864    }
865
866    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
867        Ok(self.derivatives_at(u, 1, tol)?[1])
868    }
869
870    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
871        use crate::traits::{Curve2d as _, Surface as _};
872        if n >= 3 {
873            ogeom_bail!(
874                Construction,
875                "a surface curve's third derivative needs the surface's, which the \
876                 vocabulary does not carry"
877            );
878        }
879        let d = self.pcurve.derivatives_at(u, n.max(2), tol)?;
880        let at = d[0];
881        let point = self.surface.point_at(at.x, at.y, tol)?;
882        let mut out = vec![point.to_vector()];
883        if n >= 1 {
884            let (su, sv) = self.surface.d1_at(at.x, at.y, tol)?;
885            out.push(su * d[1].x + sv * d[1].y);
886            if n >= 2 {
887                let (suu, suv, svv) = self.surface.d2_at(at.x, at.y, tol)?;
888                // The chain rule's second order: quadratic in the pcurve's
889                // slope, linear in its curvature.
890                let second = suu * (d[1].x * d[1].x)
891                    + suv * (2.0 * d[1].x * d[1].y)
892                    + svv * (d[1].y * d[1].y)
893                    + su * d[2].x
894                    + sv * d[2].y;
895                out.push(second);
896            }
897        }
898        Ok(out)
899    }
900
901    fn kind(&self) -> CurveKind {
902        CurveKind::OnSurface
903    }
904
905    fn continuity(&self) -> Continuity {
906        use crate::traits::Surface as _;
907        // The composition is as smooth as the rougher of the two.
908        self.pcurve_continuity().min(self.surface.continuity())
909    }
910
911    fn is_closed(&self, tol: Tolerances) -> bool {
912        use crate::traits::Curve2d as _;
913        self.pcurve.is_closed(tol)
914    }
915
916    fn is_periodic(&self) -> bool {
917        use crate::traits::Curve2d as _;
918        self.pcurve.is_periodic()
919    }
920}
921
922impl CurveOnSurface {
923    fn pcurve_continuity(&self) -> Continuity {
924        // Planar curves in the vocabulary are analytic or spline. The spline
925        // reports through its own knots elsewhere, and C2 is the floor the
926        // fitting machinery guarantees. Conservative either way.
927        Continuity::C2
928    }
929}
930
931impl BSplineCurve {
932    /// A polynomial B-spline from a knot vector and control points.
933    ///
934    /// # Errors
935    ///
936    /// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) if the control point
937    /// count disagrees with the knot vector.
938    pub fn new(knots: KnotVector, control: Vec<Point>, tol: Tolerances) -> OgeomResult<Self> {
939        let weighted = control
940            .into_iter()
941            .map(|p| Weighted::new(p, 1.0, tol))
942            .collect::<OgeomResult<Vec<_>>>()?;
943        Self::rational(knots, weighted)
944    }
945
946    /// A rational B-spline from a knot vector and weighted control points.
947    ///
948    /// # Errors
949    ///
950    /// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) if the control point
951    /// count disagrees with the knot vector.
952    pub fn rational(knots: KnotVector, control: Vec<Weighted<Point>>) -> OgeomResult<Self> {
953        if control.len() != knots.control_point_count() {
954            ogeom_bail!(
955                Dimension,
956                "knot vector describes {} control points, got {}",
957                knots.control_point_count(),
958                control.len()
959            );
960        }
961        // A curve whose weights are all equal is polynomial regardless of what
962        // that common value is, and saying so lets evaluation skip the divide.
963        let first = control[0].weight;
964        let rational = control
965            .iter()
966            .any(|w| (w.weight - first).abs() > 1e-12 * first.abs());
967        Ok(Self {
968            knots,
969            control,
970            rational,
971            periodic: false,
972        })
973    }
974
975    /// A smoothly periodic B-spline through a ring of control points.
976    ///
977    /// The ring is wrapped: the first `degree` controls repeat past the
978    /// end over a uniform knot vector, and evaluation wraps its parameter,
979    /// so the loop closes with `degree - 1` continuous derivatives and no
980    /// clamped seam. The domain runs one knot step per ring point.
981    ///
982    /// # Errors
983    ///
984    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the ring
985    /// has no more points than the degree, or the degree is zero.
986    pub fn periodic(control: &[Point], degree: usize, tol: Tolerances) -> OgeomResult<Self> {
987        if degree == 0 {
988            ogeom_bail!(Construction, "a curve needs a degree of at least one");
989        }
990        if control.len() <= degree {
991            ogeom_bail!(
992                Construction,
993                "a periodic ring of {} points cannot carry degree {degree}",
994                control.len()
995            );
996        }
997        let n = control.len();
998        let mut wrapped: Vec<Point> = Vec::with_capacity(n + degree);
999        wrapped.extend_from_slice(control);
1000        wrapped.extend_from_slice(&control[..degree]);
1001        #[allow(clippy::cast_precision_loss)]
1002        let knots: Vec<f64> = (0..wrapped.len() + degree + 1).map(|i| i as f64).collect();
1003        let mut built = Self::new(KnotVector::new(knots, degree)?, wrapped, tol)?;
1004        built.periodic = true;
1005        Ok(built)
1006    }
1007
1008    /// The exact periodic representation, as a reader restores it: wrapped
1009    /// knots and control, with the wrap verified rather than assumed.
1010    ///
1011    /// # Errors
1012    ///
1013    /// As [`BSplineCurve::rational`], and additionally if the trailing
1014    /// `degree` controls do not repeat the leading ones. An unwrapped ring
1015    /// evaluated periodically would tear at the seam.
1016    pub fn periodic_from_parts(
1017        knots: KnotVector,
1018        control: Vec<Weighted<Point>>,
1019        tol: Tolerances,
1020    ) -> OgeomResult<Self> {
1021        let degree = knots.degree();
1022        if control.len() <= degree {
1023            ogeom_bail!(
1024                Construction,
1025                "a periodic curve of {} controls cannot carry degree {degree}",
1026                control.len()
1027            );
1028        }
1029        let n = control.len() - degree;
1030        for i in 0..degree {
1031            let (a, b) = (control[i], control[n + i]);
1032            if !a.point().is_equal(b.point(), tol) || (a.weight - b.weight).abs() > 1e-12 {
1033                ogeom_bail!(
1034                    Construction,
1035                    "a periodic curve's trailing controls must repeat its \
1036                     leading ones; control {} does not",
1037                    n + i
1038                );
1039            }
1040        }
1041        let mut built = Self::rational(knots, control)?;
1042        built.periodic = true;
1043        Ok(built)
1044    }
1045
1046    /// The knot vector.
1047    #[must_use]
1048    pub const fn knots(&self) -> &KnotVector {
1049        &self.knots
1050    }
1051
1052    /// The weighted control points.
1053    #[must_use]
1054    pub fn control_points(&self) -> &[Weighted<Point>] {
1055        &self.control
1056    }
1057
1058    /// Whether the weights differ, so the curve is genuinely rational.
1059    #[must_use]
1060    pub const fn is_rational(&self) -> bool {
1061        self.rational
1062    }
1063
1064    /// The degree.
1065    #[must_use]
1066    pub const fn degree(&self) -> usize {
1067        self.knots.degree()
1068    }
1069
1070    /// Insert a knot without moving the curve.
1071    ///
1072    /// # Errors
1073    ///
1074    /// As [`bspline::insert_knot`].
1075    pub fn with_knot_inserted(&self, u: f64, count: usize, tol: Tolerances) -> OgeomResult<Self> {
1076        let (knots, control) = bspline::insert_knot(&self.knots, &self.control, u, count, tol)?;
1077        Ok(Self {
1078            knots,
1079            control,
1080            rational: self.rational,
1081            periodic: self.periodic,
1082        })
1083    }
1084
1085    /// Raise the degree without moving the curve.
1086    ///
1087    /// # Errors
1088    ///
1089    /// As [`bspline::elevate_degree`].
1090    pub fn elevated(&self, tol: Tolerances) -> OgeomResult<Self> {
1091        let (knots, control) = bspline::elevate_degree(&self.knots, &self.control, tol)?;
1092        Ok(Self {
1093            knots,
1094            control,
1095            rational: self.rational,
1096            periodic: self.periodic,
1097        })
1098    }
1099
1100    /// The same closed curve with its seam moved to `u`: what was the
1101    /// stretch from `u` to the end now comes first, and the stretch from
1102    /// the start to `u` follows it, joined where the old seam was. The
1103    /// domain keeps its length and begins at `u`.
1104    ///
1105    /// # Errors
1106    ///
1107    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
1108    /// curve is periodic (its seam is nowhere) or does not close, its
1109    /// two ends apart by more than a thousand confusions; as
1110    /// [`bspline::split`] if `u` is an end of the domain.
1111    pub fn reseamed_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Self> {
1112        if self.periodic {
1113            ogeom_bail!(Construction, "a periodic curve has no seam to move");
1114        }
1115        let (start, end) = self.domain();
1116        let (head, tail) = (self.point_at(start, tol)?, self.point_at(end, tol)?);
1117        // Closed to a thousand confusions: a marched section closes to
1118        // its own march's tolerance, and a loop whose ends sit a fraction
1119        // of a micron apart is closed for every purpose the seam serves.
1120        if head.distance(tail) > tol.confusion() * 1e3 {
1121            ogeom_bail!(
1122                Construction,
1123                "the curve does not close: its ends are {:.3e} apart",
1124                head.distance(tail)
1125            );
1126        }
1127        let (before, after) = self.split_at(u, tol)?;
1128        let (knots, control) = bspline::join(
1129            &(after.knots, after.control),
1130            &(before.knots, before.control),
1131        )?;
1132        Ok(Self {
1133            knots,
1134            control,
1135            rational: self.rational,
1136            periodic: self.periodic,
1137        })
1138    }
1139
1140    /// This curve continued past one end by `length` in space: the
1141    /// polynomial continuation of its own end derivatives to the order
1142    /// `continuity`, joined on. A polynomial run of degree at most that
1143    /// order continues as itself, and so does a rational arc's homogeneous
1144    /// polynomial: a circle arc continued at order two stays on its circle.
1145    ///
1146    /// The continuation's parameter span is solved until its arc length is
1147    /// `length`. Extended at the start, the original run keeps its
1148    /// parameters and the domain grows downward.
1149    ///
1150    /// # Errors
1151    ///
1152    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
1153    /// curve is periodic (it has no end to continue from) or stands still
1154    /// at that end; as [`bspline::extend`].
1155    pub fn extended(
1156        &self,
1157        at_end: bool,
1158        length: f64,
1159        continuity: usize,
1160        tol: Tolerances,
1161    ) -> OgeomResult<Self> {
1162        if !(length > 0.0 && length.is_finite()) {
1163            ogeom_bail!(
1164                Construction,
1165                "an extension needs a positive length; got {length}"
1166            );
1167        }
1168        let speed = self.end_speed(at_end, tol)?;
1169        let build = |span: f64| -> OgeomResult<Self> {
1170            let (knots, control) =
1171                bspline::extend(&self.knots, &self.control, at_end, span, continuity, tol)?;
1172            Ok(Self {
1173                knots,
1174                control,
1175                rational: self.rational,
1176                periodic: self.periodic,
1177            })
1178        };
1179        // The continuation over a span is the same polynomial whatever the
1180        // span: its length grows with the span, and the secant finds the
1181        // span that meets the length.
1182        let run = |curve: &Self, span: f64| -> OgeomResult<f64> {
1183            let (lo, hi) = curve.domain();
1184            let (a, b) = if at_end {
1185                (hi - span, hi)
1186            } else {
1187                (lo, lo + span)
1188            };
1189            curve.length_over((a, b), tol)
1190        };
1191        let mut s0 = length / speed;
1192        let mut c0 = build(s0)?;
1193        let mut l0 = run(&c0, s0)? - length;
1194        let mut s1 = s0 * 1.1;
1195        for _ in 0..40 {
1196            if l0.abs() <= tol.confusion() {
1197                break;
1198            }
1199            let c1 = build(s1)?;
1200            let l1 = run(&c1, s1)? - length;
1201            let next = if (l1 - l0).abs() > f64::EPSILON {
1202                s1 - l1 * (s1 - s0) / (l1 - l0)
1203            } else {
1204                s1
1205            };
1206            (s0, c0, l0) = (s1, c1, l1);
1207            s1 = if next > 0.0 { next } else { s0 * 0.5 };
1208        }
1209        Ok(c0)
1210    }
1211
1212    /// This curve continued past one end to `target`: a piece carrying the
1213    /// curve's end derivatives to the order `continuity` and ending at the
1214    /// point, joined on. Extended at the start, the original run keeps its
1215    /// parameters and the domain grows downward.
1216    ///
1217    /// # Errors
1218    ///
1219    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
1220    /// curve is periodic, stands still at that end, or already ends at
1221    /// `target`; as [`bspline::extend_to`].
1222    pub fn extended_to(
1223        &self,
1224        at_end: bool,
1225        target: Point,
1226        continuity: usize,
1227        tol: Tolerances,
1228    ) -> OgeomResult<Self> {
1229        let speed = self.end_speed(at_end, tol)?;
1230        let (lo, hi) = self.domain();
1231        let at = if at_end { hi } else { lo };
1232        let from = self.point_at(at, tol)?;
1233        let gap = from.distance(target);
1234        if gap <= tol.confusion() {
1235            ogeom_bail!(Construction, "the curve already ends at the point");
1236        }
1237        let end = if at_end {
1238            self.control[self.control.len() - 1]
1239        } else {
1240            self.control[0]
1241        };
1242        let weighted = Weighted::new(target, end.weight, tol)?;
1243        let (knots, control) = bspline::extend_to(
1244            &self.knots,
1245            &self.control,
1246            at_end,
1247            weighted,
1248            gap / speed,
1249            continuity,
1250            tol,
1251        )?;
1252        let rational = self.rational;
1253        Ok(Self {
1254            knots,
1255            control,
1256            rational,
1257            periodic: false,
1258        })
1259    }
1260
1261    /// The speed at one end, refused where it is none.
1262    fn end_speed(&self, at_end: bool, tol: Tolerances) -> OgeomResult<f64> {
1263        if self.periodic {
1264            ogeom_bail!(Construction, "a periodic curve has no end to continue from");
1265        }
1266        let (lo, hi) = self.domain();
1267        let at = if at_end { hi } else { lo };
1268        let speed = self.d1_at(at, tol)?.magnitude();
1269        if speed <= tol.confusion() {
1270            ogeom_bail!(
1271                Construction,
1272                "the curve stands still at its end; there is no direction to continue in"
1273            );
1274        }
1275        Ok(speed)
1276    }
1277
1278    /// The arc length over a parameter range, by Gauss-Legendre on each of
1279    /// the knot spans it covers.
1280    fn length_over(&self, range: (f64, f64), tol: Tolerances) -> OgeomResult<f64> {
1281        const NODES: [(f64, f64); 5] = [
1282            (0.0, 0.568_888_888_888_888_9),
1283            (-0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
1284            (0.538_469_310_105_683_1, 0.478_628_670_499_366_5),
1285            (-0.906_179_845_938_664, 0.236_926_885_056_189_1),
1286            (0.906_179_845_938_664, 0.236_926_885_056_189_1),
1287        ];
1288        let mut breaks: Vec<f64> = vec![range.0];
1289        breaks.extend(
1290            self.knots
1291                .distinct()
1292                .into_iter()
1293                .map(|(k, _)| k)
1294                .filter(|k| *k > range.0 && *k < range.1),
1295        );
1296        breaks.push(range.1);
1297        let mut total = 0.0;
1298        for w in breaks.windows(2) {
1299            // Each span in eight, for a rational run's uneven speed.
1300            for part in 0..8 {
1301                let a = w[0] + (w[1] - w[0]) * f64::from(part) / 8.0;
1302                let b = w[0] + (w[1] - w[0]) * f64::from(part + 1) / 8.0;
1303                let (mid, half) = (0.5 * (a + b), 0.5 * (b - a));
1304                for (x, weight) in NODES {
1305                    total += weight * half * self.d1_at(mid + half * x, tol)?.magnitude();
1306                }
1307            }
1308        }
1309        Ok(total)
1310    }
1311
1312    /// The piece of this curve over `range`, exactly and keeping its
1313    /// parameters: the piece at `t` is this curve at `t`.
1314    ///
1315    /// # Errors
1316    ///
1317    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `range` is
1318    /// empty or leaves the domain.
1319    pub fn segment(&self, range: (f64, f64), tol: Tolerances) -> OgeomResult<Self> {
1320        let (a, b) = self.knots.domain();
1321        let eps = tol.parametric();
1322        if range.1 <= range.0 + eps || range.0 < a - eps || range.1 > b + eps {
1323            ogeom_bail!(
1324                Domain,
1325                "[{}, {}] is no piece of [{a}, {b}]",
1326                range.0,
1327                range.1
1328            );
1329        }
1330        let mut piece = Self {
1331            knots: self.knots.clone(),
1332            control: self.control.clone(),
1333            rational: self.rational,
1334            periodic: false,
1335        };
1336        if range.0 > a + eps {
1337            piece = piece.split_at(range.0, tol)?.1;
1338        }
1339        if range.1 < b - eps {
1340            piece = piece.split_at(range.1, tol)?.0;
1341        }
1342        Ok(piece)
1343    }
1344
1345    /// Split into two curves meeting at `u`.
1346    ///
1347    /// # Errors
1348    ///
1349    /// As [`bspline::split`].
1350    pub fn split_at(&self, u: f64, tol: Tolerances) -> OgeomResult<(Self, Self)> {
1351        let ((lk, lc), (rk, rc)) = bspline::split(&self.knots, &self.control, u, tol)?;
1352        Ok((
1353            Self {
1354                knots: lk,
1355                control: lc,
1356                rational: self.rational,
1357                periodic: self.periodic,
1358            },
1359            Self {
1360                knots: rk,
1361                control: rc,
1362                rational: self.rational,
1363                periodic: self.periodic,
1364            },
1365        ))
1366    }
1367}
1368
1369impl TrimmedCurve {
1370    /// Restrict `basis` to `[start, end]`.
1371    ///
1372    /// # Errors
1373    ///
1374    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the range is empty or
1375    /// falls outside the basis curve's own domain.
1376    pub fn new(basis: Curve, start: f64, end: f64, tol: Tolerances) -> OgeomResult<Self> {
1377        let (a, b) = basis.domain();
1378        if !start.is_finite() || !end.is_finite() || end <= start + tol.parametric() {
1379            ogeom_bail!(Domain, "trim range [{start}, {end}] is empty");
1380        }
1381        if !basis.is_periodic() && (start < a - tol.parametric() || end > b + tol.parametric()) {
1382            ogeom_bail!(
1383                Domain,
1384                "trim range [{start}, {end}] leaves the basis domain [{a}, {b}]"
1385            );
1386        }
1387        Ok(Self {
1388            basis,
1389            domain: (start, end),
1390            reversed: false,
1391        })
1392    }
1393
1394    /// The curve being trimmed.
1395    #[must_use]
1396    pub const fn basis(&self) -> &Curve {
1397        &self.basis
1398    }
1399
1400    /// Whether the curve runs backwards along its underlying curve.
1401    ///
1402    /// Part of the curve's state and not derivable from its basis curve, so
1403    /// anything that has to reproduce this curve exactly (the native format
1404    /// above all) needs to be able to read it.
1405    #[must_use]
1406    pub const fn is_reversed(&self) -> bool {
1407        self.reversed
1408    }
1409
1410    /// This curve's parameter mapped onto the basis curve's.
1411    fn basis_parameter(&self, u: f64, tol: Tolerances) -> OgeomResult<f64> {
1412        let u = self.normalize_parameter(u, tol)?;
1413        Ok(if self.reversed {
1414            mirror(u, self.domain.0, self.domain.1)
1415        } else {
1416            u
1417        })
1418    }
1419}
1420
1421/// Reverse a parameter within `[a, b]`, so the curve runs the other way over
1422/// the same interval.
1423///
1424/// Preserving the domain matters: trimming ranges elsewhere refer to it, and a
1425/// reversal that also renumbered the parameters would invalidate them.
1426fn mirror(u: f64, a: f64, b: f64) -> f64 {
1427    a + b - u
1428}
1429
1430/// A conic's derivatives to order `n` from its first two: a circle's and an
1431/// ellipse's cycle `d1, d2, -d1, -d2` (`period` four), a hyperbola's
1432/// `d1, d2` (`period` two). Each order is times `sign` to its power, the
1433/// reversal's chain rule.
1434fn cycled(
1435    point: Vector,
1436    d1: Vector,
1437    d2: Vector,
1438    n: usize,
1439    period: usize,
1440    sign: f64,
1441) -> Vec<Vector> {
1442    let mut out = vec![point];
1443    let mut factor = 1.0;
1444    for order in 1..=n {
1445        factor *= sign;
1446        let d = match (order - 1) % period {
1447            0 => d1,
1448            1 => d2,
1449            2 => -d1,
1450            _ => -d2,
1451        };
1452        out.push(d * factor);
1453    }
1454    out
1455}
1456
1457impl Curve3d for LineCurve {
1458    fn domain(&self) -> (f64, f64) {
1459        self.domain
1460    }
1461
1462    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1463        let u = self.normalize_parameter(u, tol)?;
1464        Ok(self.axis.point_at(u))
1465    }
1466
1467    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1468        self.normalize_parameter(u, tol)?;
1469        Ok(self.axis.direction.vector())
1470    }
1471
1472    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1473        let p = self.point_at(u, tol)?;
1474        let mut out = vec![p.to_vector(), self.axis.direction.vector()];
1475        out.resize(n + 1, Vector::ZERO);
1476        out.truncate(n + 1);
1477        Ok(out)
1478    }
1479
1480    fn kind(&self) -> CurveKind {
1481        CurveKind::Line
1482    }
1483
1484    fn continuity(&self) -> Continuity {
1485        Continuity::CInfinity
1486    }
1487
1488    fn is_closed(&self, _tol: Tolerances) -> bool {
1489        false
1490    }
1491
1492    fn is_periodic(&self) -> bool {
1493        false
1494    }
1495}
1496
1497impl Curve3d for CircleCurve {
1498    fn domain(&self) -> (f64, f64) {
1499        (0.0, core::f64::consts::TAU)
1500    }
1501
1502    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1503        let u = self.normalize_parameter(u, tol)?;
1504        let angle = if self.reversed { -u } else { u };
1505        Ok(elementary::circle_at(&self.circle, angle).point)
1506    }
1507
1508    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1509        Ok(self.derivatives_at(u, 1, tol)?[1])
1510    }
1511
1512    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1513        let u = self.normalize_parameter(u, tol)?;
1514        let angle = if self.reversed { -u } else { u };
1515        let c = elementary::circle_at(&self.circle, angle);
1516        let sign = if self.reversed { -1.0 } else { 1.0 };
1517        Ok(cycled(c.point.to_vector(), c.d1, c.d2, n, 4, sign))
1518    }
1519
1520    fn kind(&self) -> CurveKind {
1521        CurveKind::Circle
1522    }
1523
1524    fn continuity(&self) -> Continuity {
1525        Continuity::CInfinity
1526    }
1527
1528    fn is_closed(&self, _tol: Tolerances) -> bool {
1529        true
1530    }
1531
1532    fn is_periodic(&self) -> bool {
1533        true
1534    }
1535}
1536
1537impl Curve3d for EllipseCurve {
1538    fn domain(&self) -> (f64, f64) {
1539        (0.0, core::f64::consts::TAU)
1540    }
1541
1542    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1543        let u = self.normalize_parameter(u, tol)?;
1544        let angle = if self.reversed { -u } else { u };
1545        Ok(elementary::ellipse_at(&self.ellipse, angle).point)
1546    }
1547
1548    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1549        Ok(self.derivatives_at(u, 1, tol)?[1])
1550    }
1551
1552    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1553        let u = self.normalize_parameter(u, tol)?;
1554        let angle = if self.reversed { -u } else { u };
1555        let c = elementary::ellipse_at(&self.ellipse, angle);
1556        let sign = if self.reversed { -1.0 } else { 1.0 };
1557        Ok(cycled(c.point.to_vector(), c.d1, c.d2, n, 4, sign))
1558    }
1559
1560    fn kind(&self) -> CurveKind {
1561        CurveKind::Ellipse
1562    }
1563
1564    fn continuity(&self) -> Continuity {
1565        Continuity::CInfinity
1566    }
1567
1568    fn is_closed(&self, _tol: Tolerances) -> bool {
1569        true
1570    }
1571
1572    fn is_periodic(&self) -> bool {
1573        true
1574    }
1575}
1576
1577impl Curve3d for HyperbolaCurve {
1578    fn domain(&self) -> (f64, f64) {
1579        self.domain
1580    }
1581
1582    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1583        let u = self.normalize_parameter(u, tol)?;
1584        let t = if self.reversed {
1585            mirror(u, self.domain.0, self.domain.1)
1586        } else {
1587            u
1588        };
1589        Ok(elementary::hyperbola_at(&self.hyperbola, t).point)
1590    }
1591
1592    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1593        Ok(self.derivatives_at(u, 1, tol)?[1])
1594    }
1595
1596    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1597        let u = self.normalize_parameter(u, tol)?;
1598        let t = if self.reversed {
1599            mirror(u, self.domain.0, self.domain.1)
1600        } else {
1601            u
1602        };
1603        let c = elementary::hyperbola_at(&self.hyperbola, t);
1604        let sign = if self.reversed { -1.0 } else { 1.0 };
1605        Ok(cycled(c.point.to_vector(), c.d1, c.d2, n, 2, sign))
1606    }
1607
1608    fn kind(&self) -> CurveKind {
1609        CurveKind::Hyperbola
1610    }
1611
1612    fn continuity(&self) -> Continuity {
1613        Continuity::CInfinity
1614    }
1615
1616    fn is_closed(&self, _tol: Tolerances) -> bool {
1617        false
1618    }
1619
1620    fn is_periodic(&self) -> bool {
1621        false
1622    }
1623}
1624
1625impl Curve3d for ParabolaCurve {
1626    fn domain(&self) -> (f64, f64) {
1627        self.domain
1628    }
1629
1630    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1631        let u = self.normalize_parameter(u, tol)?;
1632        let t = if self.reversed {
1633            mirror(u, self.domain.0, self.domain.1)
1634        } else {
1635            u
1636        };
1637        Ok(elementary::parabola_at(&self.parabola, t).point)
1638    }
1639
1640    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1641        Ok(self.derivatives_at(u, 1, tol)?[1])
1642    }
1643
1644    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1645        let u = self.normalize_parameter(u, tol)?;
1646        let t = if self.reversed {
1647            mirror(u, self.domain.0, self.domain.1)
1648        } else {
1649            u
1650        };
1651        let c = elementary::parabola_at(&self.parabola, t);
1652        let sign = if self.reversed { -1.0 } else { 1.0 };
1653        // Quadratic in its parameter: every order past the second is zero.
1654        let mut out = vec![c.point.to_vector(), c.d1 * sign, c.d2];
1655        out.resize(n.max(2) + 1, Vector::ZERO);
1656        out.truncate(n + 1);
1657        Ok(out)
1658    }
1659
1660    fn kind(&self) -> CurveKind {
1661        CurveKind::Parabola
1662    }
1663
1664    fn continuity(&self) -> Continuity {
1665        Continuity::CInfinity
1666    }
1667
1668    fn is_closed(&self, _tol: Tolerances) -> bool {
1669        false
1670    }
1671
1672    fn is_periodic(&self) -> bool {
1673        false
1674    }
1675}
1676
1677impl Curve3d for BSplineCurve {
1678    fn domain(&self) -> (f64, f64) {
1679        self.knots.domain()
1680    }
1681
1682    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1683        let u = self.normalize_parameter(u, tol)?;
1684        if self.rational {
1685            bspline::evaluate_rational(&self.knots, &self.control, u, tol)
1686        } else {
1687            // All weights equal: the projection is a no-op up to that common
1688            // factor, so the cheaper polynomial path is exact here.
1689            Ok(bspline::evaluate(&self.knots, &self.control, u, tol)?.point())
1690        }
1691    }
1692
1693    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1694        Ok(self.derivatives_at(u, 1, tol)?[1])
1695    }
1696
1697    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1698        let u = self.normalize_parameter(u, tol)?;
1699        let points = bspline::rational_derivatives(&self.knots, &self.control, u, n, tol)?;
1700        Ok(points.into_iter().map(Point::to_vector).collect())
1701    }
1702
1703    fn kind(&self) -> CurveKind {
1704        CurveKind::BSpline
1705    }
1706
1707    /// Continuity across the whole curve.
1708    ///
1709    /// A degree-`p` B-spline is `C^(p - m)` at an interior knot of multiplicity
1710    /// `m`, and the worst interior knot governs the curve. With no interior
1711    /// knots at all the curve is a single polynomial piece and so genuinely
1712    /// smooth to every order.
1713    ///
1714    /// Higher orders than `C2` report as `C2`, which is the highest
1715    /// [`Continuity`] names short of `CInfinity`. Reporting `CInfinity` for a
1716    /// merely-`C3` curve would be a claim that is false, and the distinction
1717    /// above `C2` is not one any algorithm here asks about.
1718    fn continuity(&self) -> Continuity {
1719        let degree = self.knots.degree();
1720        let (a, b) = self.knots.domain();
1721        let worst = self
1722            .knots
1723            .distinct()
1724            .into_iter()
1725            .filter(|(v, _)| *v > a && *v < b)
1726            .map(|(_, m)| m)
1727            .max();
1728        match worst {
1729            None => Continuity::CInfinity,
1730            Some(m) => match degree.saturating_sub(m) {
1731                0 => Continuity::C0,
1732                1 => Continuity::C1,
1733                _ => Continuity::C2,
1734            },
1735        }
1736    }
1737
1738    /// A periodic spline closes by construction. Any other where its ends
1739    /// meet, which its end control points say only for clamped knots.
1740    fn is_closed(&self, tol: Tolerances) -> bool {
1741        self.periodic
1742            || match (self.start(tol), self.end(tol)) {
1743                (Ok(a), Ok(b)) => a.is_equal(b, tol),
1744                _ => false,
1745            }
1746    }
1747
1748    fn is_periodic(&self) -> bool {
1749        self.periodic
1750    }
1751}
1752
1753impl Curve3d for TrimmedCurve {
1754    fn domain(&self) -> (f64, f64) {
1755        self.domain
1756    }
1757
1758    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1759        self.basis.point_at(self.basis_parameter(u, tol)?, tol)
1760    }
1761
1762    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1763        let d = self.basis.d1_at(self.basis_parameter(u, tol)?, tol)?;
1764        Ok(if self.reversed { -d } else { d })
1765    }
1766
1767    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1768        let t = self.basis_parameter(u, tol)?;
1769        let mut out = self.basis.derivatives_at(t, n, tol)?;
1770        if self.reversed {
1771            // Chain rule for u -> (s + e - u): each order picks up a factor of
1772            // -1, so odd orders flip.
1773            for (order, d) in out.iter_mut().enumerate() {
1774                if order % 2 == 1 {
1775                    *d = -*d;
1776                }
1777            }
1778        }
1779        Ok(out)
1780    }
1781
1782    fn kind(&self) -> CurveKind {
1783        CurveKind::Trimmed
1784    }
1785
1786    fn continuity(&self) -> Continuity {
1787        self.basis.continuity()
1788    }
1789
1790    fn is_closed(&self, tol: Tolerances) -> bool {
1791        match (self.start(tol), self.end(tol)) {
1792            (Ok(a), Ok(b)) => a.is_equal(b, tol),
1793            _ => false,
1794        }
1795    }
1796
1797    fn is_periodic(&self) -> bool {
1798        false
1799    }
1800}
1801
1802/// Dispatch a method across every curve variant.
1803macro_rules! dispatch {
1804    ($self:ident, $c:ident => $body:expr) => {
1805        match $self {
1806            Self::Line($c) => $body,
1807            Self::Circle($c) => $body,
1808            Self::Ellipse($c) => $body,
1809            Self::Hyperbola($c) => $body,
1810            Self::Parabola($c) => $body,
1811            Self::BSpline($c) => $body,
1812            Self::Helix($c) => $body,
1813            Self::Trimmed($c) => $body,
1814            Self::Offset($c) => $body,
1815            Self::OnSurface($c) => $body,
1816        }
1817    };
1818}
1819
1820impl Curve3d for Curve {
1821    fn domain(&self) -> (f64, f64) {
1822        dispatch!(self, c => c.domain())
1823    }
1824
1825    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point> {
1826        dispatch!(self, c => c.point_at(u, tol))
1827    }
1828
1829    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector> {
1830        dispatch!(self, c => c.d1_at(u, tol))
1831    }
1832
1833    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector>> {
1834        dispatch!(self, c => c.derivatives_at(u, n, tol))
1835    }
1836
1837    fn kind(&self) -> CurveKind {
1838        dispatch!(self, c => c.kind())
1839    }
1840
1841    fn continuity(&self) -> Continuity {
1842        dispatch!(self, c => c.continuity())
1843    }
1844
1845    fn is_closed(&self, tol: Tolerances) -> bool {
1846        dispatch!(self, c => c.is_closed(tol))
1847    }
1848
1849    fn is_periodic(&self) -> bool {
1850        dispatch!(self, c => c.is_periodic())
1851    }
1852}
1853
1854impl Transformable for Curve {
1855    fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
1856        if t.kind() == ogeom_math::TransformKind::Identity {
1857            return Ok(self.clone());
1858        }
1859        Ok(match self {
1860            Self::Line(c) => Self::Line(LineCurve {
1861                axis: Axis::new(
1862                    t.apply(c.axis.location),
1863                    t.apply_direction(c.axis.direction, tol)?,
1864                ),
1865                // The parameter is a length, so a scaling rescales the domain
1866                // with it. Otherwise the trimmed extent would silently change.
1867                domain: (
1868                    c.domain.0 * t.scale_factor().abs(),
1869                    c.domain.1 * t.scale_factor().abs(),
1870                ),
1871            }),
1872            // No parameter flip for a mirror: transforming the frame already
1873            // carries its x and y axes with it, so evaluating at the same angle
1874            // lands on the transformed point. Flipping as well would reverse
1875            // the arc twice.
1876            Self::Circle(c) => Self::Circle(CircleCurve {
1877                circle: c.circle.transformed(t, tol)?,
1878                ..*c
1879            }),
1880            Self::Ellipse(c) => Self::Ellipse(EllipseCurve {
1881                ellipse: c.ellipse.transformed(t, tol)?,
1882                ..*c
1883            }),
1884            Self::Hyperbola(c) => Self::Hyperbola(HyperbolaCurve {
1885                hyperbola: c.hyperbola.transformed(t, tol)?,
1886                ..*c
1887            }),
1888            // The parameter runs along the axis frame's `y`, a length, and a
1889            // scaling rescales it as it does a line's.
1890            Self::Parabola(c) => Self::Parabola(ParabolaCurve {
1891                parabola: c.parabola.transformed(t, tol)?,
1892                domain: (
1893                    c.domain.0 * t.scale_factor().abs(),
1894                    c.domain.1 * t.scale_factor().abs(),
1895                ),
1896                ..*c
1897            }),
1898            Self::BSpline(c) => {
1899                let control = c
1900                    .control
1901                    .iter()
1902                    .map(|w| Weighted::new(t.apply(w.point()), w.weight, tol))
1903                    .collect::<OgeomResult<Vec<_>>>()?;
1904                Self::BSpline(BSplineCurve {
1905                    control,
1906                    knots: c.knots.clone(),
1907                    rational: c.rational,
1908                    periodic: c.periodic,
1909                })
1910            }
1911            Self::Helix(c) => Self::Helix(HelixCurve {
1912                frame: t.apply_frame(&c.frame, tol)?,
1913                radius: c.radius * t.scale_factor().abs(),
1914                pitch: c.pitch * t.scale_factor().abs(),
1915                taper: c.taper * t.scale_factor().abs(),
1916                ..*c
1917            }),
1918            // The offset runs along `tangent x reference`, which a
1919            // reflection carries to the other side: the distance goes with it.
1920            Self::Offset(c) => Self::Offset(Box::new(OffsetCurve {
1921                basis: c.basis.transformed(t, tol)?,
1922                distance: c.distance
1923                    * t.scale_factor().abs()
1924                    * if t.preserves_handedness() { 1.0 } else { -1.0 },
1925                reference: t.apply_direction(c.reference, tol)?,
1926            })),
1927            // The surface's length directions rescale under a scaling (a
1928            // plane's both, a cylinder's height), and the chart curve
1929            // stretches with them so it names the same points' images.
1930            Self::OnSurface(c) => {
1931                use crate::traits::Surface as _;
1932                let surface = c.surface.transformed(t, tol)?;
1933                let (from, to) = (c.surface.domain(), surface.domain());
1934                let ratio = |a: (f64, f64), b: (f64, f64)| {
1935                    let span = a.1 - a.0;
1936                    if span.is_finite() && span != 0.0 && (b.1 - b.0).is_finite() {
1937                        (b.1 - b.0) / span
1938                    } else {
1939                        1.0
1940                    }
1941                };
1942                Self::OnSurface(Box::new(CurveOnSurface {
1943                    pcurve: c
1944                        .pcurve
1945                        .stretched(ratio(from.0, to.0), ratio(from.1, to.1), tol)?,
1946                    surface,
1947                }))
1948            }
1949            Self::Trimmed(c) => {
1950                let basis = c.basis.transformed(t, tol)?;
1951                // The trim lives in the basis's parameter and moves with it:
1952                // a line's is a length and rescales, and so does anything
1953                // built on one (a trim of a trim of a line). An angle or a
1954                // spline parameter does not, and the map is the identity.
1955                let (from, to) = (c.basis.domain(), basis.domain());
1956                Self::Trimmed(Box::new(TrimmedCurve {
1957                    domain: (
1958                        crate::surface::carried(c.domain.0, from, to),
1959                        crate::surface::carried(c.domain.1, from, to),
1960                    ),
1961                    basis,
1962                    reversed: c.reversed,
1963                }))
1964            }
1965        })
1966    }
1967}
1968
1969impl Reversible for Curve {
1970    fn reversed(&self) -> Self {
1971        match self {
1972            Self::Line(c) => Self::Line(LineCurve {
1973                axis: Axis::new(
1974                    c.axis.point_at(c.domain.0 + c.domain.1),
1975                    c.axis.direction.reversed(),
1976                ),
1977                domain: c.domain,
1978            }),
1979            Self::Circle(c) => Self::Circle(CircleCurve {
1980                reversed: !c.reversed,
1981                ..*c
1982            }),
1983            Self::Ellipse(c) => Self::Ellipse(EllipseCurve {
1984                reversed: !c.reversed,
1985                ..*c
1986            }),
1987            Self::Hyperbola(c) => Self::Hyperbola(HyperbolaCurve {
1988                reversed: !c.reversed,
1989                ..*c
1990            }),
1991            Self::Parabola(c) => Self::Parabola(ParabolaCurve {
1992                reversed: !c.reversed,
1993                ..*c
1994            }),
1995            Self::BSpline(c) => {
1996                let (knots, control) = bspline::reverse(&c.knots, &c.control);
1997                Self::BSpline(BSplineCurve {
1998                    knots,
1999                    control,
2000                    rational: c.rational,
2001                    periodic: c.periodic,
2002                })
2003            }
2004            Self::Helix(c) => Self::Helix(HelixCurve {
2005                reversed: !c.reversed,
2006                ..*c
2007            }),
2008            // Reversing flips the tangent and with it the offset direction,
2009            // so the distance negates to keep the same point set.
2010            Self::Offset(c) => Self::Offset(Box::new(OffsetCurve {
2011                basis: c.basis.reversed(),
2012                distance: -c.distance,
2013                reference: c.reference,
2014            })),
2015            Self::OnSurface(c) => Self::OnSurface(Box::new(CurveOnSurface {
2016                pcurve: c.pcurve.reversed(),
2017                surface: c.surface.clone(),
2018            })),
2019            // A flag rather than reversing the basis: mirroring the trim range
2020            // within the basis domain would move this curve's own domain, and
2021            // trimming ranges held elsewhere refer to it.
2022            Self::Trimmed(c) => Self::Trimmed(Box::new(TrimmedCurve {
2023                reversed: !c.reversed,
2024                ..(**c).clone()
2025            })),
2026        }
2027    }
2028}
2029
2030impl From<LineCurve> for Curve {
2031    fn from(c: LineCurve) -> Self {
2032        Self::Line(c)
2033    }
2034}
2035impl From<CircleCurve> for Curve {
2036    fn from(c: CircleCurve) -> Self {
2037        Self::Circle(c)
2038    }
2039}
2040impl From<EllipseCurve> for Curve {
2041    fn from(c: EllipseCurve) -> Self {
2042        Self::Ellipse(c)
2043    }
2044}
2045impl From<HelixCurve> for Curve {
2046    fn from(c: HelixCurve) -> Self {
2047        Self::Helix(c)
2048    }
2049}
2050
2051impl From<HyperbolaCurve> for Curve {
2052    fn from(c: HyperbolaCurve) -> Self {
2053        Self::Hyperbola(c)
2054    }
2055}
2056impl From<ParabolaCurve> for Curve {
2057    fn from(c: ParabolaCurve) -> Self {
2058        Self::Parabola(c)
2059    }
2060}
2061impl From<BSplineCurve> for Curve {
2062    fn from(c: BSplineCurve) -> Self {
2063        Self::BSpline(c)
2064    }
2065}
2066impl From<TrimmedCurve> for Curve {
2067    fn from(c: TrimmedCurve) -> Self {
2068        Self::Trimmed(Box::new(c))
2069    }
2070}
2071
2072#[cfg(test)]
2073#[allow(clippy::unwrap_used)]
2074mod reseam_tests {
2075    use super::*;
2076    use ogeom_core::Tolerances;
2077    use ogeom_math::{KnotVector, Point};
2078
2079    #[test]
2080    fn a_reseamed_closed_curve_is_the_same_curve_from_a_new_start() {
2081        let tol = Tolerances::millimetres();
2082        // A closed cubic: a ring of control points ending where it began.
2083        let ring = [
2084            Point::new(1.0, 0.0, 0.0),
2085            Point::new(1.0, 1.0, 0.5),
2086            Point::new(-1.0, 1.0, 0.0),
2087            Point::new(-1.0, -1.0, -0.5),
2088            Point::new(1.0, -1.0, 0.0),
2089            Point::new(1.0, 0.0, 0.0),
2090        ];
2091        let knots = KnotVector::clamped_uniform(3, ring.len()).unwrap();
2092        let curve = BSplineCurve::new(knots, ring.to_vec(), tol).unwrap();
2093        let (start, end) = curve.domain();
2094        let seam = 0.35;
2095        let moved = curve.reseamed_at(seam, tol).unwrap();
2096        let (new_start, new_end) = moved.domain();
2097        assert!((new_start - seam).abs() < 1e-12, "begins at the new seam");
2098        assert!(
2099            ((new_end - new_start) - (end - start)).abs() < 1e-12,
2100            "keeps its length"
2101        );
2102        for i in 0..=40 {
2103            let s = (end - start) * f64::from(i) / 40.0;
2104            let old_u = if seam + s <= end {
2105                seam + s
2106            } else {
2107                seam + s - (end - start)
2108            };
2109            let a = curve.point_at(old_u, tol).unwrap();
2110            let b = moved.point_at(new_start + s, tol).unwrap();
2111            assert!(a.is_equal(b, tol), "at {s} along: {a:?} against {b:?}");
2112        }
2113    }
2114}
2115
2116#[cfg(test)]
2117#[allow(clippy::unwrap_used)]
2118mod tests {
2119    use super::*;
2120    use approx::assert_relative_eq;
2121    use ogeom_math::{Direction, Frame};
2122
2123    const T: Tolerances = Tolerances::millimetres();
2124
2125    /// A reversed circle or ellipse spelled forward is the same point at
2126    /// every parameter.
2127    #[test]
2128    fn a_reversed_conic_spelled_forward_is_the_same_curve() {
2129        let frame = Frame::new(Point::new(1.0, 2.0, 3.0), Direction::Y, Direction::Z, T).unwrap();
2130        let curves = [
2131            Curve::Circle(CircleCurve::new(Circle::new(frame, 2.0, T).unwrap())),
2132            Curve::Ellipse(EllipseCurve::new(Ellipse::new(frame, 5.0, 3.0, T).unwrap())),
2133        ];
2134        for reversed in curves.map(|c| c.reversed()) {
2135            let (forward, turned) = match &reversed {
2136                Curve::Circle(c) => {
2137                    let f = c.forward(T).unwrap();
2138                    (f.is_reversed(), Curve::Circle(f))
2139                }
2140                Curve::Ellipse(e) => {
2141                    let f = e.forward(T).unwrap();
2142                    (f.is_reversed(), Curve::Ellipse(f))
2143                }
2144                _ => unreachable!(),
2145            };
2146            assert!(!forward);
2147            for i in 0..8 {
2148                let u = 0.3 + f64::from(i) * 0.8;
2149                let (a, b) = (
2150                    reversed.point_at(u, T).unwrap(),
2151                    turned.point_at(u, T).unwrap(),
2152                );
2153                assert!(a.distance(b) < 1e-12, "{u}: {a:?} {b:?}");
2154            }
2155        }
2156    }
2157
2158    /// A spline whose ends meet is closed, periodic or unclamped, and the
2159    /// surface it sweeps closes with it.
2160    #[test]
2161    fn a_periodic_spline_is_closed() {
2162        let ring = [
2163            Point::new(0.0, 0.0, 0.0),
2164            Point::new(10.0, 0.0, 1.0),
2165            Point::new(10.0, 10.0, 2.0),
2166            Point::new(0.0, 10.0, 0.0),
2167        ];
2168        let c: Curve = BSplineCurve::periodic(&ring, 3, T).unwrap().into();
2169        assert!(c.is_periodic() && c.is_closed(T));
2170        let sweep: crate::surface::SurfaceGeometry =
2171            crate::surface::ExtrusionSurface::new(c, Direction::Z, 10.0)
2172                .unwrap()
2173                .into();
2174        assert!(crate::traits::Surface::is_closed_u(&sweep, T));
2175    }
2176
2177    /// A conical helix's arc length is the integral of its speed, which
2178    /// grows with its radius: the sum of fine chords agrees with it.
2179    #[test]
2180    fn a_conical_helix_measures_its_length() {
2181        for (taper, pitch) in [(0.0, 2.0), (3.0, 2.0), (-1.5, 0.5), (4.0, 0.1)] {
2182            let helix = HelixCurve::conical(Frame::WORLD, 10.0, pitch, taper, 0.0, 12.0).unwrap();
2183            let steps = 200_000;
2184            let mut chords = 0.0;
2185            let mut last = helix.point_at(0.0, T).unwrap();
2186            for k in 1..=steps {
2187                let next = helix
2188                    .point_at(12.0 * f64::from(k) / f64::from(steps), T)
2189                    .unwrap();
2190                chords += next.distance(last);
2191                last = next;
2192            }
2193            let exact = helix.arc_length(0.0, 12.0);
2194            assert!(
2195                (exact - chords).abs() < 1e-6 * exact,
2196                "{taper} {pitch}: {exact} against {chords}"
2197            );
2198        }
2199    }
2200
2201    /// A scaled curve names, at every parameter, the image of the point it
2202    /// named: a parabola, whose parameter is a length; a trim of a trim of a
2203    /// line and a trim of an offset line in a chart; and a curve on a plane
2204    /// or on a cylinder, whose chart curve stretches with the surface's
2205    /// length directions.
2206    #[test]
2207    fn a_scaled_curve_is_the_image_of_the_original() {
2208        use crate::curve2d::{Line2d, Offset2d, PlanarCurve, Trimmed2d};
2209        use crate::surface::{CylinderSurface, PlaneSurface, SurfaceGeometry};
2210        use crate::traits::Curve2d as _;
2211        use ogeom_math::{Cylinder, Parabola, Plane, Point2, Transform, Transform2};
2212        let scaling = Transform::scaling(Point::ORIGIN, 2.0, T).unwrap();
2213        let parabola: Curve =
2214            ParabolaCurve::new(Parabola::new(Frame::WORLD, 5.0, T).unwrap(), 10.0)
2215                .unwrap()
2216                .into();
2217        let parabola: Curve = TrimmedCurve::new(parabola, -10.0, 10.0, T).unwrap().into();
2218        let segment: PlanarCurve =
2219            Line2d::segment(Point2::new(10.0, 0.0), Point2::new(10.0, 20.0), T)
2220                .unwrap()
2221                .into();
2222        let on_plane = Curve::OnSurface(Box::new(CurveOnSurface::new(
2223            segment.clone(),
2224            PlaneSurface::over(Plane::XY, (-100.0, 100.0), (-100.0, 100.0))
2225                .unwrap()
2226                .into(),
2227        )));
2228        let slant: PlanarCurve = Line2d::segment(Point2::new(0.0, 1.0), Point2::new(3.0, 4.0), T)
2229            .unwrap()
2230            .into();
2231        let drum: SurfaceGeometry =
2232            CylinderSurface::new(Cylinder::new(Frame::WORLD, 5.0, T).unwrap(), (-10.0, 10.0))
2233                .unwrap()
2234                .into();
2235        let on_drum = Curve::OnSurface(Box::new(CurveOnSurface::new(slant, drum)));
2236        for curve in [parabola, on_plane, on_drum] {
2237            let moved = curve.transformed(&scaling, T).unwrap();
2238            let (a, b) = curve.domain();
2239            let (c, d) = moved.domain();
2240            for k in 0..=8 {
2241                let f = f64::from(k) / 8.0;
2242                let want = scaling.apply(curve.point_at(a + (b - a) * f, T).unwrap());
2243                let got = moved.point_at(c + (d - c) * f, T).unwrap();
2244                assert!(got.distance(want) < 1e-9, "{got:?} against {want:?}");
2245            }
2246        }
2247        let flat = Transform2::scaling(Point2::ORIGIN, 2.0, T).unwrap();
2248        let line: PlanarCurve = Line2d::segment(Point2::new(0.0, 0.0), Point2::new(10.0, 0.0), T)
2249            .unwrap()
2250            .into();
2251        let nested: PlanarCurve = Trimmed2d::new(
2252            Trimmed2d::new(line.clone(), 1.0, 9.0, T).unwrap().into(),
2253            3.0,
2254            7.0,
2255            T,
2256        )
2257        .unwrap()
2258        .into();
2259        let beside: PlanarCurve = Trimmed2d::new(
2260            PlanarCurve::Offset(Box::new(Offset2d::new(line, 1.0).unwrap())),
2261            3.0,
2262            7.0,
2263            T,
2264        )
2265        .unwrap()
2266        .into();
2267        for curve in [nested, beside] {
2268            let moved = curve.transformed(&flat, T).unwrap();
2269            for (at, on) in [
2270                (curve.domain().0, moved.domain().0),
2271                (curve.domain().1, moved.domain().1),
2272            ] {
2273                let want = flat.apply(curve.point_at(at, T).unwrap());
2274                assert!(moved.point_at(on, T).unwrap().distance(want) < 1e-9);
2275            }
2276        }
2277    }
2278
2279    /// An offset curve carried by a mirror or a point mirror names, at every
2280    /// parameter, the image of the point it named.
2281    #[test]
2282    fn a_mirrored_offset_curve_is_the_image_of_the_original() {
2283        use ogeom_math::{Circle, Transform};
2284        let circle: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 20.0, T).unwrap()).into();
2285        let offset: Curve = Curve::Offset(Box::new(
2286            OffsetCurve::new(circle, 3.0, Direction::Z).unwrap(),
2287        ));
2288        for t in [
2289            Transform::plane_mirror(Point::ORIGIN, Direction::X),
2290            Transform::point_mirror(Point::new(1.0, 2.0, 3.0)),
2291            Transform::scaling(Point::ORIGIN, -2.0, T).unwrap(),
2292        ] {
2293            let moved = offset.transformed(&t, T).unwrap();
2294            for u in [0.0, 1.0, 2.5, 4.0] {
2295                let want = t.apply(offset.point_at(u, T).unwrap());
2296                let got = moved.point_at(u, T).unwrap();
2297                assert!(
2298                    got.distance(want) < 1e-9,
2299                    "{t:?} at {u}: {got:?} against {want:?}"
2300                );
2301            }
2302        }
2303    }
2304
2305    fn tilted() -> Frame {
2306        Frame::new(
2307            Point::new(1.0, -2.0, 3.0),
2308            Direction::from_coords(1.0, 2.0, 3.0, T).unwrap(),
2309            Direction::X,
2310            T,
2311        )
2312        .unwrap()
2313    }
2314
2315    fn every_curve() -> Vec<Curve> {
2316        let spline = {
2317            let control = vec![
2318                Point::new(0.0, 0.0, 0.0),
2319                Point::new(1.0, 2.0, 0.0),
2320                Point::new(3.0, 1.0, 1.0),
2321                Point::new(5.0, 0.0, 2.0),
2322                Point::new(6.0, -1.0, 0.0),
2323            ];
2324            let knots = KnotVector::clamped_uniform(3, control.len()).unwrap();
2325            BSplineCurve::new(knots, control, T).unwrap()
2326        };
2327        vec![
2328            LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
2329                .unwrap()
2330                .into(),
2331            CircleCurve::new(Circle::new(tilted(), 2.0, T).unwrap()).into(),
2332            EllipseCurve::new(Ellipse::new(tilted(), 5.0, 3.0, T).unwrap()).into(),
2333            HyperbolaCurve::new(Hyperbola::new(tilted(), 3.0, 4.0, T).unwrap(), 1.5)
2334                .unwrap()
2335                .into(),
2336            ParabolaCurve::new(Parabola::new(tilted(), 2.0, T).unwrap(), 4.0)
2337                .unwrap()
2338                .into(),
2339            spline.clone().into(),
2340            HelixCurve::new(tilted(), 2.5, 1.25, 2.0).unwrap().into(),
2341            TrimmedCurve::new(spline.into(), 0.2, 0.8, T)
2342                .unwrap()
2343                .into(),
2344        ]
2345    }
2346
2347    #[test]
2348    fn a_helix_rises_one_pitch_per_turn_and_knows_its_length() {
2349        let helix = HelixCurve::new(Frame::WORLD, 3.0, 2.0, 2.0).unwrap();
2350        let tau = core::f64::consts::TAU;
2351        let start = helix.point_at(0.0, T).unwrap();
2352        let after_one_turn = helix.point_at(tau, T).unwrap();
2353        assert_relative_eq!(start.x, 3.0);
2354        assert_relative_eq!(after_one_turn.x, 3.0, epsilon = 1e-12);
2355        assert_relative_eq!(after_one_turn.y, 0.0, epsilon = 1e-12);
2356        assert_relative_eq!(after_one_turn.z - start.z, 2.0, epsilon = 1e-12);
2357
2358        // Closed-form length: constant speed times swept angle, checked
2359        // against a fine chordal sum.
2360        let exact = helix.arc_length(0.0, 2.0 * tau);
2361        assert_relative_eq!(exact, 2.0 * tau * 3.0f64.hypot(2.0 / tau), epsilon = 1e-12);
2362        let mut chords = 0.0;
2363        let n = 20_000;
2364        for i in 0..n {
2365            let a = 2.0 * tau * f64::from(i) / f64::from(n);
2366            let b = 2.0 * tau * f64::from(i + 1) / f64::from(n);
2367            chords += helix
2368                .point_at(a, T)
2369                .unwrap()
2370                .distance(helix.point_at(b, T).unwrap());
2371        }
2372        assert!((exact - chords) / exact < 1e-6, "{exact} vs {chords}");
2373
2374        // A negative pitch winds the other hand: same rise magnitude, the
2375        // quarter-turn point mirrored through the xz-plane... the y stays,
2376        // the z descends.
2377        let left = HelixCurve::new(Frame::WORLD, 3.0, -2.0, 2.0).unwrap();
2378        let q = left.point_at(tau / 4.0, T).unwrap();
2379        assert_relative_eq!(q.y, 3.0, epsilon = 1e-12);
2380        assert!(q.z < 0.0);
2381    }
2382
2383    #[test]
2384    fn a_reversed_helix_swaps_its_ends_and_flips_its_tangent() {
2385        let helix: Curve = HelixCurve::new(tilted(), 2.0, 1.0, 1.5).unwrap().into();
2386        let (lo, hi) = helix.domain();
2387        let back = helix.reversed();
2388        assert_relative_eq!(
2389            helix
2390                .point_at(lo, T)
2391                .unwrap()
2392                .distance(back.point_at(hi, T).unwrap()),
2393            0.0,
2394            epsilon = 1e-12
2395        );
2396        let d_fwd = helix.d1_at(f64::midpoint(lo, hi), T).unwrap();
2397        let d_back = back.d1_at(f64::midpoint(lo, hi), T).unwrap();
2398        assert_relative_eq!((d_fwd + d_back).magnitude(), 0.0, epsilon = 1e-12);
2399    }
2400
2401    #[test]
2402    fn an_offset_circle_is_the_larger_circle() {
2403        // The offset of a circle perpendicular to its own axis is the
2404        // concentric circle: for the counterclockwise traversal, tangent x z
2405        // points radially outward, so a positive distance grows the radius.
2406        let circle: Curve = CircleCurve::new(Circle::new(tilted(), 2.0, T).unwrap()).into();
2407        let bigger = Circle::new(tilted(), 3.0, T).unwrap();
2408        let offset = OffsetCurve::new(circle, 1.0, tilted().z()).unwrap();
2409        for i in 0..8 {
2410            let t = core::f64::consts::TAU * f64::from(i) / 8.0;
2411            let p = offset.point_at(t, T).unwrap();
2412            assert_relative_eq!(p.distance(bigger.centre()), 3.0, epsilon = 1e-12);
2413        }
2414        // The exact first derivative agrees with differencing the points.
2415        let h = 1e-6;
2416        let d = offset.d1_at(1.0, T).unwrap();
2417        let fd = (offset.point_at(1.0 + h, T).unwrap() - offset.point_at(1.0 - h, T).unwrap())
2418            / (2.0 * h);
2419        assert_relative_eq!((d - fd).magnitude(), 0.0, epsilon = 1e-5);
2420        // The second derivative is refused by name, not differenced quietly.
2421        assert!(offset.derivatives_at(1.0, 2, T).is_err());
2422    }
2423
2424    #[test]
2425    fn a_sloped_line_on_a_cylinder_chart_is_a_helix() {
2426        use crate::curve2d::{Line2d, PlanarCurve};
2427        use crate::surface::{CylinderSurface, SurfaceGeometry};
2428        use ogeom_math::{Cylinder, Point2};
2429
2430        // The pcurve u = t, v = pitch·t/2π on a cylinder chart lifts to
2431        // exactly the helix with that pitch: two constructions, no shared
2432        // code path, one curve.
2433        let radius = 3.0;
2434        let pitch = 2.0;
2435        let tau = core::f64::consts::TAU;
2436        let cylinder = SurfaceGeometry::Cylinder(
2437            CylinderSurface::new(Cylinder::new(Frame::WORLD, radius, T).unwrap(), (-1.0, 5.0))
2438                .unwrap(),
2439        );
2440        let rise = pitch / tau;
2441        let slope = (1.0 + rise * rise).sqrt();
2442        let line = Line2d::segment(Point2::new(0.0, 0.0), Point2::new(tau, pitch), T).unwrap();
2443        let on_surface = CurveOnSurface::new(PlanarCurve::Line(line), cylinder);
2444        let helix = HelixCurve::new(Frame::WORLD, radius, pitch, 1.0).unwrap();
2445        for i in 0..=8 {
2446            let angle = tau * f64::from(i) / 8.0;
2447            // The line is parameterized by 2D arc length; the helix by angle.
2448            let lifted = on_surface.point_at(angle * slope, T).unwrap();
2449            let wound = helix.point_at(angle, T).unwrap();
2450            assert_relative_eq!(lifted.distance(wound), 0.0, epsilon = 1e-9);
2451        }
2452        // Exact second derivative through the chain rule, checked against
2453        // differencing the exact first.
2454        let h = 1e-6;
2455        let d2 = on_surface.derivatives_at(2.0, 2, T).unwrap()[2];
2456        let fd = (on_surface.d1_at(2.0 + h, T).unwrap() - on_surface.d1_at(2.0 - h, T).unwrap())
2457            / (2.0 * h);
2458        assert_relative_eq!((d2 - fd).magnitude(), 0.0, epsilon = 1e-5);
2459    }
2460
2461    #[test]
2462    fn a_helix_has_no_exact_spline_and_says_so() {
2463        let helix: Curve = HelixCurve::new(Frame::WORLD, 1.0, 1.0, 1.0).unwrap().into();
2464        assert!(helix.to_bspline(T).is_err());
2465    }
2466
2467    /// Sample a curve evenly across its domain, avoiding the exact ends.
2468    fn interior(c: &Curve, n: usize) -> Vec<f64> {
2469        let (a, b) = c.domain();
2470        (1..n)
2471            .map(|i| {
2472                #[allow(clippy::cast_precision_loss)]
2473                let t = i as f64 / n as f64;
2474                a + (b - a) * t
2475            })
2476            .collect()
2477    }
2478
2479    #[test]
2480    fn every_order_agrees_with_the_difference_of_the_one_below() {
2481        let h = 1e-5;
2482        let mut curves = every_curve();
2483        curves.push(
2484            HelixCurve::conical(tilted(), 4.0, 1.5, 2.0, 0.0, 6.0)
2485                .unwrap()
2486                .into(),
2487        );
2488        let reversed: Vec<Curve> = curves.iter().map(Reversible::reversed).collect();
2489        curves.extend(reversed);
2490        // The closed forms: a spline's orders jump at its knots.
2491        curves.retain(|c| !matches!(c.kind(), CurveKind::BSpline | CurveKind::Trimmed));
2492        for c in curves {
2493            for u in interior(&c, 6) {
2494                let at = c.derivatives_at(u, 5, T).unwrap();
2495                let below = c.derivatives_at(u + h, 4, T).unwrap();
2496                let above = c.derivatives_at(u - h, 4, T).unwrap();
2497                for order in 2..=5 {
2498                    let numeric = (below[order - 1] - above[order - 1]) * (1.0 / (2.0 * h));
2499                    let scale = numeric.magnitude().max(1.0);
2500                    assert!(
2501                        (at[order] - numeric).magnitude() <= 1e-5 * scale,
2502                        "{:?} order {order} at {u}: {:?} against {numeric:?}",
2503                        c.kind(),
2504                        at[order]
2505                    );
2506                }
2507            }
2508        }
2509    }
2510
2511    #[test]
2512    fn every_curves_derivative_agrees_with_finite_differences() {
2513        let h = 1e-6;
2514        for c in every_curve() {
2515            for u in interior(&c, 8) {
2516                let d1 = c.d1_at(u, T).unwrap();
2517                let numeric = (c.point_at(u + h, T).unwrap() - c.point_at(u - h, T).unwrap())
2518                    * (1.0 / (2.0 * h));
2519                let scale = numeric.magnitude().max(1.0);
2520                assert!(
2521                    (d1 - numeric).magnitude() <= 1e-5 * scale,
2522                    "{:?} at {u}: {d1:?} vs {numeric:?}",
2523                    c.kind()
2524                );
2525            }
2526        }
2527    }
2528
2529    #[test]
2530    fn derivatives_at_zero_returns_the_point_itself() {
2531        for c in every_curve() {
2532            for u in interior(&c, 4) {
2533                let d = c.derivatives_at(u, 0, T).unwrap();
2534                assert_eq!(d.len(), 1);
2535                assert!(Point::from_vector(d[0]).is_equal(c.point_at(u, T).unwrap(), T));
2536            }
2537        }
2538    }
2539
2540    #[test]
2541    fn out_of_domain_parameters_are_refused_for_non_periodic_curves() {
2542        for c in every_curve() {
2543            let (a, b) = c.domain();
2544            if c.is_periodic() {
2545                // A periodic curve accepts anything and wraps it.
2546                assert!(c.point_at(b + 1.0, T).is_ok());
2547                assert!(c.point_at(a - 1.0, T).is_ok());
2548            } else {
2549                assert!(c.point_at(b + 1.0, T).is_err(), "{:?}", c.kind());
2550                assert!(c.point_at(a - 1.0, T).is_err(), "{:?}", c.kind());
2551            }
2552        }
2553    }
2554
2555    #[test]
2556    fn a_periodic_curve_wraps_to_the_same_point() {
2557        let c: Curve = CircleCurve::new(Circle::new(tilted(), 2.0, T).unwrap()).into();
2558        assert!(c.is_periodic() && c.is_closed(T));
2559        let base = c.point_at(0.7, T).unwrap();
2560        for k in [-2.0_f64, -1.0, 1.0, 3.0] {
2561            let wrapped = c
2562                .point_at(k.mul_add(core::f64::consts::TAU, 0.7), T)
2563                .unwrap();
2564            assert!(base.is_equal(wrapped, T), "wrap by {k} moved the point");
2565        }
2566    }
2567
2568    #[test]
2569    fn reversal_traverses_the_same_points_backwards() {
2570        for c in every_curve() {
2571            let r = c.reversed();
2572            let (a, b) = c.domain();
2573            assert_eq!(r.domain(), (a, b), "{:?} changed its domain", c.kind());
2574            for i in 0..=8 {
2575                let t = f64::from(i) / 8.0;
2576                let forward = c.point_at(a + (b - a) * t, T).unwrap();
2577                let backward = r.point_at(a + (b - a) * (1.0 - t), T).unwrap();
2578                assert!(
2579                    forward.is_equal(backward, T),
2580                    "{:?} at t = {t}: {forward:?} vs {backward:?}",
2581                    c.kind()
2582                );
2583            }
2584        }
2585    }
2586
2587    #[test]
2588    fn reversing_twice_is_the_identity() {
2589        for c in every_curve() {
2590            let twice = c.reversed().reversed();
2591            for u in interior(&c, 8) {
2592                assert!(
2593                    c.point_at(u, T)
2594                        .unwrap()
2595                        .is_equal(twice.point_at(u, T).unwrap(), T),
2596                    "{:?}",
2597                    c.kind()
2598                );
2599            }
2600        }
2601    }
2602
2603    #[test]
2604    fn a_reversed_curves_tangent_points_the_other_way() {
2605        for c in every_curve() {
2606            let r = c.reversed();
2607            let (a, b) = c.domain();
2608            let u = a + (b - a) * 0.4;
2609            let forward = c.tangent_at(u, T).unwrap();
2610            let backward = r.tangent_at(mirror(u, a, b), T).unwrap();
2611            assert!(forward.is_opposite(backward, T), "{:?}", c.kind());
2612        }
2613    }
2614
2615    #[test]
2616    fn transforms_move_curves_and_preserve_their_shape() {
2617        let t =
2618            Transform::rotation(Axis::X, 0.7) * Transform::translation(Vector::new(1.0, 2.0, 3.0));
2619        for c in every_curve() {
2620            let moved = c.transformed(&t, T).unwrap();
2621            assert_eq!(moved.kind(), c.kind());
2622            for u in interior(&c, 8) {
2623                let expected = t.apply(c.point_at(u, T).unwrap());
2624                assert!(
2625                    moved.point_at(u, T).unwrap().is_equal(expected, T),
2626                    "{:?} at {u}",
2627                    c.kind()
2628                );
2629            }
2630        }
2631    }
2632
2633    #[test]
2634    fn a_scaling_rescales_a_lines_arc_length_domain() {
2635        // A line's parameter is a length, so scaling must rescale the domain or
2636        // the segment silently changes extent.
2637        let line = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T).unwrap();
2638        let c: Curve = line.into();
2639        assert_eq!(c.domain(), (0.0, 5.0));
2640        let scaled = c
2641            .transformed(&Transform::scaling(Point::ORIGIN, 2.0, T).unwrap(), T)
2642            .unwrap();
2643        assert_eq!(scaled.domain(), (0.0, 10.0));
2644        assert!(
2645            scaled
2646                .end(T)
2647                .unwrap()
2648                .is_equal(Point::new(6.0, 8.0, 0.0), T)
2649        );
2650    }
2651
2652    #[test]
2653    fn mirroring_a_circle_moves_every_point_by_the_mirror() {
2654        // The frame carries its own axes through the transform, so evaluating
2655        // the mirrored circle at a parameter lands exactly where the mirror
2656        // sends the original point. No extra parameter flip is involved.
2657        let c: Curve = CircleCurve::new(Circle::new(Frame::WORLD, 2.0, T).unwrap()).into();
2658        let m = Transform::plane_mirror(Point::ORIGIN, Direction::X);
2659        let mirrored = c.transformed(&m, T).unwrap();
2660        for u in interior(&c, 8) {
2661            let expected = m.apply(c.point_at(u, T).unwrap());
2662            assert!(
2663                mirrored.point_at(u, T).unwrap().is_equal(expected, T),
2664                "at {u}"
2665            );
2666        }
2667    }
2668
2669    #[test]
2670    fn a_line_segments_parameter_is_arc_length() {
2671        let line = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T).unwrap();
2672        assert_eq!(line.domain(), (0.0, 5.0));
2673        assert!(line.point_at(0.0, T).unwrap().is_equal(Point::ORIGIN, T));
2674        assert!(
2675            line.point_at(5.0, T)
2676                .unwrap()
2677                .is_equal(Point::new(3.0, 4.0, 0.0), T)
2678        );
2679        assert!(
2680            line.point_at(2.5, T)
2681                .unwrap()
2682                .is_equal(Point::new(1.5, 2.0, 0.0), T)
2683        );
2684        assert_relative_eq!(
2685            line.d1_at(1.0, T).unwrap().magnitude(),
2686            1.0,
2687            epsilon = 1e-15
2688        );
2689    }
2690
2691    #[test]
2692    fn degenerate_constructions_are_refused() {
2693        assert!(LineCurve::segment(Point::ORIGIN, Point::ORIGIN, T).is_err());
2694        assert!(LineCurve::over(Axis::X, 1.0, 1.0).is_err());
2695        assert!(LineCurve::over(Axis::X, 0.0, f64::NAN).is_err());
2696        assert!(HyperbolaCurve::new(Hyperbola::new(tilted(), 1.0, 1.0, T).unwrap(), 0.0).is_err());
2697        assert!(ParabolaCurve::new(Parabola::new(tilted(), 1.0, T).unwrap(), -1.0).is_err());
2698    }
2699
2700    #[test]
2701    fn trimming_is_bounds_checked() {
2702        let base: Curve = LineCurve::over(Axis::X, 0.0, 10.0).unwrap().into();
2703        assert!(TrimmedCurve::new(base.clone(), 2.0, 8.0, T).is_ok());
2704        assert!(
2705            TrimmedCurve::new(base.clone(), 8.0, 2.0, T).is_err(),
2706            "empty"
2707        );
2708        assert!(
2709            TrimmedCurve::new(base.clone(), 5.0, 5.0, T).is_err(),
2710            "empty"
2711        );
2712        assert!(
2713            TrimmedCurve::new(base, -1.0, 5.0, T).is_err(),
2714            "outside the basis"
2715        );
2716    }
2717
2718    #[test]
2719    fn a_trimmed_curve_agrees_with_its_basis() {
2720        let base: Curve = CircleCurve::new(Circle::new(tilted(), 2.0, T).unwrap()).into();
2721        let trimmed = TrimmedCurve::new(base.clone(), 0.5, 2.0, T).unwrap();
2722        assert_eq!(trimmed.domain(), (0.5, 2.0));
2723        for i in 0..=8 {
2724            let u = 0.5 + 1.5 * (f64::from(i) / 8.0);
2725            assert!(
2726                trimmed
2727                    .point_at(u, T)
2728                    .unwrap()
2729                    .is_equal(base.point_at(u, T).unwrap(), T)
2730            );
2731        }
2732        assert!(trimmed.point_at(0.4, T).is_err());
2733        assert!(trimmed.point_at(2.1, T).is_err());
2734    }
2735
2736    #[test]
2737    fn a_rational_curve_is_recognized_and_a_uniformly_weighted_one_is_not() {
2738        let knots = KnotVector::clamped_uniform(2, 3).unwrap();
2739        let points = [
2740            Point::new(1.0, 0.0, 0.0),
2741            Point::new(1.0, 1.0, 0.0),
2742            Point::new(0.0, 1.0, 0.0),
2743        ];
2744
2745        let uniform: Vec<_> = points
2746            .iter()
2747            .map(|p| Weighted::new(*p, 3.0, T).unwrap())
2748            .collect();
2749        assert!(
2750            !BSplineCurve::rational(knots.clone(), uniform)
2751                .unwrap()
2752                .is_rational(),
2753            "equal weights are polynomial whatever their value"
2754        );
2755
2756        let w = core::f64::consts::FRAC_1_SQRT_2;
2757        let arc: Vec<_> = points
2758            .iter()
2759            .zip([1.0, w, 1.0])
2760            .map(|(p, w)| Weighted::new(*p, w, T).unwrap())
2761            .collect();
2762        let c = BSplineCurve::rational(knots, arc).unwrap();
2763        assert!(c.is_rational());
2764        // And it is an exact circular arc, which no polynomial curve is.
2765        for i in 0..=20 {
2766            let u = f64::from(i) / 20.0;
2767            assert_relative_eq!(
2768                c.point_at(u, T).unwrap().to_vector().magnitude(),
2769                1.0,
2770                epsilon = 1e-14
2771            );
2772        }
2773    }
2774
2775    #[test]
2776    fn spline_continuity_follows_interior_knot_multiplicity() {
2777        let control = vec![
2778            Point::ORIGIN,
2779            Point::new(1.0, 1.0, 0.0),
2780            Point::new(2.0, 0.0, 0.0),
2781            Point::new(3.0, 1.0, 0.0),
2782            Point::new(4.0, 0.0, 0.0),
2783        ];
2784        // Degree 3 with simple interior knots is C2 there, not C-infinity,
2785        // which would be a false claim about a piecewise polynomial.
2786        let smooth = BSplineCurve::new(
2787            KnotVector::clamped_uniform(3, control.len()).unwrap(),
2788            control.clone(),
2789            T,
2790        )
2791        .unwrap();
2792        assert_eq!(smooth.continuity(), Continuity::C2);
2793
2794        // A single Bezier piece has no interior knots and is smooth to every
2795        // order.
2796        let bezier = BSplineCurve::new(
2797            KnotVector::clamped_uniform(4, control.len()).unwrap(),
2798            control.clone(),
2799            T,
2800        )
2801        .unwrap();
2802        assert_eq!(bezier.continuity(), Continuity::CInfinity);
2803
2804        // Degree 3, interior multiplicity 2: C1.
2805        let kinked = BSplineCurve::new(
2806            KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 1.0, 1.0], 3).unwrap(),
2807            {
2808                let mut c = control.clone();
2809                c.push(Point::new(5.0, 1.0, 0.0));
2810                c
2811            },
2812            T,
2813        )
2814        .unwrap();
2815        assert_eq!(kinked.continuity(), Continuity::C1);
2816
2817        // An interior knot at full multiplicity is a corner.
2818        let corner = BSplineCurve::new(
2819            KnotVector::new(vec![0.0, 0.0, 0.0, 0.5, 0.5, 1.0, 1.0, 1.0], 2).unwrap(),
2820            control,
2821            T,
2822        )
2823        .unwrap();
2824        assert_eq!(corner.continuity(), Continuity::C0);
2825    }
2826
2827    #[test]
2828    fn spline_refinement_does_not_move_the_curve() {
2829        let control = vec![
2830            Point::ORIGIN,
2831            Point::new(1.0, 2.0, 0.0),
2832            Point::new(3.0, 1.0, 1.0),
2833            Point::new(5.0, 0.0, 2.0),
2834        ];
2835        let c = BSplineCurve::new(
2836            KnotVector::clamped_uniform(3, control.len()).unwrap(),
2837            control,
2838            T,
2839        )
2840        .unwrap();
2841        let refined = c.with_knot_inserted(0.5, 1, T).unwrap();
2842        let elevated = c.elevated(T).unwrap();
2843        assert_eq!(elevated.degree(), 4);
2844        for i in 0..=20 {
2845            let u = f64::from(i) / 20.0;
2846            let base = c.point_at(u, T).unwrap();
2847            assert!(refined.point_at(u, T).unwrap().is_equal(base, T));
2848            assert!(elevated.point_at(u, T).unwrap().is_equal(base, T));
2849        }
2850    }
2851
2852    #[test]
2853    fn curvature_of_a_circle_is_the_reciprocal_of_its_radius() {
2854        for r in [0.5_f64, 2.0, 50.0] {
2855            let c: Curve = CircleCurve::new(Circle::new(tilted(), r, T).unwrap()).into();
2856            assert_relative_eq!(
2857                c.curvature_at(1.1, T).unwrap(),
2858                1.0 / r,
2859                max_relative = 1e-12
2860            );
2861        }
2862        // A line has no curvature.
2863        let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(1.0, 1.0, 1.0), T)
2864            .unwrap()
2865            .into();
2866        assert_relative_eq!(line.curvature_at(0.5, T).unwrap(), 0.0);
2867    }
2868
2869    #[test]
2870    fn kinds_are_reported_for_dispatch() {
2871        let kinds: Vec<_> = every_curve().iter().map(Curve3d::kind).collect();
2872        assert_eq!(
2873            kinds,
2874            vec![
2875                CurveKind::Line,
2876                CurveKind::Circle,
2877                CurveKind::Ellipse,
2878                CurveKind::Hyperbola,
2879                CurveKind::Parabola,
2880                CurveKind::BSpline,
2881                CurveKind::Helix,
2882                CurveKind::Trimmed,
2883            ]
2884        );
2885    }
2886}
2887
2888#[cfg(test)]
2889#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
2890mod conical_tests {
2891    use super::*;
2892    use ogeom_math::Vector;
2893
2894    /// A curve continued to a point ends there, keeps its own run, and
2895    /// meets the continuation with its tangent and curvature, at either
2896    /// end, polynomial and rational alike.
2897    #[test]
2898    fn a_curve_extended_to_a_point_ends_there_smoothly() {
2899        use crate::Curve3d as _;
2900        let cubic = BSplineCurve::rational(
2901            KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0], 3).unwrap(),
2902            [
2903                Point::new(0.0, 0.0, 0.0),
2904                Point::new(1.0, 2.0, 0.0),
2905                Point::new(3.0, 2.5, 1.0),
2906                Point::new(4.0, 0.5, 1.0),
2907                Point::new(6.0, 1.0, 0.0),
2908            ]
2909            .iter()
2910            .map(|p| Weighted::new(*p, 1.0, T).unwrap())
2911            .collect(),
2912        )
2913        .unwrap();
2914        let circle = Circle::new(Frame::WORLD, 5.0, T).unwrap();
2915        let arc = Curve::Trimmed(Box::new(
2916            TrimmedCurve::new(
2917                Curve::Circle(CircleCurve::new(circle)),
2918                0.0,
2919                core::f64::consts::FRAC_PI_2,
2920                T,
2921            )
2922            .unwrap(),
2923        ))
2924        .to_bspline(T)
2925        .unwrap();
2926        for curve in [cubic, arc] {
2927            let (lo, hi) = curve.domain();
2928            for at_end in [true, false] {
2929                let target = if at_end {
2930                    Point::new(8.0, 3.0, -1.0)
2931                } else {
2932                    Point::new(-2.0, -1.0, 0.5)
2933                };
2934                let longer = curve.extended_to(at_end, target, 2, T).unwrap();
2935                let (elo, ehi) = longer.domain();
2936                let end = longer.point_at(if at_end { ehi } else { elo }, T).unwrap();
2937                assert!(end.distance(target) < 1e-9, "ends at the point: {end:?}");
2938                for i in 0..=8 {
2939                    let u = lo + (hi - lo) * f64::from(i) / 8.0;
2940                    let (was, now) = (
2941                        curve.point_at(u, T).unwrap(),
2942                        longer.point_at(u, T).unwrap(),
2943                    );
2944                    assert!(was.distance(now) < 1e-9, "the run itself at {u}");
2945                }
2946                let join = if at_end { hi } else { lo };
2947                let step = if at_end { 1e-6 } else { -1e-6 };
2948                let inside = curve.derivatives_at(join - step, 2, T).unwrap();
2949                let outside = longer.derivatives_at(join + step, 2, T).unwrap();
2950                for order in 1..=2 {
2951                    let gap = (inside[order] - outside[order]).magnitude();
2952                    let scale = inside[order].magnitude().max(1.0);
2953                    assert!(gap < scale * 1e-3, "order {order} across the join: {gap}");
2954                }
2955            }
2956        }
2957    }
2958
2959    /// A rational quarter circle continued at order two stays on its
2960    /// circle: the homogeneous polynomial continues as itself. The original
2961    /// run keeps its points, at either end, and the continuation reaches
2962    /// the length asked.
2963    #[test]
2964    fn a_rational_arc_extended_stays_on_its_circle() {
2965        use crate::Curve3d as _;
2966        let circle = Circle::new(Frame::WORLD, 5.0, T).unwrap();
2967        let arc = TrimmedCurve::new(
2968            Curve::Circle(CircleCurve::new(circle)),
2969            0.0,
2970            core::f64::consts::FRAC_PI_2,
2971            T,
2972        )
2973        .unwrap();
2974        let spline = Curve::Trimmed(Box::new(arc)).to_bspline(T).unwrap();
2975        let (lo, hi) = spline.domain();
2976        for at_end in [true, false] {
2977            let longer = spline.extended(at_end, 4.0, 2, T).unwrap();
2978            let (elo, ehi) = longer.domain();
2979            for i in 0..=8 {
2980                let u = lo + (hi - lo) * f64::from(i) / 8.0;
2981                let (was, now) = (
2982                    spline.point_at(u, T).unwrap(),
2983                    longer.point_at(u, T).unwrap(),
2984                );
2985                assert!(was.distance(now) < 1e-9, "the arc itself at {u}");
2986            }
2987            let (from, to) = if at_end { (hi, ehi) } else { (elo, lo) };
2988            let mut swept = 0.0;
2989            let mut last = longer.point_at(from, T).unwrap();
2990            for i in 1..=64 {
2991                let u = from + (to - from) * f64::from(i) / 64.0;
2992                let p = longer.point_at(u, T).unwrap();
2993                assert!(
2994                    (p.distance(Point::ORIGIN) - 5.0).abs() < 1e-9,
2995                    "off the circle at {u}: {p:?}"
2996                );
2997                swept += last.distance(p);
2998                last = p;
2999            }
3000            assert!((swept - 4.0).abs() < 1e-3, "the length asked: {swept}");
3001        }
3002        let ring = BSplineCurve::periodic(
3003            &[
3004                Point::new(0.0, 0.0, 0.0),
3005                Point::new(1.0, 0.0, 0.0),
3006                Point::new(1.0, 1.0, 0.0),
3007                Point::new(0.0, 1.0, 0.0),
3008            ],
3009            3,
3010            T,
3011        )
3012        .unwrap();
3013        assert!(ring.extended(true, 1.0, 2, T).is_err(), "a ring has no end");
3014    }
3015
3016    const T: Tolerances = Tolerances::millimetres();
3017
3018    #[test]
3019    fn a_conical_helix_winds_its_cone_and_speaks_its_derivatives() {
3020        let tau = core::f64::consts::TAU;
3021        // Radius 5 at angle zero, growing 2 per turn, rising 3 per turn.
3022        let helix = HelixCurve::conical(Frame::WORLD, 5.0, 3.0, 2.0, 0.0, 2.0 * tau).unwrap();
3023        for i in 0..=8 {
3024            let t = 2.0 * tau * f64::from(i) / 8.0;
3025            let p = helix.point_at(t, T).unwrap();
3026            let r = 2.0f64.mul_add(t / tau, 5.0);
3027            assert!((p.to_vector().dot(Vector::Z) - 3.0 * t / tau).abs() < 1e-9);
3028            assert!((p.x.hypot(p.y) - r).abs() < 1e-9, "radius at {t}");
3029        }
3030        // The derivative against finite differences.
3031        let t = 1.234;
3032        let h = 1e-6;
3033        let d1 = helix.d1_at(t, T).unwrap();
3034        let fwd = helix.point_at(t + h, T).unwrap();
3035        let bwd = helix.point_at(t - h, T).unwrap();
3036        let fd = (fwd - bwd) / (2.0 * h);
3037        assert!((d1 - fd).magnitude() < 1e-6, "d1 {d1:?} against {fd:?}");
3038    }
3039
3040    #[test]
3041    fn a_helix_past_its_apex_is_refused_by_name() {
3042        let tau = core::f64::consts::TAU;
3043        let err = HelixCurve::conical(Frame::WORLD, 1.0, 3.0, -2.0, 0.0, 2.0 * tau).unwrap_err();
3044        assert!(err.to_string().contains("apex"), "{err}");
3045    }
3046
3047    #[test]
3048    fn a_periodic_bspline_wraps_smoothly_and_says_so() {
3049        let ring: Vec<Point> = (0..8)
3050            .map(|i| {
3051                let a = core::f64::consts::TAU * f64::from(i) / 8.0;
3052                Point::new(a.cos() * 4.0, a.sin() * 4.0, 0.0)
3053            })
3054            .collect();
3055        let curve = BSplineCurve::periodic(&ring, 3, T).unwrap();
3056        assert!(Curve3d::is_periodic(&curve));
3057        let (lo, hi) = Curve3d::domain(&curve);
3058        // The loop closes with matching tangents across the seam.
3059        let p_lo = curve.point_at(lo, T).unwrap();
3060        let p_hi = curve.point_at(hi, T).unwrap();
3061        assert!(p_lo.distance(p_hi) < 1e-9);
3062        let d_lo = curve.d1_at(lo, T).unwrap();
3063        let d_hi = curve.d1_at(hi, T).unwrap();
3064        assert!((d_lo - d_hi).magnitude() < 1e-9, "C1 across the seam");
3065        // And a wrapped parameter lands where its image does.
3066        let inside = curve.point_at(lo + 0.4, T).unwrap();
3067        let wrapped = curve.point_at(hi + 0.4, T).unwrap();
3068        assert!(inside.distance(wrapped) < 1e-9);
3069    }
3070}
3071
3072#[cfg(test)]
3073#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
3074mod widened_tests {
3075    use super::*;
3076    use ogeom_math::{Direction, Frame, Parabola};
3077
3078    const T: Tolerances = Tolerances::millimetres();
3079
3080    /// A line and a forward parabola widened trace the same points at the
3081    /// same parameters over their old domain, and their own formula past
3082    /// it; a reversed one, whose parameters mirror across its domain, and
3083    /// a whole circle are not widened.
3084    #[test]
3085    fn a_curve_widens_as_its_own_formula_or_not_at_all() {
3086        let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
3087            .unwrap()
3088            .into();
3089        let frame = Frame::new(Point::new(1.0, 2.0, 3.0), Direction::Z, Direction::X, T).unwrap();
3090        let parabola: Curve = ParabolaCurve::new(Parabola::new(frame, 0.7, T).unwrap(), 2.0)
3091            .unwrap()
3092            .into();
3093        for curve in [line, parabola] {
3094            let (a, b) = curve.domain();
3095            let wide = curve.widened((a - 1.5, b + 2.5)).expect("widens");
3096            assert_eq!(wide.domain(), (a - 1.5, b + 2.5));
3097            for k in 0..=8 {
3098                let t = a + (b - a) * f64::from(k) / 8.0;
3099                let gap = wide
3100                    .point_at(t, T)
3101                    .unwrap()
3102                    .distance(curve.point_at(t, T).unwrap());
3103                assert!(gap <= 1e-12, "{gap} at {t}");
3104            }
3105            assert!(curve.widened((a + 0.1, b)).is_none(), "a narrower range");
3106            // A reversed line is a line along the other way, and widens.
3107            let reversed = curve.reversed().widened((a - 1.0, b + 1.0));
3108            assert_eq!(reversed.is_some(), matches!(curve, Curve::Line(_)));
3109        }
3110        let circle: Curve =
3111            CircleCurve::new(ogeom_math::Circle::new(frame, 2.0, T).unwrap()).into();
3112        let (a, b) = circle.domain();
3113        assert!(circle.widened((a - 1.0, b + 1.0)).is_none());
3114    }
3115}