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