Skip to main content

ogeom_geom/
project.rs

1//! The nearest point on a surface.
2//!
3//! A foot point is where the displacement from the surface to a target is
4//! perpendicular to both tangents. It is found by Newton from a seed, and
5//! the seed is what decides which foot: a closed form, a branch and bound
6//! over a B-spline patch's spans, a grid over any other surface, a stored
7//! copy of either for many targets, or a caller's own guess.
8
9mod spans;
10
11use crate::{Surface, SurfaceGeometry, SurfaceJet};
12use ogeom_core::{OgeomResult, Tolerances};
13use ogeom_math::Point;
14use spans::SpanBoxes;
15
16/// Where a point projects onto a surface.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct SurfaceProjection {
19    /// The parameters of the nearest point found.
20    pub parameters: (f64, f64),
21    /// The nearest point.
22    pub point: Point,
23    /// The distance to it.
24    pub distance: f64,
25}
26
27/// The nearest point on a surface to `target`.
28///
29/// A seed, then Newton on the two conditions that define a foot point: the
30/// displacement from the surface to the target is perpendicular to both
31/// tangents. An elementary or swept surface seeds in closed form or along
32/// its profile. A B-spline patch seeds by branch and bound over its knot
33/// spans, to within a hundredth of the confusion of the nearest point of
34/// the whole patch, and ignores `samples`. Any other surface seeds from a grid of `samples`
35/// per direction: the distance to a surface is generally multi-modal, and
36/// the grid sets how fine a basin it can tell apart.
37///
38/// Where two feet of a B-spline patch are equally near to within a
39/// hundredth of the confusion, either is a right answer, and the one
40/// returned is fixed by the patch and the target: the one at the smaller
41/// `u`, then `v`, among the points the search seeds Newton from.
42///
43/// # Errors
44///
45/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if the surface cannot be
46/// evaluated over its own domain.
47pub fn project_on_surface(
48    surface: &SurfaceGeometry,
49    target: Point,
50    samples: usize,
51    tol: Tolerances,
52) -> OgeomResult<SurfaceProjection> {
53    // An elementary surface has its nearest point in closed form, and a
54    // swept one through its profile: where that foot lies inside the
55    // surface's window, the grid below would find the same basin by a
56    // thousand evaluations.
57    if let Some(found) = closed_form(
58        surface,
59        target,
60        Profile::of(surface, samples, tol).as_ref(),
61        tol,
62    ) {
63        return Ok(found);
64    }
65    if let Some(boxes) = span_boxes(surface) {
66        return boxes.project(surface, target, tol);
67    }
68    grid_projection(surface, target, samples, tol)
69}
70
71/// The span boxes of a B-spline patch whose hull bounds it; `None` for any
72/// other surface.
73fn span_boxes(surface: &SurfaceGeometry) -> Option<SpanBoxes> {
74    match surface {
75        SurfaceGeometry::BSpline(spline) => SpanBoxes::of(spline),
76        _ => None,
77    }
78}
79
80/// The nearest point from the seed grid alone.
81fn grid_projection(
82    surface: &SurfaceGeometry,
83    target: Point,
84    samples: usize,
85    tol: Tolerances,
86) -> OgeomResult<SurfaceProjection> {
87    let (us, vs) = seed_lines(surface, samples);
88    let mut scan = Scan::default();
89    for &u in &us {
90        let mut row = Row::with_capacity(vs.len());
91        for &v in &vs {
92            let d = surface
93                .point_at(u, v, tol)
94                .map_or(f64::INFINITY, |p| p.square_distance(target));
95            row.push((u, v, d));
96        }
97        scan.push_row(row);
98    }
99
100    scan.finish().refine(surface, target, tol)
101}
102
103/// The nearest point where a closed form or a profile scan finds it
104/// inside the surface's window, polished by [`refine_foot`]; `None` for any
105/// other surface, a foot outside the window, or a polish that fails.
106///
107/// `profile` is [`Profile::of`] the same surface.
108fn closed_form(
109    surface: &SurfaceGeometry,
110    target: Point,
111    profile: Option<&Profile>,
112    tol: Tolerances,
113) -> Option<SurfaceProjection> {
114    if let Some(profile) = profile {
115        return profile.project(surface, target, tol);
116    }
117    let seed = closed_form_foot(surface, target, surface.domain(), tol)?;
118    refine_foot(surface, target, seed, tol).ok()
119}
120
121/// The parameters of an elementary surface's nearest point where they lie
122/// in `window`: a plane's orthogonal foot, a cylinder's, cone's or torus's
123/// for a point off the axis, a sphere's for a point off its centre. A
124/// trimmed surface asks its basis, an angle shifted by whole turns into the
125/// trim. `None` for any other surface, or a foot outside the window.
126fn closed_form_foot(
127    surface: &SurfaceGeometry,
128    target: Point,
129    window: ((f64, f64), (f64, f64)),
130    tol: Tolerances,
131) -> Option<(f64, f64)> {
132    use ogeom_math::elementary;
133    let (u, v) = match surface {
134        SurfaceGeometry::Plane(p) => elementary::plane_parameters(&p.plane(), target),
135        SurfaceGeometry::Cylinder(c) => {
136            elementary::cylinder_parameters(&c.cylinder(), target, tol).ok()?
137        }
138        SurfaceGeometry::Cone(c) => elementary::cone_parameters(&c.cone(), target, tol).ok()?,
139        SurfaceGeometry::Sphere(s) => {
140            elementary::sphere_parameters(&s.sphere(), target, tol).ok()?
141        }
142        SurfaceGeometry::Torus(t) => elementary::torus_parameters(&t.torus(), target, tol).ok()?,
143        SurfaceGeometry::Trimmed(t) => return closed_form_foot(t.basis(), target, window, tol),
144        _ => return None,
145    };
146    let ((ua, ub), (va, vb)) = surface.domain();
147    let u = into_window(u, window.0, surface.is_periodic_u().then_some(ub - ua))?;
148    let v = into_window(v, window.1, surface.is_periodic_v().then_some(vb - va))?;
149    Some((u, v))
150}
151
152/// `t` in `[from, to]`, shifted by whole periods where the direction has
153/// one; `None` where no shift lands inside.
154fn into_window(t: f64, (from, to): (f64, f64), period: Option<f64>) -> Option<f64> {
155    if t >= from && t <= to {
156        return Some(t);
157    }
158    let period = period.filter(|p| *p > 0.0)?;
159    let shifted = ((from - t) / period).ceil().mul_add(period, t);
160    (shifted >= from && shifted <= to).then_some(shifted)
161}
162
163/// A profile scan: per sample, its square distance across the sweep and
164/// the seed it gives where the sweep coordinate is inside the window.
165type ProfileScan = smallvec::SmallVec<[(f64, Option<(f64, f64)>); 64]>;
166
167/// How a swept surface reduces to its profile.
168#[derive(Debug, Clone, Copy)]
169enum Sweep {
170    /// An extrusion along a unit direction: `u` runs along the profile,
171    /// `v` is the distance swept.
172    Extrusion(ogeom_math::Vector),
173    /// A revolution about an axis: `u` is the angle, `v` runs along the
174    /// profile.
175    Revolution(ogeom_math::Axis),
176}
177
178/// A swept surface's profile, sampled once for a one-dimensional foot scan.
179///
180/// The distance from a target to an extrusion's line through a profile
181/// point is the distance across the sweep direction, and to a
182/// revolution's circle through a profile point the distance in the
183/// half-plane through the axis: each is the least over the sweep
184/// coordinate, which then follows in closed form. So a scan along the
185/// profile alone finds the basins the whole grid would, from one surface
186/// line of samples instead of a grid of them.
187#[derive(Debug, Clone)]
188struct Profile {
189    sweep: Sweep,
190    /// The sweep coordinate of the sampled line: the window's first.
191    at: f64,
192    /// `(profile parameter, point on the line)` per sample, `None` where
193    /// the surface would not evaluate.
194    samples: Vec<(f64, Option<Point>)>,
195}
196
197impl Profile {
198    /// The profile of an extrusion or a revolution, plain or trimmed,
199    /// sampled as [`seed_lines`] seeds its profile direction; `None` for any
200    /// other surface.
201    fn of(surface: &SurfaceGeometry, samples: usize, tol: Tolerances) -> Option<Self> {
202        let basis = match surface {
203            SurfaceGeometry::Trimmed(t) => t.basis(),
204            other => other,
205        };
206        let sweep = match basis {
207            SurfaceGeometry::Extrusion(e) => Sweep::Extrusion(e.direction().vector()),
208            SurfaceGeometry::Revolution(r) => Sweep::Revolution(r.axis()),
209            _ => return None,
210        };
211        let (us, vs) = seed_lines(surface, samples);
212        let ((ua, _), (va, _)) = surface.domain();
213        let (at, along) = match sweep {
214            Sweep::Extrusion(_) => (va, us),
215            Sweep::Revolution(_) => (ua, vs),
216        };
217        let samples = along
218            .into_iter()
219            .map(|t| {
220                let point = match sweep {
221                    Sweep::Extrusion(_) => surface.point_at(t, at, tol),
222                    Sweep::Revolution(_) => surface.point_at(at, t, tol),
223                };
224                (t, point.ok())
225            })
226            .collect();
227        Some(Self { sweep, at, samples })
228    }
229
230    /// A sample's square distance to `target` across the sweep, and the
231    /// surface parameters of the nearest point on its sweep line or circle;
232    /// `None` for a target on a revolution's axis, where every angle is
233    /// as near.
234    fn reduce(
235        &self,
236        t: f64,
237        point: Point,
238        target: Point,
239        tol: Tolerances,
240    ) -> Option<(f64, (f64, f64))> {
241        match self.sweep {
242            Sweep::Extrusion(d) => {
243                let gap = point - target;
244                let along = gap.dot(d);
245                let across = gap - d * along;
246                Some((across.dot(across), (t, self.at - along)))
247            }
248            Sweep::Revolution(axis) => {
249                let d = axis.direction.vector();
250                let split = |p: Point| {
251                    let off = p - axis.location;
252                    let height = off.dot(d);
253                    (off - d * height, height)
254                };
255                let (to, height_to) = split(target);
256                let (from, height_from) = split(point);
257                let (ring_to, ring_from) = (to.magnitude(), from.magnitude());
258                if ring_to <= tol.confusion() {
259                    return None;
260                }
261                let turn = from.cross(to).dot(d).atan2(from.dot(to));
262                let gap = (ring_to - ring_from).hypot(height_to - height_from);
263                Some((gap * gap, (self.at + turn, t)))
264            }
265        }
266    }
267
268    /// The nearest point, from every basin of the profile scan whose sweep
269    /// coordinate lies in the window, nearest first; `None` where the
270    /// nearest basin's lies outside it, or nothing evaluates.
271    fn project(
272        &self,
273        surface: &SurfaceGeometry,
274        target: Point,
275        tol: Tolerances,
276    ) -> Option<SurfaceProjection> {
277        let ((ua, ub), (va, vb)) = surface.domain();
278        let (sweep_window, period) = match self.sweep {
279            Sweep::Extrusion(_) => ((va, vb), surface.is_periodic_v().then_some(vb - va)),
280            Sweep::Revolution(_) => ((ua, ub), surface.is_periodic_u().then_some(ub - ua)),
281        };
282        let mut scan = ProfileScan::with_capacity(self.samples.len());
283        for &(t, point) in &self.samples {
284            let (d, seed) = match point {
285                Some(point) => {
286                    let (d, (u, v)) = self.reduce(t, point, target, tol)?;
287                    // The sweep coordinate inside the window, or no seed.
288                    let seed = match self.sweep {
289                        Sweep::Extrusion(_) => into_window(v, sweep_window, period).map(|v| (u, v)),
290                        Sweep::Revolution(_) => {
291                            into_window(u, sweep_window, period).map(|u| (u, v))
292                        }
293                    };
294                    (d, seed)
295                }
296                None => (f64::INFINITY, None),
297            };
298            scan.push((d, seed));
299        }
300        // The nearest sample decides: where its foot across the sweep is
301        // outside the window, the nearest point is on the window's border,
302        // and the grid finds it.
303        let nearest = scan
304            .iter()
305            .filter(|s| s.0.is_finite())
306            .min_by(|a, b| a.0.total_cmp(&b.0))?;
307        nearest.1?;
308        let mut starts = Starts::default();
309        for (i, &(d, seed)) in scan.iter().enumerate() {
310            let Some(at) = seed else { continue };
311            let lo = i.saturating_sub(1);
312            let hi = (i + 1).min(scan.len() - 1);
313            if scan[lo..=hi].iter().any(|c| c.0 < d) {
314                continue;
315            }
316            starts.offer((i, 0), at, d);
317        }
318        starts.refine(surface, target, tol).ok()
319    }
320}
321
322/// One row of a seed scan: `(u, v, square distance)` per cell, a gap where
323/// the surface would not evaluate.
324type Row = smallvec::SmallVec<[(f64, f64, f64); 64]>;
325
326/// A seed scan that keeps the grid's local minima, row by row.
327///
328/// Three rows are enough to know whether a cell of the middle one beats
329/// its eight neighbours, so the scan never holds the grid: a projection
330/// onto a thread flank seeds thousands of cells, and a caller projecting
331/// every sample of an edge would pay that grid each time.
332#[derive(Debug, Default)]
333struct Scan {
334    before: Option<Row>,
335    last: Option<Row>,
336    rows: usize,
337    starts: Starts,
338}
339
340impl Scan {
341    /// Take the next row; the previous row's minima are now decidable.
342    fn push_row(&mut self, row: Row) {
343        if let Some(last) = self.last.take() {
344            self.starts
345                .minima(self.rows - 1, self.before.as_ref(), &last, Some(&row));
346            self.before = Some(last);
347        }
348        self.last = Some(row);
349        self.rows += 1;
350    }
351
352    /// The last row's minima, then the picks.
353    fn finish(mut self) -> Starts {
354        if let Some(last) = self.last.take() {
355            self.starts
356                .minima(self.rows - 1, self.before.as_ref(), &last, None);
357        }
358        self.starts
359    }
360}
361
362/// The few best basins of a grid scan.
363///
364/// The nearest seed is not always in the right basin. A helical flank
365/// stacks its turns a pitch apart, and where the pitch is shorter than the
366/// chord between neighbouring seeds, a seed on the turn above sits nearer
367/// the target than the seed a half-span along the right turn; Newton then
368/// converges faithfully on the wrong turn. So a scan keeps a handful of
369/// candidates, one per basin (a cell that beats its eight neighbours),
370/// and the refinement runs from each, nearest first, until one lands.
371#[derive(Debug, Default, Clone, Copy)]
372struct Starts {
373    /// Nearest first.
374    picks: [Option<Pick>; 4],
375}
376
377/// One basin of a seed scan.
378#[derive(Debug, Clone, Copy)]
379struct Pick {
380    /// The grid cell the seed came from.
381    cell: (usize, usize),
382    /// The seed's parameters.
383    at: (f64, f64),
384    /// The seed's square distance to the target.
385    d: f64,
386}
387
388impl Starts {
389    /// Offer every cell of `row` (the grid's row `r`) that is no farther
390    /// than any of its neighbours in the three rows around it.
391    fn minima(&mut self, r: usize, before: Option<&Row>, row: &Row, after: Option<&Row>) {
392        for (j, &(u, v, d)) in row.iter().enumerate() {
393            if !d.is_finite() {
394                continue;
395            }
396            let lo = j.saturating_sub(1);
397            let hi = (j + 1).min(row.len() - 1);
398            let beaten = |cells: &Row| cells[lo..=hi].iter().any(|c| c.2 < d);
399            if beaten(row) || before.is_some_and(beaten) || after.is_some_and(beaten) {
400                continue;
401            }
402            self.offer((r, j), (u, v), d);
403        }
404    }
405
406    /// Consider a seed; it displaces an adjacent pick it beats, or the
407    /// worst pick when it is nearer, and otherwise fills a free slot.
408    fn offer(&mut self, cell: (usize, usize), at: (f64, f64), d: f64) {
409        if !d.is_finite() {
410            return;
411        }
412        let pick = Pick { cell, at, d };
413        let adjacent = |a: (usize, usize)| a.0.abs_diff(cell.0) <= 1 && a.1.abs_diff(cell.1) <= 1;
414        if let Some(slot) = self
415            .picks
416            .iter()
417            .position(|p| p.is_some_and(|p| adjacent(p.cell)))
418        {
419            if self.picks[slot].is_some_and(|p| d < p.d) {
420                self.picks[slot] = Some(pick);
421                self.settle();
422            }
423            return;
424        }
425        if let Some(slot) = self.picks.iter().position(Option::is_none) {
426            self.picks[slot] = Some(pick);
427            self.settle();
428        } else if self.picks[3].is_some_and(|p| d < p.d) {
429            self.picks[3] = Some(pick);
430            self.settle();
431        }
432    }
433
434    /// Nearest first; a tie keeps the earlier pick.
435    fn settle(&mut self) {
436        self.picks.sort_by(|a, b| match (a, b) {
437            (Some(a), Some(b)) => a.d.total_cmp(&b.d),
438            (Some(_), None) => std::cmp::Ordering::Less,
439            (None, Some(_)) => std::cmp::Ordering::Greater,
440            (None, None) => std::cmp::Ordering::Equal,
441        });
442    }
443
444    /// Newton from each pick, nearest first, keeping the closest foot; a
445    /// foot within confusion ends the search, since nothing beats it.
446    fn refine(
447        self,
448        surface: &SurfaceGeometry,
449        target: Point,
450        tol: Tolerances,
451    ) -> OgeomResult<SurfaceProjection> {
452        let ((ua, _), (va, _)) = surface.domain();
453        let mut best: Option<SurfaceProjection> = None;
454        for pick in self.picks.iter().flatten() {
455            let found = refine_foot(surface, target, pick.at, tol)?;
456            let better = best.as_ref().is_none_or(|b| found.distance < b.distance);
457            if better {
458                let done = found.distance <= tol.confusion();
459                best = Some(found);
460                if done {
461                    break;
462                }
463            }
464        }
465        match best {
466            Some(found) => Ok(found),
467            None => refine_foot(surface, target, (ua, va), tol),
468        }
469    }
470}
471
472/// Where to seed a projection in each direction.
473///
474/// A fitted surface can carry hundreds of knot spans in one direction (a
475/// thread flank swept hundreds of turns down a lead screw), and a
476/// grid of sixteen or ninety-six seeds lands turns away from the nearest
477/// point, where Newton converges faithfully onto the wrong flank. So a
478/// patch is seeded *by its spans*, never by its domain: every span gets its
479/// share of the caller's budget, one seed at the least, and a span a
480/// thousand times wider than its neighbour gets no more for being wide.
481/// Everything else has a domain that means what it says, and is seeded
482/// evenly across it.
483fn seed_lines(surface: &SurfaceGeometry, samples: usize) -> (Vec<f64>, Vec<f64>) {
484    const CAP: usize = 4096;
485    let base = samples.max(4);
486    let ((ua, ub), (va, vb)) = surface.domain();
487    let SurfaceGeometry::BSpline(spline) = surface else {
488        return (spread(ua, ub, base), spread(va, vb, base));
489    };
490    // A patch's knots are where its shape is, and a file's knots are its
491    // own business: an imported patch may put every knot but the first
492    // inside the last small fraction of its domain, with its face in a
493    // fraction of that. A grid spread evenly over the domain puts one seed
494    // in the whole region the face lives in, and a projection seeded a
495    // knot span away lands wherever Newton takes it, well off an edge that
496    // sits on the surface. So the seeds follow the spans: each one gets
497    // its share, however wide the file made it.
498    (
499        per_span(&breaks(spline.u_knots()), base, CAP),
500        per_span(&breaks(spline.v_knots()), base, CAP),
501    )
502}
503
504/// `count + 1` parameters evenly across `[from, to]`.
505fn spread(from: f64, to: f64, count: usize) -> Vec<f64> {
506    #[allow(clippy::cast_precision_loss)]
507    (0..=count)
508        .map(|i| from + (to - from) * (i as f64 / count as f64))
509        .collect()
510}
511
512/// A knot vector's distinct values, without their multiplicities.
513fn breaks(knots: &ogeom_math::KnotVector) -> Vec<f64> {
514    knots.distinct().into_iter().map(|(at, _)| at).collect()
515}
516
517/// The budget shared out over the knot spans, ends included, once each.
518fn per_span(knots: &[f64], budget: usize, cap: usize) -> Vec<f64> {
519    let spans = knots.len().saturating_sub(1);
520    if spans == 0 {
521        return knots.to_vec();
522    }
523    let each = (budget / spans).min(cap / spans).max(1);
524    let mut out = Vec::with_capacity(spans * each + 1);
525    for pair in knots.windows(2) {
526        out.extend(spread(pair[0], pair[1], each).into_iter().take(each));
527    }
528    out.push(knots[knots.len() - 1]);
529    out
530}
531
532/// A surface's seeding structure, built once and asked many times.
533///
534/// [`project_on_surface`] builds the same seeding structure for every
535/// call: a B-spline patch's span boxes, or a grid of surface points
536/// for a surface with no closed form. A caller projecting *many* targets
537/// onto *one* surface builds it once, and each projection reduces to the
538/// search plus the Newton polish: the same seeds, the same refinement, the
539/// same answer to the bit.
540#[derive(Debug, Clone)]
541pub struct SurfaceSeeds {
542    /// `(parameters, point)` per cell, row by row; a gap where the surface
543    /// would not evaluate. Empty where span boxes answer.
544    rows: Vec<Vec<(f64, f64, Option<Point>)>>,
545    /// A B-spline patch's span boxes, which answer in place of the grid.
546    spans: Option<SpanBoxes>,
547    /// A swept surface's profile scan, which answers in place of the grid.
548    profile: Option<Profile>,
549}
550
551impl SurfaceSeeds {
552    /// Build the seeding structure `project_on_surface` would use, once.
553    ///
554    /// # Errors
555    ///
556    /// Never for a well-formed surface; evaluation failures leave gaps in
557    /// the grid exactly as the per-call version tolerates them.
558    pub fn over(surface: &SurfaceGeometry, samples: usize, tol: Tolerances) -> OgeomResult<Self> {
559        let profile = Profile::of(surface, samples, tol);
560        if let Some(spans) = span_boxes(surface) {
561            return Ok(Self {
562                rows: Vec::new(),
563                spans: Some(spans),
564                profile,
565            });
566        }
567        let (us, vs) = seed_lines(surface, samples);
568        let mut rows = Vec::with_capacity(us.len());
569        for &u in &us {
570            let mut row = Vec::with_capacity(vs.len());
571            for &v in &vs {
572                row.push((u, v, surface.point_at(u, v, tol).ok()));
573            }
574            rows.push(row);
575        }
576        Ok(Self {
577            rows,
578            spans: None,
579            profile,
580        })
581    }
582
583    /// Project `target`, seeded from the stored structure, bit-identical to
584    /// [`project_on_surface`] at the same sample count.
585    ///
586    /// # Errors
587    ///
588    /// As [`project_on_surface`].
589    pub fn project(
590        &self,
591        surface: &SurfaceGeometry,
592        target: Point,
593        tol: Tolerances,
594    ) -> OgeomResult<SurfaceProjection> {
595        // The same closed form [`project_on_surface`] takes first, so the
596        // two answer alike to the bit.
597        if let Some(found) = closed_form(surface, target, self.profile.as_ref(), tol) {
598            return Ok(found);
599        }
600        if let Some(spans) = &self.spans {
601            return spans.project(surface, target, tol);
602        }
603        let mut scan = Scan::default();
604        for row in &self.rows {
605            scan.push_row(
606                row.iter()
607                    .map(|&(u, v, p)| {
608                        (u, v, p.map_or(f64::INFINITY, |p| p.square_distance(target)))
609                    })
610                    .collect(),
611            );
612        }
613        scan.finish().refine(surface, target, tol)
614    }
615}
616
617/// The nearest point on a surface to `target`, starting from a guess.
618///
619/// [`project_on_surface`] brackets with a grid before refining. A caller
620/// walking *along* something (the samples of a curve being projected into a
621/// chart) already has a far better guess than any grid: where the previous
622/// sample landed. Neighbouring samples of a curve are neighbouring points of
623/// the surface, so the refinement starts inside the right basin and converges
624/// in a few steps, and the grid's hundreds of evaluations per sample are not
625/// spent at all.
626///
627/// The guess is load-bearing: this converges on the foot point nearest it, not
628/// on the globally nearest one. A caller that cannot vouch for its guess
629/// should check the reported distance and fall back to
630/// [`project_on_surface`], which is what the exchange readers do.
631///
632/// # Errors
633///
634/// As [`project_on_surface`].
635pub fn project_on_surface_from(
636    surface: &SurfaceGeometry,
637    target: Point,
638    guess: (f64, f64),
639    tol: Tolerances,
640) -> OgeomResult<SurfaceProjection> {
641    refine_foot(surface, target, guess, tol)
642}
643
644/// Newton on the foot-point conditions from a starting parameter pair.
645///
646/// The iteration is box-constrained: the parameters never leave the
647/// surface's domain. A periodic direction wraps; a bounded one clamps, and a
648/// coordinate held against its bound by the step is pinned there while the
649/// other one keeps solving on its own. Without that, every edge that runs
650/// along a face's boundary (the outer helix of a thread flank sits exactly
651/// on the flank's `u` bound) pushes the unconstrained foot a hair outside
652/// the domain, and a solver that then rejects the whole answer hands back
653/// its seed, turns away from the true foot.
654///
655/// The line search damps on the distance itself, which is the quantity a
656/// projection minimises, so an iterate that stops improving is the answer
657/// rather than a failure.
658fn refine_foot(
659    surface: &SurfaceGeometry,
660    target: Point,
661    start: (f64, f64),
662    tol: Tolerances,
663) -> OgeomResult<SurfaceProjection> {
664    let ((ua, ub), (va, vb)) = surface.domain();
665    let periodic = (surface.is_periodic_u(), surface.is_periodic_v());
666    let inside = |t: f64, a: f64, b: f64, wraps: bool| -> f64 {
667        if wraps {
668            a + (t - a).rem_euclid(b - a)
669        } else {
670            t.clamp(a, b)
671        }
672    };
673    let square_distance = |u: f64, v: f64| -> f64 {
674        surface
675            .point_at(u, v, tol)
676            .map_or(f64::INFINITY, |p| p.square_distance(target))
677    };
678
679    let mut x = (
680        inside(start.0, ua, ub, periodic.0),
681        inside(start.1, va, vb, periodic.1),
682    );
683    let mut best = (x.0, x.1, square_distance(x.0, x.1));
684
685    for _ in 0..60 {
686        // One evaluation, not three. Every value here comes from the same
687        // jet, so they are consistent with each other, which is what a
688        // Newton step needs. A surface with no second derivative (an
689        // offset) steps by Gauss-Newton on its first: the curvature terms
690        // drop out of the Jacobian, which still descends and converges to
691        // the same foot, only more slowly.
692        let jet = match surface.jet_at(x.0, x.1, tol) {
693            Ok(jet) => jet,
694            Err(_) => {
695                let Ok((point, du, dv)) = surface.point_d1_at(x.0, x.1, tol) else {
696                    break;
697                };
698                SurfaceJet {
699                    point,
700                    du,
701                    dv,
702                    d2u: ogeom_math::Vector::ZERO,
703                    duv: ogeom_math::Vector::ZERO,
704                    d2v: ogeom_math::Vector::ZERO,
705                }
706            }
707        };
708        let SurfaceJet {
709            point: p,
710            du,
711            dv,
712            d2u,
713            duv,
714            d2v,
715        } = jet;
716        let gap = p - target;
717        // The foot point conditions: (S - target) . Su = 0 and (S - target) . Sv = 0.
718        let r = [gap.dot(du), gap.dot(dv)];
719        let j = [
720            [du.dot(du) + gap.dot(d2u), du.dot(dv) + gap.dot(duv)],
721            [du.dot(dv) + gap.dot(duv), dv.dot(dv) + gap.dot(d2v)],
722        ];
723
724        // A bounded coordinate sitting on its bound with the residual pushing
725        // it further out is pinned: its condition cannot be met inside the
726        // domain, and it drops out of the system.
727        let pinned = |t: f64, a: f64, b: f64, wraps: bool, push: f64| -> bool {
728            !wraps && ((t <= a && push < 0.0) || (t >= b && push > 0.0))
729        };
730        // The residual is the gradient of the half square distance, so the
731        // descent pushes against it.
732        let pin_u = pinned(x.0, ua, ub, periodic.0, -r[0]);
733        let pin_v = pinned(x.1, va, vb, periodic.1, -r[1]);
734
735        let free_norm = match (pin_u, pin_v) {
736            (true, true) => 0.0,
737            (true, false) => r[1].abs(),
738            (false, true) => r[0].abs(),
739            (false, false) => r[0].hypot(r[1]),
740        };
741        // The residual is the gap times a derivative, so it shrinks with the
742        // chart's own speed: round a ring a sixth of a millimetre in radius,
743        // a residual under the confusion still leaves the foot several times
744        // the confusion along the surface. The gap's own tangential part, a
745        // length, must be under it as well.
746        let along = |r: f64, d: ogeom_math::Vector| -> f64 {
747            let speed = d.magnitude();
748            if speed > f64::MIN_POSITIVE {
749                (r / speed).abs()
750            } else {
751                0.0
752            }
753        };
754        let free_gap = match (pin_u, pin_v) {
755            (true, true) => 0.0,
756            (true, false) => along(r[1], dv),
757            (false, true) => along(r[0], du),
758            (false, false) => along(r[0], du).hypot(along(r[1], dv)),
759        };
760        if free_norm <= tol.confusion() && free_gap <= tol.confusion() {
761            break;
762        }
763
764        let delta = match (pin_u, pin_v) {
765            (true, true) => break,
766            (true, false) => {
767                if j[1][1].abs() <= f64::EPSILON {
768                    break;
769                }
770                [0.0, r[1] / j[1][1]]
771            }
772            (false, true) => {
773                if j[0][0].abs() <= f64::EPSILON {
774                    break;
775                }
776                [r[0] / j[0][0], 0.0]
777            }
778            (false, false) => {
779                let Some(d) = solve_2x2(j, r) else {
780                    break;
781                };
782                d
783            }
784        };
785        if !delta[0].is_finite() || !delta[1].is_finite() {
786            break;
787        }
788
789        // Damping: halve until the distance actually falls. Each candidate is
790        // put back inside the domain first, so a step aimed past a bound
791        // becomes a step to it.
792        let mut scale = 1.0;
793        let mut accepted = None;
794        for _ in 0..30 {
795            let candidate = (
796                inside(delta[0].mul_add(-scale, x.0), ua, ub, periodic.0),
797                inside(delta[1].mul_add(-scale, x.1), va, vb, periodic.1),
798            );
799            let d = square_distance(candidate.0, candidate.1);
800            if d < best.2 {
801                accepted = Some((candidate, d));
802                break;
803            }
804            scale *= 0.5;
805        }
806        let Some((next, d)) = accepted else {
807            break;
808        };
809        let step = (next.0 - x.0).hypot(next.1 - x.1);
810        x = next;
811        best = (x.0, x.1, d);
812        if step <= tol.parametric() {
813            break;
814        }
815    }
816
817    let point = surface.point_at(best.0, best.1, tol)?;
818    Ok(SurfaceProjection {
819        parameters: (best.0, best.1),
820        point,
821        distance: point.distance(target),
822    })
823}
824
825/// `j * d = r` for a two-by-two system, `None` when it is singular.
826fn solve_2x2(j: [[f64; 2]; 2], r: [f64; 2]) -> Option<[f64; 2]> {
827    let (row0, row1, rhs0, rhs1) = if j[0][0].abs() >= j[1][0].abs() {
828        (j[0], j[1], r[0], r[1])
829    } else {
830        (j[1], j[0], r[1], r[0])
831    };
832    if row0[0].abs() <= f64::EPSILON * (row1[0].abs() + row0[1].abs()).max(1.0) {
833        return None;
834    }
835    let factor = row1[0] / row0[0];
836    let denom = factor.mul_add(-row0[1], row1[1]);
837    if denom.abs() <= f64::EPSILON * row0[1].abs().max(1.0) {
838        return None;
839    }
840    let d1 = factor.mul_add(-rhs0, rhs1) / denom;
841    let d0 = d1.mul_add(-row0[1], rhs0) / row0[0];
842    Some([d0, d1])
843}
844
845#[cfg(test)]
846#[allow(clippy::unwrap_used)]
847mod tests {
848    use super::*;
849    use crate::{CylinderSurface, OffsetSurface};
850    use ogeom_math::{Cylinder, Frame};
851
852    const T: Tolerances = Tolerances::millimetres();
853
854    /// An offset surface has no second derivative, and its foot is still
855    /// found: a point set off the offset drum along its normal projects
856    /// back to the parameters it was set off from, at the distance it was
857    /// set off by, wherever it lies between the seeds.
858    #[test]
859    fn a_point_off_an_offset_drum_projects_to_its_foot() {
860        let basis: SurfaceGeometry =
861            CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.5, T).unwrap(), (-0.5, 5.5))
862                .unwrap()
863                .into();
864        let offset = SurfaceGeometry::Offset(Box::new(OffsetSurface::new(basis, 0.5).unwrap()));
865        for (u, v) in [(0.1, 5.0), (1.3, 0.37), (3.0, 2.2), (5.9, 4.91)] {
866            let on = offset.point_at(u, v, T).unwrap();
867            let target = Point::new(on.x * 1.15, on.y * 1.15, on.z);
868            let found = project_on_surface(&offset, target, 24, T).unwrap();
869            assert!(
870                found.point.distance(on) < T.confusion(),
871                "({u}, {v}) projected to {:?}",
872                found.parameters
873            );
874            assert!((found.distance - 0.3).abs() < 1e-9, "{}", found.distance);
875        }
876    }
877
878    /// The closed forms and the profile scan answer at least as near as the
879    /// grid they replace, on every surface they cover: a cone (both nappes
880    /// in reach), a spindle torus, a trimmed drum whose window starts past a
881    /// whole turn, an extrusion of a skew spline, a full and a partial
882    /// revolution of a spline. Targets are spread over a box around each,
883    /// off the axis and on it; each answer is a foot whatever the path, and
884    /// a stored grid answers to the bit as the per-call projection does.
885    #[test]
886    fn closed_forms_are_no_farther_than_the_grid() {
887        use crate::{
888            ConeSurface, Curve, ExtrusionSurface, RevolutionSurface, TorusSurface, TrimmedSurface,
889            curve::BSplineCurve,
890        };
891        use ogeom_math::{Axis, Cone, Direction, KnotVector, Torus};
892        let tilted = Frame::new(
893            Point::new(0.3, -0.2, 0.1),
894            Direction::from_coords(0.2, 0.1, 1.0, T).unwrap(),
895            Direction::from_coords(1.0, 0.0, 0.0, T).unwrap(),
896            T,
897        )
898        .unwrap();
899        let spline = Curve::BSpline(
900            BSplineCurve::new(
901                KnotVector::clamped_uniform(3, 6).unwrap(),
902                vec![
903                    Point::new(1.0, 0.0, -1.0),
904                    Point::new(1.6, 0.4, -0.5),
905                    Point::new(0.7, -0.3, 0.0),
906                    Point::new(1.9, 0.2, 0.4),
907                    Point::new(1.2, 0.5, 0.9),
908                    Point::new(0.5, 0.1, 1.4),
909                ],
910                T,
911            )
912            .unwrap(),
913        );
914        let skew = Direction::from_coords(0.1, 0.3, 1.0, T).unwrap();
915        let z = Axis::new(
916            Point::ORIGIN,
917            Direction::from_coords(0.0, 0.0, 1.0, T).unwrap(),
918        );
919        let drum: SurfaceGeometry =
920            CylinderSurface::new(Cylinder::new(tilted, 1.0, T).unwrap(), (-1.0, 1.0))
921                .unwrap()
922                .into();
923        let surfaces: Vec<SurfaceGeometry> = vec![
924            ConeSurface::new(Cone::new(tilted, 0.4, 0.5, T).unwrap(), (-2.0, 1.5))
925                .unwrap()
926                .into(),
927            TorusSurface::new(Torus::new(tilted, 1.0, 1.3, T).unwrap()).into(),
928            SurfaceGeometry::Trimmed(Box::new(
929                TrimmedSurface::new(drum, (7.0, 9.5), (-0.8, 0.6), T).unwrap(),
930            )),
931            SurfaceGeometry::Extrusion(Box::new(
932                ExtrusionSurface::over(spline.clone(), skew, (-1.0, 1.5)).unwrap(),
933            )),
934            SurfaceGeometry::Revolution(Box::new(
935                RevolutionSurface::new(spline.clone(), z, core::f64::consts::TAU).unwrap(),
936            )),
937            SurfaceGeometry::Revolution(Box::new(RevolutionSurface::new(spline, z, 2.0).unwrap())),
938        ];
939        for surface in &surfaces {
940            let mut closed = 0;
941            let seeds = SurfaceSeeds::over(surface, 24, T).unwrap();
942            for i in 0..7 {
943                for j in 0..7 {
944                    for k in 0..5 {
945                        let target = Point::new(
946                            f64::from(i).mul_add(0.55, -1.6),
947                            f64::from(j).mul_add(0.5, -1.4),
948                            f64::from(k).mul_add(0.7, -1.3),
949                        );
950                        let found = project_on_surface(surface, target, 24, T).unwrap();
951                        let profile = Profile::of(surface, 24, T);
952                        if closed_form(surface, target, profile.as_ref(), T).is_some() {
953                            closed += 1;
954                        }
955                        let grid = grid_projection(surface, target, 24, T).unwrap();
956                        assert!(
957                            found.distance <= grid.distance + 1e-9,
958                            "{:?} at {target:?}: {} against the grid's {}",
959                            surface.kind(),
960                            found.distance,
961                            grid.distance
962                        );
963                        assert_eq!(seeds.project(surface, target, T).unwrap(), found);
964                    }
965                }
966            }
967            assert!(closed > 0, "{:?} never took a closed form", surface.kind());
968        }
969    }
970
971    /// A B-spline patch from a net of `nu` by `nv` points, each with a
972    /// weight; uniform clamped knots unless `knots` gives its own.
973    fn patch(
974        degree: (usize, usize),
975        knots: Option<(Vec<f64>, Vec<f64>)>,
976        (nu, nv): (usize, usize),
977        at: impl Fn(f64, f64) -> (Point, f64),
978    ) -> SurfaceGeometry {
979        use ogeom_math::{ControlGrid, KnotVector, Weighted};
980        let (ku, kv) = match knots {
981            Some((u, v)) => (
982                KnotVector::new(u, degree.0).unwrap(),
983                KnotVector::new(v, degree.1).unwrap(),
984            ),
985            None => (
986                KnotVector::clamped_uniform(degree.0, nu).unwrap(),
987                KnotVector::clamped_uniform(degree.1, nv).unwrap(),
988            ),
989        };
990        #[allow(clippy::cast_precision_loss)]
991        let net = (0..nu)
992            .flat_map(|i| {
993                (0..nv).map(move |j| (i as f64 / (nu - 1) as f64, j as f64 / (nv - 1) as f64))
994            })
995            .map(|(s, t)| {
996                let (p, w) = at(s, t);
997                Weighted::new(p, w, T).unwrap()
998            })
999            .collect();
1000        crate::BSplineSurface::rational(ku, kv, ControlGrid::new(net, nu, nv).unwrap())
1001            .unwrap()
1002            .into()
1003    }
1004
1005    /// The nearest point by brute force: the nearest of a dense grid of
1006    /// surface points, `per` per span and direction, polished by Newton.
1007    fn brute_force(surface: &SurfaceGeometry, target: Point, per: usize) -> SurfaceProjection {
1008        let SurfaceGeometry::BSpline(spline) = surface else {
1009            unreachable!()
1010        };
1011        let lines = |k: &ogeom_math::KnotVector| -> Vec<f64> {
1012            let (a, b) = k.domain();
1013            let breaks: Vec<f64> = breaks(k)
1014                .into_iter()
1015                .filter(|x| *x >= a && *x <= b)
1016                .collect();
1017            let mut out: Vec<f64> = breaks
1018                .windows(2)
1019                .flat_map(|w| spread(w[0], w[1], per).into_iter().take(per))
1020                .collect();
1021            out.push(breaks[breaks.len() - 1]);
1022            out
1023        };
1024        let (us, vs) = (lines(spline.u_knots()), lines(spline.v_knots()));
1025        let mut best = (f64::INFINITY, (0.0, 0.0));
1026        for &u in &us {
1027            for &v in &vs {
1028                let d = surface.point_at(u, v, T).unwrap().distance(target);
1029                if d < best.0 {
1030                    best = (d, (u, v));
1031                }
1032            }
1033        }
1034        let polished = refine_foot(surface, target, best.1, T).unwrap();
1035        assert!(polished.distance <= best.0);
1036        polished
1037    }
1038
1039    /// The branch and bound over a B-spline patch's spans finds the
1040    /// nearest point of the whole patch: never farther than a dense grid
1041    /// polished by Newton, on a finely knotted wavy patch, one with its
1042    /// knots crowded into a sliver of the domain, a rational patch with
1043    /// unclamped knots, a patch with an edge collapsed to a pole, a closed
1044    /// patch with targets beside its seam and beyond its open edges, and a
1045    /// patch whose two bumps stand equally near a target between them.
1046    /// Every foot is a point on the surface at its own parameters, and a
1047    /// stored set of span boxes answers as the per-call projection does, to the
1048    /// bit.
1049    #[test]
1050    fn a_spline_foot_is_the_nearest_point_of_the_whole_patch() {
1051        let wavy = patch((3, 3), None, (43, 43), |s, t| {
1052            let (x, y) = (20.0 * s, 20.0 * t);
1053            (
1054                Point::new(x, y, 0.8 * (0.7 * x).sin() * (0.45 * y).cos()),
1055                1.0,
1056            )
1057        });
1058        // Every knot but the ends inside the last thirtieth of `u`.
1059        let mut crowded_u = vec![0.0; 4];
1060        crowded_u.extend((1..20).map(|k| 0.97 + 0.03 * f64::from(k) / 20.0));
1061        crowded_u.extend([1.0; 4]);
1062        let crowded = patch(
1063            (3, 3),
1064            Some((crowded_u, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0])),
1065            (23, 4),
1066            |s, t| {
1067                let (x, y) = (10.0 * s, 4.0 * t);
1068                (Point::new(x, y, (1.3 * x).sin() + 0.2 * y * y), 1.0)
1069            },
1070        );
1071        let unclamped = patch(
1072            (2, 3),
1073            Some((
1074                (0..12).map(f64::from).collect(),
1075                (0..13).map(|k| 0.5 * f64::from(k)).collect(),
1076            )),
1077            (9, 9),
1078            |s, t| {
1079                let (x, y) = (6.0 * s, 6.0 * t);
1080                let w = 0.6 + 1.2 * (s * t + 0.3 * (5.0 * s).sin().abs());
1081                (Point::new(x, y, (x - 3.0) * (y - 3.0) / 4.0), w)
1082            },
1083        );
1084        let pole = patch((3, 2), None, (8, 9), |s, t| {
1085            let (angle, r) = (std::f64::consts::PI * 1.5 * t, 3.0 * s);
1086            (
1087                Point::new(r * angle.cos(), r * angle.sin(), 2.0 - 2.0 * s),
1088                1.0,
1089            )
1090        });
1091        let seam = patch((3, 3), None, (13, 6), |s, t| {
1092            let angle = std::f64::consts::TAU * s;
1093            let r = 2.0 + 0.3 * (3.0 * angle).sin();
1094            (Point::new(r * angle.cos(), r * angle.sin(), 4.0 * t), 1.0)
1095        });
1096        let bumps = patch((3, 3), None, (21, 9), |s, t| {
1097            let bump = |c: f64| (-((s - c) * (s - c)) / 0.004).exp();
1098            (Point::new(s, 0.4 * t, 0.3 * (bump(0.3) + bump(0.7))), 1.0)
1099        });
1100        let targets = |lo: Point, hi: Point| -> Vec<Point> {
1101            (0..27)
1102                .map(|k| {
1103                    let f = |n: i32| f64::from((k / n) % 3) / 2.0;
1104                    Point::new(
1105                        lo.x + (hi.x - lo.x) * f(1),
1106                        lo.y + (hi.y - lo.y) * f(3),
1107                        lo.z + (hi.z - lo.z) * f(9),
1108                    )
1109                })
1110                .collect()
1111        };
1112        let cases: Vec<(&str, SurfaceGeometry, Vec<Point>)> = vec![
1113            (
1114                "wavy",
1115                wavy,
1116                targets(Point::new(-3.0, -2.0, -4.0), Point::new(23.0, 22.0, 4.0)),
1117            ),
1118            (
1119                "crowded",
1120                crowded,
1121                targets(Point::new(-1.0, -1.0, -2.0), Point::new(11.0, 5.0, 3.0)),
1122            ),
1123            (
1124                "unclamped",
1125                unclamped,
1126                targets(Point::new(-1.0, -1.0, -3.0), Point::new(7.0, 7.0, 3.0)),
1127            ),
1128            (
1129                "pole",
1130                pole,
1131                targets(Point::new(-3.5, -3.5, -1.0), Point::new(3.5, 3.5, 3.0)),
1132            ),
1133            (
1134                "seam",
1135                seam,
1136                [
1137                    targets(Point::new(1.0, -0.4, -1.0), Point::new(3.0, 0.4, 5.0)),
1138                    targets(Point::new(-3.0, -3.0, 1.0), Point::new(3.0, 3.0, 3.0)),
1139                ]
1140                .concat(),
1141            ),
1142            (
1143                "bumps",
1144                bumps,
1145                targets(Point::new(0.4, -0.2, 0.5), Point::new(0.6, 0.6, 1.5)),
1146            ),
1147        ];
1148        for (name, surface, targets) in &cases {
1149            let seeds = SurfaceSeeds::over(surface, 16, T).unwrap();
1150            for &target in targets {
1151                let found = project_on_surface(surface, target, 16, T).unwrap();
1152                let reference = brute_force(surface, target, 8);
1153                assert!(
1154                    found.distance <= reference.distance + T.confusion(),
1155                    "{name} at {target:?}: {} at {:?}, the brute force {} at {:?}",
1156                    found.distance,
1157                    found.parameters,
1158                    reference.distance,
1159                    reference.parameters
1160                );
1161                let (u, v) = found.parameters;
1162                let on = surface.point_at(u, v, T).unwrap();
1163                assert!(on.distance(found.point) < 1e-12, "{name} at {target:?}");
1164                assert_eq!(seeds.project(surface, target, T).unwrap(), found, "{name}");
1165            }
1166        }
1167    }
1168
1169    /// Two feet equally near: the target stands over the middle of a patch
1170    /// symmetric about `u = 1/2`, between two equal bumps. Either foot is
1171    /// right; the one returned is the same on every call and from a stored
1172    /// set of span boxes, and it is on one bump or the other.
1173    #[test]
1174    fn a_tie_between_two_feet_resolves_the_same_way_every_time() {
1175        let bumps = patch((3, 3), None, (21, 9), |s, t| {
1176            let bump = |c: f64| (-((s - c) * (s - c)) / 0.004).exp();
1177            (Point::new(s, 0.4 * t, 0.3 * (bump(0.3) + bump(0.7))), 1.0)
1178        });
1179        let target = Point::new(0.5, 0.2, 0.9);
1180        let first = project_on_surface(&bumps, target, 16, T).unwrap();
1181        let reference = brute_force(&bumps, target, 8);
1182        assert!(first.distance <= reference.distance + T.confusion());
1183        let u = first.parameters.0;
1184        assert!((u - 0.5).abs() > 0.1, "landed between the bumps at {u}");
1185        let seeds = SurfaceSeeds::over(&bumps, 16, T).unwrap();
1186        for _ in 0..3 {
1187            assert_eq!(project_on_surface(&bumps, target, 16, T).unwrap(), first);
1188            assert_eq!(seeds.project(&bumps, target, T).unwrap(), first);
1189        }
1190    }
1191
1192    /// A foot seeded beside a point on a thin drum lands on the point.
1193    ///
1194    /// Round a drum a sixth of a millimetre in radius, a radian of the chart
1195    /// is a sixth of a millimetre of the surface, and the residual (the gap
1196    /// times the chart's speed) is under the confusion while the foot still
1197    /// stands three confusions off along the surface. The seed is that
1198    /// close; the foot must not stay there.
1199    #[test]
1200    fn a_foot_on_a_thin_drum_converges_along_the_surface() {
1201        let drum: SurfaceGeometry =
1202            CylinderSurface::new(Cylinder::new(Frame::WORLD, 0.16, T).unwrap(), (-1.0, 1.0))
1203                .unwrap()
1204                .into();
1205        for (u, v) in [(0.5, 0.0), (3.0, 0.4), (6.0, -0.7)] {
1206            let target = drum.point_at(u, v, T).unwrap();
1207            let found = project_on_surface_from(&drum, target, (u + 2e-6, v), T).unwrap();
1208            assert!(
1209                found.distance <= T.confusion(),
1210                "({u}, {v}) stopped {:.2e} off at {:?}",
1211                found.distance,
1212                found.parameters
1213            );
1214        }
1215    }
1216}