Skip to main content

ogeom_geom/
curve2d.rs

1//! Curves in the plane: pcurves.
2//!
3//! These carry a curve through a surface's `(u, v)` parameter space. An edge
4//! holds one per adjacent face (`docs/DATA_MODEL.md` §6), and boolean face
5//! splitting happens entirely in this space: without a pcurve on each face
6//! there is nothing to split *with*.
7//!
8//! A separate type hierarchy from [`crate::curve`] rather than a generic
9//! parameter, because a pcurve is used differently from a spatial curve.
10//! Distance in parameter space is not distance in space: the same parametric
11//! step covers a metre near a cylinder's equator and nothing at all near a
12//! sphere's pole, so a function that treats the two alike is wrong, and
13//! separate types keep that from compiling.
14
15use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
16use ogeom_math::{
17    Axis2, Circle2, Direction2, Ellipse2, Hyperbola2, KnotVector, Point2, Transform2, Vector2,
18    Weighted, bspline, elementary,
19};
20
21use crate::traits::{Curve2d, CurveKind, Reversible};
22
23const TAU: f64 = core::f64::consts::TAU;
24
25/// A curve in the plane.
26#[derive(Debug, Clone, PartialEq)]
27pub enum PlanarCurve {
28    /// A straight line.
29    Line(Line2d),
30    /// A circle or arc.
31    Circle(Circle2d),
32    /// An ellipse or arc.
33    Ellipse(Ellipse2d),
34    /// One branch of a hyperbola.
35    Hyperbola(Hyperbola2d),
36    /// A polynomial or rational B-spline.
37    BSpline(BSpline2d),
38    /// Another planar curve restricted to a sub-interval.
39    Trimmed(Box<Trimmed2d>),
40    /// A curve at a constant signed distance along another's left normal.
41    Offset(Box<Offset2d>),
42    /// An affine-plus-trigonometric curve: `c + d·t + a·cos t + b·sin t`:
43    /// the exact chart trace of an oblique analytic section on a periodic
44    /// surface.
45    Trig(Trig2d),
46}
47
48/// A straight line in the plane, parameterized by length.
49#[derive(Debug, Clone, Copy, PartialEq)]
50pub struct Line2d {
51    axis: Axis2,
52    domain: (f64, f64),
53}
54
55/// A circle in the plane, parameterized by angle.
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct Circle2d {
58    circle: Circle2,
59    reversed: bool,
60}
61
62/// An ellipse in the plane, parameterized by eccentric angle.
63#[derive(Debug, Clone, Copy, PartialEq)]
64pub struct Ellipse2d {
65    ellipse: Ellipse2,
66    reversed: bool,
67}
68
69/// One branch of a hyperbola in the plane, `c + a cosh t x + b sinh t y`,
70/// parameterized by `t` over a finite domain.
71#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct Hyperbola2d {
73    hyperbola: Hyperbola2,
74    domain: (f64, f64),
75    reversed: bool,
76}
77
78/// A B-spline in the plane, polynomial or rational.
79#[derive(Debug, Clone, PartialEq)]
80pub struct BSpline2d {
81    knots: KnotVector,
82    control: Vec<Weighted<Point2>>,
83    rational: bool,
84}
85
86/// Another planar curve restricted to a sub-interval.
87#[derive(Debug, Clone, PartialEq)]
88pub struct Trimmed2d {
89    basis: PlanarCurve,
90    domain: (f64, f64),
91    reversed: bool,
92}
93
94/// Reverse a parameter within `[a, b]`, preserving the interval.
95fn mirror(u: f64, a: f64, b: f64) -> f64 {
96    a + b - u
97}
98
99/// The affine-plus-trigonometric curve `p(t) = c + d·t + a·cos t + b·sin t`.
100///
101/// This is the family chart traces of oblique analytic sections live in: an
102/// oblique plane's ellipse on a cylinder runs linearly in the chart angle
103/// and sinusoidally in height, which no line, conic or spline states
104/// exactly. Closed under similarity transforms and reversal, exact
105/// derivatives to any order.
106#[derive(Debug, Clone, Copy, PartialEq)]
107pub struct Trig2d {
108    c: Point2,
109    d: Vector2,
110    a: Vector2,
111    b: Vector2,
112    domain: (f64, f64),
113    reversed: bool,
114}
115
116impl Trig2d {
117    /// A trig curve over an increasing domain.
118    ///
119    /// # Errors
120    ///
121    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
122    /// any coefficient is not finite or the domain is not increasing.
123    pub fn new(
124        c: Point2,
125        d: Vector2,
126        a: Vector2,
127        b: Vector2,
128        domain: (f64, f64),
129    ) -> OgeomResult<Self> {
130        let finite = c.to_vector().is_finite()
131            && d.is_finite()
132            && a.is_finite()
133            && b.is_finite()
134            && domain.0.is_finite()
135            && domain.1.is_finite();
136        if !finite || domain.0 >= domain.1 {
137            ogeom_bail!(
138                Construction,
139                "a trig curve needs finite coefficients and an increasing domain"
140            );
141        }
142        Ok(Self {
143            c,
144            d,
145            a,
146            b,
147            domain,
148            reversed: false,
149        })
150    }
151
152    /// The constant term.
153    #[must_use]
154    pub const fn constant(&self) -> Point2 {
155        self.c
156    }
157
158    /// The linear coefficient.
159    #[must_use]
160    pub const fn linear(&self) -> Vector2 {
161        self.d
162    }
163
164    /// The cosine coefficient.
165    #[must_use]
166    pub const fn cosine(&self) -> Vector2 {
167        self.a
168    }
169
170    /// The sine coefficient.
171    #[must_use]
172    pub const fn sine(&self) -> Vector2 {
173        self.b
174    }
175
176    /// Whether evaluation runs the domain backwards.
177    #[must_use]
178    pub const fn is_reversed(&self) -> bool {
179        self.reversed
180    }
181
182    fn raw(&self, t: f64) -> Point2 {
183        let (sin, cos) = t.sin_cos();
184        self.c + self.d * t + self.a * cos + self.b * sin
185    }
186}
187
188impl Curve2d for Trig2d {
189    fn domain(&self) -> (f64, f64) {
190        self.domain
191    }
192
193    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
194        let u = clamp_to_domain(u, self.domain, false, tol)?;
195        let t = if self.reversed {
196            self.domain.0 + self.domain.1 - u
197        } else {
198            u
199        };
200        Ok(self.raw(t))
201    }
202
203    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
204        Ok(self.derivatives_at(u, 1, tol)?[1])
205    }
206
207    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
208        let u = clamp_to_domain(u, self.domain, false, tol)?;
209        let t = if self.reversed {
210            self.domain.0 + self.domain.1 - u
211        } else {
212            u
213        };
214        let (sin, cos) = t.sin_cos();
215        let sign = if self.reversed { -1.0 } else { 1.0 };
216        let mut out = Vec::with_capacity(n + 1);
217        out.push(self.raw(t).to_vector());
218        for order in 1..=n {
219            // The trig part cycles with period four. The linear part
220            // survives only to first order. Odd orders pick up the
221            // reversal sign.
222            let trig = match order % 4 {
223                1 => self.a * -sin + self.b * cos,
224                2 => self.a * -cos + self.b * -sin,
225                3 => self.a * sin + self.b * -cos,
226                _ => self.a * cos + self.b * sin,
227            };
228            let linear = if order == 1 { self.d } else { Vector2::ZERO };
229            let odd = if order % 2 == 1 { sign } else { 1.0 };
230            out.push((trig + linear) * odd);
231        }
232        Ok(out)
233    }
234
235    fn kind(&self) -> CurveKind {
236        CurveKind::Trig
237    }
238
239    fn is_closed(&self, tol: Tolerances) -> bool {
240        self.raw(self.domain.0).distance(self.raw(self.domain.1)) <= tol.confusion()
241    }
242
243    fn is_periodic(&self) -> bool {
244        false
245    }
246}
247
248/// A planar curve displaced a constant signed distance along its basis's
249/// left normal (the tangent turned a quarter left), sharing the basis's
250/// parameterization.
251///
252/// Point and first derivative are exact from the basis's first and second
253/// derivatives. The second would need the basis's third, which the
254/// vocabulary does not carry, so `d2_at` refuses by name. Where the basis's
255/// tangent vanishes the offset direction is undefined and evaluation
256/// refuses.
257#[derive(Debug, Clone, PartialEq)]
258pub struct Offset2d {
259    basis: PlanarCurve,
260    distance: f64,
261}
262
263impl Offset2d {
264    /// Offset `basis` by a signed `distance` along its left normal.
265    ///
266    /// # Errors
267    ///
268    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
269    /// distance is not finite and non-zero.
270    pub fn new(basis: PlanarCurve, distance: f64) -> OgeomResult<Self> {
271        if !distance.is_finite() || distance == 0.0 {
272            ogeom_bail!(
273                Construction,
274                "an offset of {distance} is not a displacement"
275            );
276        }
277        Ok(Self { basis, distance })
278    }
279
280    /// The curve being offset.
281    #[must_use]
282    pub const fn basis(&self) -> &PlanarCurve {
283        &self.basis
284    }
285
286    /// The signed displacement along the left normal.
287    #[must_use]
288    pub const fn distance(&self) -> f64 {
289        self.distance
290    }
291
292    /// The unit left normal and its derivative at `t`, from the basis's
293    /// first two derivatives.
294    fn normal_and_slope(&self, t: f64, tol: Tolerances) -> OgeomResult<(Vector2, Vector2)> {
295        let d = self.basis.derivatives_at(t, 2, tol)?;
296        let rot = |v: Vector2| Vector2::new(-v.y, v.x);
297        let w = rot(d[1]);
298        let m = w.magnitude();
299        if m <= tol.confusion() {
300            ogeom_bail!(
301                Construction,
302                "the basis has no tangent at {t}; the offset direction is undefined"
303            );
304        }
305        let n = w / m;
306        let wp = rot(d[2]);
307        let np = (wp - n * n.dot(wp)) / m;
308        Ok((n, np))
309    }
310}
311
312impl Curve2d for Offset2d {
313    fn domain(&self) -> (f64, f64) {
314        self.basis.domain()
315    }
316
317    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
318        let base = self.basis.point_at(u, tol)?;
319        let (n, _) = self.normal_and_slope(u, tol)?;
320        Ok(base + n * self.distance)
321    }
322
323    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
324        let d1 = self.basis.d1_at(u, tol)?;
325        let (_, np) = self.normal_and_slope(u, tol)?;
326        Ok(d1 + np * self.distance)
327    }
328
329    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
330        if n >= 2 {
331            ogeom_bail!(
332                Construction,
333                "an offset curve's second derivative needs its basis's third, which the \
334                 vocabulary does not carry"
335            );
336        }
337        let mut out = vec![self.point_at(u, tol)?.to_vector()];
338        if n >= 1 {
339            out.push(self.d1_at(u, tol)?);
340        }
341        Ok(out)
342    }
343
344    fn kind(&self) -> CurveKind {
345        CurveKind::Offset
346    }
347
348    fn is_closed(&self, tol: Tolerances) -> bool {
349        self.basis.is_closed(tol)
350    }
351
352    fn is_periodic(&self) -> bool {
353        self.basis.is_periodic()
354    }
355}
356
357impl Line2d {
358    /// A segment between two distinct points, parameterized by arc length.
359    ///
360    /// # Errors
361    ///
362    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the points
363    /// coincide.
364    pub fn segment(from: Point2, to: Point2, tol: Tolerances) -> OgeomResult<Self> {
365        Ok(Self {
366            axis: Axis2::through(from, to, tol)?,
367            domain: (0.0, from.distance(to)),
368        })
369    }
370
371    /// A line over an explicit parameter range.
372    ///
373    /// # Errors
374    ///
375    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the range
376    /// is empty or non-finite.
377    pub fn over(axis: Axis2, start: f64, end: f64) -> OgeomResult<Self> {
378        if !start.is_finite() || !end.is_finite() || end <= start {
379            ogeom_bail!(Construction, "line range [{start}, {end}] is empty");
380        }
381        Ok(Self {
382            axis,
383            domain: (start, end),
384        })
385    }
386
387    /// The underlying axis.
388    #[must_use]
389    pub const fn axis(&self) -> Axis2 {
390        self.axis
391    }
392}
393
394impl Circle2d {
395    /// A full circle.
396    #[must_use]
397    pub const fn new(circle: Circle2) -> Self {
398        Self {
399            circle,
400            reversed: false,
401        }
402    }
403
404    /// The underlying circle.
405    #[must_use]
406    pub const fn circle(&self) -> Circle2 {
407        self.circle
408    }
409
410    /// Whether the curve runs backwards along its underlying circle.
411    ///
412    /// Part of the curve's state and not derivable from its circle, so
413    /// anything that has to reproduce this curve exactly (the native format
414    /// above all) needs to be able to read it.
415    #[must_use]
416    pub const fn is_reversed(&self) -> bool {
417        self.reversed
418    }
419}
420
421impl Hyperbola2d {
422    /// A hyperbola branch over `[start, end]` in its natural parameter.
423    ///
424    /// # Errors
425    ///
426    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
427    /// interval is not finite and increasing.
428    pub fn over(hyperbola: Hyperbola2, start: f64, end: f64) -> OgeomResult<Self> {
429        if !start.is_finite() || !end.is_finite() || start >= end {
430            ogeom_bail!(
431                Construction,
432                "hyperbola domain [{start}, {end}] must be finite and increasing"
433            );
434        }
435        Ok(Self {
436            hyperbola,
437            domain: (start, end),
438            reversed: false,
439        })
440    }
441
442    /// The underlying hyperbola.
443    #[must_use]
444    pub const fn hyperbola(&self) -> Hyperbola2 {
445        self.hyperbola
446    }
447
448    /// Whether the curve runs its domain backwards.
449    #[must_use]
450    pub const fn is_reversed(&self) -> bool {
451        self.reversed
452    }
453}
454
455impl Ellipse2d {
456    /// A full ellipse.
457    #[must_use]
458    pub const fn new(ellipse: Ellipse2) -> Self {
459        Self {
460            ellipse,
461            reversed: false,
462        }
463    }
464
465    /// The underlying ellipse.
466    #[must_use]
467    pub const fn ellipse(&self) -> Ellipse2 {
468        self.ellipse
469    }
470
471    /// Whether the curve runs backwards along its underlying ellipse.
472    ///
473    /// Part of the curve's state and not derivable from its ellipse, so
474    /// anything that has to reproduce this curve exactly (the native format
475    /// above all) needs to be able to read it.
476    #[must_use]
477    pub const fn is_reversed(&self) -> bool {
478        self.reversed
479    }
480}
481
482impl BSpline2d {
483    /// A polynomial B-spline.
484    ///
485    /// # Errors
486    ///
487    /// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) on a shape mismatch.
488    pub fn new(knots: KnotVector, control: Vec<Point2>, tol: Tolerances) -> OgeomResult<Self> {
489        let weighted = control
490            .into_iter()
491            .map(|p| Weighted::new(p, 1.0, tol))
492            .collect::<OgeomResult<Vec<_>>>()?;
493        Self::rational(knots, weighted)
494    }
495
496    /// A rational B-spline.
497    ///
498    /// # Errors
499    ///
500    /// [`OgeomError::Dimension`](ogeom_core::OgeomError::Dimension) on a shape mismatch.
501    pub fn rational(knots: KnotVector, control: Vec<Weighted<Point2>>) -> OgeomResult<Self> {
502        if control.len() != knots.control_point_count() {
503            ogeom_bail!(
504                Dimension,
505                "knot vector describes {} control points, got {}",
506                knots.control_point_count(),
507                control.len()
508            );
509        }
510        let first = control[0].weight;
511        let rational = control
512            .iter()
513            .any(|w| (w.weight - first).abs() > 1e-12 * first.abs());
514        Ok(Self {
515            knots,
516            control,
517            rational,
518        })
519    }
520
521    /// The knot vector.
522    #[must_use]
523    pub const fn knots(&self) -> &KnotVector {
524        &self.knots
525    }
526
527    /// The weighted control points.
528    #[must_use]
529    pub fn control_points(&self) -> &[Weighted<Point2>] {
530        &self.control
531    }
532
533    /// Whether the weights differ.
534    #[must_use]
535    pub const fn is_rational(&self) -> bool {
536        self.rational
537    }
538
539    /// The same closed image with its seam moved to `u`: what was the
540    /// stretch from `u` to the end now comes first, and the stretch from
541    /// the start to `u` follows it, carried by `lap` so that it continues
542    /// from the end. `lap` is how far the end stands from the start: zero
543    /// for a loop closed in the plane, a whole period for an image winding
544    /// once round a periodic chart. The domain keeps its length and begins
545    /// at `u`, so the image at a parameter past the old end is the old
546    /// image a domain's length earlier, moved by `lap`.
547    ///
548    /// # Errors
549    ///
550    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
551    /// the end stands further than the parametric tolerance from the start
552    /// carried by `lap`; as [`bspline::split`] if `u` is an end of the
553    /// domain.
554    pub fn reseamed_at(&self, u: f64, lap: Vector2, tol: Tolerances) -> OgeomResult<Self> {
555        let (start, end) = self.domain();
556        let (head, tail) = (self.point_at(start, tol)? + lap, self.point_at(end, tol)?);
557        if head.distance(tail) > tol.parametric() {
558            ogeom_bail!(
559                Construction,
560                "the image does not close over its lap: its ends are {:.3e} apart",
561                head.distance(tail)
562            );
563        }
564        let ((before_knots, before), (after_knots, after)) =
565            bspline::split(&self.knots, &self.control, u, tol)?;
566        let carried = before
567            .iter()
568            .map(|w| Weighted::new(w.point() + lap, w.weight, tol))
569            .collect::<OgeomResult<Vec<_>>>()?;
570        let (knots, control) = bspline::join(&(after_knots, after), &(before_knots, carried))?;
571        Self::rational(knots, control)
572    }
573}
574
575impl Trimmed2d {
576    /// Restrict `basis` to `[start, end]`.
577    ///
578    /// # Errors
579    ///
580    /// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the range is empty or
581    /// leaves the basis curve's domain.
582    pub fn new(basis: PlanarCurve, start: f64, end: f64, tol: Tolerances) -> OgeomResult<Self> {
583        let (a, b) = basis.domain();
584        if !start.is_finite() || !end.is_finite() || end <= start + tol.parametric() {
585            ogeom_bail!(Domain, "trim range [{start}, {end}] is empty");
586        }
587        if !basis.is_periodic() && (start < a - tol.parametric() || end > b + tol.parametric()) {
588            ogeom_bail!(Domain, "trim range [{start}, {end}] leaves [{a}, {b}]");
589        }
590        Ok(Self {
591            basis,
592            domain: (start, end),
593            reversed: false,
594        })
595    }
596
597    /// The curve being trimmed.
598    #[must_use]
599    pub const fn basis(&self) -> &PlanarCurve {
600        &self.basis
601    }
602
603    /// Whether the curve runs backwards along its underlying curve.
604    ///
605    /// Part of the curve's state and not derivable from its basis curve, so
606    /// anything that has to reproduce this curve exactly (the native format
607    /// above all) needs to be able to read it.
608    #[must_use]
609    pub const fn is_reversed(&self) -> bool {
610        self.reversed
611    }
612
613    /// This curve's parameter mapped onto the basis curve's.
614    fn basis_parameter(&self, u: f64, tol: Tolerances) -> OgeomResult<f64> {
615        let u = clamp_to_domain(u, self.domain, false, tol)?;
616        Ok(if self.reversed {
617            mirror(u, self.domain.0, self.domain.1)
618        } else {
619            u
620        })
621    }
622}
623
624/// Bring `u` into `domain`, wrapping if periodic.
625fn clamp_to_domain(
626    u: f64,
627    domain: (f64, f64),
628    periodic: bool,
629    tol: Tolerances,
630) -> OgeomResult<f64> {
631    let (a, b) = domain;
632    if periodic {
633        return Ok(a + (u - a).rem_euclid(b - a));
634    }
635    let slack = crate::traits::domain_slack(a, b, tol);
636    if !u.is_finite() || u < a - slack || u > b + slack {
637        ogeom_bail!(Domain, "parameter {u} outside curve domain [{a}, {b}]");
638    }
639    Ok(u.clamp(a, b))
640}
641
642/// The derivatives of a turning curve to order `n`: `d1, d2, -d1, -d2`
643/// and round again, the cycle a circle's and an ellipse's take, each order
644/// times `sign` to its power (the reversal's chain rule).
645fn turning(point: Vector2, d1: Vector2, d2: Vector2, n: usize, sign: f64) -> Vec<Vector2> {
646    let mut out = vec![point];
647    let mut factor = 1.0;
648    for order in 1..=n {
649        factor *= sign;
650        let d = match order % 4 {
651            1 => d1,
652            2 => d2,
653            3 => -d1,
654            _ => -d2,
655        };
656        out.push(d * factor);
657    }
658    out
659}
660
661/// Fill a derivative list to `n + 1` entries, padding with zeros.
662fn pad(mut out: Vec<Vector2>, n: usize) -> Vec<Vector2> {
663    out.resize(n.max(out.len().saturating_sub(1)) + 1, Vector2::ZERO);
664    out.truncate(n + 1);
665    out
666}
667
668impl Curve2d for Line2d {
669    fn domain(&self) -> (f64, f64) {
670        self.domain
671    }
672
673    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
674        Ok(self
675            .axis
676            .point_at(clamp_to_domain(u, self.domain, false, tol)?))
677    }
678
679    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
680        clamp_to_domain(u, self.domain, false, tol)?;
681        Ok(self.axis.direction.vector())
682    }
683
684    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
685        let p = self.point_at(u, tol)?;
686        Ok(pad(vec![p.to_vector(), self.axis.direction.vector()], n))
687    }
688
689    fn kind(&self) -> CurveKind {
690        CurveKind::Line
691    }
692
693    fn is_closed(&self, _tol: Tolerances) -> bool {
694        false
695    }
696
697    fn is_periodic(&self) -> bool {
698        false
699    }
700}
701
702impl Curve2d for Circle2d {
703    fn domain(&self) -> (f64, f64) {
704        (0.0, TAU)
705    }
706
707    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
708        // The point alone, by the same arithmetic as `derivatives_at`.
709        let u = clamp_to_domain(u, self.domain(), true, tol)?;
710        let angle = if self.reversed { -u } else { u };
711        let f = self.circle.frame();
712        let r = self.circle.radius();
713        let (sin, cos) = angle.sin_cos();
714        let (x, y) = (f.x().vector(), f.y().vector());
715        Ok(self.circle.centre() + x * (r * cos) + y * (r * sin))
716    }
717
718    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
719        Ok(self.derivatives_at(u, 1, tol)?[1])
720    }
721
722    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
723        let u = clamp_to_domain(u, self.domain(), true, tol)?;
724        let angle = if self.reversed { -u } else { u };
725        let f = self.circle.frame();
726        let r = self.circle.radius();
727        let (sin, cos) = angle.sin_cos();
728        let (x, y) = (f.x().vector(), f.y().vector());
729        let point = self.circle.centre() + x * (r * cos) + y * (r * sin);
730        let sign = if self.reversed { -1.0 } else { 1.0 };
731        Ok(turning(
732            point.to_vector(),
733            x * (-r * sin) + y * (r * cos),
734            x * (-r * cos) + y * (-r * sin),
735            n,
736            sign,
737        ))
738    }
739
740    fn kind(&self) -> CurveKind {
741        CurveKind::Circle
742    }
743
744    fn is_closed(&self, _tol: Tolerances) -> bool {
745        true
746    }
747
748    fn is_periodic(&self) -> bool {
749        true
750    }
751}
752
753impl Curve2d for Ellipse2d {
754    fn domain(&self) -> (f64, f64) {
755        (0.0, TAU)
756    }
757
758    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
759        Ok(Point2::from_vector(self.derivatives_at(u, 0, tol)?[0]))
760    }
761
762    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
763        Ok(self.derivatives_at(u, 1, tol)?[1])
764    }
765
766    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
767        let u = clamp_to_domain(u, self.domain(), true, tol)?;
768        let angle = if self.reversed { -u } else { u };
769        let f = self.ellipse.frame();
770        let (a, b) = (self.ellipse.major_radius(), self.ellipse.minor_radius());
771        let (sin, cos) = angle.sin_cos();
772        let (x, y) = (f.x().vector(), f.y().vector());
773        let point = self.ellipse.centre() + x * (a * cos) + y * (b * sin);
774        let sign = if self.reversed { -1.0 } else { 1.0 };
775        Ok(turning(
776            point.to_vector(),
777            x * (-a * sin) + y * (b * cos),
778            x * (-a * cos) + y * (-b * sin),
779            n,
780            sign,
781        ))
782    }
783
784    fn kind(&self) -> CurveKind {
785        CurveKind::Ellipse
786    }
787
788    fn is_closed(&self, _tol: Tolerances) -> bool {
789        true
790    }
791
792    fn is_periodic(&self) -> bool {
793        true
794    }
795}
796
797impl Curve2d for Hyperbola2d {
798    fn domain(&self) -> (f64, f64) {
799        self.domain
800    }
801
802    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
803        Ok(Point2::from_vector(self.derivatives_at(u, 0, tol)?[0]))
804    }
805
806    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
807        Ok(self.derivatives_at(u, 1, tol)?[1])
808    }
809
810    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
811        let u = clamp_to_domain(u, self.domain, false, tol)?;
812        let t = if self.reversed {
813            mirror(u, self.domain.0, self.domain.1)
814        } else {
815            u
816        };
817        let f = self.hyperbola.frame();
818        let (a, b) = (self.hyperbola.major_radius(), self.hyperbola.minor_radius());
819        let (cosh, sinh) = (t.cosh(), t.sinh());
820        let (x, y) = (f.x().vector(), f.y().vector());
821        let sign = if self.reversed { -1.0 } else { 1.0 };
822        // `cosh` and `sinh` are each other's derivatives: the odd orders
823        // are the first, the even ones the position about the centre, each
824        // odd order times the reversal's sign.
825        let odd = x * (a * sinh) + y * (b * cosh);
826        let even = x * (a * cosh) + y * (b * sinh);
827        let mut out = Vec::with_capacity(n + 1);
828        out.push(self.hyperbola.centre().to_vector() + even);
829        for order in 1..=n {
830            out.push(if order % 2 == 1 { odd * sign } else { even });
831        }
832        Ok(out)
833    }
834
835    fn kind(&self) -> CurveKind {
836        CurveKind::Hyperbola
837    }
838
839    fn is_closed(&self, _tol: Tolerances) -> bool {
840        false
841    }
842
843    fn is_periodic(&self) -> bool {
844        false
845    }
846}
847
848impl Curve2d for BSpline2d {
849    fn domain(&self) -> (f64, f64) {
850        self.knots.domain()
851    }
852
853    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
854        let u = clamp_to_domain(u, self.domain(), false, tol)?;
855        if self.rational {
856            bspline::evaluate_rational(&self.knots, &self.control, u, tol)
857        } else {
858            Ok(bspline::evaluate(&self.knots, &self.control, u, tol)?.point())
859        }
860    }
861
862    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
863        Ok(self.derivatives_at(u, 1, tol)?[1])
864    }
865
866    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
867        let u = clamp_to_domain(u, self.domain(), false, tol)?;
868        let points = bspline::rational_derivatives(&self.knots, &self.control, u, n, tol)?;
869        Ok(points.into_iter().map(Point2::to_vector).collect())
870    }
871
872    fn kind(&self) -> CurveKind {
873        CurveKind::BSpline
874    }
875
876    /// Where its ends meet, which its end control points say only for
877    /// clamped knots.
878    fn is_closed(&self, tol: Tolerances) -> bool {
879        match (self.start(tol), self.end(tol)) {
880            (Ok(a), Ok(b)) => a.is_equal(b, tol),
881            _ => false,
882        }
883    }
884
885    fn is_periodic(&self) -> bool {
886        false
887    }
888}
889
890impl Curve2d for Trimmed2d {
891    fn domain(&self) -> (f64, f64) {
892        self.domain
893    }
894
895    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
896        self.basis.point_at(self.basis_parameter(u, tol)?, tol)
897    }
898
899    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
900        let d = self.basis.d1_at(self.basis_parameter(u, tol)?, tol)?;
901        Ok(if self.reversed { -d } else { d })
902    }
903
904    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
905        let t = self.basis_parameter(u, tol)?;
906        let mut out = self.basis.derivatives_at(t, n, tol)?;
907        if self.reversed {
908            for (order, d) in out.iter_mut().enumerate() {
909                if order % 2 == 1 {
910                    *d = -*d;
911                }
912            }
913        }
914        Ok(out)
915    }
916
917    fn kind(&self) -> CurveKind {
918        CurveKind::Trimmed
919    }
920
921    fn is_closed(&self, tol: Tolerances) -> bool {
922        match (self.start(tol), self.end(tol)) {
923            (Ok(a), Ok(b)) => a.is_equal(b, tol),
924            _ => false,
925        }
926    }
927
928    fn is_periodic(&self) -> bool {
929        false
930    }
931}
932
933macro_rules! dispatch {
934    ($self:ident, $c:ident => $body:expr) => {
935        match $self {
936            Self::Line($c) => $body,
937            Self::Circle($c) => $body,
938            Self::Ellipse($c) => $body,
939            Self::Hyperbola($c) => $body,
940            Self::BSpline($c) => $body,
941            Self::Trimmed($c) => $body,
942            Self::Offset($c) => $body,
943            Self::Trig($c) => $body,
944        }
945    };
946}
947
948impl Curve2d for PlanarCurve {
949    fn domain(&self) -> (f64, f64) {
950        dispatch!(self, c => c.domain())
951    }
952
953    fn point_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Point2> {
954        dispatch!(self, c => c.point_at(u, tol))
955    }
956
957    fn d1_at(&self, u: f64, tol: Tolerances) -> OgeomResult<Vector2> {
958        dispatch!(self, c => c.d1_at(u, tol))
959    }
960
961    fn derivatives_at(&self, u: f64, n: usize, tol: Tolerances) -> OgeomResult<Vec<Vector2>> {
962        dispatch!(self, c => c.derivatives_at(u, n, tol))
963    }
964
965    fn kind(&self) -> CurveKind {
966        dispatch!(self, c => c.kind())
967    }
968
969    fn is_closed(&self, tol: Tolerances) -> bool {
970        dispatch!(self, c => c.is_closed(tol))
971    }
972
973    fn is_periodic(&self) -> bool {
974        dispatch!(self, c => c.is_periodic())
975    }
976}
977
978impl PlanarCurve {
979    /// This curve moved by a planar similarity.
980    ///
981    /// # Errors
982    ///
983    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the result
984    /// would be degenerate.
985    pub fn transformed(&self, t: &Transform2, tol: Tolerances) -> OgeomResult<Self> {
986        let scale = t.scale_factor().abs();
987        Ok(match self {
988            Self::Line(c) => Self::Line(Line2d {
989                axis: Axis2::new(
990                    t.apply(c.axis.location),
991                    Direction2::new(t.apply_vector(c.axis.direction.vector()), tol)?,
992                ),
993                // A line's parameter is a length, so the domain rescales with it.
994                domain: (c.domain.0 * scale, c.domain.1 * scale),
995            }),
996            Self::Circle(c) => Self::Circle(Circle2d {
997                circle: c.circle.transformed(t, tol)?,
998                ..*c
999            }),
1000            Self::Ellipse(c) => Self::Ellipse(Ellipse2d {
1001                ellipse: c.ellipse.transformed(t, tol)?,
1002                ..*c
1003            }),
1004            Self::Hyperbola(c) => Self::Hyperbola(Hyperbola2d {
1005                hyperbola: c.hyperbola.transformed(t, tol)?,
1006                ..*c
1007            }),
1008            Self::BSpline(c) => Self::BSpline(BSpline2d {
1009                control: c
1010                    .control
1011                    .iter()
1012                    .map(|w| Weighted::new(t.apply(w.point()), w.weight, tol))
1013                    .collect::<OgeomResult<Vec<_>>>()?,
1014                ..c.clone()
1015            }),
1016            // The offset runs along the tangent turned a quarter, which a
1017            // reflection turns the other way: the distance goes with it.
1018            Self::Offset(c) => Self::Offset(Box::new(Offset2d {
1019                basis: c.basis.transformed(t, tol)?,
1020                distance: c.distance * scale * if t.preserves_handedness() { 1.0 } else { -1.0 },
1021            })),
1022            Self::Trig(c) => Self::Trig(Trig2d {
1023                c: t.apply(c.c),
1024                d: t.apply_vector(c.d),
1025                a: t.apply_vector(c.a),
1026                b: t.apply_vector(c.b),
1027                ..*c
1028            }),
1029            // The trim lives in the basis's parameter and moves with it: a
1030            // line's is a length and rescales, and so does anything built on
1031            // one (a trim of it, an offset of it), as in space.
1032            Self::Trimmed(c) => {
1033                let basis = c.basis.transformed(t, tol)?;
1034                let (from, to) = (c.basis.domain(), basis.domain());
1035                Self::Trimmed(Box::new(Trimmed2d {
1036                    domain: (
1037                        crate::surface::carried(c.domain.0, from, to),
1038                        crate::surface::carried(c.domain.1, from, to),
1039                    ),
1040                    basis,
1041                    reversed: c.reversed,
1042                }))
1043            }
1044        })
1045    }
1046}
1047
1048impl PlanarCurve {
1049    /// This curve with each point's coordinates stretched, `u` by `su` and
1050    /// `v` by `sv`, at the same parameters: the image a chart curve takes
1051    /// when its surface's length directions rescale unevenly (a cylinder's
1052    /// height under a scaling, its angle not). Equal factors are a plain
1053    /// scaling about the chart's origin.
1054    ///
1055    /// # Errors
1056    ///
1057    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) for
1058    /// an uneven stretch of a circle, an ellipse, an offset or a
1059    /// trigonometric curve, whose stretched image no curve here states at
1060    /// the same parameters.
1061    pub fn stretched(&self, su: f64, sv: f64, tol: Tolerances) -> OgeomResult<Self> {
1062        if su == sv {
1063            if su == 1.0 {
1064                return Ok(self.clone());
1065            }
1066            let scaling = ogeom_math::Transform2::scaling(Point2::ORIGIN, su, tol)?;
1067            return self.transformed(&scaling, tol);
1068        }
1069        let map = |p: Point2| Point2::new(p.x * su, p.y * sv);
1070        Ok(match self {
1071            Self::Line(c) => {
1072                let (a, b) = c.domain;
1073                let knots = KnotVector::new(vec![a, a, b, b], 1)?;
1074                Self::BSpline(BSpline2d::new(
1075                    knots,
1076                    vec![map(c.axis.point_at(a)), map(c.axis.point_at(b))],
1077                    tol,
1078                )?)
1079            }
1080            Self::BSpline(c) => Self::BSpline(BSpline2d {
1081                control: c
1082                    .control
1083                    .iter()
1084                    .map(|w| Weighted::new(map(w.point()), w.weight, tol))
1085                    .collect::<OgeomResult<Vec<_>>>()?,
1086                ..c.clone()
1087            }),
1088            Self::Trimmed(c) => Self::Trimmed(Box::new(Trimmed2d {
1089                basis: c.basis.stretched(su, sv, tol)?,
1090                ..(**c).clone()
1091            })),
1092            _ => ogeom_bail!(
1093                Construction,
1094                "a chart curve of this kind has no image under an uneven stretch"
1095            ),
1096        })
1097    }
1098}
1099
1100impl Reversible for PlanarCurve {
1101    fn reversed(&self) -> Self {
1102        match self {
1103            Self::Line(c) => Self::Line(Line2d {
1104                axis: Axis2::new(
1105                    c.axis.point_at(c.domain.0 + c.domain.1),
1106                    c.axis.direction.reversed(),
1107                ),
1108                domain: c.domain,
1109            }),
1110            Self::Circle(c) => Self::Circle(Circle2d {
1111                reversed: !c.reversed,
1112                ..*c
1113            }),
1114            Self::Ellipse(c) => Self::Ellipse(Ellipse2d {
1115                reversed: !c.reversed,
1116                ..*c
1117            }),
1118            Self::Hyperbola(c) => Self::Hyperbola(Hyperbola2d {
1119                reversed: !c.reversed,
1120                ..*c
1121            }),
1122            Self::BSpline(c) => {
1123                let (knots, control) = bspline::reverse(&c.knots, &c.control);
1124                Self::BSpline(BSpline2d {
1125                    knots,
1126                    control,
1127                    ..c.clone()
1128                })
1129            }
1130            Self::Trimmed(c) => Self::Trimmed(Box::new(Trimmed2d {
1131                reversed: !c.reversed,
1132                ..(**c).clone()
1133            })),
1134            // Reversing flips the tangent and with it the left normal, so
1135            // the distance negates to keep the same point set traversed
1136            // backwards.
1137            Self::Offset(c) => Self::Offset(Box::new(Offset2d {
1138                basis: c.basis.reversed(),
1139                distance: -c.distance,
1140            })),
1141            Self::Trig(c) => Self::Trig(Trig2d {
1142                reversed: !c.reversed,
1143                ..*c
1144            }),
1145        }
1146    }
1147}
1148
1149/// The angle a planar curve's tangent makes with the `u` axis at `t`.
1150///
1151/// The natural way to ask which way a pcurve is heading, which is what wire
1152/// ordering and outer/inner classification are built from.
1153///
1154/// # Errors
1155///
1156/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) at a cusp, where
1157/// the derivative vanishes.
1158pub fn tangent_angle(curve: &PlanarCurve, t: f64, tol: Tolerances) -> OgeomResult<f64> {
1159    let d = curve.d1_at(t, tol)?;
1160    if d.is_zero(tol) {
1161        ogeom_bail!(Construction, "curve has no tangent at {t}");
1162    }
1163    Ok(elementary::wrap_signed_angle(d.y.atan2(d.x)))
1164}
1165
1166impl From<Line2d> for PlanarCurve {
1167    fn from(c: Line2d) -> Self {
1168        Self::Line(c)
1169    }
1170}
1171impl From<Circle2d> for PlanarCurve {
1172    fn from(c: Circle2d) -> Self {
1173        Self::Circle(c)
1174    }
1175}
1176impl From<Ellipse2d> for PlanarCurve {
1177    fn from(c: Ellipse2d) -> Self {
1178        Self::Ellipse(c)
1179    }
1180}
1181impl From<Hyperbola2d> for PlanarCurve {
1182    fn from(c: Hyperbola2d) -> Self {
1183        Self::Hyperbola(c)
1184    }
1185}
1186impl From<BSpline2d> for PlanarCurve {
1187    fn from(c: BSpline2d) -> Self {
1188        Self::BSpline(c)
1189    }
1190}
1191impl From<Trimmed2d> for PlanarCurve {
1192    fn from(c: Trimmed2d) -> Self {
1193        Self::Trimmed(Box::new(c))
1194    }
1195}
1196
1197#[cfg(test)]
1198#[allow(clippy::unwrap_used)]
1199mod tests {
1200    use super::*;
1201    use approx::assert_relative_eq;
1202    use ogeom_math::Frame2;
1203
1204    /// A planar offset carried by a reflection, a half turn (a negative
1205    /// scale) or a rotation names, at every parameter, the image of the
1206    /// point it named.
1207    #[test]
1208    fn a_moved_offset_2d_is_the_image_of_the_original() {
1209        use ogeom_math::{Circle2, Transform2};
1210        let frame = Frame2::new(Point2::new(1.0, -2.0), Direction2::X);
1211        let circle = PlanarCurve::Circle(Circle2d::new(Circle2::new(frame, 2.0, T).unwrap()));
1212        let offset = PlanarCurve::Offset(Box::new(Offset2d::new(circle, 0.5).unwrap()));
1213        let mirror = Transform2::line_mirror(Point2::ORIGIN, Direction2::X);
1214        for t in [
1215            mirror,
1216            Transform2::scaling(Point2::new(3.0, 1.0), -2.0, T).unwrap(),
1217            Transform2::rotation(Point2::ORIGIN, 0.7),
1218        ] {
1219            let moved = offset.transformed(&t, T).unwrap();
1220            for u in [0.0, 1.0, 2.5, 4.0] {
1221                let want = t.apply(offset.point_at(u, T).unwrap());
1222                let got = moved.point_at(u, T).unwrap();
1223                assert!(
1224                    got.distance(want) < 1e-9,
1225                    "{t:?} at {u}: {got:?} against {want:?}"
1226                );
1227            }
1228        }
1229    }
1230
1231    #[test]
1232    fn an_offset_2d_circle_is_the_larger_circle() {
1233        use ogeom_math::Circle2;
1234        // Counterclockwise circle: the left normal points inward, so a
1235        // negative distance grows the radius.
1236        let frame = Frame2::new(Point2::new(1.0, -2.0), Direction2::X);
1237        let circle = PlanarCurve::Circle(Circle2d::new(Circle2::new(frame, 2.0, T).unwrap()));
1238        let offset = Offset2d::new(circle, -1.0).unwrap();
1239        for i in 0..8 {
1240            let t = core::f64::consts::TAU * f64::from(i) / 8.0;
1241            let p = offset.point_at(t, T).unwrap();
1242            assert!((p.distance(Point2::new(1.0, -2.0)) - 3.0).abs() < 1e-12);
1243        }
1244        let h = 1e-6;
1245        let d = offset.d1_at(1.0, T).unwrap();
1246        let fd = (offset.point_at(1.0 + h, T).unwrap() - offset.point_at(1.0 - h, T).unwrap())
1247            / (2.0 * h);
1248        assert!((d - fd).magnitude() < 1e-5);
1249        assert!(offset.derivatives_at(1.0, 2, T).is_err());
1250    }
1251
1252    const T: Tolerances = Tolerances::millimetres();
1253
1254    fn frame() -> Frame2 {
1255        Frame2::new(Point2::new(2.0, -1.0), Direction2::from_angle(0.4))
1256    }
1257
1258    fn every_curve() -> Vec<PlanarCurve> {
1259        let spline = {
1260            let control = vec![
1261                Point2::new(0.0, 0.0),
1262                Point2::new(1.0, 2.0),
1263                Point2::new(3.0, 1.0),
1264                Point2::new(5.0, 0.0),
1265                Point2::new(6.0, -1.0),
1266            ];
1267            BSpline2d::new(
1268                KnotVector::clamped_uniform(3, control.len()).unwrap(),
1269                control,
1270                T,
1271            )
1272            .unwrap()
1273        };
1274        vec![
1275            Line2d::segment(Point2::ORIGIN, Point2::new(3.0, 4.0), T)
1276                .unwrap()
1277                .into(),
1278            Circle2d::new(Circle2::new(frame(), 2.0, T).unwrap()).into(),
1279            Ellipse2d::new(Ellipse2::new(frame(), 5.0, 3.0, T).unwrap()).into(),
1280            Hyperbola2d::over(Hyperbola2::new(frame(), 2.0, 0.7, T).unwrap(), -1.1, 0.6)
1281                .unwrap()
1282                .into(),
1283            spline.clone().into(),
1284            Trimmed2d::new(spline.into(), 0.2, 0.8, T).unwrap().into(),
1285        ]
1286    }
1287
1288    fn interior(c: &PlanarCurve, n: usize) -> Vec<f64> {
1289        let (a, b) = c.domain();
1290        (1..n)
1291            .map(|i| {
1292                #[allow(clippy::cast_precision_loss)]
1293                let t = i as f64 / n as f64 + 0.0413;
1294                a + (b - a) * t
1295            })
1296            .collect()
1297    }
1298
1299    #[test]
1300    fn every_order_agrees_with_the_difference_of_the_one_below() {
1301        let h = 1e-5;
1302        let mut curves = every_curve();
1303        let reversed: Vec<PlanarCurve> = curves.iter().map(Reversible::reversed).collect();
1304        curves.extend(reversed);
1305        // The closed forms: a spline's orders jump at its knots.
1306        curves.retain(|c| !matches!(c.kind(), CurveKind::BSpline | CurveKind::Trimmed));
1307        for c in curves {
1308            for u in interior(&c, 6) {
1309                let at = c.derivatives_at(u, 5, T).unwrap();
1310                let below = c.derivatives_at(u + h, 4, T).unwrap();
1311                let above = c.derivatives_at(u - h, 4, T).unwrap();
1312                for order in 2..=5 {
1313                    let numeric = (below[order - 1] - above[order - 1]) * (1.0 / (2.0 * h));
1314                    let scale = numeric.magnitude().max(1.0);
1315                    assert!(
1316                        (at[order] - numeric).magnitude() <= 1e-5 * scale,
1317                        "{:?} order {order} at {u}: {:?} against {numeric:?}",
1318                        c.kind(),
1319                        at[order]
1320                    );
1321                }
1322            }
1323        }
1324    }
1325
1326    #[test]
1327    fn derivatives_agree_with_finite_differences() {
1328        let h = 1e-6;
1329        for c in every_curve() {
1330            for u in interior(&c, 8) {
1331                let d1 = c.d1_at(u, T).unwrap();
1332                let numeric = (c.point_at(u + h, T).unwrap() - c.point_at(u - h, T).unwrap())
1333                    * (1.0 / (2.0 * h));
1334                assert!(
1335                    (d1 - numeric).magnitude() <= 1e-5 * numeric.magnitude().max(1.0),
1336                    "{:?} at {u}",
1337                    c.kind()
1338                );
1339            }
1340        }
1341    }
1342
1343    #[test]
1344    fn derivatives_at_zero_returns_the_point() {
1345        for c in every_curve() {
1346            for u in interior(&c, 4) {
1347                let d = c.derivatives_at(u, 0, T).unwrap();
1348                assert_eq!(d.len(), 1);
1349                assert!(Point2::from_vector(d[0]).is_equal(c.point_at(u, T).unwrap(), T));
1350            }
1351        }
1352    }
1353
1354    #[test]
1355    fn out_of_domain_parameters_follow_periodicity() {
1356        for c in every_curve() {
1357            let (a, b) = c.domain();
1358            if c.is_periodic() {
1359                assert!(c.point_at(b + 1.0, T).is_ok(), "{:?}", c.kind());
1360            } else {
1361                assert!(c.point_at(b + 1.0, T).is_err(), "{:?}", c.kind());
1362                assert!(c.point_at(a - 1.0, T).is_err(), "{:?}", c.kind());
1363            }
1364        }
1365    }
1366
1367    #[test]
1368    fn reversal_traverses_the_same_points_backwards() {
1369        for c in every_curve() {
1370            let r = c.reversed();
1371            let (a, b) = c.domain();
1372            assert_eq!(r.domain(), (a, b), "{:?} changed its domain", c.kind());
1373            for i in 0..=8 {
1374                let t = f64::from(i) / 8.0;
1375                let forward = c.point_at(a + (b - a) * t, T).unwrap();
1376                let backward = r.point_at(a + (b - a) * (1.0 - t), T).unwrap();
1377                assert!(forward.is_equal(backward, T), "{:?} at {t}", c.kind());
1378            }
1379        }
1380    }
1381
1382    #[test]
1383    fn reversing_twice_is_the_identity() {
1384        for c in every_curve() {
1385            let twice = c.reversed().reversed();
1386            for u in interior(&c, 8) {
1387                assert!(
1388                    c.point_at(u, T)
1389                        .unwrap()
1390                        .is_equal(twice.point_at(u, T).unwrap(), T),
1391                    "{:?}",
1392                    c.kind()
1393                );
1394            }
1395        }
1396    }
1397
1398    #[test]
1399    fn a_reversed_curve_heads_the_other_way() {
1400        for c in every_curve() {
1401            let r = c.reversed();
1402            let (a, b) = c.domain();
1403            let u = a + (b - a) * 0.4;
1404            let forward = tangent_angle(&c, u, T).unwrap();
1405            let backward = tangent_angle(&r, mirror(u, a, b), T).unwrap();
1406            let difference = (forward - backward).abs();
1407            assert!(
1408                (difference - core::f64::consts::PI).abs() < 1e-9,
1409                "{:?}: {forward} vs {backward}",
1410                c.kind()
1411            );
1412        }
1413    }
1414
1415    #[test]
1416    fn transforms_move_curves() {
1417        let t = Transform2::rotation(Point2::new(1.0, 1.0), 0.7);
1418        for c in every_curve() {
1419            let moved = c.transformed(&t, T).unwrap();
1420            assert_eq!(moved.kind(), c.kind());
1421            for u in interior(&c, 8) {
1422                let expected = t.apply(c.point_at(u, T).unwrap());
1423                assert!(
1424                    moved.point_at(u, T).unwrap().is_equal(expected, T),
1425                    "{:?} at {u}",
1426                    c.kind()
1427                );
1428            }
1429        }
1430    }
1431
1432    #[test]
1433    fn a_line_segments_parameter_is_arc_length() {
1434        let l = Line2d::segment(Point2::ORIGIN, Point2::new(3.0, 4.0), T).unwrap();
1435        assert_eq!(l.domain(), (0.0, 5.0));
1436        assert!(
1437            l.point_at(2.5, T)
1438                .unwrap()
1439                .is_equal(Point2::new(1.5, 2.0), T)
1440        );
1441        assert_relative_eq!(l.d1_at(1.0, T).unwrap().magnitude(), 1.0, epsilon = 1e-15);
1442        assert!(Line2d::segment(Point2::ORIGIN, Point2::ORIGIN, T).is_err());
1443        assert!(Line2d::over(Axis2::X, 1.0, 1.0).is_err());
1444    }
1445
1446    #[test]
1447    fn a_rational_quadratic_traces_an_exact_arc() {
1448        let w = core::f64::consts::FRAC_1_SQRT_2;
1449        let control: Vec<_> = [
1450            (Point2::new(1.0, 0.0), 1.0),
1451            (Point2::new(1.0, 1.0), w),
1452            (Point2::new(0.0, 1.0), 1.0),
1453        ]
1454        .iter()
1455        .map(|(p, w)| Weighted::new(*p, *w, T).unwrap())
1456        .collect();
1457        let c = BSpline2d::rational(KnotVector::clamped_uniform(2, 3).unwrap(), control).unwrap();
1458        assert!(c.is_rational());
1459        for i in 0..=20 {
1460            let u = f64::from(i) / 20.0;
1461            assert_relative_eq!(
1462                c.point_at(u, T).unwrap().to_vector().magnitude(),
1463                1.0,
1464                epsilon = 1e-14
1465            );
1466        }
1467    }
1468
1469    #[test]
1470    fn tangent_angle_reports_the_heading() {
1471        // Along +x, then +y after a quarter turn of the circle.
1472        let l: PlanarCurve = Line2d::segment(Point2::ORIGIN, Point2::new(5.0, 0.0), T)
1473            .unwrap()
1474            .into();
1475        assert_relative_eq!(tangent_angle(&l, 1.0, T).unwrap(), 0.0, epsilon = 1e-15);
1476
1477        let c: PlanarCurve =
1478            Circle2d::new(Circle2::centred(Point2::ORIGIN, 1.0, T).unwrap()).into();
1479        assert_relative_eq!(
1480            tangent_angle(&c, 0.0, T).unwrap(),
1481            core::f64::consts::FRAC_PI_2,
1482            epsilon = 1e-12
1483        );
1484        assert_relative_eq!(
1485            tangent_angle(&c, core::f64::consts::FRAC_PI_2, T).unwrap(),
1486            core::f64::consts::PI,
1487            epsilon = 1e-12
1488        );
1489    }
1490
1491    #[test]
1492    fn trimming_is_bounds_checked_and_agrees_with_its_basis() {
1493        let base: PlanarCurve = Line2d::over(Axis2::X, 0.0, 10.0).unwrap().into();
1494        assert!(Trimmed2d::new(base.clone(), 2.0, 8.0, T).is_ok());
1495        assert!(Trimmed2d::new(base.clone(), 8.0, 2.0, T).is_err());
1496        assert!(Trimmed2d::new(base.clone(), -1.0, 5.0, T).is_err());
1497
1498        let trimmed = Trimmed2d::new(base.clone(), 2.0, 8.0, T).unwrap();
1499        assert_eq!(trimmed.domain(), (2.0, 8.0));
1500        for i in 0..=6 {
1501            let u = 2.0 + 6.0 * f64::from(i) / 6.0;
1502            assert!(
1503                trimmed
1504                    .point_at(u, T)
1505                    .unwrap()
1506                    .is_equal(base.point_at(u, T).unwrap(), T)
1507            );
1508        }
1509        assert!(trimmed.point_at(1.0, T).is_err());
1510    }
1511
1512    #[test]
1513    fn a_circle_in_parameter_space_closes_on_itself() {
1514        let c: PlanarCurve = Circle2d::new(Circle2::new(frame(), 2.0, T).unwrap()).into();
1515        assert!(c.is_closed(T) && c.is_periodic());
1516        let base = c.point_at(0.7, T).unwrap();
1517        for k in [-2.0_f64, 1.0, 3.0] {
1518            assert!(base.is_equal(c.point_at(k.mul_add(TAU, 0.7), T).unwrap(), T));
1519        }
1520    }
1521
1522    /// An image winding once round a periodic chart, its seam moved, is
1523    /// the same image from the new start: past the old end it is the old
1524    /// image a domain earlier, carried by the lap. An image that does not
1525    /// close over the lap it is given is refused.
1526    #[test]
1527    fn a_reseamed_winding_image_is_the_same_image_from_a_new_start() {
1528        // A cubic climbing one period in v while wobbling in u.
1529        let lap = Vector2::new(0.0, TAU);
1530        let ring = [
1531            Point2::new(1.0, 0.0),
1532            Point2::new(1.4, 1.5),
1533            Point2::new(0.7, 3.0),
1534            Point2::new(1.2, 4.5),
1535            Point2::new(0.9, 5.5),
1536            Point2::new(1.0, TAU),
1537        ];
1538        let knots = KnotVector::clamped_uniform(3, ring.len()).unwrap();
1539        let image = BSpline2d::new(knots, ring.to_vec(), T).unwrap();
1540        let (start, end) = image.domain();
1541        let seam = 0.35;
1542        let moved = image.reseamed_at(seam, lap, T).unwrap();
1543        let (new_start, new_end) = moved.domain();
1544        assert_relative_eq!(new_start, seam, epsilon = 1e-12);
1545        assert_relative_eq!(new_end - new_start, end - start, epsilon = 1e-12);
1546        for i in 0..=40 {
1547            let s = (end - start) * f64::from(i) / 40.0;
1548            let want = if seam + s <= end {
1549                image.point_at(seam + s, T).unwrap()
1550            } else {
1551                image.point_at(seam + s - (end - start), T).unwrap() + lap
1552            };
1553            let got = moved.point_at(new_start + s, T).unwrap();
1554            assert!(
1555                got.distance(want) < 1e-12,
1556                "at {s}: {got:?} against {want:?}"
1557            );
1558        }
1559        assert!(image.reseamed_at(seam, Vector2::new(0.0, 0.0), T).is_err());
1560    }
1561}