Skip to main content

geop_core_geometry/nurb_surface/
normal.rs

1use geop_core_math::{
2    combinatorics::binomial,
3    geop_error::{GeopError, GeopResult, WithContext},
4    scalars::Scalar,
5    vector::{Vector, Vector3},
6};
7
8use super::{NurbSurface, evaluate::corner_nearest};
9use crate::{
10    knot_insertion::is_clamped_at,
11    nurb_curve::dehomogenize,
12    spline::{
13        centered, de_boor, find_span, find_spans, hodograph, homogeneous_derivatives,
14        rational_derivatives,
15    },
16};
17
18impl NurbSurface<4> {
19    /// The homogeneous partial derivatives at `(u, v)`: the pure ones up to
20    /// order `n`, `([A, A_u, A_uu, …], [A, A_v, A_vv, …])`, and the mixed
21    /// `A_uv` where that order takes it in (`n >= 2`). A pure partial only differentiates along its own direction, so
22    /// each is the curve case ([`homogeneous_derivatives`]) run across the
23    /// local rows (columns), each first evaluated at `v` (`u`); the mixed one
24    /// differentiates the rows once along `v`, then once across them along
25    /// `u`. The `A` they start from is relative to a control point of the
26    /// span, so only the derivatives are meaningful.
27    #[allow(clippy::type_complexity)]
28    fn homogeneous_partials(
29        &self,
30        u: Scalar,
31        v: Scalar,
32        n: usize,
33    ) -> GeopResult<(Vec<Vector<4>>, Vec<Vector<4>>, Option<Vector<4>>)> {
34        let (p, q) = (self.degree_u, self.degree_v);
35        let (ku, kv) = (&self.knot_vector_u, &self.knot_vector_v);
36        let nv = self.num_v;
37        let span_u = find_span(p, ku, self.num_u - 1, u)?;
38        let span_v = find_span(q, kv, nv - 1, v)?;
39        // Relative to a control point of the span (see [`centered`]): the
40        // derivatives do not depend on where the patch lies, and that way
41        // neither does their width.
42        let local: Vec<Vector<4>> = (span_u - p..=span_u)
43            .flat_map(|i| (span_v - q..=span_v).map(move |j| i * nv + j))
44            .map(|k| self.control_points[k])
45            .collect();
46        let at = corner_nearest(self, (span_u, u), (span_v, v));
47        let (cp, _) = centered(&local, at);
48        let at = |i: usize, j: usize| cp[i * (q + 1) + j];
49        let mixed = n >= 2;
50        // Each row at `v`, and along `v` where the mixed partial is asked.
51        let (rows, rows_v): (Vec<Vector<4>>, Vec<Vector<4>>) = (0..=p)
52            .map(|i| {
53                let local: Vec<_> = (0..=q).map(|j| at(i, j)).collect();
54                let d = homogeneous_derivatives(q, kv, &local, span_v, v, usize::from(mixed));
55                (d[0], d.get(1).copied().unwrap_or_else(Vector::zero))
56            })
57            .unzip();
58        let cols: Vec<Vector<4>> = (0..=q)
59            .map(|j| {
60                let local: Vec<_> = (0..=p).map(|i| at(i, j)).collect();
61                homogeneous_derivatives(p, ku, &local, span_u, u, 0)[0]
62            })
63            .collect();
64        Ok((
65            homogeneous_derivatives(p, ku, &rows, span_u, u, n),
66            homogeneous_derivatives(q, kv, &cols, span_v, v, n),
67            mixed.then(|| homogeneous_derivatives(p, ku, &rows_v, span_u, u, 1)[1]),
68        ))
69    }
70
71    /// Every partial derivative up to `order` at `(u, v)`: `S[k][l]` is
72    /// `∂^(k+l) S / ∂u^k ∂v^l` for `k + l <= order`, `S[0][0]` the point.
73    /// Each homogeneous partial is the span's own net differenced `k` times
74    /// along `u` and `l` times along `v` ([`hodograph`]), then evaluated;
75    /// the Cartesian ones follow by the rational quotient rule (Piegl &
76    /// Tiller algorithm A4.4). Relative to a control point of the span, as
77    /// everything here is (see [`centered`]).
78    ///
79    /// Differenced first, then evaluated, so that an interval `(u, v)` box
80    /// gives an enclosure as wide as the box times the next derivative.
81    /// Evaluated first along `v` and differenced along `u` afterwards, as
82    /// [`Self::second_derivatives`] does for its mixed partial, the rows'
83    /// values over the box are independent intervals, and their third
84    /// differences, divided by the span's width cubed, came out a hundred
85    /// times wider than the partial itself.
86    ///
87    /// A box reaching over a knot is taken span by span, and the partials
88    /// over the pieces united (see [`find_spans`]): one span's polynomial
89    /// carried on over the next encloses nothing of the surface there, and
90    /// a proof over the box (`NurbSurface3D::enclose_foot`) would prove
91    /// nothing.
92    pub fn partials(&self, u: Scalar, v: Scalar, order: usize) -> GeopResult<Vec<Vec<Vector3>>> {
93        let (p, q) = (self.degree_u, self.degree_v);
94        let us = find_spans(p, &self.knot_vector_u, self.num_u - 1, u)?;
95        let vs = find_spans(q, &self.knot_vector_v, self.num_v - 1, v)?;
96        let mut out: Option<Vec<Vec<Vector3>>> = None;
97        for &u in &us {
98            for &v in &vs {
99                let piece = self.partials_in(u, v, order)?;
100                out = Some(match out {
101                    None => piece,
102                    Some(all) => (all.iter().zip(&piece))
103                        .map(|(a, b)| a.iter().zip(b).map(|(a, b)| a.union(b)).collect())
104                        .collect(),
105                });
106            }
107        }
108        Ok(out.expect("a span at least"))
109    }
110
111    /// [`Self::partials`] over the part `u` of span `span_u` along `u` and
112    /// `v` of `span_v` along `v`.
113    fn partials_in(
114        &self,
115        (span_u, u): (usize, Scalar),
116        (span_v, v): (usize, Scalar),
117        order: usize,
118    ) -> GeopResult<Vec<Vec<Vector3>>> {
119        let (p, q) = (self.degree_u, self.degree_v);
120        let (ku, kv) = (&self.knot_vector_u, &self.knot_vector_v);
121        let nv = self.num_v;
122        let local: Vec<Vector<4>> = (span_u - p..=span_u)
123            .flat_map(|i| (span_v - q..=span_v).map(move |j| i * nv + j))
124            .map(|k| self.control_points[k])
125            .collect();
126        let at = corner_nearest(self, (span_u, u), (span_v, v));
127        let (cp, origin) = centered(&local, at);
128        let (base_u, base_v) = (span_u - p, span_v - q);
129        // The net differenced `l` times along `v`, as rows of fixed `u`.
130        let mut along_v: Vec<Vec<Vec<Vector<4>>>> = vec![
131            (0..=p)
132                .map(|i| cp[i * (q + 1)..(i + 1) * (q + 1)].to_vec())
133                .collect(),
134        ];
135        for l in 1..=order.min(q) {
136            let previous = &along_v[l - 1];
137            along_v.push(
138                previous
139                    .iter()
140                    .map(|row| hodograph(q, kv, row, base_v, l))
141                    .collect(),
142            );
143        }
144        // `a[k][l]`, the homogeneous partial: each of those differenced `k`
145        // times along `u`, evaluated at `(u, v)`.
146        let mut a = vec![vec![Vector::<4>::zero(); order + 1]; order + 1];
147        for k in 0..=order {
148            for l in 0..=order - k {
149                if k > p || l > q {
150                    continue;
151                }
152                let mut net: Vec<Vec<Vector<4>>> = along_v[l].clone();
153                for kk in 1..=k {
154                    let columns: Vec<Vec<Vector<4>>> = (0..=q - l)
155                        .map(|j| {
156                            let column: Vec<Vector<4>> = net.iter().map(|row| row[j]).collect();
157                            hodograph(p, ku, &column, base_u, kk)
158                        })
159                        .collect();
160                    net = (0..columns[0].len())
161                        .map(|i| columns.iter().map(|c| c[i]).collect())
162                        .collect();
163                }
164                let rows: Vec<Vector<4>> = net
165                    .iter()
166                    .map(|row| de_boor(q - l, &kv[base_v + l..], row, v, q - l))
167                    .collect();
168                a[k][l] = de_boor(p - k, &ku[base_u + k..], &rows, u, p - k);
169            }
170        }
171        let w = |k: usize, l: usize| a[k][l][3];
172        let inverse = Scalar::ONE.div(w(0, 0))?;
173        let mut s: Vec<Vec<Vector3>> = vec![Vec::new(); order + 1];
174        for k in 0..=order {
175            for l in 0..=order - k {
176                let mut value = Vector3::from_array([a[k][l][0], a[k][l][1], a[k][l][2]]);
177                let term =
178                    |f: i64, wv: Scalar, sv: &Vector3| sv.prod_scalar(Scalar::from_i64(f).mul(wv));
179                for j in 1..=l {
180                    value = value.sub(&term(binomial(l, j), w(0, j), &s[k][l - j]));
181                }
182                for i in 1..=k {
183                    value = value.sub(&term(binomial(k, i), w(i, 0), &s[k - i][l]));
184                    for j in 1..=l {
185                        value = value.sub(&term(
186                            binomial(k, i) * binomial(l, j),
187                            w(i, j),
188                            &s[k - i][l - j],
189                        ));
190                    }
191                }
192                s[k].push(value.prod_scalar(inverse));
193            }
194        }
195        s[0][0] = s[0][0].add(&Vector3::from_array([origin[0], origin[1], origin[2]]));
196        Ok(s)
197    }
198
199    /// Partial derivatives `(∂Scalar/∂u, ∂Scalar/∂v)` at `(u, v)`.
200    pub fn derivatives(&self, u: Scalar, v: Scalar) -> GeopResult<(Vector3, Vector3)> {
201        let (du, dv, _) = self.homogeneous_partials(u, v, 1)?;
202        Ok((rational_derivatives(&du)?[1], rational_derivatives(&dv)?[1]))
203    }
204
205    /// Second partial derivatives `[∂²S/∂u², ∂²S/∂u∂v, ∂²S/∂v²]` at
206    /// `(u, v)`. The pure ones by the rational quotient rule along their own
207    /// direction ([`rational_derivatives`]); the mixed one by differentiating
208    /// `A = w Scalar` once along each: `A_uv = w_uv Scalar + w_u S_v + w_v S_u + w
209    /// S_uv`.
210    pub fn second_derivatives(&self, u: Scalar, v: Scalar) -> GeopResult<[Vector3; 3]> {
211        let (du, dv, a_uv) = self.homogeneous_partials(u, v, 2)?;
212        let a_uv = a_uv.expect("the second order takes in the mixed partial");
213        let (cu, cv) = (rational_derivatives(&du)?, rational_derivatives(&dv)?);
214        let (w, w_u, w_v, w_uv) = (du[0][3], du[1][3], dv[1][3], a_uv[3]);
215        let mut s_uv = Vector3::zero();
216        for c in 0..3 {
217            s_uv[c] = a_uv[c]
218                .sub(w_uv.mul(cu[0][c]))
219                .sub(w_u.mul(cv[1][c]))
220                .sub(w_v.mul(cu[1][c]))
221                .div(w)?;
222        }
223        Ok([cu[2], s_uv, cv[2]])
224    }
225
226    /// The boundary row at the start (`first`) or end of the `u` domain
227    /// (`u_fixed`) or `v` domain, and the row next to it — `None` unless the
228    /// knot vector is clamped there, so that the boundary row *is* the
229    /// surface.
230    fn boundary_rows(&self, u_fixed: bool, first: bool) -> Option<[Vec<Vector<4>>; 2]> {
231        let (nu, nv) = (self.num_u, self.num_v);
232        let (knots, degree, len) = if u_fixed {
233            (&self.knot_vector_u, self.degree_u, nu)
234        } else {
235            (&self.knot_vector_v, self.degree_v, nv)
236        };
237        if len < 2 || !is_clamped_at(knots, degree, first) {
238            return None;
239        }
240        let (edge, next) = if first { (0, 1) } else { (len - 1, len - 2) };
241        Some([
242            self.control_row(u_fixed, edge),
243            self.control_row(u_fixed, next),
244        ])
245    }
246
247    /// The point the boundary row at the start (`first`) or end of the
248    /// `u` (`u_fixed`) or `v` domain collapses to, and the row next to it.
249    /// `None` if that row is no pole. Every point of the row encloses the
250    /// pole, so the pole is what they share: no wider than the narrowest.
251    pub(crate) fn pole(&self, u_fixed: bool, first: bool) -> Option<(Vector3, Vec<Vector<4>>)> {
252        let [edge, next] = self.boundary_rows(u_fixed, first)?;
253        let edge = dehomogenize::<4, 3>(&edge);
254        let shared = edge.iter().fold(edge[0], |shared, p| {
255            Vector3::from_array(std::array::from_fn(|k| shared[k].intersect(p[k])))
256        });
257        edge.iter()
258            .all(|p| p.could_be_equal(&shared))
259            .then_some((shared, next))
260    }
261
262    /// The boundary rows `(u, v)` touches, as `(u_fixed, first)`.
263    pub(crate) fn rows_touching(&self, u: Scalar, v: Scalar) -> Vec<(bool, bool)> {
264        let ((u0, u1), (v0, v1)) = (self.domain_u(), self.domain_v());
265        [
266            (false, v, v0, true),
267            (false, v, v1, false),
268            (true, u, u0, true),
269            (true, u, u1, false),
270        ]
271        .into_iter()
272        .filter(|&(_, t, end, _)| t.could_be_equal(end))
273        .map(|(u_fixed, _, _, first)| (u_fixed, first))
274        .collect()
275    }
276
277    /// The pole `(u, v)` lies on: the point a boundary row it touches
278    /// collapses to. `None` if it touches no pole.
279    pub fn pole_at(&self, u: Scalar, v: Scalar) -> Option<Vector3> {
280        self.rows_touching(u, v)
281            .into_iter()
282            .find_map(|(u_fixed, first)| self.pole(u_fixed, first))
283            .map(|(pole, _)| pole)
284    }
285
286    /// The normal at a pole: the boundary row at the start (`first`) or end
287    /// of the `u` (`u_fixed`) or `v` domain, if it collapses to a single
288    /// point `P`. `None` if that row is no pole.
289    ///
290    /// Leaving the pole, the derivative across the row is a positive
291    /// combination of the *spokes* `E_i = X_i − P` to the next row's
292    /// Cartesian control points `X_i` (the weights only scale each spoke by
293    /// a positive factor). So the pole has a tangent plane exactly when the
294    /// spokes are coplanar, and its normal is that plane's, oriented like
295    /// `S_u × S_v` next to it by the spokes' turning `T = Σ E_i × E_{i+1}`.
296    /// Spokes that are not coplanar — an apex, like a cone's — leave no
297    /// single normal: an error, not an arbitrary pick among them.
298    fn pole_normal(&self, u_fixed: bool, first: bool) -> Option<GeopResult<Vector3>> {
299        let (pole, next) = self.pole(u_fixed, first)?;
300        let spokes: Vec<Vector3> = dehomogenize::<4, 3>(&next)
301            .iter()
302            .map(|x| x.sub(&pole))
303            .collect();
304        let turning = spokes.windows(2).fold(Vector3::zero(), |acc: Vector3, e| {
305            acc.add(&e[0].prod_cross(&e[1]))
306        });
307        let result = turning.normalize().and_then(|t| {
308            if !spokes
309                .iter()
310                .all(|e| e.prod_dot(&t).could_be_equal(Scalar::ZERO))
311            {
312                return Err(GeopError::new(format!(
313                    "the spokes {spokes:?} are not coplanar: an apex has no single normal"
314                )));
315            }
316            // Next to a collapsed `v` row `S_u × S_v ≈ (v − v₀) S_uv × S_v`,
317            // next to a collapsed `u` row `≈ (u − u₀) S_u × S_uv`; `S_uv`
318            // turns the way the spokes do, and `v − v₀` (`u − u₀`) is
319            // positive at the domain start.
320            Ok(if u_fixed == first { t } else { t.neg() })
321        });
322        Some(result.with_context(&|e: GeopError| {
323            e.with_context(format!(
324                "NurbSurface::pole_normal(pole={pole:?}, collapsed {} row at the {})",
325                if u_fixed { "u" } else { "v" },
326                if first { "start" } else { "end" }
327            ))
328        }))
329    }
330
331    /// Whether the boundary row at the start (`first`) or end of the `u`
332    /// (`u_fixed`) or `v` domain is an apex: a pole with no single normal,
333    /// its spokes not in one plane, like a cone's tip.
334    pub(crate) fn is_apex(&self, u_fixed: bool, first: bool) -> bool {
335        matches!(self.pole_normal(u_fixed, first), Some(Err(_)))
336    }
337
338    /// Unit surface normal at `(u, v)`, `normalize(∂Scalar/∂u × ∂Scalar/∂v)`.
339    ///
340    /// Whether this points outward or inward for a given face depends on
341    /// that surface's own `u`/`v` parametrization convention — it is each
342    /// surface constructor's responsibility to pick the matching
343    /// `Face::sense` (`Forward` if this normal is already outward,
344    /// `Reversed` if it needs negating) so that callers can treat
345    /// `Face::sense`-corrected `normal()` as reliably outward. See
346    /// `box_solid`'s `FACE_DEFS` (`(P10 − P00) × (P01 − P00)`, `Forward`)
347    /// and `revolve`/`sphere`'s patch constructors (natural normal is
348    /// inward, hence `Reversed`) for both cases.
349    ///
350    /// **At a pole** — `(u, v)` touching a boundary row that collapses to a
351    /// point — `S_u × S_v` vanishes, and the normal is that of the pole
352    /// itself ([`Self::pole_normal`]), the same for every `u` (`v`) along
353    /// the row. **At a corner** whose boundary curves leave it along one
354    /// line, it is the corner's ([`Self::corner_normal`]). Anything else
355    /// where the cross product vanishes has no normal and is an error.
356    pub fn normal(&self, u: Scalar, v: Scalar) -> GeopResult<Vector3> {
357        let (du, dv) = self.derivatives(u, v)?;
358        let regular = du
359            .prod_cross(&dv)
360            .normalize()
361            .with_context(&|e: GeopError| {
362                e.with_context(format!(
363                    "NurbSurface::normal({:?}): S_u = {du:?}, S_v = {dv:?}",
364                    (u, v)
365                ))
366            });
367        if regular.is_ok() {
368            return regular;
369        }
370        let rows = self.rows_touching(u, v);
371        for &(u_fixed, first) in &rows {
372            if let Some(normal) = self.pole_normal(u_fixed, first) {
373                return normal;
374            }
375        }
376        // A corner of the domain both `(u, v)` touches.
377        for &(u_fixed, u_first) in rows.iter().filter(|(u_fixed, _)| *u_fixed) {
378            for &(_, v_first) in rows.iter().filter(|(v_fixed, _)| !*v_fixed) {
379                debug_assert!(u_fixed);
380                if let Some(normal) = self.corner_normal(u_first, v_first) {
381                    return normal;
382                }
383            }
384        }
385        regular
386    }
387
388    /// The normal at a corner of the domain — at the start (`u_first`) or
389    /// end of `u`, and of `v` — where `S_u` and `S_v` could be parallel:
390    /// where the two boundary curves leave the corner along one line, its
391    /// angle opened out flat (a SubD patch at a crease vertex, where the
392    /// crease runs on smoothly). `None` where they are not, and the regular
393    /// normal holds.
394    ///
395    /// The surface may well be smooth there; its coordinates are not. With
396    /// `S_v = μ S_u` at the corner, next to it `S_u × S_v` is to first order
397    /// `du S_u × (S_uv − μ S_uu) + dv S_u × (S_vv − μ S_uv)`, `du` and `dv`
398    /// the steps into the domain, signed by which end the corner is at. The
399    /// corner has a normal exactly when those two agree in direction, and
400    /// it is theirs; where they do not, no single one — an error, not an
401    /// arbitrary pick between them.
402    fn corner_normal(&self, u_first: bool, v_first: bool) -> Option<GeopResult<Vector3>> {
403        let ((u0, u1), (v0, v1)) = (self.domain_u(), self.domain_v());
404        let (u, v) = (if u_first { u0 } else { u1 }, if v_first { v0 } else { v1 });
405        let (su, sv) = self.derivatives(u, v).ok()?;
406        if !su.could_be_parallel(&sv) {
407            return None;
408        }
409        let corner = || -> GeopResult<Vector3> {
410            let mu = su.prod_dot(&sv).div(su.prod_dot(&su))?;
411            let [suu, suv, svv] = self.second_derivatives(u, v)?;
412            let into = |first: bool| {
413                if first {
414                    Scalar::ONE
415                } else {
416                    Scalar::ONE.neg()
417                }
418            };
419            let along_u = su
420                .prod_cross(&suv.sub(&suu.prod_scalar(mu)))
421                .prod_scalar(into(u_first));
422            let along_v = su
423                .prod_cross(&svv.sub(&suv.prod_scalar(mu)))
424                .prod_scalar(into(v_first));
425            let normal = along_u.add(&along_v).normalize()?;
426            for w in [along_u, along_v] {
427                if !w.could_be_parallel(&normal)
428                    || w.prod_dot(&normal).definitely_less(Scalar::ZERO)
429                {
430                    return Err(GeopError::new(format!(
431                        "the surface's normals next to the corner turn from {along_u:?} to {along_v:?}: a corner with no single normal"
432                    )));
433                }
434            }
435            Ok(normal)
436        };
437        Some(corner().with_context(&|e: GeopError| {
438            e.with_context(format!(
439                "NurbSurface::corner_normal(corner at {:?}, S_u = {su:?}, S_v = {sv:?})",
440                (u, v)
441            ))
442        }))
443    }
444
445    /// The unit normal of this surface at `point`, which lies on it: at
446    /// its `(u, v)` there ([`Self::locate`]). An error if the point is not
447    /// on the surface.
448    pub fn normal_at(
449        &self,
450        point: &Vector3,
451        max_nodes: usize,
452        min_subdivision_size: Scalar,
453    ) -> GeopResult<Vector3> {
454        let (u, v) = self
455            .locate(point, max_nodes, min_subdivision_size)?
456            .ok_or_else(|| GeopError::new(format!("{point:?} is not on the surface")))?;
457        self.normal(u, v)
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use crate::nurb_surface::NurbSurface;
464    use crate::test_fixtures::{hom, revolved};
465    use geop_core_math::scalars::Scalar;
466
467    /// `partials` agrees with `derivatives` and `second_derivatives` at a
468    /// point, and over a box a thousandth wide is about that much wider
469    /// than at its centre, up to third order: a rational patch, its net
470    /// refined.
471    #[test]
472    fn partials_are_tight_over_a_box() {
473        let surface = revolved(1., 0.).refined([4, 4]).unwrap();
474        let (u, v) = (Scalar::from_f64(0.3), Scalar::from_f64(0.6));
475        let at = surface.partials(u, v, 3).unwrap();
476        let (su, sv) = surface.derivatives(u, v).unwrap();
477        let [suu, suv, svv] = surface.second_derivatives(u, v).unwrap();
478        for (got, expected) in [
479            (at[1][0], su),
480            (at[0][1], sv),
481            (at[2][0], suu),
482            (at[1][1], suv),
483            (at[0][2], svv),
484        ] {
485            assert!(got.could_be_equal(&expected), "{got:?} vs {expected:?}");
486        }
487        assert!(at[0][0].could_be_equal(&surface.evaluate(u, v).unwrap()));
488        // Over the box, each partial is wider by about the box's width
489        // times the next one, `|S^(k+1)| h`: never a hundred times that.
490        let h = 1e-3;
491        let over = surface
492            .partials(
493                u.add(Scalar::new(-h / 2., h / 2.)),
494                v.add(Scalar::new(-h / 2., h / 2.)),
495                3,
496            )
497            .unwrap();
498        let mut table = String::new();
499        let mut worst: f64 = 0.;
500        for k in 0..=3 {
501            for l in 0..=3 - k {
502                let width = (0..3)
503                    .map(|c| over[k][l][c].width().to_f64())
504                    .fold(0., f64::max);
505                let size = (0..3)
506                    .map(|c| at[k][l][c].abs().to_f64())
507                    .fold(0., f64::max);
508                table += &format!("S[{k}][{l}]: {size:.3e} wide {width:.3e}\n");
509                worst = worst.max(width / h);
510            }
511        }
512        // Measured: at most 24 h, the third partial along `u`. Formed by
513        // evaluating rows first, the third partials were a hundred times
514        // wider.
515        assert!(worst < 50., "{table}");
516    }
517
518    /// Over a box reaching over a knot, every partial encloses its values
519    /// on both sides of it: a surface's span is not carried on over the
520    /// next one. A net joined at a knot only by its position there, bent
521    /// at that joint, so that its derivatives jump, which one span's
522    /// polynomial carried on misses.
523    #[test]
524    fn partials_over_a_knot_enclose_both_spans() {
525        let surface = revolved(1., 0.).refined([4, 1]).unwrap();
526        // Raised, its knots are joints of position only; bent at one.
527        let mut surface = surface.elevate_degree(true).unwrap();
528        let nv = surface.num_v;
529        let mid = surface.degree_u;
530        for j in 0..nv {
531            let cp = &mut surface.control_points[mid * nv + j];
532            cp[2] = cp[2].add(cp[3].mul(Scalar::from_f64(0.3)));
533        }
534        let knot = surface.knot_vector_u[surface.degree_u + 1];
535        let box_u = knot
536            .sub(Scalar::from_f64(0.05))
537            .union(knot.add(Scalar::from_f64(0.05)));
538        let v = Scalar::from_f64(0.4);
539        let over = surface.partials(box_u, v, 2).unwrap();
540        for i in 0..=10 {
541            let u = Scalar::interpolate(
542                box_u.lower(),
543                box_u.upper(),
544                Scalar::from_ratio(i, 10).unwrap(),
545            );
546            let at = surface.partials(u, v, 2).unwrap();
547            for (k, l) in [(0, 0), (1, 0), (0, 1), (2, 0), (1, 1), (0, 2)] {
548                assert!(
549                    at[k][l].could_be_equal(&over[k][l]),
550                    "S[{k}][{l}] at {u:?}: {:?}, over the box {:?}",
551                    at[k][l],
552                    over[k][l]
553                );
554            }
555        }
556    }
557
558    /// Flat unit patch in the xy-plane: normal should be constant +z everywhere.
559    fn flat_xy() -> NurbSurface<4> {
560        let f = Scalar::from_f64;
561        NurbSurface::try_new(
562            1,
563            1,
564            vec![
565                hom(0., 0., 0., 1.),
566                hom(0., 1., 0., 1.),
567                hom(1., 0., 0., 1.),
568                hom(1., 1., 0., 1.),
569            ],
570            vec![f(0.), f(0.), f(1.), f(1.)],
571            vec![f(0.), f(0.), f(1.), f(1.)],
572        )
573        .unwrap()
574    }
575
576    #[test]
577    fn flat_surface_normal_is_constant_z() {
578        let s = flat_xy();
579        for &(u, v) in &[(0.0, 0.0), (0.5, 0.5), (1.0, 0.0), (0.2, 0.8)] {
580            let n = s.normal(Scalar::from_f64(u), Scalar::from_f64(v)).unwrap();
581            assert!(n[0].could_be_equal(Scalar::ZERO));
582            assert!(n[1].could_be_equal(Scalar::ZERO));
583            assert!(n[2].could_be_equal(Scalar::ONE));
584        }
585    }
586
587    /// Non-planar bilinear "saddle" patch: corner heights 0,1,1,0 over
588    /// x,y ∈ [0,2]. At the center (u=v=0.5) the surface is tangent to the
589    /// z=0.5 plane, so the normal there should be purely +z.
590    fn bent_surface() -> NurbSurface<4> {
591        let f = Scalar::from_f64;
592        NurbSurface::try_new(
593            1,
594            1,
595            vec![
596                hom(0., 0., 0., 1.),
597                hom(0., 2., 1., 1.),
598                hom(2., 0., 1., 1.),
599                hom(2., 2., 0., 1.),
600            ],
601            vec![f(0.), f(0.), f(1.), f(1.)],
602            vec![f(0.), f(0.), f(1.), f(1.)],
603        )
604        .unwrap()
605    }
606
607    #[test]
608    fn bent_surface_center_normal_is_z() {
609        let s = bent_surface();
610        let n = s
611            .normal(Scalar::from_f64(0.5), Scalar::from_f64(0.5))
612            .unwrap();
613        assert!(n[0].could_be_equal(Scalar::ZERO));
614        assert!(n[1].could_be_equal(Scalar::ZERO));
615        assert!(n[2].definitely_greater(Scalar::ZERO));
616    }
617
618    // ── Poles ──────────────────────────────────────────────────────────────
619
620    /// A hair off a disc's centre — the foot point of a point a shell moves
621    /// onto the disc's axis — the normal is still the disc's: `S_u` is of
622    /// the order of the hair, and its width that of the control points
623    /// relative to the pole, an ulp of their coordinates. Evaluated
624    /// relative to the far corner of the patch, as it once was, `S_u` was
625    /// as wide as the patch. (Nearer the pole than a few hundred ulps, the
626    /// width wins: there the normal is the pole's, see `pole_normal`.)
627    #[test]
628    fn disc_normal_a_hair_off_the_pole() {
629        let f = Scalar::from_f64;
630        let disc = revolved(2., 2.);
631        for (u, v) in [(0., 5.4e-14), (1., 5.4e-14), (0.3, 1e-10)] {
632            let n = disc.normal(f(u), f(v)).unwrap();
633            let (su, sv) = disc.derivatives(f(u), f(v)).unwrap();
634            assert!(
635                n[2].abs().could_be_equal(Scalar::ONE) && n[0].width().to_f64() < 0.1,
636                "at {:?}: {n:?}, S_u = {su:?}, S_v = {sv:?}",
637                (u, v)
638            );
639        }
640    }
641
642    /// A disc's centre has the disc's normal — for every `u` at once, and
643    /// the same as just off the pole.
644    #[test]
645    fn disc_centre_normal() {
646        let f = Scalar::from_f64;
647        let disc = revolved(0., 0.);
648        let every_u = f(0.).union(f(1.));
649        let n = disc.normal(every_u, f(0.)).unwrap();
650        let off = disc.normal(f(0.5), f(0.5)).unwrap();
651        assert!(n.could_be_equal(&off), "{n:?} vs {off:?}");
652        assert!(
653            n[2].abs().could_be_equal(Scalar::ONE) && n[0].could_be_equal(Scalar::ZERO),
654            "{n:?}"
655        );
656    }
657
658    /// A cone's apex has no single normal: its spokes are not coplanar.
659    #[test]
660    fn cone_apex_has_no_normal() {
661        let f = Scalar::from_f64;
662        let cone = revolved(1., 0.);
663        assert!(cone.normal(f(0.).union(f(1.)), f(0.)).is_err());
664        assert!(cone.normal(f(0.5), f(0.5)).is_ok());
665    }
666
667    /// Exact rational sphere octant; `v = 1` is the pole (0, 0, 1) — a
668    /// collapsed row at the *end* of the domain, where the limit's sign
669    /// flips.
670    #[test]
671    fn sphere_pole_normal() {
672        let f = Scalar::from_f64;
673        let w = std::f64::consts::FRAC_1_SQRT_2;
674        let sphere = NurbSurface::try_new(
675            2,
676            2,
677            vec![
678                hom(1., 0., 0., 1.),
679                hom(w, 0., w, w),
680                hom(0., 0., 1., 1.),
681                hom(w, w, 0., w),
682                hom(0.5, 0.5, 0.5, 0.5),
683                hom(0., 0., w, w),
684                hom(0., 1., 0., 1.),
685                hom(0., w, w, w),
686                hom(0., 0., 1., 1.),
687            ],
688            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
689            vec![f(0.), f(0.), f(0.), f(1.), f(1.), f(1.)],
690        )
691        .unwrap();
692        let n = sphere.normal(f(0.).union(f(1.)), f(1.)).unwrap();
693        assert!(
694            n[0].could_be_equal(Scalar::ZERO) && n[1].could_be_equal(Scalar::ZERO),
695            "{n:?}"
696        );
697        // Same orientation as the regular normal just below the pole.
698        let below = sphere.normal(f(0.5), f(0.9)).unwrap();
699        assert!(
700            n[2].mul(below[2]).definitely_greater(Scalar::ZERO),
701            "{n:?} vs {below:?}"
702        );
703    }
704}