Skip to main content

geop_core_geometry/nurb_surface/
project.rs

1use geop_core_math::{geop_error::GeopResult, scalars::Scalar, vector::Vector3};
2
3use super::NurbSurface;
4
5/// Clamp `x` into `[lo, hi]` using three-valued comparisons.
6pub fn clamp<S: Scalar>(x: S, lo: S, hi: S) -> S {
7    if x.definitely_less(lo) {
8        lo
9    } else if x.definitely_greater(hi) {
10        hi
11    } else {
12        x
13    }
14}
15
16impl<S: Scalar> NurbSurface<S, 4> {
17    /// Fixed-iteration-count Newton foot-point projection of `target` onto
18    /// this surface, starting from `(u0, v0)`.
19    ///
20    /// Each step solves the exact 2x2 system `J·Δ = r` where
21    /// `r = (residual·Su, residual·Sv)` and `J = [[Su·Su, Su·Sv], [Su·Sv,
22    /// Sv·Sv]]` (the first fundamental form — exact for the degree-1
23    /// bilinear/low-degree patches used in this crate's tests, since second
24    /// derivatives are dropped), then clamps `(u, v)` into `domain_u()` /
25    /// `domain_v()` before the next iteration. Runs `iterations` times with no
26    /// convergence tolerance — interval scalars can't judge "close enough" —
27    /// but stops early, with the identical result, once the sharpened
28    /// iterate reaches an exact fixed point (see the loop).
29    ///
30    /// `J` is singular exactly where the surface's own parametrization is —
31    /// a coordinate-singular pole (e.g. the apex of a `revolve`d disk cap,
32    /// where every `v` collapses to one point and `Sv = 0`). Rather than
33    /// erroring there (`div` by an exactly- or interval-possibly-zero
34    /// determinant), that iteration's update is simply skipped, leaving
35    /// `(u, v)` exactly where the *previous*, non-singular iteration left
36    /// it. For a target genuinely at or very near such a pole, earlier
37    /// iterations still pull `(u, v)` right up to the singular edge_loop
38    /// before this kicks in, so the frozen result is still a meaningful
39    /// (if imprecise right at the pole) answer — good enough for a caller
40    /// to then recognize "this converged onto a known singular vertex" and
41    /// handle it explicitly, instead of the whole projection just failing.
42    pub fn project(
43        &self,
44        target: Vector3<S>,
45        u0: S,
46        v0: S,
47        iterations: usize,
48    ) -> GeopResult<(S, S)> {
49        let (u_lo, u_hi) = self.domain_u();
50        let (v_lo, v_hi) = self.domain_v();
51        let mut u = clamp(u0, u_lo, u_hi);
52        let mut v = clamp(v0, v_lo, v_hi);
53
54        for iteration in 0..iterations {
55            let p = self.evaluate(u, v)?;
56            let (su, sv) = self.derivatives(u, v)?;
57            let r = target.sub(&p);
58
59            let a11 = su.prod_dot(&su);
60            let a12 = su.prod_dot(&sv);
61            let a22 = sv.prod_dot(&sv);
62            let b1 = su.prod_dot(&r);
63            let b2 = sv.prod_dot(&r);
64
65            let det = a11.mul(a22).sub(a12.mul(a12));
66            let du = b1.mul(a22).sub(b2.mul(a12)).div(det);
67            let dv = a11.mul(b2).sub(a12.mul(b1)).div(det);
68            let (Ok(du), Ok(dv)) = (du, dv) else {
69                continue;
70            };
71
72            // Sharpened every iteration, not just at the end: Newton is
73            // iterative *refinement* of a foot point this routine gets to
74            // choose, not propagation of a measured uncertainty, so any
75            // single value inside the current iterate is an equally valid
76            // starting point for the next step (see `Scalar::sharpen`).
77            // Left unsharpened, each step's `du`/`dv` widens `(u, v)`
78            // further; `clamp` cannot pull that back, since an interval
79            // merely *straddling* a domain bound is neither definitely
80            // inside nor definitely outside it. The widened parameter then
81            // reaches `evaluate`, where a wide `t` makes the de Boor
82            // weight straddle zero and the whole evaluation fail — even
83            // though the seed was sharp and well inside the domain.
84            // Sharpen *before* clamping, not after: on a sharp value
85            // `clamp`'s three-valued comparisons are exact, so the result
86            // is guaranteed inside `[lo, hi]`. The other order can escape
87            // the domain again — collapsing an interval that straddles a
88            // bound to its midpoint can land just past that bound, and the
89            // next `evaluate` then rejects it as out of domain.
90            //
91            // The *last* iteration is the exception: its result is not a
92            // seed for anything, it is the answer this function returns.
93            // `du`/`dv` inherit the width of `target` (through `r`), so
94            // that final width is the honest statement of how precisely an
95            // uncertain target pins down a foot point. Sharpening it away
96            // would return a single sharp `(u, v)` for a target that only
97            // ever determined a range of them — an answer claiming more
98            // precision than the input carried, which then fails to agree
99            // with anything else derived from the same uncertain point.
100            let (next_u, next_v) = (u.add(du), v.add(dv));
101            if iteration + 1 == iterations {
102                return Ok((clamp(next_u, u_lo, u_hi), clamp(next_v, v_lo, v_hi)));
103            }
104            let (sharp_u, sharp_v) = (
105                clamp(next_u.sharpen(), u_lo, u_hi),
106                clamp(next_v.sharpen(), v_lo, v_hi),
107            );
108            // A fixed point of the sharpened iteration: every remaining
109            // iterate would reproduce `(u, v)` exactly (each is a
110            // deterministic function of the same sharp seed), and so would
111            // the final unsharpened step, which starts from it too. Taking
112            // that final step now returns the identical result sooner — not
113            // a convergence tolerance, since nothing is judged "close
114            // enough": the iterate is bit-for-bit unchanged.
115            let same = |a: S, b: S| a.is_subset_of(b) && b.is_subset_of(a);
116            if same(sharp_u, u) && same(sharp_v, v) {
117                return Ok((clamp(next_u, u_lo, u_hi), clamp(next_v, v_lo, v_hi)));
118            }
119            u = sharp_u;
120            v = sharp_v;
121        }
122
123        Ok((u, v))
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use crate::nurb_surface::NurbSurface;
130    use geop_core_math::for_all_scalars;
131    use geop_core_math::{scalars::Scalar, vector::Vector4};
132
133    fn pt<S: Scalar>(x: f64, y: f64, z: f64, w: f64) -> Vector4<S> {
134        Vector4::from_array([
135            S::from_f64(x),
136            S::from_f64(y),
137            S::from_f64(z),
138            S::from_f64(w),
139        ])
140    }
141
142    /// Flat unit patch in the xy-plane.
143    fn flat_xy<S: Scalar>() -> NurbSurface<S, 4> {
144        let f = S::from_f64;
145        NurbSurface::try_new(
146            1,
147            1,
148            vec![
149                pt(0., 0., 0., 1.),
150                pt(0., 1., 0., 1.),
151                pt(1., 0., 0., 1.),
152                pt(1., 1., 0., 1.),
153            ],
154            vec![f(0.), f(0.), f(1.), f(1.)],
155            vec![f(0.), f(0.), f(1.), f(1.)],
156        )
157        .unwrap()
158    }
159
160    fn check_project_flat_surface_from_directly_above<S: Scalar>() {
161        let s = flat_xy::<S>();
162        let target = geop_core_math::vector::Vector3::from_array([
163            S::from_f64(0.3),
164            S::from_f64(0.7),
165            S::from_f64(2.0),
166        ]);
167        let (u, v) = s
168            .project(target, S::from_f64(0.5), S::from_f64(0.5), 5)
169            .unwrap();
170        assert!(u.could_be_equal(S::from_f64(0.3)));
171        assert!(v.could_be_equal(S::from_f64(0.7)));
172    }
173    #[test]
174    fn project_flat_surface_from_directly_above() {
175        for_all_scalars!(check_project_flat_surface_from_directly_above);
176    }
177
178    /// Non-planar bilinear "saddle" patch: corner heights 0,1,1,0 over
179    /// x,y ∈ [0,2] — same fixture used by `normal.rs`'s tests.
180    fn bent_surface<S: Scalar>() -> NurbSurface<S, 4> {
181        let f = S::from_f64;
182        NurbSurface::try_new(
183            1,
184            1,
185            vec![
186                pt(0., 0., 0., 1.),
187                pt(0., 2., 1., 1.),
188                pt(2., 0., 1., 1.),
189                pt(2., 2., 0., 1.),
190            ],
191            vec![f(0.), f(0.), f(1.), f(1.)],
192            vec![f(0.), f(0.), f(1.), f(1.)],
193        )
194        .unwrap()
195    }
196
197    fn check_project_onto_bent_surface_converges<S: Scalar>() {
198        let s = bent_surface::<S>();
199        let expected_u = S::from_f64(0.4);
200        let expected_v = S::from_f64(0.6);
201        let on_surface = s.evaluate(expected_u, expected_v).unwrap();
202        let (u, v) = s
203            .project(on_surface, S::from_f64(0.5), S::from_f64(0.5), 5)
204            .unwrap();
205        let result = s.evaluate(u, v).unwrap();
206        assert!(result[0].could_be_equal(on_surface[0]));
207        assert!(result[1].could_be_equal(on_surface[1]));
208        assert!(result[2].could_be_equal(on_surface[2]));
209    }
210    #[test]
211    fn project_onto_bent_surface_converges() {
212        for_all_scalars!(check_project_onto_bent_surface_converges);
213    }
214}