Skip to main content

ogeom_intersect/
extrema.rs

1//! The stationary approaches between two geometries.
2//!
3//! *Elsewhere* this is `Extrema` and the `GeomAPI_Extrema*` family. The
4//! consumer that drives it is minimum distance between shapes, which lives
5//! in `ogeom-algo`: this module answers for the geometry, and the shape layer
6//! assembles the answer for topology.
7//!
8//! # What an extremum is, and what it is not
9//!
10//! An approach is *stationary* when the connecting vector is perpendicular to
11//! every tangent it meets: the derivative of the squared distance is zero in
12//! each parameter. Those are the only approaches this module reports. Two
13//! kinds of candidate are deliberately not here:
14//!
15//! - **Domain-end candidates.** A pair of segments whose closest points are
16//!   endpoint to endpoint has no interior stationary approach, and the answer
17//!   comes back empty. The endpoints are *points*, and points against curves
18//!   and surfaces are projections, which the caller owns: at the shape
19//!   level, an edge's ends are vertices, and the vertex pairs cover exactly
20//!   these candidates. Folding them in here would answer the shape question
21//!   badly instead of the geometry question well.
22//! - **A guessed point on a constant-distance locus.** Parallel lines,
23//!   concentric circles, a sphere inside a sphere: the nearest distance is
24//!   attained along a whole locus, and no isolated point is *the* answer.
25//!   That is reported as [`Extrema::family`].
26//!
27//! Every approach reported is verifiable on the spot: two parameter sets, the
28//! two evaluated points, and the distance between them, which is the claim.
29
30use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
31use ogeom_geom::{Curve, Curve3d, Surface, SurfaceGeometry, SurfaceJet};
32use ogeom_math::{Point, Vector, solve};
33
34/// One stationary approach between two geometries.
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct Approach<A, B> {
37    /// The parameters on the first geometry.
38    pub on_a: A,
39    /// The parameters on the second.
40    pub on_b: B,
41    /// The evaluated point on the first geometry.
42    pub point_a: Point,
43    /// The evaluated point on the second.
44    pub point_b: Point,
45    /// The distance between them: the claim, checkable by evaluation.
46    pub distance: f64,
47}
48
49/// Every stationary approach found, nearest first.
50#[derive(Debug, Clone, PartialEq)]
51pub struct Extrema<A, B> {
52    /// Stationary approaches, sorted by distance. Nearest approaches first;
53    /// stationary *farthest* points, where they exist in the interior, are at
54    /// the back of the same list.
55    pub approaches: Vec<Approach<A, B>>,
56    /// Whether the nearest distance is attained along a locus rather than at
57    /// an isolated point: parallel lines, concentric circles, coaxial
58    /// quadrics. When set, the nearest approaches are representatives of the
59    /// family, not the family.
60    pub family: bool,
61}
62
63impl<A, B> Extrema<A, B> {
64    /// The nearest stationary approach, if any is interior to both domains.
65    #[must_use]
66    pub fn nearest(&self) -> Option<&Approach<A, B>> {
67        self.approaches.first()
68    }
69}
70
71/// How hard the seeding looks.
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct ExtremaOptions {
74    /// How many segments a curve is sampled into.
75    pub samples: usize,
76    /// How finely a surface is sampled, per direction.
77    pub grid: usize,
78}
79
80impl Default for ExtremaOptions {
81    fn default() -> Self {
82        Self {
83            samples: 64,
84            grid: 24,
85        }
86    }
87}
88
89/// A domain span beyond which sampling answers nothing.
90///
91/// An untrimmed line or plane spans ±1e9; sixty-four samples across that
92/// resolve nothing a caller could want. The analytic line/line case answers
93/// without sampling, and everything else is refused with instructions rather
94/// than sampled into a wrong answer, the same stance `surface_bounds` takes
95/// on an unbounded plane.
96const WIDEST_DOMAIN: f64 = 1e8;
97
98/// Two points closer than this many confusions are the same approach.
99const DISTINCT: f64 = 1e2;
100
101/// How many seeds a constant-distance configuration is allowed to spawn.
102///
103/// On a locus every sampled cell ties for local minimum. A few hundred
104/// polished representatives are plenty to detect the family and report it;
105/// thousands would only repeat them.
106const MOST_SEEDS: usize = 256;
107
108// --- curve / curve -----------------------------------------------------------
109
110/// The stationary approaches between two curves.
111///
112/// Line/line is answered in closed form, parallel included; everything else
113/// is seeded from a distance grid over both domains and polished by Newton on
114/// the stationarity system.
115///
116/// # Errors
117///
118/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the options
119/// are unusable; [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) if a curve's
120/// domain is too wide to sample; trim it before asking.
121pub fn extrema_curve_curve(
122    a: &Curve,
123    b: &Curve,
124    options: ExtremaOptions,
125    tol: Tolerances,
126) -> OgeomResult<Extrema<f64, f64>> {
127    if options.samples < 2 {
128        ogeom_bail!(Construction, "seeding needs at least two samples");
129    }
130    if let (Curve::Line(la), Curve::Line(lb)) = (a, b) {
131        return Ok(line_line(la, lb, tol));
132    }
133    for (name, curve) in [("first", a), ("second", b)] {
134        let (lo, hi) = curve.domain();
135        if hi - lo > WIDEST_DOMAIN {
136            ogeom_bail!(
137                Domain,
138                "the {name} curve's domain spans {:.0e}; trim it before asking",
139                hi - lo
140            );
141        }
142    }
143
144    let sa = sample_curve(a, options.samples, tol);
145    let sb = sample_curve(b, options.samples, tol);
146    if sa.len() < 2 || sb.len() < 2 {
147        ogeom_bail!(Construction, "a curve failed to evaluate over its domain");
148    }
149
150    // Local extrema of the sampled distance field seed the polish. Ties count:
151    // on a constant-distance locus everything ties, and the family is
152    // exactly what the tied seeds go on to reveal.
153    let mut seeds = Vec::new();
154    for i in 0..sa.len() {
155        for j in 0..sb.len() {
156            let here = sa[i].1.square_distance(sb[j].1);
157            let mut minimal = true;
158            let mut maximal = true;
159            let neighbours_a = sa
160                .iter()
161                .enumerate()
162                .take((i + 2).min(sa.len()))
163                .skip(i.saturating_sub(1));
164            for (ni, near_a) in neighbours_a {
165                let neighbours_b = sb
166                    .iter()
167                    .enumerate()
168                    .take((j + 2).min(sb.len()))
169                    .skip(j.saturating_sub(1));
170                for (nj, near_b) in neighbours_b {
171                    if ni == i && nj == j {
172                        continue;
173                    }
174                    let there = near_a.1.square_distance(near_b.1);
175                    if there < here {
176                        minimal = false;
177                    }
178                    if there > here {
179                        maximal = false;
180                    }
181                }
182            }
183            if minimal || maximal {
184                seeds.push((sa[i].0, sb[j].0));
185            }
186        }
187    }
188    thin(&mut seeds);
189
190    let mut approaches: Vec<Approach<f64, f64>> = Vec::new();
191    for (seed_a, seed_b) in seeds {
192        if let Some((t, s)) = stationary_curve_curve(a, b, seed_a, seed_b, tol) {
193            let (Ok(pa), Ok(pb)) = (a.point_at(t, tol), b.point_at(s, tol)) else {
194                continue;
195            };
196            keep(
197                &mut approaches,
198                Approach {
199                    on_a: t,
200                    on_b: s,
201                    point_a: pa,
202                    point_b: pb,
203                    distance: pa.distance(pb),
204                },
205                tol,
206            );
207        }
208    }
209    Ok(finish(approaches, tol))
210}
211
212/// Newton on the two stationarity conditions of the squared distance.
213pub(crate) fn stationary_curve_curve(
214    a: &Curve,
215    b: &Curve,
216    seed_a: f64,
217    seed_b: f64,
218    tol: Tolerances,
219) -> Option<(f64, f64)> {
220    let system = |x: &[f64; 2]| {
221        let (t, s) = (fold_curve(a, x[0]), fold_curve(b, x[1]));
222        let pa = a.point_at(t, tol).unwrap_or(Point::ORIGIN);
223        let pb = b.point_at(s, tol).unwrap_or(Point::ORIGIN);
224        let da = a.derivatives_at(t, 2, tol).unwrap_or_default();
225        let db = b.derivatives_at(s, 2, tol).unwrap_or_default();
226        let zero = Vector::ZERO;
227        let (d1a, d2a) = (
228            da.get(1).copied().unwrap_or(zero),
229            da.get(2).copied().unwrap_or(zero),
230        );
231        let (d1b, d2b) = (
232            db.get(1).copied().unwrap_or(zero),
233            db.get(2).copied().unwrap_or(zero),
234        );
235        let gap = pa - pb;
236        (
237            [gap.dot(d1a), -gap.dot(d1b)],
238            [
239                [d1a.dot(d1a) + gap.dot(d2a), -d1a.dot(d1b)],
240                [-d1a.dot(d1b), d1b.dot(d1b) - gap.dot(d2b)],
241            ],
242        )
243    };
244    let criteria = solve::Criteria {
245        // The residual is a gradient of a squared distance, not a distance:
246        // scale-squared, so the target is too.
247        residual: tol.confusion() * tol.confusion(),
248        step: tol.parametric(),
249        max_iterations: 40,
250    };
251    let found = solve::newton_system_fixed(system, [seed_a, seed_b], criteria).ok()?;
252    let (t, s) = (fold_curve(a, found.0[0]), fold_curve(b, found.0[1]));
253    let gap = a.point_at(t, tol).ok()? - b.point_at(s, tol).ok()?;
254    let ta = a.derivatives_at(t, 1, tol).ok()?.get(1).copied()?;
255    let tb = b.derivatives_at(s, 1, tol).ok()?.get(1).copied()?;
256    is_stationary(gap, &[ta, tb], tol).then_some((t, s))
257}
258
259/// Closed-form extrema between two lines.
260fn line_line(
261    a: &ogeom_geom::LineCurve,
262    b: &ogeom_geom::LineCurve,
263    tol: Tolerances,
264) -> Extrema<f64, f64> {
265    let (oa, da) = (a.axis().location, a.axis().direction.vector());
266    let (ob, db) = (b.axis().location, b.axis().direction.vector());
267    let cross = da.cross(db);
268    let denominator = cross.dot(cross);
269
270    if denominator <= tol.angular() * tol.angular() {
271        // Parallel: constant distance, a family. The representative sits at
272        // the middle of where the domains face each other; if they face each
273        // other nowhere, the nearest is at ends the caller owns.
274        let (a_lo, a_hi) = a.domain();
275        let (b_lo, b_hi) = b.domain();
276        let project = |p: Point| (p - oa).dot(da);
277        let (s0, s1) = (project(ob + db * b_lo), project(ob + db * b_hi));
278        let (lo, hi) = (s0.min(s1).max(a_lo), s0.max(s1).min(a_hi));
279        if lo > hi {
280            return Extrema {
281                approaches: Vec::new(),
282                family: true,
283            };
284        }
285        let t = f64::midpoint(lo, hi);
286        let pa = oa + da * t;
287        let s = (pa - ob).dot(db);
288        let pb = ob + db * s;
289        return Extrema {
290            approaches: vec![Approach {
291                on_a: t,
292                on_b: s,
293                point_a: pa,
294                point_b: pb,
295                distance: pa.distance(pb),
296            }],
297            family: true,
298        };
299    }
300
301    let between = ob - oa;
302    let t = between.cross(db).dot(cross) / denominator;
303    let s = between.cross(da).dot(cross) / denominator;
304    let (a_lo, a_hi) = a.domain();
305    let (b_lo, b_hi) = b.domain();
306    if t < a_lo || t > a_hi || s < b_lo || s > b_hi {
307        // The stationary approach exists on the unbounded lines but outside
308        // these domains: within them the nearest is at an end.
309        return Extrema {
310            approaches: Vec::new(),
311            family: false,
312        };
313    }
314    let pa = oa + da * t;
315    let pb = ob + db * s;
316    Extrema {
317        approaches: vec![Approach {
318            on_a: t,
319            on_b: s,
320            point_a: pa,
321            point_b: pb,
322            distance: pa.distance(pb),
323        }],
324        family: false,
325    }
326}
327
328// --- curve / surface ---------------------------------------------------------
329
330/// The stationary approaches between a curve and a surface.
331///
332/// # Errors
333///
334/// As [`extrema_curve_curve`].
335pub fn extrema_curve_surface(
336    curve: &Curve,
337    surface: &SurfaceGeometry,
338    options: ExtremaOptions,
339    tol: Tolerances,
340) -> OgeomResult<Extrema<f64, (f64, f64)>> {
341    if options.samples < 2 || options.grid < 2 {
342        ogeom_bail!(Construction, "seeding needs at least two steps each way");
343    }
344    let (lo, hi) = curve.domain();
345    if hi - lo > WIDEST_DOMAIN {
346        ogeom_bail!(
347            Domain,
348            "the curve's domain spans {:.0e}; trim it before asking",
349            hi - lo
350        );
351    }
352    wide_surface_check(surface)?;
353
354    let sc = sample_curve(curve, options.samples, tol);
355    let ss = sample_surface(surface, options.grid, tol);
356    if sc.len() < 2 || ss.is_empty() {
357        ogeom_bail!(
358            Construction,
359            "a geometry failed to evaluate over its domain"
360        );
361    }
362
363    // For each curve sample, its best and worst surface cells; local extrema
364    // along the curve of those fields seed the polish.
365    let mut best = Vec::with_capacity(sc.len());
366    let mut worst = Vec::with_capacity(sc.len());
367    for (_, p) in &sc {
368        let mut near = (f64::INFINITY, (0.0, 0.0));
369        let mut far = (f64::NEG_INFINITY, (0.0, 0.0));
370        for (uv, q) in &ss {
371            let d = p.square_distance(*q);
372            if d < near.0 {
373                near = (d, *uv);
374            }
375            if d > far.0 {
376                far = (d, *uv);
377            }
378        }
379        best.push(near);
380        worst.push(far);
381    }
382    let mut seeds = Vec::new();
383    for i in 0..sc.len() {
384        let lower = i == 0 || best[i].0 <= best[i - 1].0;
385        let upper = i + 1 == sc.len() || best[i].0 <= best[i + 1].0;
386        if lower && upper {
387            seeds.push((sc[i].0, best[i].1));
388        }
389        let lower = i == 0 || worst[i].0 >= worst[i - 1].0;
390        let upper = i + 1 == sc.len() || worst[i].0 >= worst[i + 1].0;
391        if lower && upper {
392            seeds.push((sc[i].0, worst[i].1));
393        }
394    }
395    thin(&mut seeds);
396
397    let mut approaches: Vec<Approach<f64, (f64, f64)>> = Vec::new();
398    for (seed_t, seed_uv) in seeds {
399        if let Some((t, u, v)) = stationary_curve_surface(curve, surface, seed_t, seed_uv, tol) {
400            let (Ok(pc), Ok(ps)) = (curve.point_at(t, tol), surface.point_at(u, v, tol)) else {
401                continue;
402            };
403            keep(
404                &mut approaches,
405                Approach {
406                    on_a: t,
407                    on_b: (u, v),
408                    point_a: pc,
409                    point_b: ps,
410                    distance: pc.distance(ps),
411                },
412                tol,
413            );
414        }
415    }
416    Ok(finish(approaches, tol))
417}
418
419/// Newton on the three stationarity conditions.
420fn stationary_curve_surface(
421    curve: &Curve,
422    surface: &SurfaceGeometry,
423    seed_t: f64,
424    seed_uv: (f64, f64),
425    tol: Tolerances,
426) -> Option<(f64, f64, f64)> {
427    let system = |x: &[f64; 3]| {
428        let t = fold_curve(curve, x[0]);
429        let (u, v) = fold_surface(surface, x[1], x[2]);
430        let pc = curve.point_at(t, tol).unwrap_or(Point::ORIGIN);
431        let dc = curve.derivatives_at(t, 2, tol).unwrap_or_default();
432        let zero = Vector::ZERO;
433        let (ct, ctt) = (
434            dc.get(1).copied().unwrap_or(zero),
435            dc.get(2).copied().unwrap_or(zero),
436        );
437        let js = jet_or_zero(surface, u, v, tol);
438        let (su, sv, suu, suv, svv) = (js.du, js.dv, js.d2u, js.duv, js.d2v);
439        let gap = pc - js.point;
440        (
441            [gap.dot(ct), gap.dot(su), gap.dot(sv)],
442            [
443                [ct.dot(ct) + gap.dot(ctt), -su.dot(ct), -sv.dot(ct)],
444                [
445                    ct.dot(su),
446                    -su.dot(su) + gap.dot(suu),
447                    -sv.dot(su) + gap.dot(suv),
448                ],
449                [
450                    ct.dot(sv),
451                    -su.dot(sv) + gap.dot(suv),
452                    -sv.dot(sv) + gap.dot(svv),
453                ],
454            ],
455        )
456    };
457    let criteria = solve::Criteria {
458        residual: tol.confusion() * tol.confusion(),
459        step: tol.parametric(),
460        max_iterations: 40,
461    };
462    let found =
463        solve::newton_system_fixed(system, [seed_t, seed_uv.0, seed_uv.1], criteria).ok()?;
464    let t = fold_curve(curve, found.0[0]);
465    let (u, v) = fold_surface(surface, found.0[1], found.0[2]);
466    let gap = curve.point_at(t, tol).ok()? - surface.point_at(u, v, tol).ok()?;
467    let tc = curve.derivatives_at(t, 1, tol).ok()?.get(1).copied()?;
468    let (su, sv) = surface.d1_at(u, v, tol).ok()?;
469    is_stationary(gap, &[tc, su, sv], tol).then_some((t, u, v))
470}
471
472// --- surface / surface -------------------------------------------------------
473
474/// The stationary approaches between two surfaces.
475///
476/// # Errors
477///
478/// As [`extrema_curve_curve`].
479pub fn extrema_surface_surface(
480    a: &SurfaceGeometry,
481    b: &SurfaceGeometry,
482    options: ExtremaOptions,
483    tol: Tolerances,
484) -> OgeomResult<Extrema<(f64, f64), (f64, f64)>> {
485    if options.grid < 2 {
486        ogeom_bail!(Construction, "seeding needs at least two steps each way");
487    }
488    wide_surface_check(a)?;
489    wide_surface_check(b)?;
490
491    let ga = sample_grid(a, options.grid, tol);
492    let gb = sample_grid(b, options.grid, tol);
493    let sa: Vec<((f64, f64), Point)> = ga.iter().flatten().copied().collect();
494    let sb: Vec<((f64, f64), Point)> = gb.iter().flatten().copied().collect();
495    if sa.is_empty() || sb.is_empty() {
496        ogeom_bail!(Construction, "a surface failed to evaluate over its domain");
497    }
498
499    // For each sample of one side, its nearest and farthest sample on the
500    // other: two fields over that side's lattice. Their local minima and
501    // maxima, from both sides, seed the polish; near and far are thinned
502    // apart, so neither crowds the other out.
503    let fields = |mine: &[Option<((f64, f64), Point)>], theirs: &[((f64, f64), Point)]| {
504        let mut near = Vec::with_capacity(mine.len());
505        let mut far = Vec::with_capacity(mine.len());
506        for cell in mine {
507            let Some((_, p)) = cell else {
508                near.push(None);
509                far.push(None);
510                continue;
511            };
512            let mut best = (f64::INFINITY, (0.0, 0.0));
513            let mut worst = (f64::NEG_INFINITY, (0.0, 0.0));
514            for (uv, q) in theirs {
515                let d = p.square_distance(*q);
516                if d < best.0 {
517                    best = (d, *uv);
518                }
519                if d > worst.0 {
520                    worst = (d, *uv);
521                }
522            }
523            near.push(Some(best));
524            far.push(Some(worst));
525        }
526        (near, far)
527    };
528    let (a_near, a_far) = fields(&ga, &sb);
529    let (b_near, b_far) = fields(&gb, &sa);
530    let distances = |field: &[Option<(f64, (f64, f64))>]| -> Vec<Option<f64>> {
531        field.iter().map(|c| c.map(|(d, _)| d)).collect()
532    };
533    let mut near_seeds = Vec::new();
534    let mut far_seeds = Vec::new();
535    for i in lattice_extrema(&distances(&a_near), options.grid, |x, y| x <= y) {
536        if let (Some((uv, _)), Some((_, other))) = (ga[i], a_near[i]) {
537            near_seeds.push((uv, other));
538        }
539    }
540    for i in lattice_extrema(&distances(&b_near), options.grid, |x, y| x <= y) {
541        if let (Some((uv, _)), Some((_, other))) = (gb[i], b_near[i]) {
542            near_seeds.push((other, uv));
543        }
544    }
545    for i in lattice_extrema(&distances(&a_far), options.grid, |x, y| x >= y) {
546        if let (Some((uv, _)), Some((_, other))) = (ga[i], a_far[i]) {
547            far_seeds.push((uv, other));
548        }
549    }
550    for i in lattice_extrema(&distances(&b_far), options.grid, |x, y| x >= y) {
551        if let (Some((uv, _)), Some((_, other))) = (gb[i], b_far[i]) {
552            far_seeds.push((other, uv));
553        }
554    }
555    thin_to(&mut near_seeds, MOST_SEEDS / 2);
556    thin_to(&mut far_seeds, MOST_SEEDS / 2);
557    let seeds = near_seeds.into_iter().chain(far_seeds);
558
559    let mut approaches: Vec<Approach<(f64, f64), (f64, f64)>> = Vec::new();
560    for (seed_a, seed_b) in seeds {
561        if let Some((ua, va, ub, vb)) = stationary_surface_surface(a, b, seed_a, seed_b, tol) {
562            let (Ok(pa), Ok(pb)) = (a.point_at(ua, va, tol), b.point_at(ub, vb, tol)) else {
563                continue;
564            };
565            keep(
566                &mut approaches,
567                Approach {
568                    on_a: (ua, va),
569                    on_b: (ub, vb),
570                    point_a: pa,
571                    point_b: pb,
572                    distance: pa.distance(pb),
573                },
574                tol,
575            );
576        }
577    }
578    Ok(finish(approaches, tol))
579}
580
581/// Newton on the four stationarity conditions.
582fn stationary_surface_surface(
583    a: &SurfaceGeometry,
584    b: &SurfaceGeometry,
585    seed_a: (f64, f64),
586    seed_b: (f64, f64),
587    tol: Tolerances,
588) -> Option<(f64, f64, f64, f64)> {
589    let system = |x: &[f64; 4]| {
590        let (ua, va) = fold_surface(a, x[0], x[1]);
591        let (ub, vb) = fold_surface(b, x[2], x[3]);
592        let ja = jet_or_zero(a, ua, va, tol);
593        let jb = jet_or_zero(b, ub, vb, tol);
594        let (au, av, auu, auv, avv) = (ja.du, ja.dv, ja.d2u, ja.duv, ja.d2v);
595        let (bu, bv, buu, buv, bvv) = (jb.du, jb.dv, jb.d2u, jb.duv, jb.d2v);
596        let gap = ja.point - jb.point;
597        (
598            [gap.dot(au), gap.dot(av), gap.dot(bu), gap.dot(bv)],
599            [
600                [
601                    au.dot(au) + gap.dot(auu),
602                    au.dot(av) + gap.dot(auv),
603                    -bu.dot(au),
604                    -bv.dot(au),
605                ],
606                [
607                    au.dot(av) + gap.dot(auv),
608                    av.dot(av) + gap.dot(avv),
609                    -bu.dot(av),
610                    -bv.dot(av),
611                ],
612                [
613                    au.dot(bu),
614                    av.dot(bu),
615                    -bu.dot(bu) + gap.dot(buu),
616                    -bv.dot(bu) + gap.dot(buv),
617                ],
618                [
619                    au.dot(bv),
620                    av.dot(bv),
621                    -bu.dot(bv) + gap.dot(buv),
622                    -bv.dot(bv) + gap.dot(bvv),
623                ],
624            ],
625        )
626    };
627    let criteria = solve::Criteria {
628        residual: tol.confusion() * tol.confusion(),
629        step: tol.parametric(),
630        max_iterations: 40,
631    };
632    let found =
633        solve::newton_system_fixed(system, [seed_a.0, seed_a.1, seed_b.0, seed_b.1], criteria)
634            .ok()?;
635    let (ua, va) = fold_surface(a, found.0[0], found.0[1]);
636    let (ub, vb) = fold_surface(b, found.0[2], found.0[3]);
637    let gap = a.point_at(ua, va, tol).ok()? - b.point_at(ub, vb, tol).ok()?;
638    let (au, av) = a.d1_at(ua, va, tol).ok()?;
639    let (bu, bv) = b.d1_at(ub, vb, tol).ok()?;
640    is_stationary(gap, &[au, av, bu, bv], tol).then_some((ua, va, ub, vb))
641}
642
643/// A surface's point and derivatives through second order, from one
644/// evaluation; the origin and zeros where it cannot be evaluated, which a
645/// Newton step reads as no progress rather than as a root.
646fn jet_or_zero(surface: &SurfaceGeometry, u: f64, v: f64, tol: Tolerances) -> SurfaceJet {
647    surface.jet_at(u, v, tol).unwrap_or(SurfaceJet {
648        point: Point::ORIGIN,
649        du: Vector::ZERO,
650        dv: Vector::ZERO,
651        d2u: Vector::ZERO,
652        duv: Vector::ZERO,
653        d2v: Vector::ZERO,
654    })
655}
656
657/// Whether the gap between two points is square to every tangent given:
658/// the distance is stationary there. Newton reports where it stopped
659/// whether or not that was a root (a stall on a slope ends on a tiny step,
660/// not a small residual), so the claim is checked where it is made. The
661/// test is on the angle, so it holds at any scale and any speed of
662/// parametrisation, with the confusion distance as the floor where the two
663/// points meet.
664fn is_stationary(gap: Vector, tangents: &[Vector], tol: Tolerances) -> bool {
665    const SQUARE: f64 = 1e-6;
666    let reach = gap.magnitude();
667    tangents
668        .iter()
669        .all(|t| gap.dot(*t).abs() <= reach.mul_add(SQUARE, tol.confusion()) * t.magnitude())
670}
671
672// --- shared machinery --------------------------------------------------------
673
674fn wide_surface_check(surface: &SurfaceGeometry) -> OgeomResult<()> {
675    let ((ua, ub), (va, vb)) = surface.domain();
676    if ub - ua > WIDEST_DOMAIN || vb - va > WIDEST_DOMAIN {
677        ogeom_bail!(
678            Domain,
679            "a surface domain spans more than {WIDEST_DOMAIN:.0e}; trim it before asking"
680        );
681    }
682    Ok(())
683}
684
685fn sample_curve(curve: &Curve, samples: usize, tol: Tolerances) -> Vec<(f64, Point)> {
686    let (lo, hi) = curve.domain();
687    let mut out = Vec::with_capacity(samples + 1);
688    for i in 0..=samples {
689        #[allow(clippy::cast_precision_loss)]
690        let t = lo + (hi - lo) * i as f64 / samples as f64;
691        if let Ok(p) = curve.point_at(t, tol) {
692            out.push((t, p));
693        }
694    }
695    out
696}
697
698#[allow(clippy::type_complexity)]
699fn sample_surface(
700    surface: &SurfaceGeometry,
701    grid: usize,
702    tol: Tolerances,
703) -> Vec<((f64, f64), Point)> {
704    sample_grid(surface, grid, tol)
705        .into_iter()
706        .flatten()
707        .collect()
708}
709
710/// A surface sampled on a `(grid + 1)` square lattice, row by row in `u`,
711/// with `None` where it failed to evaluate, so neighbours stay findable.
712fn sample_grid(
713    surface: &SurfaceGeometry,
714    grid: usize,
715    tol: Tolerances,
716) -> Vec<Option<((f64, f64), Point)>> {
717    let ((ua, ub), (va, vb)) = surface.domain();
718    let mut out = Vec::with_capacity((grid + 1) * (grid + 1));
719    for i in 0..=grid {
720        for j in 0..=grid {
721            #[allow(clippy::cast_precision_loss)]
722            let u = ua + (ub - ua) * i as f64 / grid as f64;
723            #[allow(clippy::cast_precision_loss)]
724            let v = va + (vb - va) * j as f64 / grid as f64;
725            out.push(surface.point_at(u, v, tol).ok().map(|p| ((u, v), p)));
726        }
727    }
728    out
729}
730
731/// The lattice cells whose value is no worse than any neighbour's: the
732/// local minima of `value` over a `(grid + 1)` square lattice, where
733/// `better(x, y)` says `x` is at least as good as `y`. Cells with no value
734/// are skipped, and do not count as neighbours.
735fn lattice_extrema(
736    values: &[Option<f64>],
737    grid: usize,
738    better: impl Fn(f64, f64) -> bool,
739) -> Vec<usize> {
740    let side = grid + 1;
741    let mut out = Vec::new();
742    for i in 0..side {
743        for j in 0..side {
744            let Some(here) = values[i * side + j] else {
745                continue;
746            };
747            let mut extreme = true;
748            'around: for di in -1_isize..=1 {
749                for dj in -1_isize..=1 {
750                    if di == 0 && dj == 0 {
751                        continue;
752                    }
753                    let (Some(ni), Some(nj)) = (i.checked_add_signed(di), j.checked_add_signed(dj))
754                    else {
755                        continue;
756                    };
757                    if ni >= side || nj >= side {
758                        continue;
759                    }
760                    if let Some(there) = values[ni * side + nj]
761                        && !better(here, there)
762                    {
763                        extreme = false;
764                        break 'around;
765                    }
766                }
767            }
768            if extreme {
769                out.push(i * side + j);
770            }
771        }
772    }
773    out
774}
775
776/// Cap the seed list, keeping an even spread.
777fn thin<T>(seeds: &mut Vec<T>) {
778    thin_to(seeds, MOST_SEEDS);
779}
780
781/// Cap a seed list at `most`, keeping an even spread.
782fn thin_to<T>(seeds: &mut Vec<T>, most: usize) {
783    if seeds.len() <= most {
784        return;
785    }
786    let step = seeds.len().div_ceil(most.max(1));
787    let mut index = 0;
788    seeds.retain(|_| {
789        let kept = index % step == 0;
790        index += 1;
791        kept
792    });
793}
794
795/// Add an approach unless one at the same pair of points is already known.
796fn keep<A: Copy, B: Copy>(
797    approaches: &mut Vec<Approach<A, B>>,
798    candidate: Approach<A, B>,
799    tol: Tolerances,
800) {
801    let reach = tol.confusion() * DISTINCT;
802    if approaches.iter().any(|known| {
803        known.point_a.distance(candidate.point_a) <= reach
804            && known.point_b.distance(candidate.point_b) <= reach
805    }) {
806        return;
807    }
808    approaches.push(candidate);
809}
810
811/// Sort by distance and decide whether the nearest is a family.
812fn finish<A: Copy, B: Copy>(mut approaches: Vec<Approach<A, B>>, tol: Tolerances) -> Extrema<A, B> {
813    approaches.sort_by(|a, b| {
814        a.distance
815            .partial_cmp(&b.distance)
816            .unwrap_or(core::cmp::Ordering::Equal)
817    });
818    let family = match approaches.first() {
819        None => false,
820        Some(first) => {
821            let near = tol.confusion().max(first.distance * 1e-9);
822            let ties: Vec<&Approach<A, B>> = approaches
823                .iter()
824                .take_while(|a| a.distance - first.distance <= near)
825                .collect();
826            // Three or more equally-near approaches at genuinely different
827            // places are not coincidence. They are a locus showing through
828            // the sampling.
829            ties.len() >= 3
830                && ties
831                    .iter()
832                    .any(|a| a.point_a.distance(first.point_a) > tol.confusion() * DISTINCT * 10.0)
833        }
834    };
835    Extrema { approaches, family }
836}
837
838fn fold_curve(curve: &Curve, t: f64) -> f64 {
839    let (lo, hi) = curve.domain();
840    if curve.is_periodic() {
841        let span = hi - lo;
842        if span > 0.0 {
843            return lo + (t - lo).rem_euclid(span);
844        }
845    }
846    t.clamp(lo, hi)
847}
848
849fn fold_surface(surface: &SurfaceGeometry, u: f64, v: f64) -> (f64, f64) {
850    let ((ua, ub), (va, vb)) = surface.domain();
851    let fold = |x: f64, lo: f64, hi: f64, periodic: bool| {
852        if periodic {
853            let span = hi - lo;
854            if span > 0.0 {
855                return lo + (x - lo).rem_euclid(span);
856            }
857        }
858        x.clamp(lo, hi)
859    };
860    (
861        fold(u, ua, ub, surface.is_periodic_u()),
862        fold(v, va, vb, surface.is_periodic_v()),
863    )
864}
865
866#[cfg(test)]
867#[allow(clippy::unwrap_used)]
868mod tests {
869    use super::*;
870    use ogeom_geom::{CircleCurve, CylinderSurface, LineCurve, PlaneSurface, SphereSurface};
871    use ogeom_math::{Circle, Cylinder, Direction, Frame, Plane, Sphere};
872
873    const T: Tolerances = Tolerances::millimetres();
874
875    fn segment(from: Point, to: Point) -> Curve {
876        LineCurve::segment(from, to, T).unwrap().into()
877    }
878
879    fn circle_at(centre: Point, normal: Vector, radius: f64) -> Curve {
880        CircleCurve::new(
881            Circle::new(
882                Frame::new(
883                    centre,
884                    Direction::new(normal, T).unwrap(),
885                    Direction::from_cross(normal, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
886                    T,
887                )
888                .unwrap(),
889                radius,
890                T,
891            )
892            .unwrap(),
893        )
894        .into()
895    }
896
897    fn sphere_at(centre: Point, radius: f64) -> SurfaceGeometry {
898        SphereSurface::new(Sphere::centred(centre, radius, T).unwrap()).into()
899    }
900
901    #[test]
902    fn skew_segments_meet_the_closed_form() {
903        // The x axis, and a line along y lifted by one: nearest distance one,
904        // at the origin and at (0, 0, 1).
905        let a = segment(Point::new(-5.0, 0.0, 0.0), Point::new(5.0, 0.0, 0.0));
906        let b = segment(Point::new(0.0, -5.0, 1.0), Point::new(0.0, 5.0, 1.0));
907        let found = extrema_curve_curve(&a, &b, ExtremaOptions::default(), T).unwrap();
908        let nearest = found.nearest().unwrap();
909        assert!((nearest.distance - 1.0).abs() < 1e-9);
910        assert!(nearest.point_a.is_equal(Point::ORIGIN, T));
911        assert!(nearest.point_b.is_equal(Point::new(0.0, 0.0, 1.0), T));
912        assert!(!found.family);
913    }
914
915    #[test]
916    fn endpoint_to_endpoint_nearness_is_the_callers_and_says_so() {
917        // Collinear segments end to end: no interior stationary approach
918        // exists, and pretending one did would misreport the geometry. The
919        // endpoints are vertices at the shape level, and that is where this
920        // answer lives.
921        let a = segment(Point::new(0.0, 0.0, 0.0), Point::new(1.0, 0.0, 0.0));
922        let b = segment(Point::new(3.0, 0.0, 0.0), Point::new(5.0, 0.0, 0.0));
923        let found = extrema_curve_curve(&a, &b, ExtremaOptions::default(), T).unwrap();
924        assert!(found.approaches.is_empty());
925    }
926
927    #[test]
928    fn parallel_lines_are_a_family_with_a_representative() {
929        let a = segment(Point::new(-4.0, 0.0, 0.0), Point::new(4.0, 0.0, 0.0));
930        let b = segment(Point::new(-2.0, 2.0, 0.0), Point::new(6.0, 2.0, 0.0));
931        let found = extrema_curve_curve(&a, &b, ExtremaOptions::default(), T).unwrap();
932        assert!(found.family);
933        let nearest = found.nearest().unwrap();
934        assert!((nearest.distance - 2.0).abs() < 1e-12);
935        // The representative sits where the domains face each other.
936        assert!(nearest.on_a >= -2.0 && nearest.on_a <= 8.0);
937    }
938
939    #[test]
940    fn concentric_circles_are_a_family_found_by_sampling() {
941        // No closed form handles this pair; the family shows through the
942        // seeded path as many equally-near approaches at different places.
943        let a = circle_at(Point::ORIGIN, Vector::Z, 3.0);
944        let b = circle_at(Point::ORIGIN, Vector::Z, 1.0);
945        let found = extrema_curve_curve(&a, &b, ExtremaOptions::default(), T).unwrap();
946        assert!(found.family);
947        assert!((found.nearest().unwrap().distance - 2.0).abs() < 1e-9);
948    }
949
950    #[test]
951    fn a_tilted_circle_over_a_circle_has_isolated_extrema() {
952        // Tilt one circle: the family collapses to isolated nearest and
953        // farthest approaches.
954        let a = circle_at(Point::new(0.0, 0.0, 2.0), Vector::new(0.3, 0.0, 1.0), 3.0);
955        let b = circle_at(Point::ORIGIN, Vector::Z, 3.0);
956        let found = extrema_curve_curve(&a, &b, ExtremaOptions::default(), T).unwrap();
957        assert!(!found.family);
958        let nearest = found.nearest().unwrap();
959        // Verifiable on the spot: the claim is the distance between the
960        // evaluated points.
961        assert!((nearest.point_a.distance(nearest.point_b) - nearest.distance).abs() < 1e-12);
962        assert!(nearest.distance < 2.0, "the tilt brings the rims closer");
963    }
964
965    #[test]
966    fn a_segment_passing_a_sphere_finds_the_gap_to_it() {
967        // A line at distance three from the centre of a unit sphere: nearest
968        // approach two, on the line of shortest connection.
969        let line = segment(Point::new(-5.0, 3.0, 0.0), Point::new(5.0, 3.0, 0.0));
970        let ball = sphere_at(Point::ORIGIN, 1.0);
971        let found = extrema_curve_surface(&line, &ball, ExtremaOptions::default(), T).unwrap();
972        let nearest = found.nearest().unwrap();
973        assert!((nearest.distance - 2.0).abs() < 1e-9);
974        assert!(nearest.point_a.is_equal(Point::new(0.0, 3.0, 0.0), T));
975        assert!(nearest.point_b.is_equal(Point::new(0.0, 1.0, 0.0), T));
976    }
977
978    #[test]
979    fn a_circle_parallel_to_a_plane_is_a_family_above_it() {
980        let ring = circle_at(Point::new(0.0, 0.0, 2.0), Vector::Z, 3.0);
981        let ground: SurfaceGeometry = PlaneSurface::over(
982            Plane::through(Point::ORIGIN, Direction::Z),
983            (-8.0, 8.0),
984            (-8.0, 8.0),
985        )
986        .unwrap()
987        .into();
988        let found = extrema_curve_surface(&ring, &ground, ExtremaOptions::default(), T).unwrap();
989        assert!(found.family);
990        assert!((found.nearest().unwrap().distance - 2.0).abs() < 1e-9);
991    }
992
993    #[test]
994    fn two_spheres_apart_meet_along_the_line_of_centres() {
995        let a = sphere_at(Point::ORIGIN, 1.0);
996        let b = sphere_at(Point::new(5.0, 0.0, 0.0), 2.0);
997        let found = extrema_surface_surface(&a, &b, ExtremaOptions::default(), T).unwrap();
998        let nearest = found.nearest().unwrap();
999        assert!((nearest.distance - 2.0).abs() < 1e-9);
1000        assert!(nearest.point_a.is_equal(Point::new(1.0, 0.0, 0.0), T));
1001        assert!(nearest.point_b.is_equal(Point::new(3.0, 0.0, 0.0), T));
1002        assert!(!found.family);
1003    }
1004
1005    #[test]
1006    fn concentric_spheres_are_a_family() {
1007        let a = sphere_at(Point::ORIGIN, 1.0);
1008        let b = sphere_at(Point::ORIGIN, 3.0);
1009        let found = extrema_surface_surface(&a, &b, ExtremaOptions::default(), T).unwrap();
1010        assert!(found.family);
1011        assert!((found.nearest().unwrap().distance - 2.0).abs() < 1e-9);
1012    }
1013
1014    #[test]
1015    fn a_cylinder_beside_a_plane_reports_the_ruling_gap_as_a_family() {
1016        // The nearest locus is a whole ruling of the cylinder.
1017        let drum: SurfaceGeometry =
1018            CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.0, T).unwrap(), (-3.0, 3.0))
1019                .unwrap()
1020                .into();
1021        let wall: SurfaceGeometry = PlaneSurface::over(
1022            Plane::through(Point::new(4.0, 0.0, 0.0), Direction::X),
1023            (-8.0, 8.0),
1024            (-8.0, 8.0),
1025        )
1026        .unwrap()
1027        .into();
1028        let found = extrema_surface_surface(&drum, &wall, ExtremaOptions::default(), T).unwrap();
1029        assert!(found.family);
1030        assert!((found.nearest().unwrap().distance - 3.0).abs() < 1e-9);
1031    }
1032
1033    #[test]
1034    fn an_untrimmed_line_is_refused_with_instructions() {
1035        let endless: Curve = LineCurve::new(ogeom_math::Axis {
1036            location: Point::ORIGIN,
1037            direction: Direction::X,
1038        })
1039        .into();
1040        let ring = circle_at(Point::ORIGIN, Vector::Z, 1.0);
1041        assert!(extrema_curve_curve(&endless, &ring, ExtremaOptions::default(), T).is_err());
1042        // Line against line has its closed form and needs no trimming.
1043        let other: Curve = LineCurve::new(ogeom_math::Axis {
1044            location: Point::new(0.0, 1.0, 0.0),
1045            direction: Direction::Y,
1046        })
1047        .into();
1048        assert!(extrema_curve_curve(&endless, &other, ExtremaOptions::default(), T).is_ok());
1049    }
1050
1051    /// Two unit spheres ten apart: nearest at 8 and farthest at 12, and
1052    /// nothing reported that is not stationary.
1053    #[test]
1054    fn two_spheres_meet_nearest_and_farthest() {
1055        let ball = |x: f64| -> SurfaceGeometry {
1056            SphereSurface::new(
1057                Sphere::new(
1058                    Frame::new(Point::new(x, 0.0, 0.0), Direction::Z, Direction::X, T).unwrap(),
1059                    1.0,
1060                    T,
1061                )
1062                .unwrap(),
1063            )
1064            .into()
1065        };
1066        let found =
1067            extrema_surface_surface(&ball(0.0), &ball(10.0), ExtremaOptions::default(), T).unwrap();
1068        let d: Vec<f64> = found.approaches.iter().map(|a| a.distance).collect();
1069        assert!((d[0] - 8.0).abs() < 1e-9, "{d:?}");
1070        assert!((d[d.len() - 1] - 12.0).abs() < 1e-9, "{d:?}");
1071        for a in &found.approaches {
1072            assert!(
1073                [8.0, 10.0, 12.0]
1074                    .iter()
1075                    .any(|w| (a.distance - w).abs() < 1e-9),
1076                "{d:?}"
1077            );
1078        }
1079    }
1080}