Skip to main content

ogeom_math/
conic.rs

1//! Conic sections: circle, ellipse, hyperbola, parabola.
2//!
3//! Each is a shape described by a [`Frame`] and one or two size parameters. The
4//! frame is not decoration: it fixes the parameterization. A circle's angular
5//! parameter is measured from its frame's `x` axis towards its `y` axis, so
6//! "the point at 0" is a specific place that survives the shape being stored,
7//! reloaded and transformed.
8//!
9//! Conics live in their frame's `xy` plane, with the frame's `z` as the normal.
10//!
11//! Evaluation and derivatives are in [`crate::elementary`]; this module holds
12//! the descriptions and the queries that follow directly from them.
13//!
14//! A straight line needs no type of its own: it is exactly an [`Axis`](crate::Axis),
15//! and the conventional design's separate line type carries no information the
16//! axis does not.
17
18use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
19
20use crate::{Frame, Frame2, Point, Point2, Transform, Transform2};
21
22/// The complete elliptic integral of the second kind, `E(m)`, for `m` in
23/// `[0, 1]`.
24///
25/// Computed by the arithmetic-geometric mean, which converges quadratically:
26/// the number of correct digits doubles each iteration, so `f64` accuracy costs
27/// about seven steps regardless of `m`.
28///
29/// `E(0) = pi/2` (a circle) and `E(1) = 1` (a degenerate ellipse, a doubled
30/// line segment).
31#[must_use]
32pub fn complete_elliptic_e(m: f64) -> f64 {
33    let m = m.clamp(0.0, 1.0);
34    if m >= 1.0 {
35        return 1.0;
36    }
37    let mut a = 1.0_f64;
38    let mut b = (1.0 - m).sqrt();
39    // The running sum of `2^(n-1) * c_n^2`, starting with the n = 0 term.
40    let mut sum = m * 0.5;
41    let mut power = 1.0_f64;
42    // Quadratic convergence reaches the f64 floor well inside this bound; the
43    // limit is a backstop, not the expected exit.
44    for _ in 0..20 {
45        let c = (a - b) * 0.5;
46        let next_a = f64::midpoint(a, b);
47        b = (a * b).sqrt();
48        a = next_a;
49        sum += power * c * c;
50        power *= 2.0;
51        if c.abs() <= f64::EPSILON * a {
52            break;
53        }
54    }
55    // K(m) = pi / (2 * AGM), and E(m) = K(m) * (1 - sum).
56    core::f64::consts::FRAC_PI_2 / a * (1.0 - sum)
57}
58
59/// Reject a size parameter that cannot describe a real shape.
60fn check_positive(name: &str, value: f64, tol: Tolerances) -> OgeomResult<()> {
61    if !value.is_finite() || value <= tol.confusion() {
62        ogeom_bail!(Construction, "{name} {value} must be finite and positive");
63    }
64    Ok(())
65}
66
67/// A circle in space.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct Circle {
70    frame: Frame,
71    radius: f64,
72}
73
74/// An ellipse in space.
75#[derive(Debug, Clone, Copy, PartialEq)]
76pub struct Ellipse {
77    frame: Frame,
78    major_radius: f64,
79    minor_radius: f64,
80}
81
82/// A hyperbola in space.
83///
84/// Only the branch on the positive `x` side of its frame is described; the
85/// other branch is the same hyperbola with the frame's `x` reversed.
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct Hyperbola {
88    frame: Frame,
89    major_radius: f64,
90    minor_radius: f64,
91}
92
93/// A parabola in space, opening along its frame's `x` axis.
94#[derive(Debug, Clone, Copy, PartialEq)]
95pub struct Parabola {
96    frame: Frame,
97    focal: f64,
98}
99
100/// A circle in the plane.
101#[derive(Debug, Clone, Copy, PartialEq)]
102pub struct Circle2 {
103    frame: Frame2,
104    radius: f64,
105}
106
107/// An ellipse in the plane.
108#[derive(Debug, Clone, Copy, PartialEq)]
109pub struct Ellipse2 {
110    frame: Frame2,
111    major_radius: f64,
112    minor_radius: f64,
113}
114
115/// A hyperbola in the plane.
116#[derive(Debug, Clone, Copy, PartialEq)]
117pub struct Hyperbola2 {
118    frame: Frame2,
119    major_radius: f64,
120    minor_radius: f64,
121}
122
123/// A parabola in the plane.
124#[derive(Debug, Clone, Copy, PartialEq)]
125pub struct Parabola2 {
126    frame: Frame2,
127    focal: f64,
128}
129
130impl Circle {
131    /// A circle of `radius` in the `xy` plane of `frame`.
132    ///
133    /// # Errors
134    ///
135    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `radius` is
136    /// not finite and positive.
137    pub fn new(frame: Frame, radius: f64, tol: Tolerances) -> OgeomResult<Self> {
138        check_positive("circle radius", radius, tol)?;
139        Ok(Self { frame, radius })
140    }
141
142    /// The circle through three points.
143    ///
144    /// # Errors
145    ///
146    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the points
147    /// are collinear or any two coincide; no circle passes through them.
148    pub fn through(a: Point, b: Point, c: Point, tol: Tolerances) -> OgeomResult<Self> {
149        let (ab, ac) = (b - a, c - a);
150
151        // `from_cross` doubles as the collinearity test, and it is the scale-free
152        // one: it asks whether `|ab x ac|` is small *relative to* `|ab| |ac|`,
153        // so three points a micron apart are judged by the same standard as
154        // three a metre apart. Comparing the cross product against a length
155        // tolerance instead would reject small triangles whose plane is
156        // perfectly well determined.
157        let z = crate::Direction::from_cross(ab, ac, tol)?;
158
159        let normal = ab.cross(ac);
160        let to_centre = (normal.cross(ab) * ac.square_magnitude()
161            + ac.cross(normal) * ab.square_magnitude())
162            / (2.0 * normal.square_magnitude());
163        let centre = a + to_centre;
164
165        // A circle smaller than the confusion tolerance is degenerate, and
166        // `Direction::new` reports that.
167        let x = crate::Direction::new(a - centre, tol)?;
168        Self::new(Frame::new(centre, z, x, tol)?, to_centre.magnitude(), tol)
169    }
170
171    /// The frame positioning this circle.
172    #[must_use]
173    pub const fn frame(&self) -> Frame {
174        self.frame
175    }
176
177    /// The centre.
178    #[must_use]
179    pub const fn centre(&self) -> Point {
180        self.frame.origin()
181    }
182
183    /// The radius.
184    #[must_use]
185    pub const fn radius(&self) -> f64 {
186        self.radius
187    }
188
189    /// The circumference.
190    #[must_use]
191    pub fn length(&self) -> f64 {
192        core::f64::consts::TAU * self.radius
193    }
194
195    /// The area enclosed.
196    #[must_use]
197    pub fn area(&self) -> f64 {
198        core::f64::consts::PI * self.radius * self.radius
199    }
200
201    /// The shortest distance from `p` to the circle.
202    ///
203    /// Zero on the circle, and positive both inside and outside; this is the
204    /// distance to the curve, not to the disc it bounds.
205    #[must_use]
206    pub fn distance_to(&self, p: Point) -> f64 {
207        let local = self.frame.to_local(p);
208        // Distance from a point to a circle, in the cylindrical coordinates of
209        // the circle's own frame: radially out to the circle, then along the
210        // axis.
211        let radial = local.xy().to_vector().magnitude() - self.radius;
212        radial.hypot(local.z)
213    }
214
215    /// Whether `p` lies on the circle within `tol.confusion()`.
216    #[must_use]
217    pub fn contains(&self, p: Point, tol: Tolerances) -> bool {
218        self.distance_to(p) <= tol.confusion()
219    }
220
221    /// This circle moved by `t`.
222    ///
223    /// # Errors
224    ///
225    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
226    /// transformed frame is degenerate.
227    pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
228        Self::new(
229            t.apply_frame(&self.frame, tol)?,
230            self.radius * t.scale_factor().abs(),
231            tol,
232        )
233    }
234}
235
236impl Ellipse {
237    /// An ellipse with the given radii in the `xy` plane of `frame`, the major
238    /// radius along `x`.
239    ///
240    /// # Errors
241    ///
242    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if either
243    /// radius is not finite and positive, or if the minor radius exceeds the
244    /// major.
245    pub fn new(
246        frame: Frame,
247        major_radius: f64,
248        minor_radius: f64,
249        tol: Tolerances,
250    ) -> OgeomResult<Self> {
251        check_positive("major radius", major_radius, tol)?;
252        check_positive("minor radius", minor_radius, tol)?;
253        if minor_radius > major_radius + tol.confusion() {
254            ogeom_bail!(
255                Construction,
256                "minor radius {minor_radius} exceeds major radius {major_radius}"
257            );
258        }
259        Ok(Self {
260            frame,
261            major_radius,
262            minor_radius,
263        })
264    }
265
266    /// The frame positioning this ellipse.
267    #[must_use]
268    pub const fn frame(&self) -> Frame {
269        self.frame
270    }
271
272    /// The centre.
273    #[must_use]
274    pub const fn centre(&self) -> Point {
275        self.frame.origin()
276    }
277
278    /// The major radius.
279    #[must_use]
280    pub const fn major_radius(&self) -> f64 {
281        self.major_radius
282    }
283
284    /// The minor radius.
285    #[must_use]
286    pub const fn minor_radius(&self) -> f64 {
287        self.minor_radius
288    }
289
290    /// The distance from the centre to either focus.
291    #[must_use]
292    pub fn focal_distance(&self) -> f64 {
293        // Written as a difference of squares factored into a product, which
294        // keeps precision for a nearly circular ellipse where the two radii
295        // almost cancel.
296        ((self.major_radius - self.minor_radius) * (self.major_radius + self.minor_radius)).sqrt()
297    }
298
299    /// The eccentricity, in `[0, 1)`. Zero for a circle.
300    #[must_use]
301    pub fn eccentricity(&self) -> f64 {
302        self.focal_distance() / self.major_radius
303    }
304
305    /// The two foci, on the `x` axis either side of the centre.
306    #[must_use]
307    pub fn foci(&self) -> (Point, Point) {
308        let offset = self.frame.x() * self.focal_distance();
309        (self.centre() + offset, self.centre() - offset)
310    }
311
312    /// The area enclosed.
313    #[must_use]
314    pub fn area(&self) -> f64 {
315        core::f64::consts::PI * self.major_radius * self.minor_radius
316    }
317
318    /// The circumference.
319    ///
320    /// Exact to machine precision, via the complete elliptic integral of the
321    /// second kind; see [`complete_elliptic_e`].
322    ///
323    /// Ramanujan's well-known approximation was the obvious alternative and is
324    /// not good enough: it is excellent for a nearly circular ellipse but its
325    /// relative error reaches `1.2e-5` by an axis ratio of 10:1. Perimeter
326    /// feeds arc-length parameterization and measurement, where that is a
327    /// visible error rather than a rounding detail.
328    #[must_use]
329    pub fn length(&self) -> f64 {
330        // m = e^2 = 1 - (b/a)^2, written factored to avoid cancellation when
331        // the ellipse is nearly circular.
332        let ratio = self.minor_radius / self.major_radius;
333        let m = (1.0 - ratio) * (1.0 + ratio);
334        4.0 * self.major_radius * complete_elliptic_e(m)
335    }
336
337    /// This ellipse moved by `t`.
338    ///
339    /// # Errors
340    ///
341    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
342    /// transformed frame is degenerate.
343    pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
344        let s = t.scale_factor().abs();
345        Self::new(
346            t.apply_frame(&self.frame, tol)?,
347            self.major_radius * s,
348            self.minor_radius * s,
349            tol,
350        )
351    }
352}
353
354impl Hyperbola {
355    /// A hyperbola with the given radii in the `xy` plane of `frame`.
356    ///
357    /// # Errors
358    ///
359    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if either
360    /// radius is not finite and positive. Unlike an ellipse, the minor radius
361    /// may exceed the major.
362    pub fn new(
363        frame: Frame,
364        major_radius: f64,
365        minor_radius: f64,
366        tol: Tolerances,
367    ) -> OgeomResult<Self> {
368        check_positive("major radius", major_radius, tol)?;
369        check_positive("minor radius", minor_radius, tol)?;
370        Ok(Self {
371            frame,
372            major_radius,
373            minor_radius,
374        })
375    }
376
377    /// The frame positioning this hyperbola.
378    #[must_use]
379    pub const fn frame(&self) -> Frame {
380        self.frame
381    }
382
383    /// The centre: the midpoint of the two vertices, not a point on the curve.
384    #[must_use]
385    pub const fn centre(&self) -> Point {
386        self.frame.origin()
387    }
388
389    /// The major radius: centre to vertex.
390    #[must_use]
391    pub const fn major_radius(&self) -> f64 {
392        self.major_radius
393    }
394
395    /// The minor radius, governing how fast the branches open.
396    #[must_use]
397    pub const fn minor_radius(&self) -> f64 {
398        self.minor_radius
399    }
400
401    /// The distance from the centre to either focus.
402    #[must_use]
403    pub fn focal_distance(&self) -> f64 {
404        self.major_radius.hypot(self.minor_radius)
405    }
406
407    /// The eccentricity, always greater than 1.
408    #[must_use]
409    pub fn eccentricity(&self) -> f64 {
410        self.focal_distance() / self.major_radius
411    }
412
413    /// The two foci.
414    #[must_use]
415    pub fn foci(&self) -> (Point, Point) {
416        let offset = self.frame.x() * self.focal_distance();
417        (self.centre() + offset, self.centre() - offset)
418    }
419
420    /// The vertex of the described branch.
421    #[must_use]
422    pub fn vertex(&self) -> Point {
423        self.centre() + self.frame.x() * self.major_radius
424    }
425
426    /// This hyperbola moved by `t`.
427    ///
428    /// # Errors
429    ///
430    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
431    /// transformed frame is degenerate.
432    pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
433        let s = t.scale_factor().abs();
434        Self::new(
435            t.apply_frame(&self.frame, tol)?,
436            self.major_radius * s,
437            self.minor_radius * s,
438            tol,
439        )
440    }
441}
442
443impl Parabola {
444    /// A parabola with the given focal length in the `xy` plane of `frame`.
445    ///
446    /// The apex is at the frame origin and the curve opens along `+x`; the
447    /// focus sits at distance `focal` from the apex along `+x`.
448    ///
449    /// # Errors
450    ///
451    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `focal` is
452    /// not finite and positive. A zero focal length degenerates to a ray.
453    pub fn new(frame: Frame, focal: f64, tol: Tolerances) -> OgeomResult<Self> {
454        check_positive("focal length", focal, tol)?;
455        Ok(Self { frame, focal })
456    }
457
458    /// The frame positioning this parabola.
459    #[must_use]
460    pub const fn frame(&self) -> Frame {
461        self.frame
462    }
463
464    /// The apex.
465    #[must_use]
466    pub const fn apex(&self) -> Point {
467        self.frame.origin()
468    }
469
470    /// The focal length: apex to focus.
471    #[must_use]
472    pub const fn focal(&self) -> f64 {
473        self.focal
474    }
475
476    /// The focus.
477    #[must_use]
478    pub fn focus(&self) -> Point {
479        self.apex() + self.frame.x() * self.focal
480    }
481
482    /// The eccentricity, which is `1` for every parabola.
483    #[must_use]
484    pub const fn eccentricity(&self) -> f64 {
485        1.0
486    }
487
488    /// This parabola moved by `t`.
489    ///
490    /// # Errors
491    ///
492    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
493    /// transformed frame is degenerate.
494    pub fn transformed(&self, t: &Transform, tol: Tolerances) -> OgeomResult<Self> {
495        Self::new(
496            t.apply_frame(&self.frame, tol)?,
497            self.focal * t.scale_factor().abs(),
498            tol,
499        )
500    }
501}
502
503impl Circle2 {
504    /// A circle of `radius` centred on `frame`'s origin.
505    ///
506    /// # Errors
507    ///
508    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `radius` is
509    /// not finite and positive.
510    pub fn new(frame: Frame2, radius: f64, tol: Tolerances) -> OgeomResult<Self> {
511        check_positive("circle radius", radius, tol)?;
512        Ok(Self { frame, radius })
513    }
514
515    /// A circle from a centre and a radius, with the default orientation.
516    ///
517    /// # Errors
518    ///
519    /// As [`Circle2::new`].
520    pub fn centred(centre: Point2, radius: f64, tol: Tolerances) -> OgeomResult<Self> {
521        Self::new(Frame2::new(centre, crate::Direction2::X), radius, tol)
522    }
523
524    /// The frame positioning this circle.
525    #[must_use]
526    pub const fn frame(&self) -> Frame2 {
527        self.frame
528    }
529
530    /// The centre.
531    #[must_use]
532    pub const fn centre(&self) -> Point2 {
533        self.frame.origin()
534    }
535
536    /// The radius.
537    #[must_use]
538    pub const fn radius(&self) -> f64 {
539        self.radius
540    }
541
542    /// The circumference.
543    #[must_use]
544    pub fn length(&self) -> f64 {
545        core::f64::consts::TAU * self.radius
546    }
547
548    /// The area enclosed.
549    #[must_use]
550    pub fn area(&self) -> f64 {
551        core::f64::consts::PI * self.radius * self.radius
552    }
553
554    /// The signed distance from `p` to the circle, negative inside.
555    #[must_use]
556    pub fn signed_distance_to(&self, p: Point2) -> f64 {
557        self.centre().distance(p) - self.radius
558    }
559
560    /// Whether `p` lies on the circle within `tol.confusion()`.
561    #[must_use]
562    pub fn contains(&self, p: Point2, tol: Tolerances) -> bool {
563        self.signed_distance_to(p).abs() <= tol.confusion()
564    }
565
566    /// Whether `p` lies strictly inside.
567    #[must_use]
568    pub fn encloses(&self, p: Point2, tol: Tolerances) -> bool {
569        self.signed_distance_to(p) < -tol.confusion()
570    }
571
572    /// This circle moved by `t`.
573    ///
574    /// The frame goes through the transform intact rather than being rebuilt
575    /// from the centre. The frame fixes where the angular parameter starts, so
576    /// discarding its orientation would keep the shape and silently renumber
577    /// every point on it, invisible to a distance check, and wrong for
578    /// anything holding a parameter.
579    ///
580    /// # Errors
581    ///
582    /// As [`Circle2::new`].
583    pub fn transformed(&self, t: &Transform2, tol: Tolerances) -> OgeomResult<Self> {
584        Self::new(
585            t.apply_frame(&self.frame, tol)?,
586            self.radius * t.scale_factor().abs(),
587            tol,
588        )
589    }
590}
591
592impl Ellipse2 {
593    /// An ellipse with the given radii, the major radius along `frame`'s `x`.
594    ///
595    /// # Errors
596    ///
597    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a radius is
598    /// not finite and positive, or the minor exceeds the major.
599    pub fn new(
600        frame: Frame2,
601        major_radius: f64,
602        minor_radius: f64,
603        tol: Tolerances,
604    ) -> OgeomResult<Self> {
605        check_positive("major radius", major_radius, tol)?;
606        check_positive("minor radius", minor_radius, tol)?;
607        if minor_radius > major_radius + tol.confusion() {
608            ogeom_bail!(
609                Construction,
610                "minor radius {minor_radius} exceeds major radius {major_radius}"
611            );
612        }
613        Ok(Self {
614            frame,
615            major_radius,
616            minor_radius,
617        })
618    }
619
620    /// The frame positioning this ellipse.
621    #[must_use]
622    pub const fn frame(&self) -> Frame2 {
623        self.frame
624    }
625
626    /// The centre.
627    #[must_use]
628    pub const fn centre(&self) -> Point2 {
629        self.frame.origin()
630    }
631
632    /// The major radius.
633    #[must_use]
634    pub const fn major_radius(&self) -> f64 {
635        self.major_radius
636    }
637
638    /// The minor radius.
639    #[must_use]
640    pub const fn minor_radius(&self) -> f64 {
641        self.minor_radius
642    }
643
644    /// The distance from the centre to either focus.
645    #[must_use]
646    pub fn focal_distance(&self) -> f64 {
647        ((self.major_radius - self.minor_radius) * (self.major_radius + self.minor_radius)).sqrt()
648    }
649
650    /// The eccentricity, in `[0, 1)`.
651    #[must_use]
652    pub fn eccentricity(&self) -> f64 {
653        self.focal_distance() / self.major_radius
654    }
655
656    /// The area enclosed.
657    #[must_use]
658    pub fn area(&self) -> f64 {
659        core::f64::consts::PI * self.major_radius * self.minor_radius
660    }
661
662    /// This ellipse moved by `t`.
663    ///
664    /// # Errors
665    ///
666    /// As [`Ellipse2::new`].
667    pub fn transformed(&self, t: &Transform2, tol: Tolerances) -> OgeomResult<Self> {
668        let s = t.scale_factor().abs();
669        Self::new(
670            t.apply_frame(&self.frame, tol)?,
671            self.major_radius * s,
672            self.minor_radius * s,
673            tol,
674        )
675    }
676}
677
678impl Hyperbola2 {
679    /// A hyperbola with the given radii.
680    ///
681    /// # Errors
682    ///
683    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if a radius is
684    /// not finite and positive.
685    pub fn new(
686        frame: Frame2,
687        major_radius: f64,
688        minor_radius: f64,
689        tol: Tolerances,
690    ) -> OgeomResult<Self> {
691        check_positive("major radius", major_radius, tol)?;
692        check_positive("minor radius", minor_radius, tol)?;
693        Ok(Self {
694            frame,
695            major_radius,
696            minor_radius,
697        })
698    }
699
700    /// The frame positioning this hyperbola.
701    #[must_use]
702    pub const fn frame(&self) -> Frame2 {
703        self.frame
704    }
705
706    /// The centre.
707    #[must_use]
708    pub const fn centre(&self) -> Point2 {
709        self.frame.origin()
710    }
711
712    /// The major radius.
713    #[must_use]
714    pub const fn major_radius(&self) -> f64 {
715        self.major_radius
716    }
717
718    /// The minor radius.
719    #[must_use]
720    pub const fn minor_radius(&self) -> f64 {
721        self.minor_radius
722    }
723
724    /// The eccentricity, always greater than 1.
725    #[must_use]
726    pub fn eccentricity(&self) -> f64 {
727        self.major_radius.hypot(self.minor_radius) / self.major_radius
728    }
729}
730
731impl Parabola2 {
732    /// A parabola with the given focal length, opening along `frame`'s `x`.
733    ///
734    /// # Errors
735    ///
736    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `focal` is
737    /// not finite and positive.
738    pub fn new(frame: Frame2, focal: f64, tol: Tolerances) -> OgeomResult<Self> {
739        check_positive("focal length", focal, tol)?;
740        Ok(Self { frame, focal })
741    }
742
743    /// The frame positioning this parabola.
744    #[must_use]
745    pub const fn frame(&self) -> Frame2 {
746        self.frame
747    }
748
749    /// The apex.
750    #[must_use]
751    pub const fn apex(&self) -> Point2 {
752        self.frame.origin()
753    }
754
755    /// The focal length.
756    #[must_use]
757    pub const fn focal(&self) -> f64 {
758        self.focal
759    }
760
761    /// The focus.
762    #[must_use]
763    pub fn focus(&self) -> Point2 {
764        self.apex() + self.frame.x() * self.focal
765    }
766}
767
768#[cfg(test)]
769#[allow(clippy::unwrap_used)]
770mod tests {
771    use super::*;
772    use crate::{Axis, Direction, Vector};
773    use approx::assert_relative_eq;
774
775    const T: Tolerances = Tolerances::millimetres();
776
777    #[test]
778    fn degenerate_sizes_are_refused() {
779        let f = Frame::WORLD;
780        assert!(Circle::new(f, 0.0, T).is_err());
781        assert!(Circle::new(f, -1.0, T).is_err());
782        assert!(Circle::new(f, f64::NAN, T).is_err());
783        assert!(Circle::new(f, f64::INFINITY, T).is_err());
784        assert!(Parabola::new(f, 0.0, T).is_err());
785        // An ellipse whose minor radius exceeds its major is not an ellipse with
786        // the axes swapped, it is a construction error.
787        assert!(Ellipse::new(f, 1.0, 2.0, T).is_err());
788        assert!(Ellipse::new(f, 2.0, 1.0, T).is_ok());
789        // A hyperbola has no such constraint.
790        assert!(Hyperbola::new(f, 1.0, 2.0, T).is_ok());
791    }
792
793    #[test]
794    fn circle_measurements() {
795        let c = Circle::new(Frame::WORLD, 2.0, T).unwrap();
796        assert_relative_eq!(c.length(), core::f64::consts::TAU * 2.0);
797        assert_relative_eq!(c.area(), core::f64::consts::PI * 4.0);
798        assert_eq!(c.centre(), Point::ORIGIN);
799    }
800
801    #[test]
802    fn circle_distance_is_to_the_curve_not_the_disc() {
803        let c = Circle::new(Frame::WORLD, 5.0, T).unwrap();
804        // The centre is 5 from the circle, not 0.
805        assert_relative_eq!(c.distance_to(Point::ORIGIN), 5.0);
806        assert_relative_eq!(c.distance_to(Point::new(5.0, 0.0, 0.0)), 0.0);
807        assert_relative_eq!(c.distance_to(Point::new(7.0, 0.0, 0.0)), 2.0);
808        assert_relative_eq!(c.distance_to(Point::new(3.0, 0.0, 0.0)), 2.0);
809        // Off the plane, the distance combines radial and axial parts.
810        assert_relative_eq!(c.distance_to(Point::new(5.0, 0.0, 3.0)), 3.0);
811        assert_relative_eq!(c.distance_to(Point::new(1.0, 0.0, 3.0)), 5.0);
812        assert!(c.contains(Point::new(0.0, 5.0, 0.0), T));
813    }
814
815    #[test]
816    fn circle_through_three_points() {
817        // Three points on the unit circle in the xy plane.
818        let c = Circle::through(
819            Point::new(1.0, 0.0, 0.0),
820            Point::new(0.0, 1.0, 0.0),
821            Point::new(-1.0, 0.0, 0.0),
822            T,
823        )
824        .unwrap();
825        assert_relative_eq!(c.radius(), 1.0, epsilon = 1e-12);
826        assert!(c.centre().is_equal(Point::ORIGIN, T));
827        assert!(c.contains(Point::new(0.0, -1.0, 0.0), T));
828    }
829
830    #[test]
831    fn circle_through_three_points_works_off_axis_and_at_scale() {
832        for scale in [1e-3_f64, 1.0, 1e3] {
833            let a = Point::new(3.0 * scale, scale, 2.0 * scale);
834            let b = Point::new(scale, 5.0 * scale, -scale);
835            let c = Point::new(-2.0 * scale, 0.0, 4.0 * scale);
836            let circle = Circle::through(a, b, c, T).unwrap();
837            // Defining property: all three are on it, equidistant from centre.
838            for p in [a, b, c] {
839                assert_relative_eq!(
840                    circle.centre().distance(p),
841                    circle.radius(),
842                    max_relative = 1e-10
843                );
844            }
845        }
846    }
847
848    #[test]
849    fn collinear_points_admit_no_circle() {
850        let a = Point::ORIGIN;
851        let b = Point::new(1.0, 1.0, 1.0);
852        let c = Point::new(2.0, 2.0, 2.0);
853        assert!(Circle::through(a, b, c, T).is_err());
854        assert!(Circle::through(a, b, b, T).is_err());
855        assert!(Circle::through(a, a, a, T).is_err());
856    }
857
858    #[test]
859    fn collinearity_test_is_scale_free() {
860        // Three points a micron apart are not collinear just because they are
861        // close together. An absolute threshold on the cross product would
862        // wrongly reject this.
863        let s = 1e-6;
864        let circle = Circle::through(
865            Point::new(s, 0.0, 0.0),
866            Point::new(0.0, s, 0.0),
867            Point::new(-s, 0.0, 0.0),
868            T,
869        )
870        .unwrap();
871        assert_relative_eq!(circle.radius(), s, max_relative = 1e-9);
872    }
873
874    #[test]
875    fn ellipse_focal_geometry() {
876        let e = Ellipse::new(Frame::WORLD, 5.0, 3.0, T).unwrap();
877        assert_relative_eq!(e.focal_distance(), 4.0, epsilon = 1e-12);
878        assert_relative_eq!(e.eccentricity(), 0.8, epsilon = 1e-12);
879        let (f1, f2) = e.foci();
880        assert!(f1.is_equal(Point::new(4.0, 0.0, 0.0), T));
881        assert!(f2.is_equal(Point::new(-4.0, 0.0, 0.0), T));
882        // The defining property: distances to the two foci sum to 2a.
883        let on_curve = Point::new(0.0, 3.0, 0.0);
884        assert_relative_eq!(
885            on_curve.distance(f1) + on_curve.distance(f2),
886            10.0,
887            epsilon = 1e-12
888        );
889    }
890
891    #[test]
892    fn nearly_circular_ellipse_keeps_focal_precision() {
893        // a^2 - b^2 with a and b nearly equal cancels catastrophically; the
894        // factored form does not.
895        let a = 1.0;
896        let b = 1.0 - 1e-12;
897        let e = Ellipse::new(Frame::WORLD, a, b, T).unwrap();
898        let expected = ((a - b) * (a + b)).sqrt();
899        assert_relative_eq!(e.focal_distance(), expected, max_relative = 1e-12);
900        assert!(e.focal_distance() > 0.0, "must not collapse to zero");
901    }
902
903    #[test]
904    fn ellipse_length_matches_the_circle_it_degenerates_to() {
905        let r = 3.0;
906        let e = Ellipse::new(Frame::WORLD, r, r, T).unwrap();
907        assert_relative_eq!(e.length(), core::f64::consts::TAU * r, max_relative = 1e-12);
908        assert_relative_eq!(e.area(), core::f64::consts::PI * r * r);
909        assert_relative_eq!(e.eccentricity(), 0.0);
910    }
911
912    #[test]
913    fn ellipse_length_is_accurate_at_every_eccentricity() {
914        // Reference values from numerical quadrature of the arc-length
915        // integral, independent of the implementation under test.
916        let cases = [
917            (10.0, 1.0, 40.639_741_801_0),
918            (2.0, 1.0, 9.688_448_220_5),
919            (1.0, 1.0, core::f64::consts::TAU),
920            (100.0, 1.0, 400.109_832_972_2),
921        ];
922        for (a, b, expected) in cases {
923            let e = Ellipse::new(Frame::WORLD, a, b, T).unwrap();
924            // The references come from quadrature, which itself carries error,
925            // so the bound reflects the reference rather than the method.
926            assert_relative_eq!(e.length(), expected, max_relative = 1e-9);
927        }
928    }
929
930    #[test]
931    fn complete_elliptic_integral_endpoints() {
932        assert_relative_eq!(complete_elliptic_e(0.0), core::f64::consts::FRAC_PI_2);
933        assert_relative_eq!(complete_elliptic_e(1.0), 1.0);
934        // Monotonically decreasing in m.
935        let mut previous = complete_elliptic_e(0.0);
936        for i in 1..=20 {
937            let e = complete_elliptic_e(f64::from(i) / 20.0);
938            assert!(
939                e < previous,
940                "not decreasing at m = {}",
941                f64::from(i) / 20.0
942            );
943            previous = e;
944        }
945    }
946
947    #[test]
948    fn hyperbola_focal_geometry() {
949        let h = Hyperbola::new(Frame::WORLD, 3.0, 4.0, T).unwrap();
950        assert_relative_eq!(h.focal_distance(), 5.0, epsilon = 1e-12);
951        assert_relative_eq!(h.eccentricity(), 5.0 / 3.0, epsilon = 1e-12);
952        assert!(h.eccentricity() > 1.0);
953        assert!(h.vertex().is_equal(Point::new(3.0, 0.0, 0.0), T));
954    }
955
956    #[test]
957    fn parabola_focal_geometry() {
958        let p = Parabola::new(Frame::WORLD, 2.0, T).unwrap();
959        assert!(p.apex().is_equal(Point::ORIGIN, T));
960        assert!(p.focus().is_equal(Point::new(2.0, 0.0, 0.0), T));
961        assert_relative_eq!(p.eccentricity(), 1.0);
962    }
963
964    #[test]
965    fn transforms_scale_sizes_and_move_frames() {
966        let c = Circle::new(Frame::WORLD, 2.0, T).unwrap();
967        let t = Transform::scaling(Point::ORIGIN, 3.0, T).unwrap()
968            * Transform::translation(Vector::new(1.0, 0.0, 0.0));
969        let moved = c.transformed(&t, T).unwrap();
970        assert_relative_eq!(moved.radius(), 6.0, epsilon = 1e-12);
971        assert!(moved.centre().is_equal(Point::new(3.0, 0.0, 0.0), T));
972
973        // A rotation leaves the radius alone but moves the frame.
974        let r = Transform::rotation(Axis::X, core::f64::consts::FRAC_PI_2);
975        let rotated = c.transformed(&r, T).unwrap();
976        assert_relative_eq!(rotated.radius(), 2.0, epsilon = 1e-12);
977        assert!(rotated.frame().z().is_equal(-Direction::Y, T));
978    }
979
980    #[test]
981    fn mirroring_a_circle_keeps_a_positive_radius() {
982        // A negative scale factor must not produce a negative radius; the shape
983        // is mirrored through its frame, not inverted.
984        let c = Circle::new(Frame::WORLD, 2.0, T).unwrap();
985        let m = Transform::point_mirror(Point::ORIGIN);
986        let mirrored = c.transformed(&m, T).unwrap();
987        assert_relative_eq!(mirrored.radius(), 2.0);
988    }
989
990    #[test]
991    fn circle2_signed_distance_and_containment() {
992        let c = Circle2::centred(Point2::new(1.0, 1.0), 2.0, T).unwrap();
993        assert_relative_eq!(c.signed_distance_to(Point2::new(1.0, 1.0)), -2.0);
994        assert_relative_eq!(c.signed_distance_to(Point2::new(3.0, 1.0)), 0.0);
995        assert_relative_eq!(c.signed_distance_to(Point2::new(5.0, 1.0)), 2.0);
996        assert!(c.encloses(Point2::new(1.0, 1.0), T));
997        assert!(
998            !c.encloses(Point2::new(3.0, 1.0), T),
999            "on the boundary is not inside"
1000        );
1001        assert!(c.contains(Point2::new(3.0, 1.0), T));
1002    }
1003
1004    #[test]
1005    fn a_planar_circles_frame_survives_a_transform() {
1006        // The frame fixes where the angular parameter starts. Rebuilding it
1007        // from the centre would keep the shape and renumber every point on it,
1008        // which is invisible to a distance check and wrong for anything that
1009        // refers to a parameter.
1010        let f = Frame2::new(Point2::new(1.0, 2.0), crate::Direction2::from_angle(0.9));
1011        let c = Circle2::new(f, 2.0, T).unwrap();
1012        let rot = Transform2::rotation(Point2::ORIGIN, 0.5);
1013        let moved = c.transformed(&rot, T).unwrap();
1014
1015        assert!(moved.centre().is_equal(rot.apply(c.centre()), T));
1016        let expected = rot.apply_direction(f.x(), T).unwrap();
1017        assert!(moved.frame().x().is_equal(expected, T));
1018
1019        // The point at angle zero moves with the transform, rather than jumping
1020        // to wherever a rebuilt frame would have put it.
1021        let at_zero = c.centre() + f.x() * 2.0;
1022        let moved_at_zero = moved.centre() + moved.frame().x() * 2.0;
1023        assert!(moved_at_zero.is_equal(rot.apply(at_zero), T));
1024    }
1025
1026    #[test]
1027    fn planar_conics_mirror_their_spatial_counterparts() {
1028        let e = Ellipse2::new(Frame2::WORLD, 5.0, 3.0, T).unwrap();
1029        assert_relative_eq!(e.focal_distance(), 4.0, epsilon = 1e-12);
1030        assert_relative_eq!(e.eccentricity(), 0.8, epsilon = 1e-12);
1031        assert_relative_eq!(e.area(), core::f64::consts::PI * 15.0);
1032
1033        let h = Hyperbola2::new(Frame2::WORLD, 3.0, 4.0, T).unwrap();
1034        assert_relative_eq!(h.eccentricity(), 5.0 / 3.0, epsilon = 1e-12);
1035
1036        let p = Parabola2::new(Frame2::WORLD, 2.0, T).unwrap();
1037        assert!(p.focus().is_equal(Point2::new(2.0, 0.0), T));
1038
1039        assert!(Ellipse2::new(Frame2::WORLD, 1.0, 2.0, T).is_err());
1040    }
1041}