Skip to main content

ogeom_math/
elementary.rs

1//! Parameterization of the analytic primitives.
2//!
3//! Evaluation, derivatives and parameter inversion for every shape in
4//! [`crate::conic`] and [`crate::quadric`]. Each shape's parameterization is
5//! defined relative to its own [`Frame`](crate::Frame), which is what makes a
6//! parameter value mean the same place across a save, a reload and a transform.
7//!
8//! # Conventions
9//!
10//! - **Circle, ellipse:** angle from the frame's `x` axis towards `y`.
11//! - **Hyperbola:** `(a cosh t, b sinh t)`, describing the `+x` branch.
12//! - **Parabola:** `(t^2 / (4 f), t)` with the apex at `t = 0`.
13//! - **Plane:** `(u, v)` are the coordinates along `x` and `y`.
14//! - **Cylinder:** `(angle, height)`.
15//! - **Cone:** `(angle, height)`, with the radius varying along the height.
16//! - **Sphere:** `(longitude, latitude)`, latitude in `[-pi/2, pi/2]`.
17//! - **Torus:** `(angle about the axis, angle around the tube)`.
18//!
19//! Every surface here follows the same `(u, v)` order, `u` going around and `v`
20//! going along. Getting that consistent matters: a caller that has to remember
21//! which surface reverses the convention will eventually forget.
22
23use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
24
25use crate::{
26    Axis, Circle, Cone, Cylinder, Direction, Ellipse, Hyperbola, Parabola, Plane, Point, Sphere,
27    Torus, Vector,
28};
29
30/// A point on a curve with its first two derivatives.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct CurvePoint {
33    /// The position.
34    pub point: Point,
35    /// First derivative with respect to the parameter.
36    pub d1: Vector,
37    /// Second derivative.
38    pub d2: Vector,
39}
40
41impl CurvePoint {
42    /// The unit tangent.
43    ///
44    /// # Errors
45    ///
46    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) at a cusp,
47    /// where the first derivative vanishes and there is no tangent direction.
48    pub fn tangent(&self, tol: Tolerances) -> OgeomResult<Direction> {
49        Direction::new(self.d1, tol)
50    }
51
52    /// The curvature.
53    ///
54    /// `|d1 x d2| / |d1|^3`. Zero on a straight section, and the reciprocal of
55    /// the radius on a circle.
56    #[must_use]
57    pub fn curvature(&self) -> f64 {
58        let speed = self.d1.magnitude();
59        if speed == 0.0 {
60            return 0.0;
61        }
62        self.d1.cross(self.d2).magnitude() / (speed * speed * speed)
63    }
64}
65
66/// A point on a surface with its first derivatives.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub struct SurfacePoint {
69    /// The position.
70    pub point: Point,
71    /// Derivative along `u`.
72    pub du: Vector,
73    /// Derivative along `v`.
74    pub dv: Vector,
75}
76
77impl SurfacePoint {
78    /// The unit normal, `du x dv` normalized.
79    ///
80    /// # Errors
81    ///
82    /// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) at a
83    /// degeneracy (a sphere's pole or a cone's apex), where no normal is
84    /// determined by the tangents.
85    pub fn normal(&self, tol: Tolerances) -> OgeomResult<Direction> {
86        if self.is_degenerate(tol) {
87            ogeom_bail!(
88                Construction,
89                "surface point is degenerate; the tangents determine no normal"
90            );
91        }
92        Direction::new(self.du.cross(self.dv), tol)
93    }
94
95    /// Whether the tangents fail to determine a normal.
96    ///
97    /// Compared against the *square of the larger tangent*, not against the
98    /// product of the two. The product test asks whether the tangents are
99    /// collinear, which is only one of the two ways a surface degenerates: at a
100    /// sphere's pole or a cone's apex one tangent *vanishes*, and there the
101    /// product is itself near zero, so a relative-to-product test finds the
102    /// cross product respectably large by comparison and declares the point
103    /// healthy. Squaring the larger tangent keeps a fixed scale to judge
104    /// against and catches both cases.
105    #[must_use]
106    pub fn is_degenerate(&self, tol: Tolerances) -> bool {
107        let scale = self.du.magnitude().max(self.dv.magnitude());
108        self.du.cross(self.dv).magnitude() <= tol.angular() * scale * scale
109    }
110}
111
112/// Wrap `angle` into `[0, 2*pi)`.
113#[must_use]
114pub fn wrap_angle(angle: f64) -> f64 {
115    let wrapped = angle.rem_euclid(core::f64::consts::TAU);
116    // `rem_euclid` can return exactly TAU for a tiny negative input, which
117    // would put a supposedly-normalized angle outside its own range.
118    if wrapped >= core::f64::consts::TAU {
119        0.0
120    } else {
121        wrapped
122    }
123}
124
125/// Wrap `angle` into `(-pi, pi]`.
126#[must_use]
127pub fn wrap_signed_angle(angle: f64) -> f64 {
128    let wrapped = wrap_angle(angle);
129    if wrapped > core::f64::consts::PI {
130        wrapped - core::f64::consts::TAU
131    } else {
132        wrapped
133    }
134}
135
136/// Evaluate a line at `t`, measured in length along its direction.
137#[must_use]
138pub fn line_at(axis: Axis, t: f64) -> CurvePoint {
139    CurvePoint {
140        point: axis.point_at(t),
141        d1: axis.direction.vector(),
142        d2: Vector::ZERO,
143    }
144}
145
146/// The parameter of the projection of `p` onto a line.
147#[must_use]
148pub fn line_parameter(axis: Axis, p: Point) -> f64 {
149    axis.parameter_of(p)
150}
151
152/// Evaluate a circle at `angle`.
153#[must_use]
154pub fn circle_at(circle: &Circle, angle: f64) -> CurvePoint {
155    let f = circle.frame();
156    let r = circle.radius();
157    let (sin, cos) = angle.sin_cos();
158    let (x, y) = (f.x().vector(), f.y().vector());
159    CurvePoint {
160        point: circle.centre() + x * (r * cos) + y * (r * sin),
161        d1: x * (-r * sin) + y * (r * cos),
162        d2: x * (-r * cos) + y * (-r * sin),
163    }
164}
165
166/// The angle of the point on a circle nearest `p`, in `[0, 2*pi)`.
167///
168/// # Errors
169///
170/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `p` lies on the
171/// circle's axis, where every angle is equally near.
172pub fn circle_parameter(circle: &Circle, p: Point, tol: Tolerances) -> OgeomResult<f64> {
173    let local = circle.frame().to_local(p);
174    if local.x.hypot(local.y) <= tol.confusion() {
175        ogeom_bail!(
176            Construction,
177            "point is on the circle's axis; no nearest angle"
178        );
179    }
180    Ok(wrap_angle(local.y.atan2(local.x)))
181}
182
183/// Evaluate an ellipse at `angle`.
184///
185/// The parameter is the *eccentric* angle, not the polar one: the point is
186/// `(a cos t, b sin t)`. That keeps evaluation free of trigonometric inversion
187/// and matches every exchange format.
188#[must_use]
189pub fn ellipse_at(ellipse: &Ellipse, angle: f64) -> CurvePoint {
190    let f = ellipse.frame();
191    let (a, b) = (ellipse.major_radius(), ellipse.minor_radius());
192    let (sin, cos) = angle.sin_cos();
193    let (x, y) = (f.x().vector(), f.y().vector());
194    CurvePoint {
195        point: ellipse.centre() + x * (a * cos) + y * (b * sin),
196        d1: x * (-a * sin) + y * (b * cos),
197        d2: x * (-a * cos) + y * (-b * sin),
198    }
199}
200
201/// The eccentric angle of the point on an ellipse in the direction of `p`.
202///
203/// # Errors
204///
205/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `p` projects to
206/// the centre.
207pub fn ellipse_parameter(ellipse: &Ellipse, p: Point, tol: Tolerances) -> OgeomResult<f64> {
208    let local = ellipse.frame().to_local(p);
209    if local.x.hypot(local.y) <= tol.confusion() {
210        ogeom_bail!(Construction, "point projects to the ellipse's centre");
211    }
212    // Undo the axis scaling before taking the angle, or the result is the polar
213    // angle rather than the eccentric one.
214    Ok(wrap_angle(
215        (local.y / ellipse.minor_radius()).atan2(local.x / ellipse.major_radius()),
216    ))
217}
218
219/// Evaluate a hyperbola at `t`, on the `+x` branch.
220#[must_use]
221pub fn hyperbola_at(hyperbola: &Hyperbola, t: f64) -> CurvePoint {
222    let f = hyperbola.frame();
223    let (a, b) = (hyperbola.major_radius(), hyperbola.minor_radius());
224    let (cosh, sinh) = (t.cosh(), t.sinh());
225    let (x, y) = (f.x().vector(), f.y().vector());
226    CurvePoint {
227        point: hyperbola.centre() + x * (a * cosh) + y * (b * sinh),
228        d1: x * (a * sinh) + y * (b * cosh),
229        // The second derivative of cosh is cosh, and of sinh is sinh, so this
230        // is the position vector again; a hyperbola's acceleration points
231        // away from its centre.
232        d2: x * (a * cosh) + y * (b * sinh),
233    }
234}
235
236/// The parameter of the point on a hyperbola in the direction of `p`.
237///
238/// # Errors
239///
240/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if `p` projects to the far
241/// branch, which this hyperbola does not describe.
242pub fn hyperbola_parameter(hyperbola: &Hyperbola, p: Point, tol: Tolerances) -> OgeomResult<f64> {
243    let local = hyperbola.frame().to_local(p);
244    let a = local.x / hyperbola.major_radius();
245    if a < 1.0 - tol.parametric() {
246        ogeom_bail!(
247            Domain,
248            "point is not on the described branch of the hyperbola"
249        );
250    }
251    // asinh rather than acosh: acosh loses precision near t = 0, where its
252    // argument approaches 1 and its derivative is unbounded.
253    Ok((local.y / hyperbola.minor_radius()).asinh())
254}
255
256/// Evaluate a parabola at `t`.
257#[must_use]
258pub fn parabola_at(parabola: &Parabola, t: f64) -> CurvePoint {
259    let f = parabola.frame();
260    let focal = parabola.focal();
261    let (x, y) = (f.x().vector(), f.y().vector());
262    let scale = 1.0 / (4.0 * focal);
263    CurvePoint {
264        point: parabola.apex() + x * (t * t * scale) + y * t,
265        d1: x * (2.0 * t * scale) + y,
266        d2: x * (2.0 * scale),
267    }
268}
269
270/// The parameter of the point on a parabola level with `p`.
271#[must_use]
272pub fn parabola_parameter(parabola: &Parabola, p: Point) -> f64 {
273    parabola.frame().to_local(p).y
274}
275
276/// Evaluate a plane at `(u, v)`, the coordinates along its `x` and `y` axes.
277#[must_use]
278pub fn plane_at(plane: &Plane, u: f64, v: f64) -> SurfacePoint {
279    let f = plane.frame();
280    SurfacePoint {
281        point: f.origin() + f.x() * u + f.y() * v,
282        du: f.x().vector(),
283        dv: f.y().vector(),
284    }
285}
286
287/// The `(u, v)` of the projection of `p` onto a plane.
288#[must_use]
289pub fn plane_parameters(plane: &Plane, p: Point) -> (f64, f64) {
290    let local = plane.frame().to_local(p);
291    (local.x, local.y)
292}
293
294/// Evaluate a cylinder at `(angle, height)`.
295#[must_use]
296pub fn cylinder_at(cylinder: &Cylinder, angle: f64, height: f64) -> SurfacePoint {
297    let f = cylinder.frame();
298    let r = cylinder.radius();
299    let (sin, cos) = angle.sin_cos();
300    let (x, y, z) = (f.x().vector(), f.y().vector(), f.z().vector());
301    SurfacePoint {
302        point: f.origin() + x * (r * cos) + y * (r * sin) + z * height,
303        du: x * (-r * sin) + y * (r * cos),
304        dv: z,
305    }
306}
307
308/// The `(angle, height)` of the point on a cylinder nearest `p`.
309///
310/// # Errors
311///
312/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `p` lies on the
313/// axis.
314pub fn cylinder_parameters(
315    cylinder: &Cylinder,
316    p: Point,
317    tol: Tolerances,
318) -> OgeomResult<(f64, f64)> {
319    let local = cylinder.frame().to_local(p);
320    if local.x.hypot(local.y) <= tol.confusion() {
321        ogeom_bail!(
322            Construction,
323            "point is on the cylinder's axis; no nearest angle"
324        );
325    }
326    Ok((wrap_angle(local.y.atan2(local.x)), local.z))
327}
328
329/// Evaluate a cone at `(angle, height)`.
330///
331/// At the apex the two tangents are collinear and the surface has no normal;
332/// [`SurfacePoint::normal`] reports that rather than returning a made-up
333/// direction.
334#[must_use]
335pub fn cone_at(cone: &Cone, angle: f64, height: f64) -> SurfacePoint {
336    let f = cone.frame();
337    let r = cone.radius_at(height);
338    let slope = cone.half_angle().tan();
339    let (sin, cos) = angle.sin_cos();
340    let (x, y, z) = (f.x().vector(), f.y().vector(), f.z().vector());
341    SurfacePoint {
342        point: f.origin() + x * (r * cos) + y * (r * sin) + z * height,
343        du: x * (-r * sin) + y * (r * cos),
344        dv: x * (slope * cos) + y * (slope * sin) + z,
345    }
346}
347
348/// The `(angle, height)` of the point on a cone nearest `p`.
349///
350/// # Errors
351///
352/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `p` lies on the
353/// axis.
354pub fn cone_parameters(cone: &Cone, p: Point, tol: Tolerances) -> OgeomResult<(f64, f64)> {
355    let local = cone.frame().to_local(p);
356    if local.x.hypot(local.y) <= tol.confusion() {
357        ogeom_bail!(
358            Construction,
359            "point is on the cone's axis; no nearest angle"
360        );
361    }
362    Ok((wrap_angle(local.y.atan2(local.x)), local.z))
363}
364
365/// Evaluate a sphere at `(longitude, latitude)`.
366///
367/// Latitude runs from `-pi/2` at the `-z` pole to `+pi/2` at `+z`.
368#[must_use]
369pub fn sphere_at(sphere: &Sphere, longitude: f64, latitude: f64) -> SurfacePoint {
370    let f = sphere.frame();
371    let r = sphere.radius();
372    let (sin_lon, cos_lon) = longitude.sin_cos();
373    let (sin_lat, cos_lat) = latitude.sin_cos();
374    let (x, y, z) = (f.x().vector(), f.y().vector(), f.z().vector());
375    let ring = r * cos_lat;
376    SurfacePoint {
377        point: sphere.centre() + x * (ring * cos_lon) + y * (ring * sin_lon) + z * (r * sin_lat),
378        du: x * (-ring * sin_lon) + y * (ring * cos_lon),
379        dv: x * (-r * sin_lat * cos_lon) + y * (-r * sin_lat * sin_lon) + z * (r * cos_lat),
380    }
381}
382
383/// The `(longitude, latitude)` of the point on a sphere nearest `p`.
384///
385/// # Errors
386///
387/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `p` is the
388/// centre. At a pole the latitude is well defined but the longitude is
389/// arbitrary; zero is returned rather than an error, since the *position* is
390/// unambiguous and callers overwhelmingly want it.
391pub fn sphere_parameters(sphere: &Sphere, p: Point, tol: Tolerances) -> OgeomResult<(f64, f64)> {
392    let local = sphere.frame().to_local(p);
393    let ring = local.x.hypot(local.y);
394    if ring.hypot(local.z) <= tol.confusion() {
395        ogeom_bail!(Construction, "point is the sphere's centre");
396    }
397    let longitude = if ring <= tol.confusion() {
398        0.0
399    } else {
400        wrap_angle(local.y.atan2(local.x))
401    };
402    // atan2 of z against the ring radius, not asin of z/r: the point need not
403    // be exactly on the sphere, and this stays correct and accurate when it is
404    // not.
405    Ok((longitude, local.z.atan2(ring)))
406}
407
408/// Evaluate a torus at `(around the axis, around the tube)`.
409#[must_use]
410pub fn torus_at(torus: &Torus, u: f64, v: f64) -> SurfacePoint {
411    let f = torus.frame();
412    let (major, minor) = (torus.major_radius(), torus.minor_radius());
413    let (sin_u, cos_u) = u.sin_cos();
414    let (sin_v, cos_v) = v.sin_cos();
415    let (x, y, z) = (f.x().vector(), f.y().vector(), f.z().vector());
416    let out = x * cos_u + y * sin_u;
417    let radius = minor.mul_add(cos_v, major);
418    SurfacePoint {
419        point: f.origin() + out * radius + z * (minor * sin_v),
420        du: (x * -sin_u + y * cos_u) * radius,
421        dv: out * (-minor * sin_v) + z * (minor * cos_v),
422    }
423}
424
425/// The `(u, v)` of the point on a torus nearest `p`.
426///
427/// # Errors
428///
429/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if `p` lies on the
430/// axis, or on the tube's centre circle where every `v` is equally near.
431pub fn torus_parameters(torus: &Torus, p: Point, tol: Tolerances) -> OgeomResult<(f64, f64)> {
432    let local = torus.frame().to_local(p);
433    let ring = local.x.hypot(local.y);
434    if ring <= tol.confusion() {
435        ogeom_bail!(Construction, "point is on the torus's axis");
436    }
437    let u = wrap_angle(local.y.atan2(local.x));
438    let radial = ring - torus.major_radius();
439    if radial.hypot(local.z) <= tol.confusion() {
440        ogeom_bail!(Construction, "point is on the tube's centre circle");
441    }
442    Ok((u, wrap_angle(local.z.atan2(radial))))
443}
444
445#[cfg(test)]
446#[allow(clippy::unwrap_used)]
447mod tests {
448    use super::*;
449    use crate::Frame;
450    use approx::assert_relative_eq;
451
452    const T: Tolerances = Tolerances::millimetres();
453    const PI: f64 = core::f64::consts::PI;
454
455    fn tilted() -> Frame {
456        Frame::new(
457            Point::new(1.0, -2.0, 3.0),
458            Direction::from_coords(1.0, 2.0, 3.0, T).unwrap(),
459            Direction::from_coords(1.0, 0.0, 0.0, T).unwrap(),
460            T,
461        )
462        .unwrap()
463    }
464
465    /// Check a curve's analytic derivatives against central differences.
466    fn check_curve_derivatives(f: impl Fn(f64) -> CurvePoint, t: f64) {
467        let h = 1e-6;
468        let (a, b, c) = (f(t - h), f(t), f(t + h));
469        let d1 = (c.point - a.point) * (1.0 / (2.0 * h));
470        let d2 = (c.d1 - a.d1) * (1.0 / (2.0 * h));
471        let scale = d1.magnitude().max(1.0);
472        assert!((b.d1 - d1).magnitude() <= 1e-5 * scale, "d1 wrong at {t}");
473        assert!(
474            (b.d2 - d2).magnitude() <= 1e-5 * d2.magnitude().max(1.0),
475            "d2 wrong at {t}"
476        );
477    }
478
479    /// Check a surface's analytic derivatives against central differences.
480    fn check_surface_derivatives(f: impl Fn(f64, f64) -> SurfacePoint, u: f64, v: f64) {
481        let h = 1e-6;
482        let du = (f(u + h, v).point - f(u - h, v).point) * (1.0 / (2.0 * h));
483        let dv = (f(u, v + h).point - f(u, v - h).point) * (1.0 / (2.0 * h));
484        let p = f(u, v);
485        assert!(
486            (p.du - du).magnitude() <= 1e-5 * du.magnitude().max(1.0),
487            "du wrong"
488        );
489        assert!(
490            (p.dv - dv).magnitude() <= 1e-5 * dv.magnitude().max(1.0),
491            "dv wrong"
492        );
493    }
494
495    #[test]
496    fn angle_wrapping_stays_inside_its_range() {
497        for a in [-10.0_f64, -PI, -1e-18, 0.0, 1.0, PI, 7.0, 100.0] {
498            let w = wrap_angle(a);
499            assert!(
500                (0.0..core::f64::consts::TAU).contains(&w),
501                "{a} wrapped to {w}"
502            );
503            let s = wrap_signed_angle(a);
504            assert!(
505                s > -PI - 1e-15 && s <= PI + 1e-15,
506                "{a} signed-wrapped to {s}"
507            );
508        }
509        // A tiny negative input is the case rem_euclid can round up to exactly
510        // tau, which would put a normalized angle outside its own range.
511        assert_eq!(wrap_angle(-1e-300), 0.0);
512    }
513
514    #[test]
515    fn line_evaluation_and_inversion() {
516        let axis = Axis::new(Point::new(1.0, 2.0, 3.0), Direction::Z);
517        let c = line_at(axis, 5.0);
518        assert!(c.point.is_equal(Point::new(1.0, 2.0, 8.0), T));
519        assert!(c.d1.is_equal(Vector::Z, T));
520        assert_relative_eq!(c.curvature(), 0.0);
521        assert_relative_eq!(line_parameter(axis, c.point), 5.0, epsilon = 1e-12);
522    }
523
524    #[test]
525    fn circle_evaluation_derivatives_and_inversion() {
526        let c = Circle::new(tilted(), 3.0, T).unwrap();
527        for i in 0..12 {
528            let angle = f64::from(i) * PI / 6.0;
529            let p = circle_at(&c, angle);
530            assert!(c.contains(p.point, T));
531            assert_relative_eq!(
532                circle_parameter(&c, p.point, T).unwrap(),
533                wrap_angle(angle),
534                epsilon = 1e-12
535            );
536            check_curve_derivatives(|t| circle_at(&c, t), angle);
537            // Curvature is the reciprocal of the radius, everywhere.
538            assert_relative_eq!(p.curvature(), 1.0 / 3.0, epsilon = 1e-12);
539        }
540        assert!(circle_parameter(&c, c.centre(), T).is_err());
541    }
542
543    #[test]
544    fn circle_starts_on_its_frames_x_axis() {
545        // This is the whole point of carrying a frame: parameter zero is a
546        // specific, reproducible place.
547        let f = tilted();
548        let c = Circle::new(f, 2.0, T).unwrap();
549        assert!(
550            circle_at(&c, 0.0)
551                .point
552                .is_equal(c.centre() + f.x() * 2.0, T)
553        );
554        assert!(
555            circle_at(&c, PI / 2.0)
556                .point
557                .is_equal(c.centre() + f.y() * 2.0, T)
558        );
559    }
560
561    #[test]
562    fn ellipse_evaluation_derivatives_and_inversion() {
563        let e = Ellipse::new(tilted(), 5.0, 3.0, T).unwrap();
564        for i in 0..12 {
565            let angle = f64::from(i) * PI / 6.0;
566            let p = ellipse_at(&e, angle);
567            assert_relative_eq!(
568                ellipse_parameter(&e, p.point, T).unwrap(),
569                wrap_angle(angle),
570                epsilon = 1e-12
571            );
572            check_curve_derivatives(|t| ellipse_at(&e, t), angle);
573        }
574        // The eccentric angle is not the polar one; at 45 degrees eccentric the
575        // point is not at 45 degrees polar.
576        let p = ellipse_at(&e, PI / 4.0);
577        let local = e.frame().to_local(p.point);
578        assert_relative_eq!(
579            local.x,
580            5.0 * core::f64::consts::FRAC_1_SQRT_2,
581            epsilon = 1e-12
582        );
583        assert_relative_eq!(
584            local.y,
585            3.0 * core::f64::consts::FRAC_1_SQRT_2,
586            epsilon = 1e-12
587        );
588    }
589
590    #[test]
591    fn ellipse_curvature_is_extreme_at_the_ends_of_its_axes() {
592        let e = Ellipse::new(Frame::WORLD, 5.0, 3.0, T).unwrap();
593        // At the end of the major axis, curvature is b/a^2 * a... = a/b^2 form:
594        // kappa = a / b^2 at the minor-axis end, b / a^2 at the major-axis end.
595        assert_relative_eq!(ellipse_at(&e, 0.0).curvature(), 5.0 / 9.0, epsilon = 1e-12);
596        assert_relative_eq!(
597            ellipse_at(&e, PI / 2.0).curvature(),
598            3.0 / 25.0,
599            epsilon = 1e-12
600        );
601    }
602
603    #[test]
604    fn hyperbola_evaluation_and_inversion() {
605        let h = Hyperbola::new(tilted(), 3.0, 4.0, T).unwrap();
606        for t in [-2.0_f64, -0.5, 0.0, 0.5, 2.0] {
607            let p = hyperbola_at(&h, t);
608            assert_relative_eq!(
609                hyperbola_parameter(&h, p.point, T).unwrap(),
610                t,
611                epsilon = 1e-11
612            );
613            check_curve_derivatives(|s| hyperbola_at(&h, s), t);
614        }
615        assert!(hyperbola_at(&h, 0.0).point.is_equal(h.vertex(), T));
616        // The far branch is a different curve.
617        let far = h.centre() - h.frame().x() * 5.0;
618        assert!(hyperbola_parameter(&h, far, T).is_err());
619    }
620
621    #[test]
622    fn hyperbola_inversion_is_accurate_near_the_vertex() {
623        // The reason for asinh rather than acosh: at t near zero, acosh's
624        // argument approaches 1 where its derivative is unbounded, so it loses
625        // most of its precision exactly where curves are usually trimmed.
626        let h = Hyperbola::new(Frame::WORLD, 3.0, 4.0, T).unwrap();
627        for t in [1e-8_f64, 1e-5, 1e-3] {
628            let p = hyperbola_at(&h, t);
629            assert_relative_eq!(
630                hyperbola_parameter(&h, p.point, T).unwrap(),
631                t,
632                max_relative = 1e-9
633            );
634        }
635    }
636
637    #[test]
638    fn parabola_evaluation_and_inversion() {
639        let p = Parabola::new(tilted(), 2.0, T).unwrap();
640        for t in [-4.0_f64, -1.0, 0.0, 1.0, 4.0] {
641            let c = parabola_at(&p, t);
642            assert_relative_eq!(parabola_parameter(&p, c.point), t, epsilon = 1e-11);
643            check_curve_derivatives(|s| parabola_at(&p, s), t);
644        }
645        assert!(parabola_at(&p, 0.0).point.is_equal(p.apex(), T));
646        // Every point is equidistant from the focus and the directrix.
647        let focus = p.focus();
648        for t in [-3.0_f64, 1.0, 5.0] {
649            let point = parabola_at(&p, t).point;
650            let local = p.frame().to_local(point);
651            let to_directrix = local.x + p.focal();
652            assert_relative_eq!(point.distance(focus), to_directrix, epsilon = 1e-11);
653        }
654    }
655
656    #[test]
657    fn plane_evaluation_and_inversion() {
658        let plane = Plane::new(tilted());
659        for (u, v) in [(0.0, 0.0), (3.0, -2.0), (-100.0, 50.0)] {
660            let p = plane_at(&plane, u, v);
661            assert!(plane.contains(p.point, T));
662            let (bu, bv) = plane_parameters(&plane, p.point);
663            assert_relative_eq!(bu, u, epsilon = 1e-11);
664            assert_relative_eq!(bv, v, epsilon = 1e-11);
665            assert!(p.normal(T).unwrap().is_equal(plane.normal(), T));
666        }
667        check_surface_derivatives(|u, v| plane_at(&plane, u, v), 1.0, 2.0);
668    }
669
670    #[test]
671    fn cylinder_evaluation_inversion_and_normal() {
672        let c = Cylinder::new(tilted(), 2.0, T).unwrap();
673        for i in 0..8 {
674            let angle = f64::from(i) * PI / 4.0;
675            for h in [-5.0_f64, 0.0, 7.0] {
676                let p = cylinder_at(&c, angle, h);
677                assert!(c.contains(p.point, T));
678                let (ba, bh) = cylinder_parameters(&c, p.point, T).unwrap();
679                assert_relative_eq!(ba, wrap_angle(angle), epsilon = 1e-11);
680                assert_relative_eq!(bh, h, epsilon = 1e-11);
681                // The normal is radial, so perpendicular to the axis.
682                assert!(p.normal(T).unwrap().dot(c.frame().z()).abs() < 1e-12);
683            }
684        }
685        check_surface_derivatives(|u, v| cylinder_at(&c, u, v), 0.7, 3.0);
686        assert!(cylinder_parameters(&c, c.frame().origin(), T).is_err());
687    }
688
689    #[test]
690    fn cone_evaluation_inversion_and_apex_degeneracy() {
691        let c = Cone::new(tilted(), 3.0, 0.6, T).unwrap();
692        for i in 0..8 {
693            let angle = f64::from(i) * PI / 4.0;
694            for h in [-1.0_f64, 0.0, 4.0] {
695                let p = cone_at(&c, angle, h);
696                assert!(
697                    c.contains(p.point, T),
698                    "distance {}",
699                    c.distance_to(p.point)
700                );
701                let (ba, bh) = cone_parameters(&c, p.point, T).unwrap();
702                assert_relative_eq!(ba, wrap_angle(angle), epsilon = 1e-11);
703                assert_relative_eq!(bh, h, epsilon = 1e-11);
704            }
705        }
706        check_surface_derivatives(|u, v| cone_at(&c, u, v), 0.7, 2.0);
707
708        // At the apex the radius is zero, so the u-tangent vanishes and there
709        // is no normal. Reporting that beats inventing a direction.
710        let apex_height = -3.0 / 0.6_f64.tan();
711        let at_apex = cone_at(&c, 1.0, apex_height);
712        assert!(at_apex.point.is_equal(c.apex(), T));
713        assert!(at_apex.is_degenerate(T));
714        assert!(at_apex.normal(T).is_err());
715    }
716
717    #[test]
718    fn sphere_evaluation_inversion_and_poles() {
719        let s = Sphere::new(tilted(), 4.0, T).unwrap();
720        for i in 0..8 {
721            let lon = f64::from(i) * PI / 4.0;
722            for lat in [-1.2_f64, -0.4, 0.0, 0.9] {
723                let p = sphere_at(&s, lon, lat);
724                assert!(s.contains(p.point, T));
725                let (blon, blat) = sphere_parameters(&s, p.point, T).unwrap();
726                assert_relative_eq!(blon, wrap_angle(lon), epsilon = 1e-11);
727                assert_relative_eq!(blat, lat, epsilon = 1e-11);
728                // The normal is radial.
729                assert!(
730                    p.normal(T)
731                        .unwrap()
732                        .is_equal(s.normal_at(p.point, T).unwrap(), T)
733                );
734            }
735        }
736        check_surface_derivatives(|u, v| sphere_at(&s, u, v), 1.1, 0.3);
737
738        // At a pole the position is unambiguous even though the longitude is
739        // not, so inversion succeeds and picks zero.
740        let north = sphere_at(&s, 2.0, PI / 2.0);
741        assert!(north.point.is_equal(s.centre() + s.frame().z() * 4.0, T));
742        assert!(north.is_degenerate(T));
743        let (lon, lat) = sphere_parameters(&s, north.point, T).unwrap();
744        assert_relative_eq!(lon, 0.0);
745        assert_relative_eq!(lat, PI / 2.0, epsilon = 1e-8);
746        assert!(sphere_parameters(&s, s.centre(), T).is_err());
747    }
748
749    #[test]
750    fn torus_evaluation_inversion_and_normal() {
751        let t = Torus::new(tilted(), 5.0, 2.0, T).unwrap();
752        for i in 0..6 {
753            let u = f64::from(i) * PI / 3.0;
754            for j in 0..6 {
755                let v = f64::from(j) * PI / 3.0;
756                let p = torus_at(&t, u, v);
757                assert!(
758                    t.contains(p.point, T),
759                    "distance {}",
760                    t.distance_to(p.point)
761                );
762                let (bu, bv) = torus_parameters(&t, p.point, T).unwrap();
763                assert_relative_eq!(bu, wrap_angle(u), epsilon = 1e-10);
764                assert_relative_eq!(bv, wrap_angle(v), epsilon = 1e-10);
765                assert!(p.normal(T).is_ok());
766            }
767        }
768        check_surface_derivatives(|u, v| torus_at(&t, u, v), 0.7, 2.0);
769        assert!(torus_parameters(&t, t.centre(), T).is_err());
770    }
771
772    #[test]
773    fn curvature_of_a_circle_is_the_reciprocal_of_its_radius() {
774        for r in [0.1_f64, 1.0, 100.0] {
775            let c = Circle::new(Frame::WORLD, r, T).unwrap();
776            assert_relative_eq!(
777                circle_at(&c, 1.3).curvature(),
778                1.0 / r,
779                max_relative = 1e-12
780            );
781        }
782    }
783
784    #[test]
785    fn tangent_of_a_circle_is_perpendicular_to_its_radius() {
786        let c = Circle::new(tilted(), 3.0, T).unwrap();
787        for i in 0..8 {
788            let angle = f64::from(i) * PI / 4.0;
789            let p = circle_at(&c, angle);
790            let radius = p.point - c.centre();
791            assert!(p.tangent(T).unwrap().dot_vector(radius).abs() < 1e-12);
792        }
793    }
794}