Skip to main content

brepkit_math/nurbs/
projection.rs

1//! Point projection onto NURBS curves and surfaces.
2//!
3//! Finds the closest point on a curve or surface to a given point in space.
4//! Used for Boolean classification, snapping, distance queries, and
5//! tessellation refinement.
6//!
7//! Algorithms follow NURBS Book A6.1–A6.6: subdivision for initial guess
8//! followed by Newton–Raphson refinement.
9
10use crate::MathError;
11use crate::nurbs::curve::NurbsCurve;
12use crate::nurbs::decompose::curve_to_bezier_segments;
13use crate::nurbs::surface::NurbsSurface;
14use crate::vec::Point3;
15
16/// Maximum Newton iterations before declaring convergence failure.
17const MAX_ITERATIONS: usize = 50;
18
19/// Number of grid subdivisions per direction for surface coarse search.
20const SURFACE_GRID_SIZE: usize = 8;
21
22// ---------------------------------------------------------------------------
23// Public result types
24// ---------------------------------------------------------------------------
25
26/// Result of projecting a point onto a curve.
27#[derive(Debug, Clone, Copy)]
28pub struct CurveProjection {
29    /// Parameter value at the closest point.
30    pub parameter: f64,
31    /// The closest point on the curve.
32    pub point: Point3,
33    /// Distance from the input point to the closest point.
34    pub distance: f64,
35}
36
37/// Result of projecting a point onto a surface.
38#[derive(Debug, Clone, Copy)]
39pub struct SurfaceProjection {
40    /// Parameter value u at the closest point.
41    pub u: f64,
42    /// Parameter value v at the closest point.
43    pub v: f64,
44    /// The closest point on the surface.
45    pub point: Point3,
46    /// Distance from the input point to the closest point.
47    pub distance: f64,
48}
49
50// ---------------------------------------------------------------------------
51// Curve projection
52// ---------------------------------------------------------------------------
53
54/// Find the closest point on a NURBS curve to the given point.
55///
56/// Uses Bezier decomposition for initial guess, then Newton–Raphson
57/// refinement (NURBS Book A6.1 + A6.3–A6.4).
58///
59/// # Errors
60///
61/// Returns an error if Bezier decomposition fails (invalid curve data).
62pub fn project_point_to_curve(
63    curve: &NurbsCurve,
64    point: Point3,
65    tolerance: f64,
66) -> Result<CurveProjection, MathError> {
67    let knots = curve.knots();
68    let p = curve.degree();
69    let u_min = knots[p];
70    let u_max = knots[knots.len() - p - 1];
71
72    let candidates = curve_coarse_search(curve, point)?;
73
74    // Run Newton from each candidate and keep the globally closest result.
75    let mut best_u = u_min;
76    let mut best_pt = curve.evaluate(u_min);
77    let mut best_dist = (best_pt - point).length();
78
79    for u_guess in candidates {
80        let (u_refined, pt_refined) =
81            curve_newton_refine(curve, point, u_guess, u_min, u_max, tolerance);
82        let dist = (pt_refined - point).length();
83        if dist < best_dist {
84            best_dist = dist;
85            best_u = u_refined;
86            best_pt = pt_refined;
87        }
88    }
89
90    Ok(CurveProjection {
91        parameter: best_u,
92        point: best_pt,
93        distance: best_dist,
94    })
95}
96
97/// Coarse search: decompose into Bezier segments and sample points to find
98/// multiple candidate parameter values for Newton refinement.
99///
100/// Returns a sorted list of candidate parameters (best first) to use as
101/// Newton seeds. Using multiple seeds avoids converging to a local minimum.
102#[allow(clippy::cast_precision_loss)]
103fn curve_coarse_search(curve: &NurbsCurve, point: Point3) -> Result<Vec<f64>, MathError> {
104    let segments = curve_to_bezier_segments(curve)?;
105
106    // Collect all (distance_sq, parameter) samples.
107    let mut samples: Vec<(f64, f64)> = Vec::new();
108
109    for seg in &segments {
110        let knots = seg.knots();
111        let p = seg.degree();
112        let u_start = knots[p];
113        let u_end = knots[knots.len() - p - 1];
114
115        // Sample points along the segment.
116        let n_samples = (p + 1).max(5) * 2;
117        for i in 0..=n_samples {
118            let t = i as f64 / n_samples as f64;
119            let u = t.mul_add(u_end - u_start, u_start);
120            let pt = seg.evaluate(u);
121            let d_sq = (pt - point).length_squared();
122            samples.push((d_sq, u));
123        }
124    }
125
126    // Sort by distance and return the best candidates.
127    samples.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
128
129    // Take the top few unique candidates (spatially separated).
130    let mut candidates = Vec::new();
131    let max_candidates = 5;
132    for &(_, u) in &samples {
133        if candidates.len() >= max_candidates {
134            break;
135        }
136        // Skip candidates too close to one we already have.
137        let dominated = candidates.iter().any(|&c: &f64| (c - u).abs() < 1e-10);
138        if !dominated {
139            candidates.push(u);
140        }
141    }
142
143    Ok(candidates)
144}
145
146/// Newton–Raphson refinement for curve point projection.
147///
148/// Finds parameter u that minimizes ||C(u) - P|| starting from `u_init`.
149/// Always returns a result — falls back to the best iterate if formal
150/// convergence criteria are not met within [`MAX_ITERATIONS`].
151#[allow(clippy::suspicious_operation_groupings)]
152fn curve_newton_refine(
153    curve: &NurbsCurve,
154    point: Point3,
155    u_init: f64,
156    u_min: f64,
157    u_max: f64,
158    tolerance: f64,
159) -> (f64, Point3) {
160    let tol_sq = tolerance * tolerance;
161    let mut u = u_init;
162    let mut best_u = u;
163    let mut best_dist_sq = f64::INFINITY;
164
165    for _ in 0..MAX_ITERATIONS {
166        let ders = curve.derivatives(u, 2);
167        let c_pt = Point3::new(ders[0].x(), ders[0].y(), ders[0].z());
168        let c_prime = ders[1]; // C'(u)
169        let c_double_prime = ders[2]; // C''(u)
170        let diff = c_pt - point; // C(u) - P
171
172        let dist_sq = diff.length_squared();
173
174        if dist_sq < best_dist_sq {
175            best_dist_sq = dist_sq;
176            best_u = u;
177        }
178
179        // Convergence check 1: point coincidence.
180        if dist_sq < tol_sq {
181            return (u, c_pt);
182        }
183
184        // f(u) = C'(u) · (C(u) - P)
185        let f_val = c_prime.dot(diff);
186
187        // Convergence check 2: zero cosine (perpendicularity).
188        // cos²(angle) = (C'·diff)² / (|C'|² · |diff|²) < tol²
189        let c_prime_len_sq = c_prime.length_squared();
190        if c_prime_len_sq > 1e-30 && dist_sq > tol_sq {
191            let cos_sq = (f_val * f_val) / (c_prime_len_sq * dist_sq);
192            if cos_sq < tol_sq {
193                return (u, c_pt);
194            }
195        }
196
197        // f'(u) = C''(u) · (C(u) - P) + |C'(u)|²
198        let f_prime = c_double_prime.dot(diff) + c_prime_len_sq;
199
200        // Guard against zero denominator.
201        if f_prime.abs() < 1e-30 {
202            return (u, c_pt);
203        }
204
205        let delta_u = f_val / f_prime;
206        let u_new = (u - delta_u).clamp(u_min, u_max);
207
208        // Guard NaN.
209        if u_new.is_nan() {
210            break;
211        }
212
213        // Convergence check 3: parameter step negligible.
214        let du = (u_new - u).abs();
215        if du < tolerance * (1.0 + u.abs()) {
216            let pt = curve.evaluate(u_new);
217            return (u_new, pt);
218        }
219
220        u = u_new;
221    }
222
223    // Return the best point found during iteration.
224    let pt = curve.evaluate(best_u);
225    (best_u, pt)
226}
227
228// ---------------------------------------------------------------------------
229// Surface projection
230// ---------------------------------------------------------------------------
231
232/// Find the closest point on a NURBS surface to the given point.
233///
234/// Uses grid evaluation for initial guess, then 2D Newton–Raphson
235/// refinement (NURBS Book A6.2 + A6.5–A6.6).
236///
237/// # Errors
238///
239/// Returns [`MathError::ConvergenceFailure`] if Newton iteration does not
240/// converge within the maximum number of iterations.
241pub fn project_point_to_surface(
242    surface: &NurbsSurface,
243    point: Point3,
244    tolerance: f64,
245) -> Result<SurfaceProjection, MathError> {
246    let (u_guess, v_guess) = surface_coarse_search(surface, point);
247
248    let knots_u = surface.knots_u();
249    let knots_v = surface.knots_v();
250    let pu = surface.degree_u();
251    let pv = surface.degree_v();
252    let u_min = knots_u[pu];
253    let u_max = knots_u[knots_u.len() - pu - 1];
254    let v_min = knots_v[pv];
255    let v_max = knots_v[knots_v.len() - pv - 1];
256
257    let (u_final, v_final, pt_final) = surface_newton_refine(
258        surface, point, u_guess, v_guess, u_min, u_max, v_min, v_max, tolerance,
259    )?;
260    let dist = (pt_final - point).length();
261
262    Ok(SurfaceProjection {
263        u: u_final,
264        v: v_final,
265        point: pt_final,
266        distance: dist,
267    })
268}
269
270/// Coarse search: evaluate surface on a uniform grid and find the closest
271/// grid point.
272#[allow(clippy::cast_precision_loss)]
273fn surface_coarse_search(surface: &NurbsSurface, point: Point3) -> (f64, f64) {
274    let knots_u = surface.knots_u();
275    let knots_v = surface.knots_v();
276    let pu = surface.degree_u();
277    let pv = surface.degree_v();
278    let u_min = knots_u[pu];
279    let u_max = knots_u[knots_u.len() - pu - 1];
280    let v_min = knots_v[pv];
281    let v_max = knots_v[knots_v.len() - pv - 1];
282
283    let mut best_u = u_min;
284    let mut best_v = v_min;
285    let mut best_dist_sq = f64::INFINITY;
286
287    let n = SURFACE_GRID_SIZE;
288    for i in 0..=n {
289        let u = (i as f64 / n as f64).mul_add(u_max - u_min, u_min);
290        for j in 0..=n {
291            let v = (j as f64 / n as f64).mul_add(v_max - v_min, v_min);
292            let pt = surface.evaluate(u, v);
293            let d_sq = (pt - point).length_squared();
294            if d_sq < best_dist_sq {
295                best_dist_sq = d_sq;
296                best_u = u;
297                best_v = v;
298            }
299        }
300    }
301
302    (best_u, best_v)
303}
304
305/// 2D Newton–Raphson refinement for surface point projection.
306///
307/// Solves the 2×2 system at each step to find the (u, v) that minimizes
308/// ||S(u,v) - P||.
309#[allow(clippy::too_many_arguments, clippy::similar_names)]
310#[allow(clippy::suspicious_operation_groupings)]
311fn surface_newton_refine(
312    surface: &NurbsSurface,
313    point: Point3,
314    u_init: f64,
315    v_init: f64,
316    u_min: f64,
317    u_max: f64,
318    v_min: f64,
319    v_max: f64,
320    tolerance: f64,
321) -> Result<(f64, f64, Point3), MathError> {
322    let mut u = u_init;
323    let mut v = v_init;
324
325    for _ in 0..MAX_ITERATIONS {
326        let ders = surface.derivatives(u, v, 1);
327        let s_pt = Point3::new(ders[0][0].x(), ders[0][0].y(), ders[0][0].z());
328        let deriv_u = ders[1][0]; // ∂S/∂u
329        let deriv_v = ders[0][1]; // ∂S/∂v
330        let r = s_pt - point; // S(u,v) - P
331
332        // Convergence check 1: point coincidence.
333        let dist = r.length();
334        if dist < tolerance {
335            return Ok((u, v, s_pt));
336        }
337
338        // Convergence check 2: zero cosine in both directions.
339        let du_len = deriv_u.length();
340        let dv_len = deriv_v.length();
341        let dot_du_r = deriv_u.dot(r);
342        let dot_dv_r = deriv_v.dot(r);
343        if du_len > 0.0 && dv_len > 0.0 {
344            let cos_u = dot_du_r.abs() / (du_len * dist);
345            let cos_v = dot_dv_r.abs() / (dv_len * dist);
346            if cos_u < tolerance && cos_v < tolerance {
347                return Ok((u, v, s_pt));
348            }
349        }
350
351        // Build the 2×2 Jacobian and right-hand side.
352        // J = [S_u · S_u,  S_u · S_v]
353        //     [S_v · S_u,  S_v · S_v]
354        let j00 = deriv_u.dot(deriv_u);
355        let j01 = deriv_u.dot(deriv_v);
356        let j11 = deriv_v.dot(deriv_v);
357        // rhs = [-S_u · r, -S_v · r]
358        let rhs0 = -dot_du_r;
359        let rhs1 = -dot_dv_r;
360
361        // Solve 2×2 system via Cramer's rule: det = j00*j11 - j01²
362        // Use a relative threshold so the singularity test stays meaningful
363        // near surface poles / cone apex where both derivatives shrink to zero.
364        let det = j00.mul_add(j11, -(j01 * j01));
365        let (delta_u, delta_v) = if det.abs() < (j00 + j11).max(1e-30) * 1e-12 {
366            // Near-singular: apply Tikhonov (Levenberg–Marquardt) regularisation
367            // by adding λI to the normal equations.  This yields a step biased
368            // toward zero rather than blowing up, preserving convergence near
369            // poles and cone apices.
370            let lambda = (j00 + j11).max(1e-10) * 1e-4;
371            let j00r = j00 + lambda;
372            let j11r = j11 + lambda;
373            let det_r = j00r.mul_add(j11r, -(j01 * j01));
374            if det_r.abs() < 1e-30 {
375                // Still singular even after regularisation — fall back to a 1-D
376                // search along whichever parameter axis has more gradient.
377                if j00 > j11 {
378                    (rhs0 / j00.max(1e-30), 0.0)
379                } else if j11 > 1e-30 {
380                    (0.0, rhs1 / j11.max(1e-30))
381                } else {
382                    return Ok((u, v, s_pt));
383                }
384            } else {
385                (
386                    rhs0.mul_add(j11r, -(rhs1 * j01)) / det_r,
387                    j00r.mul_add(rhs1, -(j01 * rhs0)) / det_r,
388                )
389            }
390        } else {
391            (
392                rhs0.mul_add(j11, -(rhs1 * j01)) / det,
393                j00.mul_add(rhs1, -(j01 * rhs0)) / det,
394            )
395        };
396
397        let u_new = (u + delta_u).clamp(u_min, u_max);
398        let v_new = (v + delta_v).clamp(v_min, v_max);
399
400        // Convergence check 3: parameter step negligible.
401        let step = (deriv_u * (u_new - u) + deriv_v * (v_new - v)).length();
402        if step < tolerance {
403            let pt = surface.evaluate(u_new, v_new);
404            return Ok((u_new, v_new, pt));
405        }
406
407        u = u_new;
408        v = v_new;
409    }
410
411    Err(MathError::ConvergenceFailure {
412        iterations: MAX_ITERATIONS,
413    })
414}
415
416// ---------------------------------------------------------------------------
417// Tests
418// ---------------------------------------------------------------------------
419
420#[cfg(test)]
421#[allow(clippy::expect_used)]
422mod tests {
423    use super::*;
424
425    const TOL: f64 = 1e-8;
426
427    /// A simple line from (0,0,0) to (10,0,0) as a degree-1 NURBS.
428    fn line_curve() -> NurbsCurve {
429        NurbsCurve::new(
430            1,
431            vec![0.0, 0.0, 1.0, 1.0],
432            vec![Point3::new(0.0, 0.0, 0.0), Point3::new(10.0, 0.0, 0.0)],
433            vec![1.0, 1.0],
434        )
435        .expect("valid line")
436    }
437
438    /// Quarter circle arc as a rational NURBS (degree 2).
439    fn quarter_circle() -> NurbsCurve {
440        let w = std::f64::consts::FRAC_1_SQRT_2;
441        NurbsCurve::new(
442            2,
443            vec![0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
444            vec![
445                Point3::new(1.0, 0.0, 0.0),
446                Point3::new(1.0, 1.0, 0.0),
447                Point3::new(0.0, 1.0, 0.0),
448            ],
449            vec![1.0, w, 1.0],
450        )
451        .expect("valid quarter circle")
452    }
453
454    /// Cubic Bezier curve.
455    fn cubic_bezier() -> NurbsCurve {
456        NurbsCurve::new(
457            3,
458            vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0],
459            vec![
460                Point3::new(0.0, 0.0, 0.0),
461                Point3::new(1.0, 2.0, 0.0),
462                Point3::new(3.0, 2.0, 0.0),
463                Point3::new(4.0, 0.0, 0.0),
464            ],
465            vec![1.0, 1.0, 1.0, 1.0],
466        )
467        .expect("valid cubic")
468    }
469
470    /// Bilinear flat patch (z=0 plane, from (0,0) to (1,1)).
471    fn flat_patch() -> NurbsSurface {
472        NurbsSurface::new(
473            1,
474            1,
475            vec![0.0, 0.0, 1.0, 1.0],
476            vec![0.0, 0.0, 1.0, 1.0],
477            vec![
478                vec![Point3::new(0.0, 0.0, 0.0), Point3::new(1.0, 0.0, 0.0)],
479                vec![Point3::new(0.0, 1.0, 0.0), Point3::new(1.0, 1.0, 0.0)],
480            ],
481            vec![vec![1.0, 1.0], vec![1.0, 1.0]],
482        )
483        .expect("valid flat patch")
484    }
485
486    // -- Curve tests -------------------------------------------------------
487
488    #[test]
489    fn project_to_line() {
490        let c = line_curve();
491        // Point (5, 3, 0) — closest point should be (5, 0, 0) at u=0.5.
492        let res =
493            project_point_to_curve(&c, Point3::new(5.0, 3.0, 0.0), TOL).expect("should converge");
494        assert!((res.parameter - 0.5).abs() < TOL, "u={}", res.parameter);
495        assert!((res.point.x() - 5.0).abs() < TOL);
496        assert!((res.point.y()).abs() < TOL);
497        assert!((res.distance - 3.0).abs() < TOL, "dist={}", res.distance);
498    }
499
500    #[test]
501    #[allow(clippy::suboptimal_flops)]
502    fn project_to_circle() {
503        let c = quarter_circle();
504        // Point (2, 2, 0) — closest point should be on the unit circle at 45°.
505        let res =
506            project_point_to_curve(&c, Point3::new(2.0, 2.0, 0.0), TOL).expect("should converge");
507        let expected = std::f64::consts::FRAC_1_SQRT_2;
508        assert!(
509            (res.point.x() - expected).abs() < 1e-6,
510            "x={} expected={}",
511            res.point.x(),
512            expected
513        );
514        assert!(
515            (res.point.y() - expected).abs() < 1e-6,
516            "y={} expected={}",
517            res.point.y(),
518            expected
519        );
520        // Distance from (2,2) to unit circle at 45° = sqrt(8) - 1.
521        let expected_dist = 2.0_f64.hypot(2.0) - 1.0;
522        assert!(
523            (res.distance - expected_dist).abs() < 1e-6,
524            "dist={} expected={}",
525            res.distance,
526            expected_dist
527        );
528    }
529
530    #[test]
531    fn project_endpoint() {
532        let c = cubic_bezier();
533        // Project a point very close to the start endpoint.
534        let res =
535            project_point_to_curve(&c, Point3::new(0.0, 0.01, 0.0), TOL).expect("should converge");
536        assert!(res.distance < 0.02, "dist={}", res.distance);
537        assert!(res.parameter < 0.1, "u={}", res.parameter);
538    }
539
540    #[test]
541    fn project_far_point() {
542        let c = cubic_bezier();
543        // A point far away should still converge.
544        let res =
545            project_point_to_curve(&c, Point3::new(2.0, 100.0, 0.0), TOL).expect("should converge");
546        // The closest point should be roughly at the top of the curve (y ≈ 1.5).
547        assert!(res.point.y() > 0.0);
548        assert!(res.distance < 100.0);
549    }
550
551    #[test]
552    fn project_on_curve() {
553        let c = cubic_bezier();
554        // Evaluate a point on the curve, then project it back.
555        let u_orig = 0.3;
556        let pt_on = c.evaluate(u_orig);
557        let res = project_point_to_curve(&c, pt_on, TOL).expect("should converge");
558        assert!(res.distance < TOL, "dist={}", res.distance);
559        assert!(
560            (res.parameter - u_orig).abs() < 1e-4,
561            "u={} expected={}",
562            res.parameter,
563            u_orig
564        );
565    }
566
567    // -- Surface tests -----------------------------------------------------
568
569    #[test]
570    fn project_to_flat_quad() {
571        let s = flat_patch();
572        // Point (0.5, 0.5, 3.0) — should project to (0.5, 0.5, 0.0).
573        let res =
574            project_point_to_surface(&s, Point3::new(0.5, 0.5, 3.0), TOL).expect("should converge");
575        assert!((res.point.x() - 0.5).abs() < TOL, "x={}", res.point.x());
576        assert!((res.point.y() - 0.5).abs() < TOL, "y={}", res.point.y());
577        assert!((res.point.z()).abs() < TOL, "z={}", res.point.z());
578        assert!((res.distance - 3.0).abs() < TOL, "dist={}", res.distance);
579    }
580
581    #[test]
582    fn project_on_surface() {
583        let s = flat_patch();
584        // Point directly on the surface.
585        let res =
586            project_point_to_surface(&s, Point3::new(0.3, 0.7, 0.0), TOL).expect("should converge");
587        assert!(res.distance < TOL, "dist={}", res.distance);
588    }
589
590    #[test]
591    fn project_above_surface() {
592        let s = flat_patch();
593        // Point at height 1 above the center.
594        let res =
595            project_point_to_surface(&s, Point3::new(0.5, 0.5, 1.0), TOL).expect("should converge");
596        assert!(
597            (res.distance - 1.0).abs() < TOL,
598            "dist={} expected=1.0",
599            res.distance
600        );
601        assert!((res.u - 0.5).abs() < TOL, "u={}", res.u);
602        assert!((res.v - 0.5).abs() < TOL, "v={}", res.v);
603    }
604
605    /// Bilinear degenerate "cone apex" patch.
606    ///
607    /// Control grid:
608    ///   v=0 row: apex=(0,0,0)  apex=(0,0,0)   ← S_u = 0 everywhere on this row
609    ///   v=1 row: (-1,0,1)      (1,0,1)
610    ///
611    /// Parametric formula: S(u,v) = (v·(2u-1), 0, v)
612    ///
613    /// The Jacobian is rank-1 at v=0 (both S_u and S_v are degenerate there),
614    /// which triggers the LM-regularisation branch in `project_point_to_surface`.
615    fn apex_patch() -> NurbsSurface {
616        NurbsSurface::new(
617            1,
618            1,
619            vec![0.0, 0.0, 1.0, 1.0],
620            vec![0.0, 0.0, 1.0, 1.0],
621            vec![
622                vec![Point3::new(0.0, 0.0, 0.0), Point3::new(0.0, 0.0, 0.0)], // v=0: apex
623                vec![Point3::new(-1.0, 0.0, 1.0), Point3::new(1.0, 0.0, 1.0)], // v=1: base
624            ],
625            vec![vec![1.0, 1.0], vec![1.0, 1.0]],
626        )
627        .expect("valid apex patch")
628    }
629
630    /// Project a point whose nearest surface location is the degenerate apex.
631    ///
632    /// The surface S(u,v)=(v(2u−1), 0, v) lies in the xz-plane.  The query
633    /// point (0, 1, 0) is displaced only in y, so its nearest surface point is
634    /// the apex (0,0,0) — the only point that minimises the xz-distance.
635    /// Without LM regularisation the Newton step blows up at v→0; with it the
636    /// solver should converge and return (u≈0.5, v≈0, dist≈1).
637    #[test]
638    fn project_to_apex_singularity() {
639        let s = apex_patch();
640        let res = project_point_to_surface(&s, Point3::new(0.0, 1.0, 0.0), 1e-6)
641            .expect("should converge at cone apex singularity");
642        // Nearest point must be the apex.
643        assert!(
644            res.point.x().abs() < 1e-6 && res.point.y().abs() < 1e-6 && res.point.z().abs() < 1e-6,
645            "nearest point should be apex, got ({:.4},{:.4},{:.4})",
646            res.point.x(),
647            res.point.y(),
648            res.point.z()
649        );
650        assert!(
651            (res.distance - 1.0).abs() < 1e-6,
652            "distance to apex should be 1.0, got {:.8}",
653            res.distance
654        );
655    }
656
657    /// Project a point off-axis but close to the apex.  The solver must still
658    /// converge despite starting near the singularity.
659    #[test]
660    fn project_near_apex_off_axis() {
661        let s = apex_patch();
662        // S(0.7, 0.05) = (0.05*(2*0.7-1), 0, 0.05) = (0.05*0.4, 0, 0.05) = (0.02, 0, 0.05)
663        // Query close to that surface point but displaced in y.
664        let res = project_point_to_surface(&s, Point3::new(0.02, 0.3, 0.05), 1e-6)
665            .expect("should converge near apex");
666        assert!(
667            (res.distance - 0.3).abs() < 0.02,
668            "expected distance ≈ 0.3, got {:.6}",
669            res.distance
670        );
671        // Nearest surface point should be close to S(0.7, 0.05) = (0.02, 0, 0.05).
672        assert!(
673            (res.point.z() - 0.05).abs() < 0.02,
674            "nearest point z should be ≈ 0.05, got z={:.4}",
675            res.point.z()
676        );
677    }
678}