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