1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
26use ogeom_math::{Axis, Cone, Cylinder, Direction, Frame, Plane, Point, Sphere, Torus, Vector};
27
28#[derive(Debug, Clone, PartialEq)]
30pub enum Canonical {
31 Plane(Plane),
33 Cylinder(Cylinder),
35 Cone(Cone),
37 Sphere(Sphere),
39 Torus(Torus),
41 Swept(Box<SweptShape>),
45}
46
47#[derive(Debug, Clone, PartialEq)]
49pub struct SweptShape {
50 pub surface: ogeom_geom::SurfaceGeometry,
54 pub tol: Tolerances,
56 profile: Vec<(f64, Point)>,
59 grid: Vec<((f64, f64), Point)>,
62 blocks: Vec<(Point, f64, Vec<usize>)>,
67}
68
69const PROFILE_SAMPLES: usize = 256;
71
72const GRID_SAMPLES: usize = 40;
75
76const GRID_SEEDS: usize = 3;
78
79const GRID_BLOCK: usize = 5;
81
82impl SweptShape {
83 #[must_use]
86 pub fn new(surface: ogeom_geom::SurfaceGeometry, tol: Tolerances) -> Self {
87 use ogeom_geom::{Curve3d as _, Surface as _, SurfaceGeometry as S};
88 let curve = match &surface {
89 S::Revolution(r) => Some(r.curve().clone()),
90 S::Extrusion(e) => Some(e.curve().clone()),
91 _ => None,
92 };
93 let profile = curve
94 .map(|c| {
95 let (lo, hi) = c.domain();
96 (0..=PROFILE_SAMPLES)
97 .filter_map(|k| {
98 #[allow(clippy::cast_precision_loss, reason = "a sample index")]
99 let t = lo + (hi - lo) * (k as f64) / (PROFILE_SAMPLES as f64);
100 c.point_at(t, tol).ok().map(|p| (t, p))
101 })
102 .collect()
103 })
104 .unwrap_or_default();
105 let grid = if matches!(surface, S::BSpline(_)) {
106 let ((u0, u1), (v0, v1)) = surface.domain();
107 #[allow(clippy::cast_precision_loss, reason = "a sample index")]
108 let at = |k: usize, lo: f64, hi: f64| {
109 lo + (hi - lo) * (k as f64 + 0.5) / (GRID_SAMPLES as f64)
110 };
111 (0..GRID_SAMPLES)
112 .flat_map(|i| (0..GRID_SAMPLES).map(move |j| (i, j)))
113 .filter_map(|(i, j)| {
114 let (u, v) = (at(i, u0, u1), at(j, v0, v1));
115 surface.point_at(u, v, tol).ok().map(|p| ((u, v), p))
116 })
117 .collect()
118 } else {
119 Vec::new()
120 };
121 let mut blocks = Vec::new();
122 if grid.len() == GRID_SAMPLES * GRID_SAMPLES {
123 for bi in (0..GRID_SAMPLES).step_by(GRID_BLOCK) {
124 for bj in (0..GRID_SAMPLES).step_by(GRID_BLOCK) {
125 let members: Vec<usize> = (bi..bi + GRID_BLOCK)
126 .flat_map(|i| (bj..bj + GRID_BLOCK).map(move |j| i * GRID_SAMPLES + j))
127 .collect();
128 let mut sum = Vector::ZERO;
129 for &k in &members {
130 sum += grid[k].1.to_vector();
131 }
132 #[allow(clippy::cast_precision_loss, reason = "a block's sample count")]
133 let centre = Point::from_vector(sum / members.len() as f64);
134 let radius = members
135 .iter()
136 .map(|&k| grid[k].1.distance(centre))
137 .fold(0.0, f64::max);
138 blocks.push((centre, radius, members));
139 }
140 }
141 }
142 Self {
143 surface,
144 tol,
145 profile,
146 grid,
147 blocks,
148 }
149 }
150
151 fn on_profile(&self, curve: &ogeom_geom::Curve, p: Point) -> Option<(f64, f64)> {
156 use ogeom_geom::Curve3d as _;
157 let near = |range: core::ops::Range<usize>, step: usize| {
159 range.step_by(step).min_by(|&a, &b| {
160 self.profile[a]
161 .1
162 .distance(p)
163 .total_cmp(&self.profile[b].1.distance(p))
164 })
165 };
166 let coarse = near(0..self.profile.len(), 16)?;
167 let i = near(
168 coarse.saturating_sub(16)..(coarse + 17).min(self.profile.len()),
169 1,
170 )?;
171 let lo = self.profile[i.saturating_sub(1)].0;
172 let hi = self.profile[(i + 1).min(self.profile.len() - 1)].0;
173 let mut t = self.profile[i].0;
174 for _ in 0..12 {
175 let d = curve.derivatives_at(t, 2, self.tol).ok()?;
176 let (c, c1, c2) = (Point::from_vector(d[0]), d[1], d[2]);
177 let w = c - p;
178 let slope = w.dot(c1);
179 let curvature = c1.dot(c1) + w.dot(c2);
180 if curvature <= 0.0 {
181 break;
182 }
183 let next = (t - slope / curvature).clamp(lo, hi);
184 let moved = (next - t).abs();
185 t = next;
186 if moved <= self.tol.parametric() * (hi - lo).abs().max(1.0) * 1e-3 {
187 break;
188 }
189 }
190 Some((t, curve.point_at(t, self.tol).ok()?.distance(p)))
191 }
192
193 #[must_use]
196 pub fn foot(&self, p: Point) -> Option<crate::measure::SurfaceProjection> {
197 use ogeom_geom::{Curve3d as _, Surface as _, SurfaceGeometry as S};
198 match &self.surface {
199 S::Revolution(r) => {
203 let axis = r.axis();
204 let a = axis.direction.vector();
205 let radial = |q: Point| {
206 let w = q - axis.location;
207 w - a * w.dot(a)
208 };
209 let (lo, _) = r.curve().domain();
210 let reference = radial(r.curve().point_at(lo, self.tol).ok()?);
211 let own = radial(p);
212 let mut u = reference.cross(own).dot(a).atan2(reference.dot(own));
213 if u < 0.0 {
214 u += core::f64::consts::TAU;
215 }
216 let back = ogeom_math::Transform::rotation(axis, -u).apply(p);
217 let (v, distance) = self.on_profile(r.curve(), back)?;
218 let point = self.surface.point_at(u, v, self.tol).ok()?;
219 Some(crate::measure::SurfaceProjection {
220 parameters: (u, v),
221 point,
222 distance,
223 })
224 }
225 S::Extrusion(e) => {
228 let d = e.direction().vector();
229 let (lo, _) = e.curve().domain();
230 let base = e.curve().point_at(lo, self.tol).ok()?;
231 let v = (p - base).dot(d);
232 let shadow = p - d * v;
233 let (u, _) = self.on_profile(e.curve(), shadow)?;
234 let point = e.curve().point_at(u, self.tol).ok()? + d * v;
235 Some(crate::measure::SurfaceProjection {
236 parameters: (u, v),
237 point,
238 distance: point.distance(p),
239 })
240 }
241 S::BSpline(_) if !self.grid.is_empty() => {
248 let mut nearest = [(f64::INFINITY, (0.0, 0.0)); GRID_SEEDS];
249 let mut near_blocks: Vec<(f64, usize)> = self
250 .blocks
251 .iter()
252 .enumerate()
253 .map(|(b, (centre, radius, _))| {
254 ((p.distance(*centre) - radius).max(0.0).powi(2), b)
255 })
256 .collect();
257 near_blocks.sort_by(|a, b| a.0.total_cmp(&b.0));
258 for (bound, b) in near_blocks {
259 if bound > nearest[GRID_SEEDS - 1].0 {
260 break;
261 }
262 for &k in &self.blocks[b].2 {
263 let (at, q) = self.grid[k];
264 let d = q.square_distance(p);
265 if d < nearest[GRID_SEEDS - 1].0 {
266 nearest[GRID_SEEDS - 1] = (d, at);
267 nearest.sort_by(|a, b| a.0.total_cmp(&b.0));
268 }
269 }
270 }
271 nearest
272 .iter()
273 .filter(|(d, _)| d.is_finite())
274 .filter_map(|(_, start)| {
275 crate::measure::project_on_surface_from(&self.surface, p, *start, self.tol)
276 .ok()
277 })
278 .min_by(|a, b| a.distance.total_cmp(&b.distance))
279 }
280 other => crate::measure::project_on_surface(other, p, 24, self.tol).ok(),
281 }
282 }
283}
284
285impl Canonical {
286 pub(crate) fn resolves(&self, distance: f64) -> bool {
292 let far = |frame: ogeom_math::Frame| frame.origin().to_vector().magnitude();
293 let reach = match self {
294 Self::Plane(_) | Self::Swept(_) => return true,
295 Self::Cylinder(c) => far(c.frame()) + c.radius(),
296 Self::Cone(c) => far(c.frame()) + c.reference_radius(),
297 Self::Sphere(s) => s.centre().to_vector().magnitude() + s.radius(),
298 Self::Torus(t) => far(t.frame()) + t.major_radius() + t.minor_radius(),
299 };
300 reach * 16.0 * f64::EPSILON <= distance
301 }
302
303 #[must_use]
305 pub fn distance_to(&self, p: Point) -> f64 {
306 match self {
307 Self::Plane(plane) => plane.signed_distance_to(p).abs(),
308 Self::Cylinder(c) => c.distance_to(p),
309 Self::Cone(c) => c.distance_to(p),
310 Self::Sphere(s) => (p.distance(s.centre()) - s.radius()).abs(),
311 Self::Torus(t) => t.distance_to(p),
312 Self::Swept(s) => s.foot(p).map_or(f64::INFINITY, |f| f.distance),
313 }
314 }
315
316 #[must_use]
322 pub fn signed_distance_to(&self, p: Point) -> f64 {
323 let radial = |frame: ogeom_math::Frame| {
324 let w = p - frame.origin();
325 let h = w.dot(frame.z().vector());
326 ((w - frame.z().vector() * h).magnitude(), h)
327 };
328 match self {
329 Self::Plane(plane) => plane.signed_distance_to(p),
330 Self::Cylinder(c) => radial(c.frame()).0 - c.radius(),
331 Self::Cone(c) => {
332 let (r, h) = radial(c.frame());
333 let (sin, cos) = c.half_angle().sin_cos();
334 (r - c.reference_radius()).mul_add(cos, -(h * sin))
337 }
338 Self::Sphere(s) => p.distance(s.centre()) - s.radius(),
339 Self::Torus(t) => {
340 let (r, h) = radial(t.frame());
341 (r - t.major_radius()).hypot(h) - t.minor_radius()
342 }
343 Self::Swept(s) => {
344 use ogeom_geom::Surface as _;
345 let Some(foot) = s.foot(p) else {
346 return f64::INFINITY;
347 };
348 let (u, v) = foot.parameters;
349 match s.surface.normal_at(u, v, s.tol) {
350 Ok(n) if (p - foot.point).dot(n.vector()) < 0.0 => -foot.distance,
351 _ => foot.distance,
352 }
353 }
354 }
355 }
356}
357
358#[derive(Debug, Clone, PartialEq)]
360pub struct Recognized {
361 pub surface: Canonical,
363 pub deviation: f64,
365}
366
367pub fn recognize_points(
382 points: &[Point],
383 normals: &[Vector],
384 tolerance: f64,
385 tol: Tolerances,
386) -> OgeomResult<Option<Recognized>> {
387 if points.len() < 3 || points.len() != normals.len() {
388 ogeom_bail!(
389 Construction,
390 "recognition needs at least three samples with matching normals"
391 );
392 }
393 if !tolerance.is_finite() || tolerance <= 0.0 {
394 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
395 }
396 if let Some(plane) = fit_plane(points, tol) {
397 let deviation = worst_deviation(&plane, points);
398 if deviation <= tolerance {
399 return Ok(Some(Recognized {
400 surface: plane,
401 deviation,
402 }));
403 }
404 }
405 Ok(recognize_curved(points, normals, &[], tolerance, tol))
406}
407
408pub(crate) fn is_flat(points: &[Point], tolerance: f64, tol: Tolerances) -> bool {
410 fit_plane(points, tol).is_some_and(|plane| worst_deviation(&plane, points) <= tolerance)
411}
412
413pub(crate) fn recognize_curved(
421 points: &[Point],
422 normals: &[Vector],
423 chords: &[(Point, Point)],
424 tolerance: f64,
425 tol: Tolerances,
426) -> Option<Recognized> {
427 choose(
428 &curved_fits(points, normals, tolerance, tol),
429 chords,
430 tolerance,
431 )
432}
433
434pub(crate) const TORUS_SAMPLES: usize = 11;
437
438fn curved_fits(
443 points: &[Point],
444 normals: &[Vector],
445 hopeless: f64,
446 tol: Tolerances,
447) -> Vec<Recognized> {
448 const FIT_SAMPLES: usize = 1500;
449 let stride = points.len().div_ceil(FIT_SAMPLES).max(1);
450 let (sub_points, sub_normals): (Vec<Point>, Vec<Vector>) = points
451 .iter()
452 .zip(normals)
453 .step_by(stride)
454 .map(|(p, n)| (*p, *n))
455 .unzip();
456 let n = points.len();
457 type Fit = fn(&[Point], &[Vector], Tolerances) -> Option<Canonical>;
458 let attempts: [(usize, Fit); 4] = [
462 (6, |p, _, tol| fit_sphere(p, tol)),
463 (8, fit_cylinder),
464 (9, fit_cone),
465 (TORUS_SAMPLES, fit_torus),
466 ];
467 let mut fits = Vec::with_capacity(4);
468 for (floor, fit) in attempts {
469 if n < floor {
470 continue;
471 }
472 let Some(seed) = fit(&sub_points, &sub_normals, tol) else {
473 continue;
474 };
475 let refined = refine(seed.clone(), &sub_points, hopeless, tol).unwrap_or(seed);
476 let deviation = worst_deviation(&refined, points);
477 fits.push(Recognized {
478 surface: refined,
479 deviation,
480 });
481 }
482 let degenerate: Vec<Recognized> = fits
489 .iter()
490 .filter_map(|f| {
491 let Canonical::Torus(t) = &f.surface else {
492 return None;
493 };
494 if f.deviation > hopeless {
495 return None;
496 }
497 let seed = cylinder_in_torus(t, &sub_points, tol)?;
498 let refined = refine(seed.clone(), &sub_points, hopeless, tol).unwrap_or(seed);
499 let deviation = worst_deviation(&refined, points);
500 (deviation <= tie(f.deviation, hopeless)).then_some(Recognized {
501 surface: refined,
502 deviation,
503 })
504 })
505 .collect();
506 for found in degenerate {
507 match fits
508 .iter_mut()
509 .find(|f| matches!(f.surface, Canonical::Cylinder(_)))
510 {
511 Some(cylinder) if cylinder.deviation <= found.deviation => {}
512 Some(cylinder) => *cylinder = found,
513 None => fits.push(found),
514 }
515 }
516 if let Some(band) = two_row_band(&fits, points, hopeless, tol) {
520 fits.retain(|f| !matches!(f.surface, Canonical::Sphere(_) | Canonical::Torus(_)));
521 fits.push(band);
522 }
523 fits
524}
525
526fn tie(best: f64, tolerance: f64) -> f64 {
529 (2.0 * best).max(tolerance * 1e-3)
530}
531
532fn cylinder_in_torus(torus: &Torus, points: &[Point], tol: Tolerances) -> Option<Canonical> {
538 let first = *points.first()?;
539 let span = points
540 .iter()
541 .map(|p| p.distance(first))
542 .fold(0.0_f64, f64::max);
543 if span <= tol.confusion() {
544 return None;
545 }
546 let frame = torus.frame();
547 let (o, z) = (frame.origin(), frame.z().vector());
548 #[allow(
549 clippy::cast_precision_loss,
550 reason = "sample counts are far below 2^52"
551 )]
552 let count = points.len() as f64;
553 if torus.minor_radius() >= span * 10.0 {
554 let radius = points
555 .iter()
556 .map(|p| {
557 let r = *p - o;
558 (r - z * r.dot(z)).magnitude()
559 })
560 .sum::<f64>()
561 / count;
562 return Some(Canonical::Cylinder(Cylinder::new(frame, radius, tol).ok()?));
563 }
564 if torus.major_radius() >= span * 10.0 {
565 let (x, y) = (frame.x().vector(), frame.y().vector());
566 let (s, c) = points.iter().fold((0.0, 0.0), |(s, c), p| {
567 let r = *p - o;
568 let a = r.dot(y).atan2(r.dot(x));
569 (s + a.sin(), c + a.cos())
570 });
571 let angle = s.atan2(c);
572 let radial = x * angle.cos() + y * angle.sin();
573 let spine = o + radial * torus.major_radius();
574 let along = Direction::new(z.cross(radial), tol).ok()?;
575 return Some(Canonical::Cylinder(
576 Cylinder::new(Frame::about(spine, along), torus.minor_radius(), tol).ok()?,
577 ));
578 }
579 None
580}
581
582fn choose(fits: &[Recognized], chords: &[(Point, Point)], tolerance: f64) -> Option<Recognized> {
597 let tie = |best: f64| tie(best, tolerance);
598 let within: Vec<&Recognized> = fits
599 .iter()
600 .filter(|f| f.deviation <= tolerance && f.surface.resolves(tolerance))
601 .collect();
602 let closest = within
603 .iter()
604 .map(|f| f.deviation)
605 .fold(f64::INFINITY, f64::min);
606 let within: Vec<&Recognized> = within
607 .into_iter()
608 .filter(|f| {
609 !matches!(f.surface, Canonical::Sphere(_) | Canonical::Torus(_))
610 || f.deviation <= tie(closest)
611 })
612 .collect();
613 let on = |f: &Recognized| {
614 chords
615 .iter()
616 .filter(|(a, b)| {
617 let mid = Point::from_vector((a.to_vector() + b.to_vector()) * 0.5);
618 f.surface.distance_to(mid) <= tolerance
619 })
620 .count()
621 };
622 let most = within.iter().map(|f| on(f)).max()?;
623 let within: Vec<&Recognized> = within.into_iter().filter(|f| on(f) == most).collect();
624 let best = within
625 .iter()
626 .map(|f| f.deviation)
627 .fold(f64::INFINITY, f64::min);
628 let rank = |f: &Recognized| match f.surface {
633 Canonical::Cylinder(_) => 0,
634 Canonical::Cone(_) => 1,
635 Canonical::Sphere(_) => 2,
636 _ => 3,
637 };
638 let mut ranked = within;
639 ranked.sort_by_key(|f| rank(f));
640 ranked
641 .into_iter()
642 .find(|f| f.deviation <= tie(best))
643 .cloned()
644}
645
646pub(crate) fn recognize_trimmed(
653 points: &[Point],
654 normals: &[Vector],
655 chords: &[(Point, Point)],
656 tolerance: f64,
657 tol: Tolerances,
658) -> Result<(Recognized, Vec<bool>), f64> {
659 let mut keep = vec![true; points.len()];
660 let mut closest = f64::INFINITY;
661 for _ in 0..3 {
662 let (kept_points, kept_normals): (Vec<Point>, Vec<Vector>) = points
663 .iter()
664 .zip(normals)
665 .zip(&keep)
666 .filter(|(_, k)| **k)
667 .map(|((p, n), _)| (*p, *n))
668 .unzip();
669 if kept_points.len() < 8 {
670 break;
671 }
672 let fits = curved_fits(&kept_points, &kept_normals, tolerance, tol);
673 if let Some(found) = choose(&fits, chords, tolerance) {
674 return Ok((found, keep));
675 }
676 let Some(candidate) = fits
677 .iter()
678 .min_by(|a, b| a.deviation.total_cmp(&b.deviation))
679 else {
680 break;
681 };
682 closest = closest.min(candidate.deviation);
683 let mut misses: Vec<f64> = kept_points
684 .iter()
685 .map(|p| candidate.surface.distance_to(*p))
686 .collect();
687 misses.sort_by(f64::total_cmp);
688 if misses[misses.len() / 2] > tolerance * 10.0 {
691 break;
692 }
693 let bound = misses[misses.len() * 9 / 10].max(tolerance);
694 let before = keep.iter().filter(|k| **k).count();
695 for (k, p) in keep.iter_mut().zip(points) {
696 *k = *k && candidate.surface.distance_to(*p) <= bound;
697 }
698 if keep.iter().filter(|k| **k).count() == before {
699 break;
700 }
701 }
702 Err(closest)
703}
704
705fn two_row_band(
715 fits: &[Recognized],
716 points: &[Point],
717 hopeless: f64,
718 tol: Tolerances,
719) -> Option<Recognized> {
720 let round = fits.iter().any(|f| {
721 matches!(f.surface, Canonical::Sphere(_) | Canonical::Torus(_)) && f.deviation <= hopeless
722 });
723 if !round {
724 return None;
725 }
726 let rows = match fits.iter().find_map(|f| match f.surface {
727 Canonical::Torus(t) if f.deviation <= hopeless => Some(t),
728 _ => None,
729 }) {
730 Some(torus) => rows_by_tube(&torus, points, hopeless)?,
731 None => rows_by_planes(points, hopeless)?,
732 };
733 let stray = points.len() - rows[0].len() - rows[1].len();
734 if stray >= 3_usize.max(points.len() / 10) {
735 return None;
736 }
737 let rim = |row: &[usize]| -> Option<(Point, Direction)> {
738 if row.len() < 3 {
739 return None;
740 }
741 let at: Vec<Point> = row.iter().map(|&i| points[i]).collect();
742 let (centre, normal, _, _) = circle_through(&at, tol)?;
743 Some((centre, normal))
744 };
745 let (centre, a) = rim(&rows[0])?;
746 let (_, b) = rim(&rows[1])?;
747 let b = if a.vector().dot(b.vector()) < 0.0 {
748 -b
749 } else {
750 b
751 };
752 let axis = Direction::new(a.vector() + b.vector(), tol).ok()?;
753 let on_rows: Vec<Point> = rows[..2].iter().flatten().map(|&i| points[i]).collect();
754 let seed = ruled_about(&on_rows, centre, axis, hopeless, tol)?;
755 let band = refine(seed.clone(), &on_rows, hopeless, tol).unwrap_or(seed);
756 let deviation = worst_deviation(&band, points);
757 Some(Recognized {
758 surface: band,
759 deviation,
760 })
761}
762
763fn rows_by_tube(torus: &Torus, points: &[Point], hopeless: f64) -> Option<[Vec<usize>; 2]> {
766 let frame = torus.frame();
767 let (z, x, y) = (frame.z().vector(), frame.x().vector(), frame.y().vector());
768 let mut angles: Vec<(f64, usize)> = points
769 .iter()
770 .enumerate()
771 .map(|(i, p)| {
772 let d = *p - frame.origin();
773 let radial = d.dot(x).hypot(d.dot(y)) - torus.major_radius();
774 (d.dot(z).atan2(radial), i)
775 })
776 .collect();
777 angles.sort_by(|a, b| a.0.total_cmp(&b.0));
778 let gap = hopeless * 10.0 / torus.minor_radius();
779 let mut rows: Vec<Vec<usize>> = vec![vec![angles.first()?.1]];
780 for w in angles.windows(2) {
781 if w[1].0 - w[0].0 > gap {
782 rows.push(vec![w[1].1]);
783 } else if let Some(last) = rows.last_mut() {
784 last.push(w[1].1);
785 }
786 }
787 rows.sort_by_key(|r| core::cmp::Reverse(r.len()));
788 if rows.len() < 2 {
789 return None;
790 }
791 let second = rows.swap_remove(1);
792 let first = rows.swap_remove(0);
793 Some([first, second])
794}
795
796fn rows_by_planes(points: &[Point], hopeless: f64) -> Option<[Vec<usize>; 2]> {
803 const EVERY: usize = 24;
804 const TRIES: usize = 400;
805 let n = points.len();
806 if n < 6 {
807 return None;
808 }
809 let triples: Vec<(usize, usize, usize)> = if n <= EVERY {
810 (0..n)
811 .flat_map(|i| (i + 1..n).flat_map(move |j| (j + 1..n).map(move |k| (i, j, k))))
812 .collect()
813 } else {
814 let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
816 let mut next = || {
817 state = state
818 .wrapping_mul(6_364_136_223_846_793_005)
819 .wrapping_add(1_442_695_040_888_963_407);
820 #[allow(clippy::cast_possible_truncation)]
821 let at = (state >> 33) as usize % n;
822 at
823 };
824 (0..TRIES).map(|_| (next(), next(), next())).collect()
825 };
826 let within = |normal: Vector, at: Point, skip: &[usize]| -> Vec<usize> {
827 (0..n)
828 .filter(|i| !skip.contains(i) && (points[*i] - at).dot(normal).abs() <= hopeless)
829 .collect()
830 };
831 let mut best: Option<(Vec<usize>, Vector)> = None;
832 for (i, j, k) in triples {
833 let normal = (points[j] - points[i]).cross(points[k] - points[i]);
834 let m = normal.magnitude();
835 if m <= f64::MIN_POSITIVE {
836 continue;
837 }
838 let normal = normal / m;
839 let row = within(normal, points[i], &[]);
840 if best.as_ref().is_none_or(|(held, _)| row.len() > held.len()) {
841 best = Some((row, normal));
842 }
843 }
844 let (first, normal) = best?;
845 let second = (0..n)
848 .filter(|i| !first.contains(i))
849 .map(|i| within(normal, points[i], &first))
850 .max_by_key(Vec::len)?;
851 (first.len() >= 3 && second.len() >= 3).then_some([first, second])
852}
853
854pub(crate) fn worst_deviation(candidate: &Canonical, points: &[Point]) -> f64 {
856 points
857 .iter()
858 .map(|p| candidate.distance_to(*p))
859 .fold(0.0, f64::max)
860}
861
862pub(crate) fn centroid(points: &[Point]) -> Vector {
863 let mut sum = Vector::ZERO;
864 for p in points {
865 sum += p.to_vector();
866 }
867 #[allow(
868 clippy::cast_precision_loss,
869 reason = "sample counts are far below 2^52"
870 )]
871 let n = points.len() as f64;
872 sum / n
873}
874
875pub(crate) fn smallest_direction(m: nalgebra::Matrix3<f64>) -> Vector {
877 let eigen = nalgebra::SymmetricEigen::new(m);
878 let mut best = 0;
879 for i in 1..3 {
880 if eigen.eigenvalues[i] < eigen.eigenvalues[best] {
881 best = i;
882 }
883 }
884 let v = eigen.eigenvectors.column(best);
885 Vector::new(v[0], v[1], v[2])
886}
887
888pub(crate) fn covariance(vectors: impl Iterator<Item = Vector>) -> nalgebra::Matrix3<f64> {
889 let mut m = nalgebra::Matrix3::zeros();
890 for v in vectors {
891 let n = nalgebra::Vector3::new(v.x, v.y, v.z);
892 m += n * n.transpose();
893 }
894 m
895}
896
897fn fit_plane(points: &[Point], tol: Tolerances) -> Option<Canonical> {
898 let c = centroid(points);
899 let m = covariance(points.iter().map(|p| p.to_vector() - c));
900 let normal = Direction::new(smallest_direction(m), tol).ok()?;
901 Some(Canonical::Plane(Plane::new(Frame::about(
902 Point::from_vector(c),
903 normal,
904 ))))
905}
906
907pub(crate) fn fit_sphere(points: &[Point], tol: Tolerances) -> Option<Canonical> {
908 let reference = *points.first()?;
912 let mut a = nalgebra::Matrix4::zeros();
913 let mut b = nalgebra::Vector4::zeros();
914 for p in points {
915 let p = *p - reference;
916 let row = nalgebra::Vector4::new(-2.0 * p.x, -2.0 * p.y, -2.0 * p.z, 1.0);
917 let rhs = -p.dot(p);
918 a += row * row.transpose();
919 b += row * rhs;
920 }
921 let solved = a.lu().solve(&b)?;
922 let offset = Vector::new(solved[0], solved[1], solved[2]);
923 let centre = reference + offset;
924 let r2 = offset.dot(offset) - solved[3];
925 if r2 <= tol.confusion() {
926 return None;
927 }
928 Some(Canonical::Sphere(
929 Sphere::centred(centre, r2.sqrt(), tol).ok()?,
930 ))
931}
932
933pub(crate) fn revolution_axis(
941 points: &[Point],
942 normals: &[Vector],
943 tol: Tolerances,
944) -> Option<(Point, Direction)> {
945 let mut weights = vec![1.0; points.len()];
951 let mut axis = None;
952 for _ in 0..8 {
953 let found = weighted_axis(points, normals, &weights, tol)?;
954 let (through, direction) = found;
955 axis = Some(found);
956 let a = direction.vector();
957 let misses: Vec<f64> = points
958 .iter()
959 .zip(normals)
960 .map(|(p, n)| {
961 let w = *p - through;
962 let across = a.cross(*n);
963 let m = across.magnitude();
964 if m > 1e-9 {
965 w.dot(across).abs() / m
966 } else {
967 w.cross(a).magnitude()
968 }
969 })
970 .collect();
971 let mut sorted = misses.clone();
972 sorted.sort_by(f64::total_cmp);
973 let typical = sorted[sorted.len() / 2].max(tol.confusion());
974 for (w, miss) in weights.iter_mut().zip(&misses) {
975 let r = miss / (2.0 * typical);
976 *w = 1.0 / r.mul_add(r, 1.0);
977 }
978 }
979 axis
980}
981
982fn weighted_axis(
983 points: &[Point],
984 normals: &[Vector],
985 weights: &[f64],
986 tol: Tolerances,
987) -> Option<(Point, Direction)> {
988 let reference = *points.first()?;
998 let mut dd = nalgebra::Matrix3::<f64>::zeros();
999 let mut dm = nalgebra::Matrix3::<f64>::zeros();
1000 let mut mm = nalgebra::Matrix3::<f64>::zeros();
1001 for ((p, n), w) in points.iter().zip(normals).zip(weights) {
1002 let moment = (*p - reference).cross(*n);
1003 let a = nalgebra::Vector3::new(moment.x, moment.y, moment.z);
1004 let b = nalgebra::Vector3::new(n.x, n.y, n.z);
1005 dd += a * a.transpose() * *w;
1006 dm += a * b.transpose() * *w;
1007 mm += b * b.transpose() * *w;
1008 }
1009 let scale = mm.norm().max(f64::MIN_POSITIVE);
1010 let pinv = mm.pseudo_inverse(scale * 1e-9).ok()?;
1011 let schur = dd - dm * pinv * dm.transpose();
1012 let schur = (schur + schur.transpose()) * 0.5;
1013 let d = smallest_direction(schur);
1014 let dv = nalgebra::Vector3::new(d.x, d.y, d.z);
1015 let mv = -(pinv * dm.transpose() * dv);
1016 let moment = Vector::new(mv[0], mv[1], mv[2]);
1017 let length = d.magnitude();
1018 if length <= f64::MIN_POSITIVE {
1019 return None;
1020 }
1021 let d = d / length;
1022 let moment = (moment - d * d.dot(moment)) / length;
1023 let through = reference + d.cross(moment);
1024 Some((through, Direction::new(d, tol).ok()?))
1025}
1026
1027fn profile(points: &[Point], through: Point, axis: Direction) -> Vec<(f64, f64)> {
1030 let a = axis.vector();
1031 points
1032 .iter()
1033 .map(|p| {
1034 let w = *p - through;
1035 let h = w.dot(a);
1036 ((w - a * h).magnitude(), h)
1037 })
1038 .collect()
1039}
1040
1041fn centred_on(points: &[Point], through: Point, axis: Direction) -> Point {
1044 let c = Point::from_vector(centroid(points));
1045 through + axis.vector() * (c - through).dot(axis.vector())
1046}
1047
1048pub(crate) fn fit_cylinder(
1049 points: &[Point],
1050 normals: &[Vector],
1051 tol: Tolerances,
1052) -> Option<Canonical> {
1053 let (through, axis) = revolution_axis(points, normals, tol)?;
1054 let rows = profile(points, through, axis);
1055 #[allow(
1056 clippy::cast_precision_loss,
1057 reason = "sample counts are far below 2^52"
1058 )]
1059 let radius = rows.iter().map(|(rho, _)| rho).sum::<f64>() / rows.len() as f64;
1060 Some(Canonical::Cylinder(
1061 Cylinder::new(
1062 Frame::about(centred_on(points, through, axis), axis),
1063 radius,
1064 tol,
1065 )
1066 .ok()?,
1067 ))
1068}
1069
1070pub(crate) fn fit_cone(points: &[Point], normals: &[Vector], tol: Tolerances) -> Option<Canonical> {
1071 let (through, axis) = revolution_axis(points, normals, tol)?;
1072 match ruled_about(points, through, axis, 0.0, tol)? {
1073 cone @ Canonical::Cone(_) => Some(cone),
1074 _ => None,
1075 }
1076}
1077
1078pub(crate) fn ruled_about(
1083 points: &[Point],
1084 through: Point,
1085 axis: Direction,
1086 flat: f64,
1087 tol: Tolerances,
1088) -> Option<Canonical> {
1089 let origin = centred_on(points, through, axis);
1090 let (mut sh, mut shh, mut sr, mut shr, mut count) = (0.0, 0.0, 0.0, 0.0, 0.0);
1092 for (rho, h) in profile(points, origin, axis) {
1093 sh += h;
1094 shh += h * h;
1095 sr += rho;
1096 shr += h * rho;
1097 count += 1.0;
1098 }
1099 let det = f64::mul_add(count, shh, -(sh * sh));
1100 if det.abs() <= f64::MIN_POSITIVE {
1101 return None;
1102 }
1103 let k = f64::mul_add(count, shr, -(sh * sr)) / det;
1104 let rho0 = f64::mul_add(shh, sr, -(sh * shr)) / det;
1105 let heights: Vec<f64> = profile(points, origin, axis)
1106 .into_iter()
1107 .map(|(_, h)| h)
1108 .collect();
1109 let span = heights.iter().copied().fold(f64::NEG_INFINITY, f64::max)
1110 - heights.iter().copied().fold(f64::INFINITY, f64::min);
1111 if k.abs() <= tol.angular() || k.abs() * span <= flat {
1112 let radius = sr / count;
1114 return Some(Canonical::Cylinder(
1115 Cylinder::new(Frame::about(origin, axis), radius, tol).ok()?,
1116 ));
1117 }
1118 let (axis, k) = if k < 0.0 { (-axis, -k) } else { (axis, k) };
1120 Some(Canonical::Cone(
1121 Cone::new(
1122 Frame::about(origin, axis),
1123 rho0.max(tol.confusion()),
1124 k.atan(),
1125 tol,
1126 )
1127 .ok()?,
1128 ))
1129}
1130
1131pub(crate) fn circle_through(
1135 points: &[Point],
1136 tol: Tolerances,
1137) -> Option<(Point, Direction, f64, f64)> {
1138 let c = centroid(points);
1139 let normal = Direction::new(
1140 smallest_direction(covariance(points.iter().map(|p| p.to_vector() - c))),
1141 tol,
1142 )
1143 .ok()?;
1144 let a = normal.vector();
1145 let e1 = normal.any_perpendicular().vector();
1146 let e2 = a.cross(e1);
1147 let mut m = nalgebra::Matrix3::zeros();
1148 let mut b = nalgebra::Vector3::zeros();
1149 for p in points {
1150 let d = p.to_vector() - c;
1151 let (x, y) = (d.dot(e1), d.dot(e2));
1152 let row = nalgebra::Vector3::new(-2.0 * x, -2.0 * y, 1.0);
1153 let rhs = -x.mul_add(x, y * y);
1154 m += row * row.transpose();
1155 b += row * rhs;
1156 }
1157 let solved = m.lu().solve(&b)?;
1158 let r2 = solved[0].mul_add(solved[0], solved[1] * solved[1]) - solved[2];
1159 if r2 <= 0.0 {
1160 return None;
1161 }
1162 let radius = r2.sqrt();
1163 let centre = Point::from_vector(c + e1 * solved[0] + e2 * solved[1]);
1164 let mut miss = 0.0;
1165 for p in points {
1166 let d = *p - centre;
1167 let off = d.dot(a);
1168 let within = (d - a * off).magnitude() - radius;
1169 miss += off.mul_add(off, within * within);
1170 }
1171 #[allow(
1172 clippy::cast_precision_loss,
1173 reason = "sample counts are far below 2^52"
1174 )]
1175 let rms = (miss / points.len() as f64).sqrt();
1176 Some((centre, normal, radius, rms))
1177}
1178
1179pub(crate) fn profile_circle(pts: &[Point], base: Point, axis: Axis) -> Option<(f64, f64, f64)> {
1184 let d = axis.direction.vector();
1185 let mut m = nalgebra::Matrix3::<f64>::zeros();
1186 let mut b = nalgebra::Vector3::<f64>::zeros();
1187 for p in pts {
1188 let w = *p - base;
1189 let h = w.dot(d);
1190 let rho = (w - d * h).magnitude();
1191 let row = nalgebra::Vector3::new(2.0 * rho, 2.0 * h, 1.0);
1192 m += row * row.transpose();
1193 b += row * rho.mul_add(rho, h * h);
1194 }
1195 let x = m.lu().solve(&b)?;
1196 let r2 = x[2] + x[0].mul_add(x[0], x[1] * x[1]);
1197 (r2 > 0.0).then(|| (x[0], x[1], r2.sqrt()))
1198}
1199
1200pub(crate) fn fit_torus(
1201 points: &[Point],
1202 normals: &[Vector],
1203 tol: Tolerances,
1204) -> Option<Canonical> {
1205 let stride = points.len().div_ceil(160).max(1);
1217 let picked: Vec<usize> = (0..points.len()).step_by(stride).collect();
1218 let mut turns: Vec<f64> = Vec::with_capacity(picked.len() * picked.len() / 2);
1219 for (a, &i) in picked.iter().enumerate() {
1220 for &j in &picked[a + 1..] {
1221 let gap = points[i].distance(points[j]);
1222 if gap > 0.0 {
1223 turns.push((normals[i] - normals[j]).magnitude() / gap);
1224 }
1225 }
1226 }
1227 if turns.is_empty() {
1228 return None;
1229 }
1230 turns.sort_by(f64::total_cmp);
1231 let sharpest = turns[turns.len() * 9 / 10];
1232 if sharpest <= 0.0 {
1233 return None;
1234 }
1235 let estimate = 1.0 / sharpest;
1236 let miss = |r: f64| -> Option<(Point, Direction, f64, f64)> {
1237 let shifted: Vec<Point> = points
1238 .iter()
1239 .zip(normals)
1240 .map(|(p, n)| *p - *n * r)
1241 .collect();
1242 circle_through(&shifted, tol)
1243 };
1244 let score = |r: f64| {
1248 miss(r)
1249 .filter(|m| m.2 > r.abs())
1250 .map_or(f64::INFINITY, |m| m.3)
1251 };
1252 let steps = 60_i32;
1253 let ratio = 4.0_f64.powf(1.0 / f64::from(steps));
1254 let mut best: Option<(f64, f64)> = None;
1255 for sign in [-1.0, 1.0] {
1256 for k in 0..=steps {
1257 let r = sign * estimate * 0.5 * ratio.powi(k);
1258 let rms = score(r);
1259 if rms.is_finite() && best.is_none_or(|(held, _)| rms < held) {
1260 best = Some((rms, r));
1261 }
1262 }
1263 }
1264 let (_, tube) = best?;
1265 let (mut lo, mut hi) = (tube / ratio, tube * ratio);
1266 if lo > hi {
1267 core::mem::swap(&mut lo, &mut hi);
1268 }
1269 for _ in 0..40 {
1270 let m1 = lo + (hi - lo) * 0.382;
1271 let m2 = lo + (hi - lo) * 0.618;
1272 if score(m1) < score(m2) {
1273 hi = m2;
1274 } else {
1275 lo = m1;
1276 }
1277 }
1278 let tube = f64::midpoint(lo, hi);
1279 let (centre, axis, major, _) = miss(tube)?;
1280 let minor = tube.abs();
1281 if minor <= tol.confusion() || minor >= major {
1282 return None;
1283 }
1284 Some(Canonical::Torus(
1285 Torus::new(Frame::about(centre, axis), major, minor, tol).ok()?,
1286 ))
1287}
1288
1289fn parameters(surface: &Canonical) -> Option<Vec<f64>> {
1292 let flat = |o: Point, d: Direction, rest: &[f64]| {
1293 let v = d.vector();
1294 let mut out = vec![o.x, o.y, o.z, v.x, v.y, v.z];
1295 out.extend_from_slice(rest);
1296 out
1297 };
1298 Some(match surface {
1299 Canonical::Plane(_) | Canonical::Swept(_) => return None,
1300 Canonical::Cylinder(c) => flat(c.frame().origin(), c.frame().z(), &[c.radius()]),
1301 Canonical::Cone(c) => flat(
1302 c.frame().origin(),
1303 c.frame().z(),
1304 &[c.reference_radius(), c.half_angle()],
1305 ),
1306 Canonical::Sphere(s) => {
1307 let o = s.centre();
1308 vec![o.x, o.y, o.z, s.radius()]
1309 }
1310 Canonical::Torus(t) => flat(
1311 t.frame().origin(),
1312 t.frame().z(),
1313 &[t.major_radius(), t.minor_radius()],
1314 ),
1315 })
1316}
1317
1318pub(crate) fn residual(like: &Canonical, x: &[f64], p: Point) -> f64 {
1321 Gauge::of(like, x).residual(p)
1322}
1323
1324enum Gauge {
1328 Sphere {
1329 o: Point,
1330 radius: f64,
1331 },
1332 Cylinder {
1333 o: Point,
1334 a: Vector,
1335 radius: f64,
1336 },
1337 Cone {
1338 o: Point,
1339 a: Vector,
1340 r0: f64,
1341 tan: f64,
1342 cos: f64,
1343 },
1344 Torus {
1345 o: Point,
1346 a: Vector,
1347 major: f64,
1348 minor: f64,
1349 },
1350 Nothing,
1351}
1352
1353impl Gauge {
1354 fn of(like: &Canonical, x: &[f64]) -> Self {
1355 let o = Point::new(x[0], x[1], x[2]);
1356 if let Canonical::Sphere(_) = like {
1357 return Self::Sphere { o, radius: x[3] };
1358 }
1359 let d = Vector::new(x[3], x[4], x[5]);
1360 let m = d.magnitude();
1361 let a = if m > 0.0 { d / m } else { Vector::Z };
1362 match like {
1363 Canonical::Cylinder(_) => Self::Cylinder { o, a, radius: x[6] },
1364 Canonical::Cone(_) => Self::Cone {
1365 o,
1366 a,
1367 r0: x[6],
1368 tan: x[7].tan(),
1369 cos: x[7].cos(),
1370 },
1371 Canonical::Torus(_) => Self::Torus {
1372 o,
1373 a,
1374 major: x[6],
1375 minor: x[7],
1376 },
1377 Canonical::Plane(_) | Canonical::Sphere(_) | Canonical::Swept(_) => Self::Nothing,
1378 }
1379 }
1380
1381 fn residual(&self, p: Point) -> f64 {
1383 let around = |o: Point, a: Vector| {
1385 let w = p - o;
1386 let h = w.dot(a);
1387 (h, (w - a * h).magnitude())
1388 };
1389 match *self {
1390 Self::Sphere { o, radius } => p.distance(o) - radius,
1391 Self::Cylinder { o, a, radius } => around(o, a).1 - radius,
1392 Self::Cone { o, a, r0, tan, cos } => {
1393 let (h, rho) = around(o, a);
1394 (rho - h.mul_add(tan, r0)) * cos
1395 }
1396 Self::Torus { o, a, major, minor } => {
1397 let (h, rho) = around(o, a);
1398 (rho - major).hypot(h) - minor
1399 }
1400 Self::Nothing => 0.0,
1401 }
1402 }
1403}
1404
1405pub(crate) fn rebuild(like: &Canonical, x: &[f64], tol: Tolerances) -> Option<Canonical> {
1406 let o = Point::new(x[0], x[1], x[2]);
1407 if let Canonical::Sphere(_) = like {
1408 return Some(Canonical::Sphere(Sphere::centred(o, x[3], tol).ok()?));
1409 }
1410 let axis = Direction::new(Vector::new(x[3], x[4], x[5]), tol).ok()?;
1411 let frame = Frame::about(o, axis);
1412 Some(match like {
1413 Canonical::Cylinder(_) => Canonical::Cylinder(Cylinder::new(frame, x[6], tol).ok()?),
1414 Canonical::Cone(_) => {
1415 if x[7] <= 0.0 || x[7] >= core::f64::consts::FRAC_PI_2 {
1416 return None;
1417 }
1418 Canonical::Cone(Cone::new(frame, x[6].max(tol.confusion()), x[7], tol).ok()?)
1419 }
1420 Canonical::Torus(_) => {
1421 if x[7] <= 0.0 || x[7] >= x[6] {
1422 return None;
1423 }
1424 Canonical::Torus(Torus::new(frame, x[6], x[7], tol).ok()?)
1425 }
1426 Canonical::Plane(_) | Canonical::Sphere(_) | Canonical::Swept(_) => return None,
1427 })
1428}
1429
1430fn regauged(like: &Canonical, mut x: Vec<f64>, points: &[Point]) -> Vec<f64> {
1434 if let Canonical::Sphere(_) = like {
1435 return x;
1436 }
1437 let d = Vector::new(x[3], x[4], x[5]);
1438 let m = d.magnitude();
1439 if m == 0.0 {
1440 return x;
1441 }
1442 let a = d / m;
1443 x[3..6].copy_from_slice(&[a.x, a.y, a.z]);
1444 if let Canonical::Torus(_) = like {
1446 return x;
1447 }
1448 let o = Point::new(x[0], x[1], x[2]);
1449 let c = Point::from_vector(centroid(points));
1450 let shift = (c - o).dot(a);
1451 let moved = o + a * shift;
1452 if let Canonical::Cone(_) = like {
1453 x[6] = shift.mul_add(x[7].tan(), x[6]);
1455 }
1456 x[..3].copy_from_slice(&[moved.x, moved.y, moved.z]);
1457 x
1458}
1459
1460pub(crate) fn refine(
1467 seed: Canonical,
1468 points: &[Point],
1469 hopeless: f64,
1470 tol: Tolerances,
1471) -> Option<Canonical> {
1472 let mut x = parameters(&seed)?;
1473 let n = x.len();
1474 let cost = |x: &[f64]| -> f64 {
1475 let gauge = Gauge::of(&seed, x);
1476 points.iter().map(|p| gauge.residual(*p).powi(2)).sum()
1477 };
1478 let scale = points
1479 .iter()
1480 .map(|p| p.distance(points[0]))
1481 .fold(tol.confusion(), f64::max);
1482 let mut current = cost(&x);
1483 #[allow(
1484 clippy::cast_precision_loss,
1485 reason = "sample counts are far below 2^52"
1486 )]
1487 let count = points.len() as f64;
1488 let mut slowed = false;
1489 let patient = matches!(seed, Canonical::Torus(_));
1493 let (steps_allowed, least_gain) = if patient { (60, 1e-10) } else { (30, 1e-4) };
1494 for iteration in 0..steps_allowed {
1495 if !patient && iteration >= 3 && slowed && (current / count).sqrt() > hopeless {
1501 break;
1502 }
1503 let mut jacobian = nalgebra::DMatrix::<f64>::zeros(points.len(), n);
1504 let mut r = nalgebra::DVector::<f64>::zeros(points.len());
1505 let steps: Vec<f64> = x.iter().map(|v| 1e-7 * v.abs().max(scale)).collect();
1506 let gauge = Gauge::of(&seed, &x);
1507 for (i, p) in points.iter().enumerate() {
1508 r[i] = gauge.residual(*p);
1509 }
1510 for k in 0..n {
1511 let mut up = x.clone();
1512 let mut down = x.clone();
1513 up[k] += steps[k];
1514 down[k] -= steps[k];
1515 let (up, down) = (Gauge::of(&seed, &up), Gauge::of(&seed, &down));
1516 for (i, p) in points.iter().enumerate() {
1517 jacobian[(i, k)] = (up.residual(*p) - down.residual(*p)) / (2.0 * steps[k]);
1518 }
1519 }
1520 let svd = jacobian.svd(true, true);
1521 let largest = svd.singular_values.max();
1522 let Ok(step) = svd.solve(&(-r), largest * 1e-8) else {
1523 break;
1524 };
1525 let mut scale_step = 1.0;
1526 let mut accepted = false;
1527 for _ in 0..12 {
1528 let trial: Vec<f64> = x
1529 .iter()
1530 .zip(step.iter())
1531 .map(|(a, b)| b.mul_add(scale_step, *a))
1532 .collect();
1533 let trial = regauged(&seed, trial, points);
1534 let c = cost(&trial);
1535 if c.is_finite() && c < current {
1536 let gain = current - c;
1537 slowed = c > current * 0.5;
1538 x = trial;
1539 current = c;
1540 accepted = gain > current * least_gain;
1545 break;
1546 }
1547 scale_step *= 0.5;
1548 }
1549 if !accepted {
1550 break;
1551 }
1552 }
1553 rebuild(&seed, &x, tol)
1554}
1555
1556#[cfg(test)]
1557mod tests {
1558 #![allow(clippy::unwrap_used, reason = "test code")]
1559 use super::*;
1560
1561 const T: Tolerances = Tolerances::millimetres();
1562
1563 #[test]
1568 fn a_sphere_too_far_off_to_measure_is_no_fit() {
1569 let far = Sphere::centred(Point::new(1e17, 0.0, 0.0), 1e17, T).unwrap();
1570 let off = Point::new(-5.0, 0.0, 0.0);
1571 assert!((Canonical::Sphere(far).distance_to(off) - 5.0).abs() > 1.0);
1572 let fit = |sphere: Sphere| {
1573 choose(
1574 &[Recognized {
1575 surface: Canonical::Sphere(sphere),
1576 deviation: 0.0,
1577 }],
1578 &[],
1579 1e-3,
1580 )
1581 };
1582 assert!(fit(far).is_none());
1583 assert!(fit(Sphere::centred(Point::new(10.0, 0.0, 0.0), 10.0, T).unwrap()).is_some());
1584 }
1585
1586 #[test]
1590 fn a_cylinder_is_recognized_from_rough_normals() {
1591 let axis = Direction::new(Vector::new(0.2, -0.1, 1.0), T).unwrap();
1592 let frame = Frame::about(Point::new(3.0, -2.0, 1.0), axis);
1593 let (mut points, mut normals) = (Vec::new(), Vec::new());
1594 for i in 0..24 {
1595 for j in 0..5 {
1596 let u = f64::from(i) * 0.2;
1597 let h = f64::from(j) * 2.0;
1598 let radial = frame.x().vector() * u.cos() + frame.y().vector() * u.sin();
1599 points.push(frame.origin() + radial * 7.5 + axis.vector() * h);
1600 let tipped = radial + axis.vector() * 0.05 * f64::from(j % 2);
1601 normals.push(tipped / tipped.magnitude());
1602 }
1603 }
1604 let found = recognize_points(&points, &normals, 1e-6, T)
1605 .unwrap()
1606 .unwrap();
1607 let Canonical::Cylinder(c) = found.surface else {
1608 panic!("a cylinder: {found:?}");
1609 };
1610 assert!((c.radius() - 7.5).abs() < 1e-7, "{c:?}");
1611 }
1612
1613 #[test]
1618 fn a_small_patch_of_a_thick_torus_is_that_torus() {
1619 let torus = Torus::new(Frame::WORLD, 40.0, 10.0, T).unwrap();
1620 let (mut points, mut normals) = (Vec::new(), Vec::new());
1621 for i in 0..6 {
1622 for j in 0..5 {
1623 let (u, v) = (0.3 + f64::from(i) * 0.0126, 1.0 + f64::from(j) * 0.0314);
1624 let at = ogeom_math::elementary::torus_at(&torus, u, v);
1625 points.push(at.point);
1626 let n = at.du.cross(at.dv);
1627 normals.push(n / n.magnitude() + Vector::new(0.003, 0.0, 0.0));
1628 }
1629 }
1630 let found = recognize_points(&points, &normals, 1e-9, T)
1631 .unwrap()
1632 .unwrap();
1633 let Canonical::Torus(t) = found.surface else {
1634 panic!("a torus: {found:?}");
1635 };
1636 assert!((t.minor_radius() - 10.0).abs() < 1e-6, "{t:?}");
1637 assert!((t.major_radius() - 40.0).abs() < 1e-6, "{t:?}");
1638 }
1639
1640 #[test]
1648 fn a_coarse_strip_of_a_narrow_round_is_its_cylinder() {
1649 let r = 0.432;
1650 let at = |degrees: f64, x: f64| {
1651 let a = degrees.to_radians();
1652 (
1653 Point::new(x, r * a.cos(), r * a.sin()),
1654 Vector::new(0.0, a.cos(), a.sin()),
1655 )
1656 };
1657 let (mut points, mut normals) = (Vec::new(), Vec::new());
1658 let mut chords = Vec::new();
1659 for i in 0..13 {
1660 let x = 45.0 + f64::from(i) * 0.98;
1661 for degrees in [135.9, 157.9] {
1662 let (p, n) = at(degrees, x);
1663 points.push(p);
1664 normals.push(n);
1665 }
1666 let (a, b) = (at(135.9, x).0, at(157.9, x).0);
1667 let apex = at(179.9, 116.0).0;
1668 chords.extend([(a, b), (b, apex)]);
1669 if i > 0 {
1670 let back = x - 0.98;
1671 chords.extend([
1672 (at(135.9, back).0, a),
1673 (at(157.9, back).0, b),
1674 (at(157.9, back).0, a),
1675 ]);
1676 }
1677 }
1678 let (apex, n) = at(179.9, 116.0);
1679 points.push(apex);
1680 normals.push(n);
1681 let (found, _) = recognize_trimmed(&points, &normals, &chords, 1e-3, T).unwrap();
1682 let Canonical::Cylinder(c) = found.surface else {
1683 panic!("the round's cylinder: {found:?}");
1684 };
1685 assert!((c.radius() - r).abs() < 1e-9, "{c:?}");
1686 }
1687
1688 #[test]
1689 fn a_free_form_patch_refuses_every_canonical() {
1690 let (mut points, mut normals) = (Vec::new(), Vec::new());
1691 for i in 0..=9 {
1692 for j in 0..=9 {
1693 let (x, y) = (f64::from(i) - 4.5, f64::from(j) - 4.5);
1694 points.push(Point::new(x, y, x * y));
1695 let n = Vector::new(-y, -x, 1.0);
1696 normals.push(n / n.magnitude());
1697 }
1698 }
1699 assert!(
1700 recognize_points(&points, &normals, 1e-3, T)
1701 .unwrap()
1702 .is_none()
1703 );
1704 }
1705}