Skip to main content

geop_core_geometry/nurb_curve/
area.rs

1//! The signed area a closed loop of 2-D curves encloses, integrated from
2//! the curves themselves ([`loop_signed_area`]) rather than from a polygon
3//! sampled along them.
4//!
5//! By Green's theorem the area a closed loop encloses is
6//!
7//! ```text
8//! A = ½ ∮ (u v' − v u') dt,
9//! ```
10//!
11//! positive counter-clockwise, and each curve of the loop adds its own part
12//! ([`NurbCurve::area_term`]). On a span where the weights are all the same
13//! number the curve is a polynomial of its degree `p`, the integrand one of
14//! degree `2p − 1`, and the Gauss–Legendre rule of `p` points integrates it
15//! exactly: the result is proven, with no truncation error. A rational
16//! curve, an arc say, is integrated by adaptive Gauss–Kronrod, its
17//! truncation error estimated (see [`geop_core_math::quadrature`]).
18//!
19//! A polygon of a fixed number of samples per curve has neither: the gap
20//! between it and the curve is not part of its area's enclosure, so a thin
21//! loop, or one of many spans, could come out with the wrong sign, and
22//! definitely so.
23
24use geop_core_math::{
25    geop_error::{GeopError, GeopResult},
26    quadrature::{MAX_POLYNOMIAL_DEGREE, Quadrature, integrate, integrate_polynomial},
27    scalars::Scalar,
28    vector::Vector2,
29};
30
31use super::NurbCurve2D;
32
33/// How hard the integral along a rational curve tries.
34const RATIONAL: Quadrature = Quadrature {
35    relative_tolerance: 1e-10,
36    max_panels: 64,
37};
38
39impl NurbCurve2D {
40    /// `½ ∫ ((u − o_u) v' − (v − o_v) u') dt` over the curve, `o` the
41    /// `origin`: its part of the signed area of a closed loop it belongs to
42    /// (see the module docs). The parts of one loop must be measured from
43    /// the same origin; their sum then does not depend on it.
44    ///
45    /// The curve is moved to `origin` once, so that every point of the
46    /// integrand comes with its tangent from one pass over the spline.
47    pub fn area_term(&self, origin: &Vector2) -> GeopResult<Scalar> {
48        let local = self.translate(Vector2::from_array([origin[0].neg(), origin[1].neg()]));
49        let integrand = |t: Scalar| -> GeopResult<Vec<Scalar>> {
50            let [p, d] = local.cartesian_derivatives::<2>(t, 1)?[..] else {
51                unreachable!("a point and its first derivative");
52            };
53            Ok(vec![p[0].mul(d[1]).sub(p[1].mul(d[0]))])
54        };
55        let breaks = self.breakpoints();
56        let degree = (2 * self.degree).saturating_sub(1);
57        let integral = if self.is_polynomial() && degree <= MAX_POLYNOMIAL_DEGREE {
58            integrate_polynomial(integrand, &breaks, 1, degree)?
59        } else {
60            integrate(integrand, &breaks, 1, &RATIONAL)?
61        };
62        integral.value[0].div(Scalar::TWO)
63    }
64
65    /// Whether every weight is the same number — the same enclosure, not
66    /// merely overlapping ones — so that the curve is a polynomial spline.
67    fn is_polynomial(&self) -> bool {
68        let w = self.control_points[0][2];
69        let same = |a: Scalar| a.is_subset_of(w) && w.is_subset_of(a);
70        self.control_points.iter().all(|c| same(c[2]))
71    }
72}
73
74/// The signed area the closed loop `pcurves` encloses, in order: positive
75/// counter-clockwise, negative clockwise (see the module docs).
76///
77/// Measured from the loop's first point, sharpened: where the parts are
78/// measured from is a free choice, and one near the loop keeps the large
79/// correlated products of a loop far from the origin out of the integrand.
80pub fn loop_signed_area<'a>(
81    pcurves: impl IntoIterator<Item = &'a NurbCurve2D>,
82) -> GeopResult<Scalar> {
83    let mut pcurves = pcurves.into_iter().peekable();
84    let first = pcurves
85        .peek()
86        .ok_or_else(|| GeopError::new("loop_signed_area: a loop of no curves"))?;
87    let origin = first.ends()?.0.sharpen();
88    pcurves.try_fold(Scalar::ZERO, |area, pcurve| {
89        Ok(area.add(pcurve.area_term(&origin)?))
90    })
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use geop_core_math::scalars::Scalar;
97
98    use crate::nurb_curve::sqrt2_over_2;
99
100    fn v2(u: f64, v: f64) -> Vector2 {
101        Vector2::from_array([Scalar::from_f64(u), Scalar::from_f64(v)])
102    }
103
104    /// The unit square, counter-clockwise and then clockwise, far from the
105    /// origin: an area of exactly one, of either sign.
106    #[test]
107    fn square_area_is_exact() {
108        let corners = [(1000., 500.), (1001., 500.), (1001., 501.), (1000., 501.)];
109        let ccw: Vec<NurbCurve2D> = (0..4)
110            .map(|k| {
111                let (a, b) = (corners[k], corners[(k + 1) % 4]);
112                NurbCurve2D::line(v2(a.0, a.1), v2(b.0, b.1)).unwrap()
113            })
114            .collect();
115        let area = loop_signed_area(&ccw).unwrap();
116        assert!(area.could_be_equal(Scalar::ONE), "{area:?}");
117        assert!(area.definitely_greater(Scalar::ZERO), "{area:?}");
118        let cw: Vec<NurbCurve2D> = ccw.iter().rev().map(|c| c.reverse()).collect();
119        let area = loop_signed_area(&cw).unwrap();
120        assert!(area.could_be_equal(Scalar::ONE.neg()), "{area:?}");
121    }
122
123    /// A disc of four rational quarter arcs encloses `π r²`: within the
124    /// enclosure, which takes in the quadrature's estimated error.
125    #[test]
126    fn disc_area() {
127        let r = 2.0;
128        let w = sqrt2_over_2();
129        let at = |k: usize| {
130            let (c, s) = [(1., 0.), (0., 1.), (-1., 0.), (0., -1.)][k % 4];
131            v2(3. + r * c, -1. + r * s)
132        };
133        let center = v2(3., -1.);
134        let quarters: Vec<NurbCurve2D> = (0..4)
135            .map(|k| {
136                let mid = at(k).add(&at(k + 1)).sub(&center);
137                NurbCurve2D::arc(at(k), mid, at(k + 1), w).unwrap()
138            })
139            .collect();
140        let area = loop_signed_area(&quarters).unwrap();
141        let exact = std::f64::consts::PI * r * r;
142        assert!(
143            area.could_be_equal(Scalar::from_f64(exact)),
144            "{area:?}, expected {exact}"
145        );
146    }
147}