1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
30use ogeom_geom::{Curve, Curve3d, Surface, SurfaceGeometry, SurfaceJet};
31use ogeom_math::{Point, Vector, solve};
32
33#[derive(Debug, Clone, Copy, PartialEq)]
35pub struct Approach<A, B> {
36 pub on_a: A,
38 pub on_b: B,
40 pub point_a: Point,
42 pub point_b: Point,
44 pub distance: f64,
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub struct Extrema<A, B> {
51 pub approaches: Vec<Approach<A, B>>,
55 pub family: bool,
60}
61
62impl<A, B> Extrema<A, B> {
63 #[must_use]
65 pub fn nearest(&self) -> Option<&Approach<A, B>> {
66 self.approaches.first()
67 }
68}
69
70#[derive(Debug, Clone, Copy, PartialEq)]
72pub struct ExtremaOptions {
73 pub samples: usize,
75 pub grid: usize,
77}
78
79impl Default for ExtremaOptions {
80 fn default() -> Self {
81 Self {
82 samples: 64,
83 grid: 24,
84 }
85 }
86}
87
88const WIDEST_DOMAIN: f64 = 1e8;
96
97const DISTINCT: f64 = 1e2;
99
100const MOST_SEEDS: usize = 256;
106
107pub fn extrema_curve_curve(
121 a: &Curve,
122 b: &Curve,
123 options: ExtremaOptions,
124 tol: Tolerances,
125) -> OgeomResult<Extrema<f64, f64>> {
126 if options.samples < 2 {
127 ogeom_bail!(Construction, "seeding needs at least two samples");
128 }
129 if let (Curve::Line(la), Curve::Line(lb)) = (a, b) {
130 return Ok(line_line(la, lb, tol));
131 }
132 for (name, curve) in [("first", a), ("second", b)] {
133 let (lo, hi) = curve.domain();
134 if hi - lo > WIDEST_DOMAIN {
135 ogeom_bail!(
136 Domain,
137 "the {name} curve's domain spans {:.0e}; trim it before asking",
138 hi - lo
139 );
140 }
141 }
142
143 let sa = sample_curve(a, options.samples, tol);
144 let sb = sample_curve(b, options.samples, tol);
145 if sa.len() < 2 || sb.len() < 2 {
146 ogeom_bail!(Construction, "a curve failed to evaluate over its domain");
147 }
148
149 let mut seeds = Vec::new();
153 for i in 0..sa.len() {
154 for j in 0..sb.len() {
155 let here = sa[i].1.square_distance(sb[j].1);
156 let mut minimal = true;
157 let mut maximal = true;
158 let neighbours_a = sa
159 .iter()
160 .enumerate()
161 .take((i + 2).min(sa.len()))
162 .skip(i.saturating_sub(1));
163 for (ni, near_a) in neighbours_a {
164 let neighbours_b = sb
165 .iter()
166 .enumerate()
167 .take((j + 2).min(sb.len()))
168 .skip(j.saturating_sub(1));
169 for (nj, near_b) in neighbours_b {
170 if ni == i && nj == j {
171 continue;
172 }
173 let there = near_a.1.square_distance(near_b.1);
174 if there < here {
175 minimal = false;
176 }
177 if there > here {
178 maximal = false;
179 }
180 }
181 }
182 if minimal || maximal {
183 seeds.push((sa[i].0, sb[j].0));
184 }
185 }
186 }
187 thin(&mut seeds);
188
189 let mut approaches: Vec<Approach<f64, f64>> = Vec::new();
190 for (seed_a, seed_b) in seeds {
191 if let Some((t, s)) = stationary_curve_curve(a, b, seed_a, seed_b, tol) {
192 let (Ok(pa), Ok(pb)) = (a.point_at(t, tol), b.point_at(s, tol)) else {
193 continue;
194 };
195 keep(
196 &mut approaches,
197 Approach {
198 on_a: t,
199 on_b: s,
200 point_a: pa,
201 point_b: pb,
202 distance: pa.distance(pb),
203 },
204 tol,
205 );
206 }
207 }
208 Ok(finish(approaches, tol))
209}
210
211pub(crate) fn stationary_curve_curve(
213 a: &Curve,
214 b: &Curve,
215 seed_a: f64,
216 seed_b: f64,
217 tol: Tolerances,
218) -> Option<(f64, f64)> {
219 let system = |x: &[f64; 2]| {
220 let (t, s) = (fold_curve(a, x[0]), fold_curve(b, x[1]));
221 let pa = a.point_at(t, tol).unwrap_or(Point::ORIGIN);
222 let pb = b.point_at(s, tol).unwrap_or(Point::ORIGIN);
223 let da = a.derivatives_at(t, 2, tol).unwrap_or_default();
224 let db = b.derivatives_at(s, 2, tol).unwrap_or_default();
225 let zero = Vector::ZERO;
226 let (d1a, d2a) = (
227 da.get(1).copied().unwrap_or(zero),
228 da.get(2).copied().unwrap_or(zero),
229 );
230 let (d1b, d2b) = (
231 db.get(1).copied().unwrap_or(zero),
232 db.get(2).copied().unwrap_or(zero),
233 );
234 let gap = pa - pb;
235 (
236 [gap.dot(d1a), -gap.dot(d1b)],
237 [
238 [d1a.dot(d1a) + gap.dot(d2a), -d1a.dot(d1b)],
239 [-d1a.dot(d1b), d1b.dot(d1b) - gap.dot(d2b)],
240 ],
241 )
242 };
243 let criteria = solve::Criteria {
244 residual: tol.confusion() * tol.confusion(),
247 step: tol.parametric(),
248 max_iterations: 40,
249 };
250 let found = solve::newton_system_fixed(system, [seed_a, seed_b], criteria).ok()?;
251 let (t, s) = (fold_curve(a, found.0[0]), fold_curve(b, found.0[1]));
252 let gap = a.point_at(t, tol).ok()? - b.point_at(s, tol).ok()?;
253 let ta = a.derivatives_at(t, 1, tol).ok()?.get(1).copied()?;
254 let tb = b.derivatives_at(s, 1, tol).ok()?.get(1).copied()?;
255 is_stationary(gap, &[ta, tb], tol).then_some((t, s))
256}
257
258fn line_line(
260 a: &ogeom_geom::LineCurve,
261 b: &ogeom_geom::LineCurve,
262 tol: Tolerances,
263) -> Extrema<f64, f64> {
264 let (oa, da) = (a.axis().location, a.axis().direction.vector());
265 let (ob, db) = (b.axis().location, b.axis().direction.vector());
266 let cross = da.cross(db);
267 let denominator = cross.dot(cross);
268
269 if denominator <= tol.angular() * tol.angular() {
270 let (a_lo, a_hi) = a.domain();
274 let (b_lo, b_hi) = b.domain();
275 let project = |p: Point| (p - oa).dot(da);
276 let (s0, s1) = (project(ob + db * b_lo), project(ob + db * b_hi));
277 let (lo, hi) = (s0.min(s1).max(a_lo), s0.max(s1).min(a_hi));
278 if lo > hi {
279 return Extrema {
280 approaches: Vec::new(),
281 family: true,
282 };
283 }
284 let t = f64::midpoint(lo, hi);
285 let pa = oa + da * t;
286 let s = (pa - ob).dot(db);
287 let pb = ob + db * s;
288 return Extrema {
289 approaches: vec![Approach {
290 on_a: t,
291 on_b: s,
292 point_a: pa,
293 point_b: pb,
294 distance: pa.distance(pb),
295 }],
296 family: true,
297 };
298 }
299
300 let between = ob - oa;
301 let t = between.cross(db).dot(cross) / denominator;
302 let s = between.cross(da).dot(cross) / denominator;
303 let (a_lo, a_hi) = a.domain();
304 let (b_lo, b_hi) = b.domain();
305 if t < a_lo || t > a_hi || s < b_lo || s > b_hi {
306 return Extrema {
309 approaches: Vec::new(),
310 family: false,
311 };
312 }
313 let pa = oa + da * t;
314 let pb = ob + db * s;
315 Extrema {
316 approaches: vec![Approach {
317 on_a: t,
318 on_b: s,
319 point_a: pa,
320 point_b: pb,
321 distance: pa.distance(pb),
322 }],
323 family: false,
324 }
325}
326
327pub fn extrema_curve_surface(
335 curve: &Curve,
336 surface: &SurfaceGeometry,
337 options: ExtremaOptions,
338 tol: Tolerances,
339) -> OgeomResult<Extrema<f64, (f64, f64)>> {
340 if options.samples < 2 || options.grid < 2 {
341 ogeom_bail!(Construction, "seeding needs at least two steps each way");
342 }
343 let (lo, hi) = curve.domain();
344 if hi - lo > WIDEST_DOMAIN {
345 ogeom_bail!(
346 Domain,
347 "the curve's domain spans {:.0e}; trim it before asking",
348 hi - lo
349 );
350 }
351 wide_surface_check(surface)?;
352
353 let sc = sample_curve(curve, options.samples, tol);
354 let ss = sample_surface(surface, options.grid, tol);
355 if sc.len() < 2 || ss.is_empty() {
356 ogeom_bail!(
357 Construction,
358 "a geometry failed to evaluate over its domain"
359 );
360 }
361
362 let mut best = Vec::with_capacity(sc.len());
365 let mut worst = Vec::with_capacity(sc.len());
366 for (_, p) in &sc {
367 let mut near = (f64::INFINITY, (0.0, 0.0));
368 let mut far = (f64::NEG_INFINITY, (0.0, 0.0));
369 for (uv, q) in &ss {
370 let d = p.square_distance(*q);
371 if d < near.0 {
372 near = (d, *uv);
373 }
374 if d > far.0 {
375 far = (d, *uv);
376 }
377 }
378 best.push(near);
379 worst.push(far);
380 }
381 let mut seeds = Vec::new();
382 for i in 0..sc.len() {
383 let lower = i == 0 || best[i].0 <= best[i - 1].0;
384 let upper = i + 1 == sc.len() || best[i].0 <= best[i + 1].0;
385 if lower && upper {
386 seeds.push((sc[i].0, best[i].1));
387 }
388 let lower = i == 0 || worst[i].0 >= worst[i - 1].0;
389 let upper = i + 1 == sc.len() || worst[i].0 >= worst[i + 1].0;
390 if lower && upper {
391 seeds.push((sc[i].0, worst[i].1));
392 }
393 }
394 thin(&mut seeds);
395
396 let mut approaches: Vec<Approach<f64, (f64, f64)>> = Vec::new();
397 for (seed_t, seed_uv) in seeds {
398 if let Some((t, u, v)) = stationary_curve_surface(curve, surface, seed_t, seed_uv, tol) {
399 let (Ok(pc), Ok(ps)) = (curve.point_at(t, tol), surface.point_at(u, v, tol)) else {
400 continue;
401 };
402 keep(
403 &mut approaches,
404 Approach {
405 on_a: t,
406 on_b: (u, v),
407 point_a: pc,
408 point_b: ps,
409 distance: pc.distance(ps),
410 },
411 tol,
412 );
413 }
414 }
415 Ok(finish(approaches, tol))
416}
417
418fn stationary_curve_surface(
420 curve: &Curve,
421 surface: &SurfaceGeometry,
422 seed_t: f64,
423 seed_uv: (f64, f64),
424 tol: Tolerances,
425) -> Option<(f64, f64, f64)> {
426 let system = |x: &[f64; 3]| {
427 let t = fold_curve(curve, x[0]);
428 let (u, v) = fold_surface(surface, x[1], x[2]);
429 let pc = curve.point_at(t, tol).unwrap_or(Point::ORIGIN);
430 let dc = curve.derivatives_at(t, 2, tol).unwrap_or_default();
431 let zero = Vector::ZERO;
432 let (ct, ctt) = (
433 dc.get(1).copied().unwrap_or(zero),
434 dc.get(2).copied().unwrap_or(zero),
435 );
436 let js = jet_or_zero(surface, u, v, tol);
437 let (su, sv, suu, suv, svv) = (js.du, js.dv, js.d2u, js.duv, js.d2v);
438 let gap = pc - js.point;
439 (
440 [gap.dot(ct), gap.dot(su), gap.dot(sv)],
441 [
442 [ct.dot(ct) + gap.dot(ctt), -su.dot(ct), -sv.dot(ct)],
443 [
444 ct.dot(su),
445 -su.dot(su) + gap.dot(suu),
446 -sv.dot(su) + gap.dot(suv),
447 ],
448 [
449 ct.dot(sv),
450 -su.dot(sv) + gap.dot(suv),
451 -sv.dot(sv) + gap.dot(svv),
452 ],
453 ],
454 )
455 };
456 let criteria = solve::Criteria {
457 residual: tol.confusion() * tol.confusion(),
458 step: tol.parametric(),
459 max_iterations: 40,
460 };
461 let found =
462 solve::newton_system_fixed(system, [seed_t, seed_uv.0, seed_uv.1], criteria).ok()?;
463 let t = fold_curve(curve, found.0[0]);
464 let (u, v) = fold_surface(surface, found.0[1], found.0[2]);
465 let gap = curve.point_at(t, tol).ok()? - surface.point_at(u, v, tol).ok()?;
466 let tc = curve.derivatives_at(t, 1, tol).ok()?.get(1).copied()?;
467 let (su, sv) = surface.d1_at(u, v, tol).ok()?;
468 is_stationary(gap, &[tc, su, sv], tol).then_some((t, u, v))
469}
470
471pub fn extrema_surface_surface(
479 a: &SurfaceGeometry,
480 b: &SurfaceGeometry,
481 options: ExtremaOptions,
482 tol: Tolerances,
483) -> OgeomResult<Extrema<(f64, f64), (f64, f64)>> {
484 if options.grid < 2 {
485 ogeom_bail!(Construction, "seeding needs at least two steps each way");
486 }
487 wide_surface_check(a)?;
488 wide_surface_check(b)?;
489
490 let ga = sample_grid(a, options.grid, tol);
491 let gb = sample_grid(b, options.grid, tol);
492 let sa: Vec<((f64, f64), Point)> = ga.iter().flatten().copied().collect();
493 let sb: Vec<((f64, f64), Point)> = gb.iter().flatten().copied().collect();
494 if sa.is_empty() || sb.is_empty() {
495 ogeom_bail!(Construction, "a surface failed to evaluate over its domain");
496 }
497
498 let fields = |mine: &[Option<((f64, f64), Point)>], theirs: &[((f64, f64), Point)]| {
503 let mut near = Vec::with_capacity(mine.len());
504 let mut far = Vec::with_capacity(mine.len());
505 for cell in mine {
506 let Some((_, p)) = cell else {
507 near.push(None);
508 far.push(None);
509 continue;
510 };
511 let mut best = (f64::INFINITY, (0.0, 0.0));
512 let mut worst = (f64::NEG_INFINITY, (0.0, 0.0));
513 for (uv, q) in theirs {
514 let d = p.square_distance(*q);
515 if d < best.0 {
516 best = (d, *uv);
517 }
518 if d > worst.0 {
519 worst = (d, *uv);
520 }
521 }
522 near.push(Some(best));
523 far.push(Some(worst));
524 }
525 (near, far)
526 };
527 let (a_near, a_far) = fields(&ga, &sb);
528 let (b_near, b_far) = fields(&gb, &sa);
529 let distances = |field: &[Option<(f64, (f64, f64))>]| -> Vec<Option<f64>> {
530 field.iter().map(|c| c.map(|(d, _)| d)).collect()
531 };
532 let mut near_seeds = Vec::new();
533 let mut far_seeds = Vec::new();
534 for i in lattice_extrema(&distances(&a_near), options.grid, |x, y| x <= y) {
535 if let (Some((uv, _)), Some((_, other))) = (ga[i], a_near[i]) {
536 near_seeds.push((uv, other));
537 }
538 }
539 for i in lattice_extrema(&distances(&b_near), options.grid, |x, y| x <= y) {
540 if let (Some((uv, _)), Some((_, other))) = (gb[i], b_near[i]) {
541 near_seeds.push((other, uv));
542 }
543 }
544 for i in lattice_extrema(&distances(&a_far), options.grid, |x, y| x >= y) {
545 if let (Some((uv, _)), Some((_, other))) = (ga[i], a_far[i]) {
546 far_seeds.push((uv, other));
547 }
548 }
549 for i in lattice_extrema(&distances(&b_far), options.grid, |x, y| x >= y) {
550 if let (Some((uv, _)), Some((_, other))) = (gb[i], b_far[i]) {
551 far_seeds.push((other, uv));
552 }
553 }
554 thin_to(&mut near_seeds, MOST_SEEDS / 2);
555 thin_to(&mut far_seeds, MOST_SEEDS / 2);
556 let seeds = near_seeds.into_iter().chain(far_seeds);
557
558 let mut approaches: Vec<Approach<(f64, f64), (f64, f64)>> = Vec::new();
559 for (seed_a, seed_b) in seeds {
560 if let Some((ua, va, ub, vb)) = stationary_surface_surface(a, b, seed_a, seed_b, 40, tol) {
561 let (Ok(pa), Ok(pb)) = (a.point_at(ua, va, tol), b.point_at(ub, vb, tol)) else {
562 continue;
563 };
564 keep(
565 &mut approaches,
566 Approach {
567 on_a: (ua, va),
568 on_b: (ub, vb),
569 point_a: pa,
570 point_b: pb,
571 distance: pa.distance(pb),
572 },
573 tol,
574 );
575 }
576 }
577 Ok(finish(approaches, tol))
578}
579pub(crate) fn stationary_surface_surface(
583 a: &SurfaceGeometry,
584 b: &SurfaceGeometry,
585 seed_a: (f64, f64),
586 seed_b: (f64, f64),
587 iterations: usize,
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: iterations,
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
644fn 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
658fn 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
673fn 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
711fn 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
732fn 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
777fn thin<T>(seeds: &mut Vec<T>) {
779 thin_to(seeds, MOST_SEEDS);
780}
781
782fn 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
796fn 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
812fn 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 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 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 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 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 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 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 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 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 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 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 #[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}