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};
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]| {
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            vec![gap.dot(d1a), -gap.dot(d1b)],
239            vec![
240                vec![d1a.dot(d1a) + gap.dot(d2a), -d1a.dot(d1b)],
241                vec![-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(system, &[seed_a, seed_b], criteria).ok()?;
253    Some((fold_curve(a, found.value[0]), fold_curve(b, found.value[1])))
254}
255
256/// Closed-form extrema between two lines.
257fn line_line(
258    a: &ogeom_geom::LineCurve,
259    b: &ogeom_geom::LineCurve,
260    tol: Tolerances,
261) -> Extrema<f64, f64> {
262    let (oa, da) = (a.axis().location, a.axis().direction.vector());
263    let (ob, db) = (b.axis().location, b.axis().direction.vector());
264    let cross = da.cross(db);
265    let denominator = cross.dot(cross);
266
267    if denominator <= tol.angular() * tol.angular() {
268        // Parallel: constant distance, a family. The representative sits at
269        // the middle of where the domains face each other; if they face each
270        // other nowhere, the nearest is at ends the caller owns.
271        let (a_lo, a_hi) = a.domain();
272        let (b_lo, b_hi) = b.domain();
273        let project = |p: Point| (p - oa).dot(da);
274        let (s0, s1) = (project(ob + db * b_lo), project(ob + db * b_hi));
275        let (lo, hi) = (s0.min(s1).max(a_lo), s0.max(s1).min(a_hi));
276        if lo > hi {
277            return Extrema {
278                approaches: Vec::new(),
279                family: true,
280            };
281        }
282        let t = f64::midpoint(lo, hi);
283        let pa = oa + da * t;
284        let s = (pa - ob).dot(db);
285        let pb = ob + db * s;
286        return Extrema {
287            approaches: vec![Approach {
288                on_a: t,
289                on_b: s,
290                point_a: pa,
291                point_b: pb,
292                distance: pa.distance(pb),
293            }],
294            family: true,
295        };
296    }
297
298    let between = ob - oa;
299    let t = between.cross(db).dot(cross) / denominator;
300    let s = between.cross(da).dot(cross) / denominator;
301    let (a_lo, a_hi) = a.domain();
302    let (b_lo, b_hi) = b.domain();
303    if t < a_lo || t > a_hi || s < b_lo || s > b_hi {
304        // The stationary approach exists on the unbounded lines but outside
305        // these domains: within them the nearest is at an end.
306        return Extrema {
307            approaches: Vec::new(),
308            family: false,
309        };
310    }
311    let pa = oa + da * t;
312    let pb = ob + db * s;
313    Extrema {
314        approaches: vec![Approach {
315            on_a: t,
316            on_b: s,
317            point_a: pa,
318            point_b: pb,
319            distance: pa.distance(pb),
320        }],
321        family: false,
322    }
323}
324
325// --- curve / surface ---------------------------------------------------------
326
327/// The stationary approaches between a curve and a surface.
328///
329/// # Errors
330///
331/// As [`extrema_curve_curve`].
332pub fn extrema_curve_surface(
333    curve: &Curve,
334    surface: &SurfaceGeometry,
335    options: ExtremaOptions,
336    tol: Tolerances,
337) -> OgeomResult<Extrema<f64, (f64, f64)>> {
338    if options.samples < 2 || options.grid < 2 {
339        ogeom_bail!(Construction, "seeding needs at least two steps each way");
340    }
341    let (lo, hi) = curve.domain();
342    if hi - lo > WIDEST_DOMAIN {
343        ogeom_bail!(
344            Domain,
345            "the curve's domain spans {:.0e}; trim it before asking",
346            hi - lo
347        );
348    }
349    wide_surface_check(surface)?;
350
351    let sc = sample_curve(curve, options.samples, tol);
352    let ss = sample_surface(surface, options.grid, tol);
353    if sc.len() < 2 || ss.is_empty() {
354        ogeom_bail!(
355            Construction,
356            "a geometry failed to evaluate over its domain"
357        );
358    }
359
360    // For each curve sample, its best and worst surface cells; local extrema
361    // along the curve of those fields seed the polish.
362    let mut best = Vec::with_capacity(sc.len());
363    let mut worst = Vec::with_capacity(sc.len());
364    for (_, p) in &sc {
365        let mut near = (f64::INFINITY, (0.0, 0.0));
366        let mut far = (f64::NEG_INFINITY, (0.0, 0.0));
367        for (uv, q) in &ss {
368            let d = p.square_distance(*q);
369            if d < near.0 {
370                near = (d, *uv);
371            }
372            if d > far.0 {
373                far = (d, *uv);
374            }
375        }
376        best.push(near);
377        worst.push(far);
378    }
379    let mut seeds = Vec::new();
380    for i in 0..sc.len() {
381        let lower = i == 0 || best[i].0 <= best[i - 1].0;
382        let upper = i + 1 == sc.len() || best[i].0 <= best[i + 1].0;
383        if lower && upper {
384            seeds.push((sc[i].0, best[i].1));
385        }
386        let lower = i == 0 || worst[i].0 >= worst[i - 1].0;
387        let upper = i + 1 == sc.len() || worst[i].0 >= worst[i + 1].0;
388        if lower && upper {
389            seeds.push((sc[i].0, worst[i].1));
390        }
391    }
392    thin(&mut seeds);
393
394    let mut approaches: Vec<Approach<f64, (f64, f64)>> = Vec::new();
395    for (seed_t, seed_uv) in seeds {
396        if let Some((t, u, v)) = stationary_curve_surface(curve, surface, seed_t, seed_uv, tol) {
397            let (Ok(pc), Ok(ps)) = (curve.point_at(t, tol), surface.point_at(u, v, tol)) else {
398                continue;
399            };
400            keep(
401                &mut approaches,
402                Approach {
403                    on_a: t,
404                    on_b: (u, v),
405                    point_a: pc,
406                    point_b: ps,
407                    distance: pc.distance(ps),
408                },
409                tol,
410            );
411        }
412    }
413    Ok(finish(approaches, tol))
414}
415
416/// Newton on the three stationarity conditions.
417fn stationary_curve_surface(
418    curve: &Curve,
419    surface: &SurfaceGeometry,
420    seed_t: f64,
421    seed_uv: (f64, f64),
422    tol: Tolerances,
423) -> Option<(f64, f64, f64)> {
424    let system = |x: &[f64]| {
425        let t = fold_curve(curve, x[0]);
426        let (u, v) = fold_surface(surface, x[1], x[2]);
427        let pc = curve.point_at(t, tol).unwrap_or(Point::ORIGIN);
428        let ps = surface.point_at(u, v, tol).unwrap_or(Point::ORIGIN);
429        let dc = curve.derivatives_at(t, 2, tol).unwrap_or_default();
430        let zero = Vector::ZERO;
431        let (ct, ctt) = (
432            dc.get(1).copied().unwrap_or(zero),
433            dc.get(2).copied().unwrap_or(zero),
434        );
435        let (su, sv) = surface.d1_at(u, v, tol).unwrap_or((zero, zero));
436        let (suu, suv, svv) = surface.d2_at(u, v, tol).unwrap_or((zero, zero, zero));
437        let gap = pc - ps;
438        (
439            vec![gap.dot(ct), gap.dot(su), gap.dot(sv)],
440            vec![
441                vec![ct.dot(ct) + gap.dot(ctt), -su.dot(ct), -sv.dot(ct)],
442                vec![
443                    ct.dot(su),
444                    -su.dot(su) + gap.dot(suu),
445                    -sv.dot(su) + gap.dot(suv),
446                ],
447                vec![
448                    ct.dot(sv),
449                    -su.dot(sv) + gap.dot(suv),
450                    -sv.dot(sv) + gap.dot(svv),
451                ],
452            ],
453        )
454    };
455    let criteria = solve::Criteria {
456        residual: tol.confusion() * tol.confusion(),
457        step: tol.parametric(),
458        max_iterations: 40,
459    };
460    let found = solve::newton_system(system, &[seed_t, seed_uv.0, seed_uv.1], criteria).ok()?;
461    let t = fold_curve(curve, found.value[0]);
462    let (u, v) = fold_surface(surface, found.value[1], found.value[2]);
463    Some((t, u, v))
464}
465
466// --- surface / surface -------------------------------------------------------
467
468/// The stationary approaches between two surfaces.
469///
470/// # Errors
471///
472/// As [`extrema_curve_curve`].
473pub fn extrema_surface_surface(
474    a: &SurfaceGeometry,
475    b: &SurfaceGeometry,
476    options: ExtremaOptions,
477    tol: Tolerances,
478) -> OgeomResult<Extrema<(f64, f64), (f64, f64)>> {
479    if options.grid < 2 {
480        ogeom_bail!(Construction, "seeding needs at least two steps each way");
481    }
482    wide_surface_check(a)?;
483    wide_surface_check(b)?;
484
485    let sa = sample_surface(a, options.grid, tol);
486    let sb = sample_surface(b, options.grid, tol);
487    if sa.is_empty() || sb.is_empty() {
488        ogeom_bail!(Construction, "a surface failed to evaluate over its domain");
489    }
490
491    // Each side seeds from its own best-facing view of the other; the union
492    // covers approaches either sampling would resolve.
493    let mut seeds = Vec::new();
494    for (uv_a, p) in &sa {
495        let mut near = (f64::INFINITY, (0.0, 0.0));
496        let mut far = (f64::NEG_INFINITY, (0.0, 0.0));
497        for (uv_b, q) in &sb {
498            let d = p.square_distance(*q);
499            if d < near.0 {
500                near = (d, *uv_b);
501            }
502            if d > far.0 {
503                far = (d, *uv_b);
504            }
505        }
506        seeds.push((*uv_a, near.1));
507        seeds.push((*uv_a, far.1));
508    }
509    for (uv_b, q) in &sb {
510        let mut near = (f64::INFINITY, (0.0, 0.0));
511        for (uv_a, p) in &sa {
512            let d = q.square_distance(*p);
513            if d < near.0 {
514                near = (d, *uv_a);
515            }
516        }
517        seeds.push((near.1, *uv_b));
518    }
519    thin(&mut seeds);
520
521    let mut approaches: Vec<Approach<(f64, f64), (f64, f64)>> = Vec::new();
522    for (seed_a, seed_b) in seeds {
523        if let Some((ua, va, ub, vb)) = stationary_surface_surface(a, b, seed_a, seed_b, tol) {
524            let (Ok(pa), Ok(pb)) = (a.point_at(ua, va, tol), b.point_at(ub, vb, tol)) else {
525                continue;
526            };
527            keep(
528                &mut approaches,
529                Approach {
530                    on_a: (ua, va),
531                    on_b: (ub, vb),
532                    point_a: pa,
533                    point_b: pb,
534                    distance: pa.distance(pb),
535                },
536                tol,
537            );
538        }
539    }
540    Ok(finish(approaches, tol))
541}
542
543/// Newton on the four stationarity conditions.
544fn stationary_surface_surface(
545    a: &SurfaceGeometry,
546    b: &SurfaceGeometry,
547    seed_a: (f64, f64),
548    seed_b: (f64, f64),
549    tol: Tolerances,
550) -> Option<(f64, f64, f64, f64)> {
551    let system = |x: &[f64]| {
552        let (ua, va) = fold_surface(a, x[0], x[1]);
553        let (ub, vb) = fold_surface(b, x[2], x[3]);
554        let zero = Vector::ZERO;
555        let pa = a.point_at(ua, va, tol).unwrap_or(Point::ORIGIN);
556        let pb = b.point_at(ub, vb, tol).unwrap_or(Point::ORIGIN);
557        let (au, av) = a.d1_at(ua, va, tol).unwrap_or((zero, zero));
558        let (auu, auv, avv) = a.d2_at(ua, va, tol).unwrap_or((zero, zero, zero));
559        let (bu, bv) = b.d1_at(ub, vb, tol).unwrap_or((zero, zero));
560        let (buu, buv, bvv) = b.d2_at(ub, vb, tol).unwrap_or((zero, zero, zero));
561        let gap = pa - pb;
562        (
563            vec![gap.dot(au), gap.dot(av), gap.dot(bu), gap.dot(bv)],
564            vec![
565                vec![
566                    au.dot(au) + gap.dot(auu),
567                    au.dot(av) + gap.dot(auv),
568                    -bu.dot(au),
569                    -bv.dot(au),
570                ],
571                vec![
572                    au.dot(av) + gap.dot(auv),
573                    av.dot(av) + gap.dot(avv),
574                    -bu.dot(av),
575                    -bv.dot(av),
576                ],
577                vec![
578                    au.dot(bu),
579                    av.dot(bu),
580                    -bu.dot(bu) + gap.dot(buu),
581                    -bv.dot(bu) + gap.dot(buv),
582                ],
583                vec![
584                    au.dot(bv),
585                    av.dot(bv),
586                    -bu.dot(bv) + gap.dot(buv),
587                    -bv.dot(bv) + gap.dot(bvv),
588                ],
589            ],
590        )
591    };
592    let criteria = solve::Criteria {
593        residual: tol.confusion() * tol.confusion(),
594        step: tol.parametric(),
595        max_iterations: 40,
596    };
597    let found =
598        solve::newton_system(system, &[seed_a.0, seed_a.1, seed_b.0, seed_b.1], criteria).ok()?;
599    let (ua, va) = fold_surface(a, found.value[0], found.value[1]);
600    let (ub, vb) = fold_surface(b, found.value[2], found.value[3]);
601    Some((ua, va, ub, vb))
602}
603
604// --- shared machinery --------------------------------------------------------
605
606fn wide_surface_check(surface: &SurfaceGeometry) -> OgeomResult<()> {
607    let ((ua, ub), (va, vb)) = surface.domain();
608    if ub - ua > WIDEST_DOMAIN || vb - va > WIDEST_DOMAIN {
609        ogeom_bail!(
610            Domain,
611            "a surface domain spans more than {WIDEST_DOMAIN:.0e}; trim it before asking"
612        );
613    }
614    Ok(())
615}
616
617fn sample_curve(curve: &Curve, samples: usize, tol: Tolerances) -> Vec<(f64, Point)> {
618    let (lo, hi) = curve.domain();
619    let mut out = Vec::with_capacity(samples + 1);
620    for i in 0..=samples {
621        #[allow(clippy::cast_precision_loss)]
622        let t = lo + (hi - lo) * i as f64 / samples as f64;
623        if let Ok(p) = curve.point_at(t, tol) {
624            out.push((t, p));
625        }
626    }
627    out
628}
629
630#[allow(clippy::type_complexity)]
631fn sample_surface(
632    surface: &SurfaceGeometry,
633    grid: usize,
634    tol: Tolerances,
635) -> Vec<((f64, f64), Point)> {
636    let ((ua, ub), (va, vb)) = surface.domain();
637    let mut out = Vec::with_capacity((grid + 1) * (grid + 1));
638    for i in 0..=grid {
639        for j in 0..=grid {
640            #[allow(clippy::cast_precision_loss)]
641            let u = ua + (ub - ua) * i as f64 / grid as f64;
642            #[allow(clippy::cast_precision_loss)]
643            let v = va + (vb - va) * j as f64 / grid as f64;
644            if let Ok(p) = surface.point_at(u, v, tol) {
645                out.push(((u, v), p));
646            }
647        }
648    }
649    out
650}
651
652/// Cap the seed list, keeping an even spread.
653fn thin<T>(seeds: &mut Vec<T>) {
654    if seeds.len() <= MOST_SEEDS {
655        return;
656    }
657    let step = seeds.len().div_ceil(MOST_SEEDS);
658    let mut index = 0;
659    seeds.retain(|_| {
660        let kept = index % step == 0;
661        index += 1;
662        kept
663    });
664}
665
666/// Add an approach unless one at the same pair of points is already known.
667fn keep<A: Copy, B: Copy>(
668    approaches: &mut Vec<Approach<A, B>>,
669    candidate: Approach<A, B>,
670    tol: Tolerances,
671) {
672    let reach = tol.confusion() * DISTINCT;
673    if approaches.iter().any(|known| {
674        known.point_a.distance(candidate.point_a) <= reach
675            && known.point_b.distance(candidate.point_b) <= reach
676    }) {
677        return;
678    }
679    approaches.push(candidate);
680}
681
682/// Sort by distance and decide whether the nearest is a family.
683fn finish<A: Copy, B: Copy>(mut approaches: Vec<Approach<A, B>>, tol: Tolerances) -> Extrema<A, B> {
684    approaches.sort_by(|a, b| {
685        a.distance
686            .partial_cmp(&b.distance)
687            .unwrap_or(core::cmp::Ordering::Equal)
688    });
689    let family = match approaches.first() {
690        None => false,
691        Some(first) => {
692            let near = tol.confusion().max(first.distance * 1e-9);
693            let ties: Vec<&Approach<A, B>> = approaches
694                .iter()
695                .take_while(|a| a.distance - first.distance <= near)
696                .collect();
697            // Three or more equally-near approaches at genuinely different
698            // places are not coincidence; they are a locus showing through
699            // the sampling.
700            ties.len() >= 3
701                && ties
702                    .iter()
703                    .any(|a| a.point_a.distance(first.point_a) > tol.confusion() * DISTINCT * 10.0)
704        }
705    };
706    Extrema { approaches, family }
707}
708
709fn fold_curve(curve: &Curve, t: f64) -> f64 {
710    let (lo, hi) = curve.domain();
711    if curve.is_periodic() {
712        let span = hi - lo;
713        if span > 0.0 {
714            return lo + (t - lo).rem_euclid(span);
715        }
716    }
717    t.clamp(lo, hi)
718}
719
720fn fold_surface(surface: &SurfaceGeometry, u: f64, v: f64) -> (f64, f64) {
721    let ((ua, ub), (va, vb)) = surface.domain();
722    let fold = |x: f64, lo: f64, hi: f64, periodic: bool| {
723        if periodic {
724            let span = hi - lo;
725            if span > 0.0 {
726                return lo + (x - lo).rem_euclid(span);
727            }
728        }
729        x.clamp(lo, hi)
730    };
731    (
732        fold(u, ua, ub, surface.is_periodic_u()),
733        fold(v, va, vb, surface.is_periodic_v()),
734    )
735}
736
737#[cfg(test)]
738#[allow(clippy::unwrap_used)]
739mod tests {
740    use super::*;
741    use ogeom_geom::{CircleCurve, CylinderSurface, LineCurve, PlaneSurface, SphereSurface};
742    use ogeom_math::{Circle, Cylinder, Direction, Frame, Plane, Sphere};
743
744    const T: Tolerances = Tolerances::millimetres();
745
746    fn segment(from: Point, to: Point) -> Curve {
747        LineCurve::segment(from, to, T).unwrap().into()
748    }
749
750    fn circle_at(centre: Point, normal: Vector, radius: f64) -> Curve {
751        CircleCurve::new(
752            Circle::new(
753                Frame::new(
754                    centre,
755                    Direction::new(normal, T).unwrap(),
756                    Direction::from_cross(normal, Vector::new(0.3, 0.5, 0.9), T).unwrap(),
757                    T,
758                )
759                .unwrap(),
760                radius,
761                T,
762            )
763            .unwrap(),
764        )
765        .into()
766    }
767
768    fn sphere_at(centre: Point, radius: f64) -> SurfaceGeometry {
769        SphereSurface::new(Sphere::centred(centre, radius, T).unwrap()).into()
770    }
771
772    #[test]
773    fn skew_segments_meet_the_closed_form() {
774        // The x axis, and a line along y lifted by one: nearest distance one,
775        // at the origin and at (0, 0, 1).
776        let a = segment(Point::new(-5.0, 0.0, 0.0), Point::new(5.0, 0.0, 0.0));
777        let b = segment(Point::new(0.0, -5.0, 1.0), Point::new(0.0, 5.0, 1.0));
778        let found = extrema_curve_curve(&a, &b, ExtremaOptions::default(), T).unwrap();
779        let nearest = found.nearest().unwrap();
780        assert!((nearest.distance - 1.0).abs() < 1e-9);
781        assert!(nearest.point_a.is_equal(Point::ORIGIN, T));
782        assert!(nearest.point_b.is_equal(Point::new(0.0, 0.0, 1.0), T));
783        assert!(!found.family);
784    }
785
786    #[test]
787    fn endpoint_to_endpoint_nearness_is_the_callers_and_says_so() {
788        // Collinear segments end to end: no interior stationary approach
789        // exists, and pretending one did would misreport the geometry. The
790        // endpoints are vertices at the shape level, and that is where this
791        // answer lives.
792        let a = segment(Point::new(0.0, 0.0, 0.0), Point::new(1.0, 0.0, 0.0));
793        let b = segment(Point::new(3.0, 0.0, 0.0), Point::new(5.0, 0.0, 0.0));
794        let found = extrema_curve_curve(&a, &b, ExtremaOptions::default(), T).unwrap();
795        assert!(found.approaches.is_empty());
796    }
797
798    #[test]
799    fn parallel_lines_are_a_family_with_a_representative() {
800        let a = segment(Point::new(-4.0, 0.0, 0.0), Point::new(4.0, 0.0, 0.0));
801        let b = segment(Point::new(-2.0, 2.0, 0.0), Point::new(6.0, 2.0, 0.0));
802        let found = extrema_curve_curve(&a, &b, ExtremaOptions::default(), T).unwrap();
803        assert!(found.family);
804        let nearest = found.nearest().unwrap();
805        assert!((nearest.distance - 2.0).abs() < 1e-12);
806        // The representative sits where the domains face each other.
807        assert!(nearest.on_a >= -2.0 && nearest.on_a <= 8.0);
808    }
809
810    #[test]
811    fn concentric_circles_are_a_family_found_by_sampling() {
812        // No closed form handles this pair; the family shows through the
813        // seeded path as many equally-near approaches at different places.
814        let a = circle_at(Point::ORIGIN, Vector::Z, 3.0);
815        let b = circle_at(Point::ORIGIN, Vector::Z, 1.0);
816        let found = extrema_curve_curve(&a, &b, ExtremaOptions::default(), T).unwrap();
817        assert!(found.family);
818        assert!((found.nearest().unwrap().distance - 2.0).abs() < 1e-9);
819    }
820
821    #[test]
822    fn a_tilted_circle_over_a_circle_has_isolated_extrema() {
823        // Tilt one circle: the family collapses to isolated nearest and
824        // farthest approaches.
825        let a = circle_at(Point::new(0.0, 0.0, 2.0), Vector::new(0.3, 0.0, 1.0), 3.0);
826        let b = circle_at(Point::ORIGIN, Vector::Z, 3.0);
827        let found = extrema_curve_curve(&a, &b, ExtremaOptions::default(), T).unwrap();
828        assert!(!found.family);
829        let nearest = found.nearest().unwrap();
830        // Verifiable on the spot: the claim is the distance between the
831        // evaluated points.
832        assert!((nearest.point_a.distance(nearest.point_b) - nearest.distance).abs() < 1e-12);
833        assert!(nearest.distance < 2.0, "the tilt brings the rims closer");
834    }
835
836    #[test]
837    fn a_segment_passing_a_sphere_finds_the_gap_to_it() {
838        // A line at distance three from the centre of a unit sphere: nearest
839        // approach two, on the line of shortest connection.
840        let line = segment(Point::new(-5.0, 3.0, 0.0), Point::new(5.0, 3.0, 0.0));
841        let ball = sphere_at(Point::ORIGIN, 1.0);
842        let found = extrema_curve_surface(&line, &ball, ExtremaOptions::default(), T).unwrap();
843        let nearest = found.nearest().unwrap();
844        assert!((nearest.distance - 2.0).abs() < 1e-9);
845        assert!(nearest.point_a.is_equal(Point::new(0.0, 3.0, 0.0), T));
846        assert!(nearest.point_b.is_equal(Point::new(0.0, 1.0, 0.0), T));
847    }
848
849    #[test]
850    fn a_circle_parallel_to_a_plane_is_a_family_above_it() {
851        let ring = circle_at(Point::new(0.0, 0.0, 2.0), Vector::Z, 3.0);
852        let ground: SurfaceGeometry = PlaneSurface::over(
853            Plane::through(Point::ORIGIN, Direction::Z),
854            (-8.0, 8.0),
855            (-8.0, 8.0),
856        )
857        .unwrap()
858        .into();
859        let found = extrema_curve_surface(&ring, &ground, ExtremaOptions::default(), T).unwrap();
860        assert!(found.family);
861        assert!((found.nearest().unwrap().distance - 2.0).abs() < 1e-9);
862    }
863
864    #[test]
865    fn two_spheres_apart_meet_along_the_line_of_centres() {
866        let a = sphere_at(Point::ORIGIN, 1.0);
867        let b = sphere_at(Point::new(5.0, 0.0, 0.0), 2.0);
868        let found = extrema_surface_surface(&a, &b, ExtremaOptions::default(), T).unwrap();
869        let nearest = found.nearest().unwrap();
870        assert!((nearest.distance - 2.0).abs() < 1e-9);
871        assert!(nearest.point_a.is_equal(Point::new(1.0, 0.0, 0.0), T));
872        assert!(nearest.point_b.is_equal(Point::new(3.0, 0.0, 0.0), T));
873        assert!(!found.family);
874    }
875
876    #[test]
877    fn concentric_spheres_are_a_family() {
878        let a = sphere_at(Point::ORIGIN, 1.0);
879        let b = sphere_at(Point::ORIGIN, 3.0);
880        let found = extrema_surface_surface(&a, &b, ExtremaOptions::default(), T).unwrap();
881        assert!(found.family);
882        assert!((found.nearest().unwrap().distance - 2.0).abs() < 1e-9);
883    }
884
885    #[test]
886    fn a_cylinder_beside_a_plane_reports_the_ruling_gap_as_a_family() {
887        // The nearest locus is a whole ruling of the cylinder.
888        let drum: SurfaceGeometry =
889            CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.0, T).unwrap(), (-3.0, 3.0))
890                .unwrap()
891                .into();
892        let wall: SurfaceGeometry = PlaneSurface::over(
893            Plane::through(Point::new(4.0, 0.0, 0.0), Direction::X),
894            (-8.0, 8.0),
895            (-8.0, 8.0),
896        )
897        .unwrap()
898        .into();
899        let found = extrema_surface_surface(&drum, &wall, ExtremaOptions::default(), T).unwrap();
900        assert!(found.family);
901        assert!((found.nearest().unwrap().distance - 3.0).abs() < 1e-9);
902    }
903
904    #[test]
905    fn an_untrimmed_line_is_refused_with_instructions() {
906        let endless: Curve = LineCurve::new(ogeom_math::Axis {
907            location: Point::ORIGIN,
908            direction: Direction::X,
909        })
910        .into();
911        let ring = circle_at(Point::ORIGIN, Vector::Z, 1.0);
912        assert!(extrema_curve_curve(&endless, &ring, ExtremaOptions::default(), T).is_err());
913        // Line against line has its closed form and needs no trimming.
914        let other: Curve = LineCurve::new(ogeom_math::Axis {
915            location: Point::new(0.0, 1.0, 0.0),
916            direction: Direction::Y,
917        })
918        .into();
919        assert!(extrema_curve_curve(&endless, &other, ExtremaOptions::default(), T).is_ok());
920    }
921}