1mod spans;
10
11use crate::{Surface, SurfaceGeometry, SurfaceJet};
12use ogeom_core::{OgeomResult, Tolerances};
13use ogeom_math::Point;
14use spans::SpanBoxes;
15
16#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct SurfaceProjection {
19 pub parameters: (f64, f64),
21 pub point: Point,
23 pub distance: f64,
25}
26
27pub fn project_on_surface(
48 surface: &SurfaceGeometry,
49 target: Point,
50 samples: usize,
51 tol: Tolerances,
52) -> OgeomResult<SurfaceProjection> {
53 if let Some(found) = closed_form(
58 surface,
59 target,
60 Profile::of(surface, samples, tol).as_ref(),
61 tol,
62 ) {
63 return Ok(found);
64 }
65 if let Some(boxes) = span_boxes(surface) {
66 return boxes.project(surface, target, tol);
67 }
68 grid_projection(surface, target, samples, tol)
69}
70
71fn span_boxes(surface: &SurfaceGeometry) -> Option<SpanBoxes> {
74 match surface {
75 SurfaceGeometry::BSpline(spline) => SpanBoxes::of(spline),
76 _ => None,
77 }
78}
79
80fn grid_projection(
82 surface: &SurfaceGeometry,
83 target: Point,
84 samples: usize,
85 tol: Tolerances,
86) -> OgeomResult<SurfaceProjection> {
87 let (us, vs) = seed_lines(surface, samples);
88 let mut scan = Scan::default();
89 for &u in &us {
90 let mut row = Row::with_capacity(vs.len());
91 for &v in &vs {
92 let d = surface
93 .point_at(u, v, tol)
94 .map_or(f64::INFINITY, |p| p.square_distance(target));
95 row.push((u, v, d));
96 }
97 scan.push_row(row);
98 }
99
100 scan.finish().refine(surface, target, tol)
101}
102
103fn closed_form(
109 surface: &SurfaceGeometry,
110 target: Point,
111 profile: Option<&Profile>,
112 tol: Tolerances,
113) -> Option<SurfaceProjection> {
114 if let Some(profile) = profile {
115 return profile.project(surface, target, tol);
116 }
117 let seed = closed_form_foot(surface, target, surface.domain(), tol)?;
118 refine_foot(surface, target, seed, tol).ok()
119}
120
121fn closed_form_foot(
127 surface: &SurfaceGeometry,
128 target: Point,
129 window: ((f64, f64), (f64, f64)),
130 tol: Tolerances,
131) -> Option<(f64, f64)> {
132 use ogeom_math::elementary;
133 let (u, v) = match surface {
134 SurfaceGeometry::Plane(p) => elementary::plane_parameters(&p.plane(), target),
135 SurfaceGeometry::Cylinder(c) => {
136 elementary::cylinder_parameters(&c.cylinder(), target, tol).ok()?
137 }
138 SurfaceGeometry::Cone(c) => elementary::cone_parameters(&c.cone(), target, tol).ok()?,
139 SurfaceGeometry::Sphere(s) => {
140 elementary::sphere_parameters(&s.sphere(), target, tol).ok()?
141 }
142 SurfaceGeometry::Torus(t) => elementary::torus_parameters(&t.torus(), target, tol).ok()?,
143 SurfaceGeometry::Trimmed(t) => return closed_form_foot(t.basis(), target, window, tol),
144 _ => return None,
145 };
146 let ((ua, ub), (va, vb)) = surface.domain();
147 let u = into_window(u, window.0, surface.is_periodic_u().then_some(ub - ua))?;
148 let v = into_window(v, window.1, surface.is_periodic_v().then_some(vb - va))?;
149 Some((u, v))
150}
151
152fn into_window(t: f64, (from, to): (f64, f64), period: Option<f64>) -> Option<f64> {
155 if t >= from && t <= to {
156 return Some(t);
157 }
158 let period = period.filter(|p| *p > 0.0)?;
159 let shifted = ((from - t) / period).ceil().mul_add(period, t);
160 (shifted >= from && shifted <= to).then_some(shifted)
161}
162
163type ProfileScan = smallvec::SmallVec<[(f64, Option<(f64, f64)>); 64]>;
166
167#[derive(Debug, Clone, Copy)]
169enum Sweep {
170 Extrusion(ogeom_math::Vector),
173 Revolution(ogeom_math::Axis),
176}
177
178#[derive(Debug, Clone)]
188struct Profile {
189 sweep: Sweep,
190 at: f64,
192 samples: Vec<(f64, Option<Point>)>,
195}
196
197impl Profile {
198 fn of(surface: &SurfaceGeometry, samples: usize, tol: Tolerances) -> Option<Self> {
202 let basis = match surface {
203 SurfaceGeometry::Trimmed(t) => t.basis(),
204 other => other,
205 };
206 let sweep = match basis {
207 SurfaceGeometry::Extrusion(e) => Sweep::Extrusion(e.direction().vector()),
208 SurfaceGeometry::Revolution(r) => Sweep::Revolution(r.axis()),
209 _ => return None,
210 };
211 let (us, vs) = seed_lines(surface, samples);
212 let ((ua, _), (va, _)) = surface.domain();
213 let (at, along) = match sweep {
214 Sweep::Extrusion(_) => (va, us),
215 Sweep::Revolution(_) => (ua, vs),
216 };
217 let samples = along
218 .into_iter()
219 .map(|t| {
220 let point = match sweep {
221 Sweep::Extrusion(_) => surface.point_at(t, at, tol),
222 Sweep::Revolution(_) => surface.point_at(at, t, tol),
223 };
224 (t, point.ok())
225 })
226 .collect();
227 Some(Self { sweep, at, samples })
228 }
229
230 fn reduce(
235 &self,
236 t: f64,
237 point: Point,
238 target: Point,
239 tol: Tolerances,
240 ) -> Option<(f64, (f64, f64))> {
241 match self.sweep {
242 Sweep::Extrusion(d) => {
243 let gap = point - target;
244 let along = gap.dot(d);
245 let across = gap - d * along;
246 Some((across.dot(across), (t, self.at - along)))
247 }
248 Sweep::Revolution(axis) => {
249 let d = axis.direction.vector();
250 let split = |p: Point| {
251 let off = p - axis.location;
252 let height = off.dot(d);
253 (off - d * height, height)
254 };
255 let (to, height_to) = split(target);
256 let (from, height_from) = split(point);
257 let (ring_to, ring_from) = (to.magnitude(), from.magnitude());
258 if ring_to <= tol.confusion() {
259 return None;
260 }
261 let turn = from.cross(to).dot(d).atan2(from.dot(to));
262 let gap = (ring_to - ring_from).hypot(height_to - height_from);
263 Some((gap * gap, (self.at + turn, t)))
264 }
265 }
266 }
267
268 fn project(
272 &self,
273 surface: &SurfaceGeometry,
274 target: Point,
275 tol: Tolerances,
276 ) -> Option<SurfaceProjection> {
277 let ((ua, ub), (va, vb)) = surface.domain();
278 let (sweep_window, period) = match self.sweep {
279 Sweep::Extrusion(_) => ((va, vb), surface.is_periodic_v().then_some(vb - va)),
280 Sweep::Revolution(_) => ((ua, ub), surface.is_periodic_u().then_some(ub - ua)),
281 };
282 let mut scan = ProfileScan::with_capacity(self.samples.len());
283 for &(t, point) in &self.samples {
284 let (d, seed) = match point {
285 Some(point) => {
286 let (d, (u, v)) = self.reduce(t, point, target, tol)?;
287 let seed = match self.sweep {
289 Sweep::Extrusion(_) => into_window(v, sweep_window, period).map(|v| (u, v)),
290 Sweep::Revolution(_) => {
291 into_window(u, sweep_window, period).map(|u| (u, v))
292 }
293 };
294 (d, seed)
295 }
296 None => (f64::INFINITY, None),
297 };
298 scan.push((d, seed));
299 }
300 let nearest = scan
304 .iter()
305 .filter(|s| s.0.is_finite())
306 .min_by(|a, b| a.0.total_cmp(&b.0))?;
307 nearest.1?;
308 let mut starts = Starts::default();
309 for (i, &(d, seed)) in scan.iter().enumerate() {
310 let Some(at) = seed else { continue };
311 let lo = i.saturating_sub(1);
312 let hi = (i + 1).min(scan.len() - 1);
313 if scan[lo..=hi].iter().any(|c| c.0 < d) {
314 continue;
315 }
316 starts.offer((i, 0), at, d);
317 }
318 starts.refine(surface, target, tol).ok()
319 }
320}
321
322type Row = smallvec::SmallVec<[(f64, f64, f64); 64]>;
325
326#[derive(Debug, Default)]
333struct Scan {
334 before: Option<Row>,
335 last: Option<Row>,
336 rows: usize,
337 starts: Starts,
338}
339
340impl Scan {
341 fn push_row(&mut self, row: Row) {
343 if let Some(last) = self.last.take() {
344 self.starts
345 .minima(self.rows - 1, self.before.as_ref(), &last, Some(&row));
346 self.before = Some(last);
347 }
348 self.last = Some(row);
349 self.rows += 1;
350 }
351
352 fn finish(mut self) -> Starts {
354 if let Some(last) = self.last.take() {
355 self.starts
356 .minima(self.rows - 1, self.before.as_ref(), &last, None);
357 }
358 self.starts
359 }
360}
361
362#[derive(Debug, Default, Clone, Copy)]
372struct Starts {
373 picks: [Option<Pick>; 4],
375}
376
377#[derive(Debug, Clone, Copy)]
379struct Pick {
380 cell: (usize, usize),
382 at: (f64, f64),
384 d: f64,
386}
387
388impl Starts {
389 fn minima(&mut self, r: usize, before: Option<&Row>, row: &Row, after: Option<&Row>) {
392 for (j, &(u, v, d)) in row.iter().enumerate() {
393 if !d.is_finite() {
394 continue;
395 }
396 let lo = j.saturating_sub(1);
397 let hi = (j + 1).min(row.len() - 1);
398 let beaten = |cells: &Row| cells[lo..=hi].iter().any(|c| c.2 < d);
399 if beaten(row) || before.is_some_and(beaten) || after.is_some_and(beaten) {
400 continue;
401 }
402 self.offer((r, j), (u, v), d);
403 }
404 }
405
406 fn offer(&mut self, cell: (usize, usize), at: (f64, f64), d: f64) {
409 if !d.is_finite() {
410 return;
411 }
412 let pick = Pick { cell, at, d };
413 let adjacent = |a: (usize, usize)| a.0.abs_diff(cell.0) <= 1 && a.1.abs_diff(cell.1) <= 1;
414 if let Some(slot) = self
415 .picks
416 .iter()
417 .position(|p| p.is_some_and(|p| adjacent(p.cell)))
418 {
419 if self.picks[slot].is_some_and(|p| d < p.d) {
420 self.picks[slot] = Some(pick);
421 self.settle();
422 }
423 return;
424 }
425 if let Some(slot) = self.picks.iter().position(Option::is_none) {
426 self.picks[slot] = Some(pick);
427 self.settle();
428 } else if self.picks[3].is_some_and(|p| d < p.d) {
429 self.picks[3] = Some(pick);
430 self.settle();
431 }
432 }
433
434 fn settle(&mut self) {
436 self.picks.sort_by(|a, b| match (a, b) {
437 (Some(a), Some(b)) => a.d.total_cmp(&b.d),
438 (Some(_), None) => std::cmp::Ordering::Less,
439 (None, Some(_)) => std::cmp::Ordering::Greater,
440 (None, None) => std::cmp::Ordering::Equal,
441 });
442 }
443
444 fn refine(
447 self,
448 surface: &SurfaceGeometry,
449 target: Point,
450 tol: Tolerances,
451 ) -> OgeomResult<SurfaceProjection> {
452 let ((ua, _), (va, _)) = surface.domain();
453 let mut best: Option<SurfaceProjection> = None;
454 for pick in self.picks.iter().flatten() {
455 let found = refine_foot(surface, target, pick.at, tol)?;
456 let better = best.as_ref().is_none_or(|b| found.distance < b.distance);
457 if better {
458 let done = found.distance <= tol.confusion();
459 best = Some(found);
460 if done {
461 break;
462 }
463 }
464 }
465 match best {
466 Some(found) => Ok(found),
467 None => refine_foot(surface, target, (ua, va), tol),
468 }
469 }
470}
471
472fn seed_lines(surface: &SurfaceGeometry, samples: usize) -> (Vec<f64>, Vec<f64>) {
484 const CAP: usize = 4096;
485 let base = samples.max(4);
486 let ((ua, ub), (va, vb)) = surface.domain();
487 let SurfaceGeometry::BSpline(spline) = surface else {
488 return (spread(ua, ub, base), spread(va, vb, base));
489 };
490 (
499 per_span(&breaks(spline.u_knots()), base, CAP),
500 per_span(&breaks(spline.v_knots()), base, CAP),
501 )
502}
503
504fn spread(from: f64, to: f64, count: usize) -> Vec<f64> {
506 #[allow(clippy::cast_precision_loss)]
507 (0..=count)
508 .map(|i| from + (to - from) * (i as f64 / count as f64))
509 .collect()
510}
511
512fn breaks(knots: &ogeom_math::KnotVector) -> Vec<f64> {
514 knots.distinct().into_iter().map(|(at, _)| at).collect()
515}
516
517fn per_span(knots: &[f64], budget: usize, cap: usize) -> Vec<f64> {
519 let spans = knots.len().saturating_sub(1);
520 if spans == 0 {
521 return knots.to_vec();
522 }
523 let each = (budget / spans).min(cap / spans).max(1);
524 let mut out = Vec::with_capacity(spans * each + 1);
525 for pair in knots.windows(2) {
526 out.extend(spread(pair[0], pair[1], each).into_iter().take(each));
527 }
528 out.push(knots[knots.len() - 1]);
529 out
530}
531
532#[derive(Debug, Clone)]
541pub struct SurfaceSeeds {
542 rows: Vec<Vec<(f64, f64, Option<Point>)>>,
545 spans: Option<SpanBoxes>,
547 profile: Option<Profile>,
549}
550
551impl SurfaceSeeds {
552 pub fn over(surface: &SurfaceGeometry, samples: usize, tol: Tolerances) -> OgeomResult<Self> {
559 let profile = Profile::of(surface, samples, tol);
560 if let Some(spans) = span_boxes(surface) {
561 return Ok(Self {
562 rows: Vec::new(),
563 spans: Some(spans),
564 profile,
565 });
566 }
567 let (us, vs) = seed_lines(surface, samples);
568 let mut rows = Vec::with_capacity(us.len());
569 for &u in &us {
570 let mut row = Vec::with_capacity(vs.len());
571 for &v in &vs {
572 row.push((u, v, surface.point_at(u, v, tol).ok()));
573 }
574 rows.push(row);
575 }
576 Ok(Self {
577 rows,
578 spans: None,
579 profile,
580 })
581 }
582
583 pub fn project(
590 &self,
591 surface: &SurfaceGeometry,
592 target: Point,
593 tol: Tolerances,
594 ) -> OgeomResult<SurfaceProjection> {
595 if let Some(found) = closed_form(surface, target, self.profile.as_ref(), tol) {
598 return Ok(found);
599 }
600 if let Some(spans) = &self.spans {
601 return spans.project(surface, target, tol);
602 }
603 let mut scan = Scan::default();
604 for row in &self.rows {
605 scan.push_row(
606 row.iter()
607 .map(|&(u, v, p)| {
608 (u, v, p.map_or(f64::INFINITY, |p| p.square_distance(target)))
609 })
610 .collect(),
611 );
612 }
613 scan.finish().refine(surface, target, tol)
614 }
615}
616
617pub fn project_on_surface_from(
636 surface: &SurfaceGeometry,
637 target: Point,
638 guess: (f64, f64),
639 tol: Tolerances,
640) -> OgeomResult<SurfaceProjection> {
641 refine_foot(surface, target, guess, tol)
642}
643
644fn refine_foot(
659 surface: &SurfaceGeometry,
660 target: Point,
661 start: (f64, f64),
662 tol: Tolerances,
663) -> OgeomResult<SurfaceProjection> {
664 let ((ua, ub), (va, vb)) = surface.domain();
665 let periodic = (surface.is_periodic_u(), surface.is_periodic_v());
666 let inside = |t: f64, a: f64, b: f64, wraps: bool| -> f64 {
667 if wraps {
668 a + (t - a).rem_euclid(b - a)
669 } else {
670 t.clamp(a, b)
671 }
672 };
673 let square_distance = |u: f64, v: f64| -> f64 {
674 surface
675 .point_at(u, v, tol)
676 .map_or(f64::INFINITY, |p| p.square_distance(target))
677 };
678
679 let mut x = (
680 inside(start.0, ua, ub, periodic.0),
681 inside(start.1, va, vb, periodic.1),
682 );
683 let mut best = (x.0, x.1, square_distance(x.0, x.1));
684
685 for _ in 0..60 {
686 let jet = match surface.jet_at(x.0, x.1, tol) {
693 Ok(jet) => jet,
694 Err(_) => {
695 let Ok((point, du, dv)) = surface.point_d1_at(x.0, x.1, tol) else {
696 break;
697 };
698 SurfaceJet {
699 point,
700 du,
701 dv,
702 d2u: ogeom_math::Vector::ZERO,
703 duv: ogeom_math::Vector::ZERO,
704 d2v: ogeom_math::Vector::ZERO,
705 }
706 }
707 };
708 let SurfaceJet {
709 point: p,
710 du,
711 dv,
712 d2u,
713 duv,
714 d2v,
715 } = jet;
716 let gap = p - target;
717 let r = [gap.dot(du), gap.dot(dv)];
719 let j = [
720 [du.dot(du) + gap.dot(d2u), du.dot(dv) + gap.dot(duv)],
721 [du.dot(dv) + gap.dot(duv), dv.dot(dv) + gap.dot(d2v)],
722 ];
723
724 let pinned = |t: f64, a: f64, b: f64, wraps: bool, push: f64| -> bool {
728 !wraps && ((t <= a && push < 0.0) || (t >= b && push > 0.0))
729 };
730 let pin_u = pinned(x.0, ua, ub, periodic.0, -r[0]);
733 let pin_v = pinned(x.1, va, vb, periodic.1, -r[1]);
734
735 let free_norm = match (pin_u, pin_v) {
736 (true, true) => 0.0,
737 (true, false) => r[1].abs(),
738 (false, true) => r[0].abs(),
739 (false, false) => r[0].hypot(r[1]),
740 };
741 let along = |r: f64, d: ogeom_math::Vector| -> f64 {
747 let speed = d.magnitude();
748 if speed > f64::MIN_POSITIVE {
749 (r / speed).abs()
750 } else {
751 0.0
752 }
753 };
754 let free_gap = match (pin_u, pin_v) {
755 (true, true) => 0.0,
756 (true, false) => along(r[1], dv),
757 (false, true) => along(r[0], du),
758 (false, false) => along(r[0], du).hypot(along(r[1], dv)),
759 };
760 if free_norm <= tol.confusion() && free_gap <= tol.confusion() {
761 break;
762 }
763
764 let delta = match (pin_u, pin_v) {
765 (true, true) => break,
766 (true, false) => {
767 if j[1][1].abs() <= f64::EPSILON {
768 break;
769 }
770 [0.0, r[1] / j[1][1]]
771 }
772 (false, true) => {
773 if j[0][0].abs() <= f64::EPSILON {
774 break;
775 }
776 [r[0] / j[0][0], 0.0]
777 }
778 (false, false) => {
779 let Some(d) = solve_2x2(j, r) else {
780 break;
781 };
782 d
783 }
784 };
785 if !delta[0].is_finite() || !delta[1].is_finite() {
786 break;
787 }
788
789 let mut scale = 1.0;
793 let mut accepted = None;
794 for _ in 0..30 {
795 let candidate = (
796 inside(delta[0].mul_add(-scale, x.0), ua, ub, periodic.0),
797 inside(delta[1].mul_add(-scale, x.1), va, vb, periodic.1),
798 );
799 let d = square_distance(candidate.0, candidate.1);
800 if d < best.2 {
801 accepted = Some((candidate, d));
802 break;
803 }
804 scale *= 0.5;
805 }
806 let Some((next, d)) = accepted else {
807 break;
808 };
809 let step = (next.0 - x.0).hypot(next.1 - x.1);
810 x = next;
811 best = (x.0, x.1, d);
812 if step <= tol.parametric() {
813 break;
814 }
815 }
816
817 let point = surface.point_at(best.0, best.1, tol)?;
818 Ok(SurfaceProjection {
819 parameters: (best.0, best.1),
820 point,
821 distance: point.distance(target),
822 })
823}
824
825fn solve_2x2(j: [[f64; 2]; 2], r: [f64; 2]) -> Option<[f64; 2]> {
827 let (row0, row1, rhs0, rhs1) = if j[0][0].abs() >= j[1][0].abs() {
828 (j[0], j[1], r[0], r[1])
829 } else {
830 (j[1], j[0], r[1], r[0])
831 };
832 if row0[0].abs() <= f64::EPSILON * (row1[0].abs() + row0[1].abs()).max(1.0) {
833 return None;
834 }
835 let factor = row1[0] / row0[0];
836 let denom = factor.mul_add(-row0[1], row1[1]);
837 if denom.abs() <= f64::EPSILON * row0[1].abs().max(1.0) {
838 return None;
839 }
840 let d1 = factor.mul_add(-rhs0, rhs1) / denom;
841 let d0 = d1.mul_add(-row0[1], rhs0) / row0[0];
842 Some([d0, d1])
843}
844
845#[cfg(test)]
846#[allow(clippy::unwrap_used)]
847mod tests {
848 use super::*;
849 use crate::{CylinderSurface, OffsetSurface};
850 use ogeom_math::{Cylinder, Frame};
851
852 const T: Tolerances = Tolerances::millimetres();
853
854 #[test]
859 fn a_point_off_an_offset_drum_projects_to_its_foot() {
860 let basis: SurfaceGeometry =
861 CylinderSurface::new(Cylinder::new(Frame::WORLD, 1.5, T).unwrap(), (-0.5, 5.5))
862 .unwrap()
863 .into();
864 let offset = SurfaceGeometry::Offset(Box::new(OffsetSurface::new(basis, 0.5).unwrap()));
865 for (u, v) in [(0.1, 5.0), (1.3, 0.37), (3.0, 2.2), (5.9, 4.91)] {
866 let on = offset.point_at(u, v, T).unwrap();
867 let target = Point::new(on.x * 1.15, on.y * 1.15, on.z);
868 let found = project_on_surface(&offset, target, 24, T).unwrap();
869 assert!(
870 found.point.distance(on) < T.confusion(),
871 "({u}, {v}) projected to {:?}",
872 found.parameters
873 );
874 assert!((found.distance - 0.3).abs() < 1e-9, "{}", found.distance);
875 }
876 }
877
878 #[test]
886 fn closed_forms_are_no_farther_than_the_grid() {
887 use crate::{
888 ConeSurface, Curve, ExtrusionSurface, RevolutionSurface, TorusSurface, TrimmedSurface,
889 curve::BSplineCurve,
890 };
891 use ogeom_math::{Axis, Cone, Direction, KnotVector, Torus};
892 let tilted = Frame::new(
893 Point::new(0.3, -0.2, 0.1),
894 Direction::from_coords(0.2, 0.1, 1.0, T).unwrap(),
895 Direction::from_coords(1.0, 0.0, 0.0, T).unwrap(),
896 T,
897 )
898 .unwrap();
899 let spline = Curve::BSpline(
900 BSplineCurve::new(
901 KnotVector::clamped_uniform(3, 6).unwrap(),
902 vec![
903 Point::new(1.0, 0.0, -1.0),
904 Point::new(1.6, 0.4, -0.5),
905 Point::new(0.7, -0.3, 0.0),
906 Point::new(1.9, 0.2, 0.4),
907 Point::new(1.2, 0.5, 0.9),
908 Point::new(0.5, 0.1, 1.4),
909 ],
910 T,
911 )
912 .unwrap(),
913 );
914 let skew = Direction::from_coords(0.1, 0.3, 1.0, T).unwrap();
915 let z = Axis::new(
916 Point::ORIGIN,
917 Direction::from_coords(0.0, 0.0, 1.0, T).unwrap(),
918 );
919 let drum: SurfaceGeometry =
920 CylinderSurface::new(Cylinder::new(tilted, 1.0, T).unwrap(), (-1.0, 1.0))
921 .unwrap()
922 .into();
923 let surfaces: Vec<SurfaceGeometry> = vec![
924 ConeSurface::new(Cone::new(tilted, 0.4, 0.5, T).unwrap(), (-2.0, 1.5))
925 .unwrap()
926 .into(),
927 TorusSurface::new(Torus::new(tilted, 1.0, 1.3, T).unwrap()).into(),
928 SurfaceGeometry::Trimmed(Box::new(
929 TrimmedSurface::new(drum, (7.0, 9.5), (-0.8, 0.6), T).unwrap(),
930 )),
931 SurfaceGeometry::Extrusion(Box::new(
932 ExtrusionSurface::over(spline.clone(), skew, (-1.0, 1.5)).unwrap(),
933 )),
934 SurfaceGeometry::Revolution(Box::new(
935 RevolutionSurface::new(spline.clone(), z, core::f64::consts::TAU).unwrap(),
936 )),
937 SurfaceGeometry::Revolution(Box::new(RevolutionSurface::new(spline, z, 2.0).unwrap())),
938 ];
939 for surface in &surfaces {
940 let mut closed = 0;
941 let seeds = SurfaceSeeds::over(surface, 24, T).unwrap();
942 for i in 0..7 {
943 for j in 0..7 {
944 for k in 0..5 {
945 let target = Point::new(
946 f64::from(i).mul_add(0.55, -1.6),
947 f64::from(j).mul_add(0.5, -1.4),
948 f64::from(k).mul_add(0.7, -1.3),
949 );
950 let found = project_on_surface(surface, target, 24, T).unwrap();
951 let profile = Profile::of(surface, 24, T);
952 if closed_form(surface, target, profile.as_ref(), T).is_some() {
953 closed += 1;
954 }
955 let grid = grid_projection(surface, target, 24, T).unwrap();
956 assert!(
957 found.distance <= grid.distance + 1e-9,
958 "{:?} at {target:?}: {} against the grid's {}",
959 surface.kind(),
960 found.distance,
961 grid.distance
962 );
963 assert_eq!(seeds.project(surface, target, T).unwrap(), found);
964 }
965 }
966 }
967 assert!(closed > 0, "{:?} never took a closed form", surface.kind());
968 }
969 }
970
971 fn patch(
974 degree: (usize, usize),
975 knots: Option<(Vec<f64>, Vec<f64>)>,
976 (nu, nv): (usize, usize),
977 at: impl Fn(f64, f64) -> (Point, f64),
978 ) -> SurfaceGeometry {
979 use ogeom_math::{ControlGrid, KnotVector, Weighted};
980 let (ku, kv) = match knots {
981 Some((u, v)) => (
982 KnotVector::new(u, degree.0).unwrap(),
983 KnotVector::new(v, degree.1).unwrap(),
984 ),
985 None => (
986 KnotVector::clamped_uniform(degree.0, nu).unwrap(),
987 KnotVector::clamped_uniform(degree.1, nv).unwrap(),
988 ),
989 };
990 #[allow(clippy::cast_precision_loss)]
991 let net = (0..nu)
992 .flat_map(|i| {
993 (0..nv).map(move |j| (i as f64 / (nu - 1) as f64, j as f64 / (nv - 1) as f64))
994 })
995 .map(|(s, t)| {
996 let (p, w) = at(s, t);
997 Weighted::new(p, w, T).unwrap()
998 })
999 .collect();
1000 crate::BSplineSurface::rational(ku, kv, ControlGrid::new(net, nu, nv).unwrap())
1001 .unwrap()
1002 .into()
1003 }
1004
1005 fn brute_force(surface: &SurfaceGeometry, target: Point, per: usize) -> SurfaceProjection {
1008 let SurfaceGeometry::BSpline(spline) = surface else {
1009 unreachable!()
1010 };
1011 let lines = |k: &ogeom_math::KnotVector| -> Vec<f64> {
1012 let (a, b) = k.domain();
1013 let breaks: Vec<f64> = breaks(k)
1014 .into_iter()
1015 .filter(|x| *x >= a && *x <= b)
1016 .collect();
1017 let mut out: Vec<f64> = breaks
1018 .windows(2)
1019 .flat_map(|w| spread(w[0], w[1], per).into_iter().take(per))
1020 .collect();
1021 out.push(breaks[breaks.len() - 1]);
1022 out
1023 };
1024 let (us, vs) = (lines(spline.u_knots()), lines(spline.v_knots()));
1025 let mut best = (f64::INFINITY, (0.0, 0.0));
1026 for &u in &us {
1027 for &v in &vs {
1028 let d = surface.point_at(u, v, T).unwrap().distance(target);
1029 if d < best.0 {
1030 best = (d, (u, v));
1031 }
1032 }
1033 }
1034 let polished = refine_foot(surface, target, best.1, T).unwrap();
1035 assert!(polished.distance <= best.0);
1036 polished
1037 }
1038
1039 #[test]
1050 fn a_spline_foot_is_the_nearest_point_of_the_whole_patch() {
1051 let wavy = patch((3, 3), None, (43, 43), |s, t| {
1052 let (x, y) = (20.0 * s, 20.0 * t);
1053 (
1054 Point::new(x, y, 0.8 * (0.7 * x).sin() * (0.45 * y).cos()),
1055 1.0,
1056 )
1057 });
1058 let mut crowded_u = vec![0.0; 4];
1060 crowded_u.extend((1..20).map(|k| 0.97 + 0.03 * f64::from(k) / 20.0));
1061 crowded_u.extend([1.0; 4]);
1062 let crowded = patch(
1063 (3, 3),
1064 Some((crowded_u, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0])),
1065 (23, 4),
1066 |s, t| {
1067 let (x, y) = (10.0 * s, 4.0 * t);
1068 (Point::new(x, y, (1.3 * x).sin() + 0.2 * y * y), 1.0)
1069 },
1070 );
1071 let unclamped = patch(
1072 (2, 3),
1073 Some((
1074 (0..12).map(f64::from).collect(),
1075 (0..13).map(|k| 0.5 * f64::from(k)).collect(),
1076 )),
1077 (9, 9),
1078 |s, t| {
1079 let (x, y) = (6.0 * s, 6.0 * t);
1080 let w = 0.6 + 1.2 * (s * t + 0.3 * (5.0 * s).sin().abs());
1081 (Point::new(x, y, (x - 3.0) * (y - 3.0) / 4.0), w)
1082 },
1083 );
1084 let pole = patch((3, 2), None, (8, 9), |s, t| {
1085 let (angle, r) = (std::f64::consts::PI * 1.5 * t, 3.0 * s);
1086 (
1087 Point::new(r * angle.cos(), r * angle.sin(), 2.0 - 2.0 * s),
1088 1.0,
1089 )
1090 });
1091 let seam = patch((3, 3), None, (13, 6), |s, t| {
1092 let angle = std::f64::consts::TAU * s;
1093 let r = 2.0 + 0.3 * (3.0 * angle).sin();
1094 (Point::new(r * angle.cos(), r * angle.sin(), 4.0 * t), 1.0)
1095 });
1096 let bumps = patch((3, 3), None, (21, 9), |s, t| {
1097 let bump = |c: f64| (-((s - c) * (s - c)) / 0.004).exp();
1098 (Point::new(s, 0.4 * t, 0.3 * (bump(0.3) + bump(0.7))), 1.0)
1099 });
1100 let targets = |lo: Point, hi: Point| -> Vec<Point> {
1101 (0..27)
1102 .map(|k| {
1103 let f = |n: i32| f64::from((k / n) % 3) / 2.0;
1104 Point::new(
1105 lo.x + (hi.x - lo.x) * f(1),
1106 lo.y + (hi.y - lo.y) * f(3),
1107 lo.z + (hi.z - lo.z) * f(9),
1108 )
1109 })
1110 .collect()
1111 };
1112 let cases: Vec<(&str, SurfaceGeometry, Vec<Point>)> = vec![
1113 (
1114 "wavy",
1115 wavy,
1116 targets(Point::new(-3.0, -2.0, -4.0), Point::new(23.0, 22.0, 4.0)),
1117 ),
1118 (
1119 "crowded",
1120 crowded,
1121 targets(Point::new(-1.0, -1.0, -2.0), Point::new(11.0, 5.0, 3.0)),
1122 ),
1123 (
1124 "unclamped",
1125 unclamped,
1126 targets(Point::new(-1.0, -1.0, -3.0), Point::new(7.0, 7.0, 3.0)),
1127 ),
1128 (
1129 "pole",
1130 pole,
1131 targets(Point::new(-3.5, -3.5, -1.0), Point::new(3.5, 3.5, 3.0)),
1132 ),
1133 (
1134 "seam",
1135 seam,
1136 [
1137 targets(Point::new(1.0, -0.4, -1.0), Point::new(3.0, 0.4, 5.0)),
1138 targets(Point::new(-3.0, -3.0, 1.0), Point::new(3.0, 3.0, 3.0)),
1139 ]
1140 .concat(),
1141 ),
1142 (
1143 "bumps",
1144 bumps,
1145 targets(Point::new(0.4, -0.2, 0.5), Point::new(0.6, 0.6, 1.5)),
1146 ),
1147 ];
1148 for (name, surface, targets) in &cases {
1149 let seeds = SurfaceSeeds::over(surface, 16, T).unwrap();
1150 for &target in targets {
1151 let found = project_on_surface(surface, target, 16, T).unwrap();
1152 let reference = brute_force(surface, target, 8);
1153 assert!(
1154 found.distance <= reference.distance + T.confusion(),
1155 "{name} at {target:?}: {} at {:?}, the brute force {} at {:?}",
1156 found.distance,
1157 found.parameters,
1158 reference.distance,
1159 reference.parameters
1160 );
1161 let (u, v) = found.parameters;
1162 let on = surface.point_at(u, v, T).unwrap();
1163 assert!(on.distance(found.point) < 1e-12, "{name} at {target:?}");
1164 assert_eq!(seeds.project(surface, target, T).unwrap(), found, "{name}");
1165 }
1166 }
1167 }
1168
1169 #[test]
1174 fn a_tie_between_two_feet_resolves_the_same_way_every_time() {
1175 let bumps = patch((3, 3), None, (21, 9), |s, t| {
1176 let bump = |c: f64| (-((s - c) * (s - c)) / 0.004).exp();
1177 (Point::new(s, 0.4 * t, 0.3 * (bump(0.3) + bump(0.7))), 1.0)
1178 });
1179 let target = Point::new(0.5, 0.2, 0.9);
1180 let first = project_on_surface(&bumps, target, 16, T).unwrap();
1181 let reference = brute_force(&bumps, target, 8);
1182 assert!(first.distance <= reference.distance + T.confusion());
1183 let u = first.parameters.0;
1184 assert!((u - 0.5).abs() > 0.1, "landed between the bumps at {u}");
1185 let seeds = SurfaceSeeds::over(&bumps, 16, T).unwrap();
1186 for _ in 0..3 {
1187 assert_eq!(project_on_surface(&bumps, target, 16, T).unwrap(), first);
1188 assert_eq!(seeds.project(&bumps, target, T).unwrap(), first);
1189 }
1190 }
1191
1192 #[test]
1200 fn a_foot_on_a_thin_drum_converges_along_the_surface() {
1201 let drum: SurfaceGeometry =
1202 CylinderSurface::new(Cylinder::new(Frame::WORLD, 0.16, T).unwrap(), (-1.0, 1.0))
1203 .unwrap()
1204 .into();
1205 for (u, v) in [(0.5, 0.0), (3.0, 0.4), (6.0, -0.7)] {
1206 let target = drum.point_at(u, v, T).unwrap();
1207 let found = project_on_surface_from(&drum, target, (u + 2e-6, v), T).unwrap();
1208 assert!(
1209 found.distance <= T.confusion(),
1210 "({u}, {v}) stopped {:.2e} off at {:?}",
1211 found.distance,
1212 found.parameters
1213 );
1214 }
1215 }
1216}