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