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