Skip to main content

geop_core_geometry/nurb_surface/
sphere.rs

1//! The piece of a sphere bounded by three great arcs
2//! ([`NurbSurface3D::spherical_triangle`]): what rounds a corner where three
3//! fillets of one radius meet.
4
5use geop_core_math::{
6    combinatorics::binomial,
7    geop_error::{GeopError, GeopResult},
8    scalars::Scalar,
9    vector::{Vector3, Vector4},
10};
11
12use super::NurbSurface3D;
13
14/// The Bernstein coefficients of the product of the polynomials whose
15/// Bernstein coefficients are `a` (scalars) and `b` (homogeneous points):
16/// of degree the sum of theirs. A coefficient made of one term of factor
17/// one — at both ends — is taken as it is: multiplying by an exact one
18/// would only widen it by rounding.
19fn product(a: &[Scalar], b: &[Vector4]) -> GeopResult<Vec<Vector4>> {
20    let (m, n) = (a.len() - 1, b.len() - 1);
21    (0..=m + n)
22        .map(|k| {
23            let mut sum: Option<Vector4> = None;
24            let first = k.saturating_sub(n);
25            for (i, &ai) in a.iter().enumerate().take(k.min(m) + 1).skip(first) {
26                let j = k - i;
27                let factor =
28                    Scalar::from_ratio(binomial(m, i) * binomial(n, j), binomial(m + n, k))?
29                        .mul(ai);
30                let term = b[j].prod_scalar(factor);
31                sum = Some(match sum {
32                    None => term,
33                    Some(s) => s.add(&term),
34                });
35            }
36            Ok(sum.expect("at least one term"))
37        })
38        .collect()
39}
40
41/// The Bernstein coefficients `a` raised by `by` degrees: the same
42/// polynomial. Its two end coefficients stay exactly as they are.
43fn elevate(a: &[Vector4], by: usize) -> GeopResult<Vec<Vector4>> {
44    let m = a.len() - 1;
45    (0..=m + by)
46        .map(|k| {
47            if k == 0 {
48                return Ok(a[0]);
49            }
50            if k == m + by {
51                return Ok(a[m]);
52            }
53            let mut sum: Option<Vector4> = None;
54            let first = k.saturating_sub(by);
55            for (i, ai) in a.iter().enumerate().take(k.min(m) + 1).skip(first) {
56                let factor =
57                    Scalar::from_ratio(binomial(m, i) * binomial(by, k - i), binomial(m + by, k))?;
58                let term = ai.prod_scalar(factor);
59                sum = Some(match sum {
60                    None => term,
61                    Some(s) => s.add(&term),
62                });
63            }
64            Ok(sum.expect("at least one term"))
65        })
66        .collect()
67}
68
69impl NurbSurface3D {
70    /// The piece of the sphere of `radius` around `center` bounded by the
71    /// great arcs between its points `corners`, each less than a half turn
72    /// from the others: exactly, a rational patch of degree 4 along `u`
73    /// and 2 along `v`.
74    ///
75    /// `v = 0` is the great arc from the first corner (`u = 0`) to the
76    /// second (`u = 1`), in its usual form — the rational quadratic whose
77    /// middle control point is where the arc's end tangents meet, weighted
78    /// by the cosine of half its angle (degree-raised, which leaves it the
79    /// same curve). Every `u`-line is the great arc from that arc's point to
80    /// the third corner, which `v = 1` collapses to: a pole. So `u = 0` and
81    /// `u = 1` are the great arcs from the first and the second corner to
82    /// the third, again each in its usual form. The normal faces out of the
83    /// sphere where the corners run counter-clockwise, seen from outside.
84    ///
85    /// Why it is exact: the great arc from a point `B` to `P`, with `b = B -
86    /// C` and `p = P - C`, has weights `(1, cos(t/2), 1)`, `t` the angle
87    /// between them — or any `(a, m, c)` with `m^2 / (a c) = (1 + cos t) /
88    /// 2`. Along the base arc `B(u) = X(u) / beta(u)`, `X` and `beta`
89    /// quadratic, `1 + cos t = gamma / (beta r^2)` with `gamma = beta r^2 +
90    /// (X - beta C) · p`, also quadratic. Then the row `(X, beta)`, `tau
91    /// (C delta + r^2 (X + beta p), gamma)` (`delta = gamma - beta r^2`, the
92    /// middle point where the tangents meet, weighted `tau gamma`), `2 r^2
93    /// tau^2 gamma (P, 1)` has that ratio for every `u` and any `tau(u)`;
94    /// `tau` linear, chosen so that both side arcs come out in their usual
95    /// form, makes the row polynomial of degree at most 4.
96    ///
97    /// The rows multiply the center and the corners by weights and by
98    /// each other, so it is built about the center's middle, a free choice
99    /// of origin, and moved back by one rounding: built where it lies, a
100    /// corner of a block at `(1e4, 1e4, 0)` came out with control points
101    /// `8e-4` wide, and at `1e5` with no normal left at all.
102    pub fn spherical_triangle(
103        center: &Vector3,
104        radius: Scalar,
105        corners: [Vector3; 3],
106    ) -> GeopResult<Self> {
107        let origin = center.sharpen();
108        let center = &center.sub(&origin);
109        let corners = corners.map(|c| c.sub(&origin));
110        let r2 = radius.mul(radius);
111        let [p1, p2, p3] = &corners;
112        let (b1, b2, p) = (p1.sub(center), p2.sub(center), p3.sub(center));
113        // The base arc, in its usual form.
114        let one_plus_cos = Scalar::ONE.add(b1.prod_dot(&b2).div(r2)?);
115        if !one_plus_cos.definitely_greater(Scalar::ZERO) {
116            return Err(GeopError::new(format!(
117                "NurbSurface::spherical_triangle: corners {p1:?} and {p2:?} could lie across the sphere from each other"
118            )));
119        }
120        let middle = center.add(&b1.add(&b2).prod_scalar(Scalar::ONE.div(one_plus_cos)?));
121        let w = one_plus_cos.div(Scalar::TWO)?.sqrt()?;
122        let base = [
123            p1.homogeneous(Scalar::ONE),
124            middle.homogeneous(w),
125            p2.homogeneous(Scalar::ONE),
126        ];
127        // The middle column before `tau`, and `gamma`, coefficient by
128        // coefficient: both are linear in the base's control points.
129        let mut gamma = Vec::with_capacity(3);
130        let mut inner = Vec::with_capacity(3);
131        for q in &base {
132            let x = Vector3::from_array([q[0], q[1], q[2]]);
133            let beta = q[3];
134            let delta = x.sub(&center.prod_scalar(beta)).prod_dot(&p);
135            let g = beta.mul(r2).add(delta);
136            if !g.definitely_greater(Scalar::ZERO) {
137                return Err(GeopError::new(format!(
138                    "NurbSurface::spherical_triangle: the base arc from {p1:?} to {p2:?} could reach across the sphere from {p3:?}"
139                )));
140            }
141            let m = center
142                .prod_scalar(delta)
143                .add(&x.add(&p.prod_scalar(beta)).prod_scalar(r2));
144            inner.push(Vector4::from_array([m[0], m[1], m[2], g]));
145            gamma.push(g);
146        }
147        // `tau` at both ends, so that `2 r^2 tau^2 gamma` is the base's end
148        // weight, one: each side arc in its usual form.
149        let tau = |g: Scalar| Scalar::ONE.div(Scalar::TWO.mul(r2).mul(g).sqrt()?);
150        let tau = [tau(gamma[0])?, tau(gamma[2])?];
151        let tau_sq = [tau[0].mul(tau[0]), tau[0].mul(tau[1]), tau[1].mul(tau[1])];
152        let pole_weights = product(
153            &tau_sq,
154            &gamma
155                .iter()
156                .map(|&g| p3.homogeneous(Scalar::ONE).prod_scalar(g))
157                .collect::<Vec<_>>(),
158        )?;
159        let columns = [
160            elevate(&base, 2)?,
161            elevate(&product(&tau, &inner)?, 1)?,
162            pole_weights
163                .iter()
164                .map(|q| q.prod_scalar(Scalar::TWO.mul(r2)))
165                .collect(),
166        ];
167        let control_points = (0..5)
168            .flat_map(|i| columns.iter().map(move |c| c[i]))
169            .collect();
170        let knots = |degree: usize| {
171            let mut k = vec![Scalar::ZERO; degree + 1];
172            k.extend(vec![Scalar::ONE; degree + 1]);
173            k
174        };
175        Ok(NurbSurface3D::try_new(4, 2, control_points, knots(4), knots(2))?.translate(origin))
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use geop_core_math::{scalars::Scalar, vector::Vector3};
182
183    use super::super::NurbSurface3D;
184
185    fn v3(x: f64, y: f64, z: f64) -> Vector3 {
186        Vector3::from_array([
187            Scalar::from_f64(x),
188            Scalar::from_f64(y),
189            Scalar::from_f64(z),
190        ])
191    }
192
193    /// A corner's ball far from the origin, as a block placed at
194    /// `(1e5, -1e5, 0)` has with every edge rounded: its patch is as
195    /// narrow as its corners allow, and has a normal. Built where it lies,
196    /// its weighted control points came out `1e-5` wide here.
197    #[test]
198    fn far_from_the_origin_is_as_narrow() {
199        let radius = Scalar::from_f64(2.0);
200        for [x, y, z] in [[2.0, 2.0, 2.0], [1e5 + 2.0, -1e5 + 2.0, 2.0]] {
201            let center = v3(x, y, z);
202            let corners = [v3(x - 2.0, y, z), v3(x, y - 2.0, z), v3(x, y, z - 2.0)];
203            let s = NurbSurface3D::spherical_triangle(&center, radius, corners).unwrap();
204            let widest = (s.control_points.iter())
205                .flat_map(|p| (0..4).map(move |c| p[c].width().to_f64()))
206                .fold(0.0, f64::max);
207            assert!(widest < 1e-9, "at {center:?}: {widest:e}");
208            let (u, v) = (Scalar::from_f64(0.4), Scalar::from_f64(0.3));
209            assert!(s.normal(u, v).is_ok(), "at {center:?}");
210        }
211    }
212
213    /// Every point of the patch lies on the sphere, its corners are where
214    /// they were asked to be, and its sides lie on the great circles
215    /// through them — for an octant and for a lopsided triangle.
216    #[test]
217    fn lies_on_the_sphere() {
218        let center = v3(1.0, -2.0, 0.5);
219        let radius = Scalar::from_f64(0.7);
220        let on = |x: f64, y: f64, z: f64| {
221            let d = v3(x, y, z).normalize().unwrap();
222            center.add(&d.prod_scalar(radius))
223        };
224        for corners in [
225            [on(1.0, 0.0, 0.0), on(0.0, 1.0, 0.0), on(0.0, 0.0, 1.0)],
226            [on(1.0, 0.2, 0.1), on(-0.3, 1.0, 0.4), on(0.2, 0.5, 1.0)],
227        ] {
228            let s = NurbSurface3D::spherical_triangle(&center, radius, corners).unwrap();
229            for i in 0..=6 {
230                for j in 0..=6 {
231                    let (u, v) = (
232                        Scalar::from_f64(i as f64 / 6.0),
233                        Scalar::from_f64(j as f64 / 6.0),
234                    );
235                    let p = s.evaluate(u, v).unwrap();
236                    let r = p.sub(&center).norm();
237                    assert!(r.could_be_equal(radius), "({i}, {j}): radius {r:?}");
238                }
239            }
240            let at = |u: f64, v: f64| {
241                s.evaluate(Scalar::from_f64(u), Scalar::from_f64(v))
242                    .unwrap()
243            };
244            assert!(at(0.0, 0.0).could_be_equal(&corners[0]));
245            assert!(at(1.0, 0.0).could_be_equal(&corners[1]));
246            assert!(at(0.3, 1.0).could_be_equal(&corners[2]));
247            // A side lies on its great circle: in the plane through the
248            // center and its two ends.
249            for (a, b, side) in [
250                (0, 1, (None, Some(0.0))),
251                (0, 2, (Some(0.0), None)),
252                (1, 2, (Some(1.0), None)),
253            ] {
254                let n = corners[a].sub(&center).prod_cross(&corners[b].sub(&center));
255                for k in 0..=4 {
256                    let t = k as f64 / 4.0;
257                    let p = match side {
258                        (Some(u), None) => at(u, t),
259                        (None, Some(v)) => at(t, v),
260                        _ => unreachable!(),
261                    };
262                    assert!(p.sub(&center).prod_dot(&n).could_be_equal(Scalar::ZERO));
263                }
264            }
265            // Counter-clockwise seen from outside: the normal faces out.
266            let (u, v) = (Scalar::from_f64(0.4), Scalar::from_f64(0.3));
267            let p = s.evaluate(u, v).unwrap();
268            assert!(
269                s.normal(u, v)
270                    .unwrap()
271                    .prod_dot(&p.sub(&center))
272                    .definitely_greater(Scalar::ZERO)
273            );
274        }
275    }
276}