1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
31use ogeom_geom::{Curve, Curve3d, Surface, SurfaceGeometry, SurfaceJet};
32use ogeom_math::{Point, Vector, solve};
33
34#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct Approach<A, B> {
37 pub on_a: A,
39 pub on_b: B,
41 pub point_a: Point,
43 pub point_b: Point,
45 pub distance: f64,
47}
48
49#[derive(Debug, Clone, PartialEq)]
51pub struct Extrema<A, B> {
52 pub approaches: Vec<Approach<A, B>>,
56 pub family: bool,
61}
62
63impl<A, B> Extrema<A, B> {
64 #[must_use]
66 pub fn nearest(&self) -> Option<&Approach<A, B>> {
67 self.approaches.first()
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq)]
73pub struct ExtremaOptions {
74 pub samples: usize,
76 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
89const WIDEST_DOMAIN: f64 = 1e8;
97
98const DISTINCT: f64 = 1e2;
100
101const MOST_SEEDS: usize = 256;
107
108pub 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 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
212pub(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 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
259fn 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 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 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
328pub 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 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
419fn 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
472pub 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 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
581fn 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
643fn 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
657fn 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
672fn 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
710fn 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
731fn 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
776fn thin<T>(seeds: &mut Vec<T>) {
778 thin_to(seeds, MOST_SEEDS);
779}
780
781fn 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
795fn 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
811fn 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 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 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 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 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 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 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 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 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 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 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 #[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}