1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
26use ogeom_math::{Cone, Cylinder, Direction, Frame, Plane, Point, Sphere, Torus, Vector};
27
28#[derive(Debug, Clone, Copy, PartialEq)]
30pub enum Canonical {
31 Plane(Plane),
33 Cylinder(Cylinder),
35 Cone(Cone),
37 Sphere(Sphere),
39 Torus(Torus),
41}
42
43impl Canonical {
44 #[must_use]
46 pub fn distance_to(&self, p: Point) -> f64 {
47 match self {
48 Self::Plane(plane) => plane.signed_distance_to(p).abs(),
49 Self::Cylinder(c) => c.distance_to(p),
50 Self::Cone(c) => c.distance_to(p),
51 Self::Sphere(s) => (p.distance(s.centre()) - s.radius()).abs(),
52 Self::Torus(t) => t.distance_to(p),
53 }
54 }
55
56 #[must_use]
62 pub fn signed_distance_to(&self, p: Point) -> f64 {
63 let radial = |frame: ogeom_math::Frame| {
64 let w = p - frame.origin();
65 let h = w.dot(frame.z().vector());
66 ((w - frame.z().vector() * h).magnitude(), h)
67 };
68 match self {
69 Self::Plane(plane) => plane.signed_distance_to(p),
70 Self::Cylinder(c) => radial(c.frame()).0 - c.radius(),
71 Self::Cone(c) => {
72 let (r, h) = radial(c.frame());
73 let (sin, cos) = c.half_angle().sin_cos();
74 (r - c.reference_radius()).mul_add(cos, -(h * sin))
77 }
78 Self::Sphere(s) => p.distance(s.centre()) - s.radius(),
79 Self::Torus(t) => {
80 let (r, h) = radial(t.frame());
81 (r - t.major_radius()).hypot(h) - t.minor_radius()
82 }
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct Recognized {
90 pub surface: Canonical,
92 pub deviation: f64,
94}
95
96pub fn recognize_points(
111 points: &[Point],
112 normals: &[Vector],
113 tolerance: f64,
114 tol: Tolerances,
115) -> OgeomResult<Option<Recognized>> {
116 if points.len() < 3 || points.len() != normals.len() {
117 ogeom_bail!(
118 Construction,
119 "recognition needs at least three samples with matching normals"
120 );
121 }
122 if !tolerance.is_finite() || tolerance <= 0.0 {
123 ogeom_bail!(Construction, "a tolerance of {tolerance} is not a distance");
124 }
125 if let Some(plane) = fit_plane(points, tol) {
126 let deviation = worst_deviation(&plane, points);
127 if deviation <= tolerance {
128 return Ok(Some(Recognized {
129 surface: plane,
130 deviation,
131 }));
132 }
133 }
134 Ok(recognize_curved(points, normals, &[], tolerance, tol))
135}
136
137pub(crate) fn is_flat(points: &[Point], tolerance: f64, tol: Tolerances) -> bool {
139 fit_plane(points, tol).is_some_and(|plane| worst_deviation(&plane, points) <= tolerance)
140}
141
142pub(crate) fn recognize_curved(
150 points: &[Point],
151 normals: &[Vector],
152 chords: &[(Point, Point)],
153 tolerance: f64,
154 tol: Tolerances,
155) -> Option<Recognized> {
156 choose(
157 &curved_fits(points, normals, tolerance, tol),
158 chords,
159 tolerance,
160 )
161}
162
163fn curved_fits(
168 points: &[Point],
169 normals: &[Vector],
170 hopeless: f64,
171 tol: Tolerances,
172) -> Vec<Recognized> {
173 const FIT_SAMPLES: usize = 1500;
174 let stride = points.len().div_ceil(FIT_SAMPLES).max(1);
175 let (sub_points, sub_normals): (Vec<Point>, Vec<Vector>) = points
176 .iter()
177 .zip(normals)
178 .step_by(stride)
179 .map(|(p, n)| (*p, *n))
180 .unzip();
181 let n = points.len();
182 type Fit = fn(&[Point], &[Vector], Tolerances) -> Option<Canonical>;
183 let attempts: [(usize, Fit); 4] = [
187 (6, |p, _, tol| fit_sphere(p, tol)),
188 (8, fit_cylinder),
189 (9, fit_cone),
190 (11, fit_torus),
191 ];
192 let mut fits = Vec::with_capacity(4);
193 for (floor, fit) in attempts {
194 if n < floor {
195 continue;
196 }
197 let Some(seed) = fit(&sub_points, &sub_normals, tol) else {
198 continue;
199 };
200 let refined = refine(seed, &sub_points, hopeless, tol).unwrap_or(seed);
201 fits.push(Recognized {
202 surface: refined,
203 deviation: worst_deviation(&refined, points),
204 });
205 }
206 fits
207}
208
209fn choose(fits: &[Recognized], chords: &[(Point, Point)], tolerance: f64) -> Option<Recognized> {
218 let within: Vec<&Recognized> = fits.iter().filter(|f| f.deviation <= tolerance).collect();
219 let on = |f: &Recognized| {
220 chords
221 .iter()
222 .filter(|(a, b)| {
223 let mid = Point::from_vector((a.to_vector() + b.to_vector()) * 0.5);
224 f.surface.distance_to(mid) <= tolerance
225 })
226 .count()
227 };
228 let most = within.iter().map(|f| on(f)).max()?;
229 let within: Vec<&Recognized> = within.into_iter().filter(|f| on(f) == most).collect();
230 let best = within
231 .iter()
232 .map(|f| f.deviation)
233 .fold(f64::INFINITY, f64::min);
234 within
235 .into_iter()
236 .find(|f| f.deviation <= (2.0 * best).max(tolerance * 1e-3))
237 .copied()
238}
239
240pub(crate) fn recognize_trimmed(
247 points: &[Point],
248 normals: &[Vector],
249 chords: &[(Point, Point)],
250 tolerance: f64,
251 tol: Tolerances,
252) -> Result<(Recognized, Vec<bool>), f64> {
253 let mut keep = vec![true; points.len()];
254 let mut closest = f64::INFINITY;
255 for _ in 0..3 {
256 let (kept_points, kept_normals): (Vec<Point>, Vec<Vector>) = points
257 .iter()
258 .zip(normals)
259 .zip(&keep)
260 .filter(|(_, k)| **k)
261 .map(|((p, n), _)| (*p, *n))
262 .unzip();
263 if kept_points.len() < 8 {
264 break;
265 }
266 let fits = curved_fits(&kept_points, &kept_normals, tolerance, tol);
267 if let Some(found) = choose(&fits, chords, tolerance) {
268 return Ok((found, keep));
269 }
270 let Some(candidate) = fits
271 .iter()
272 .min_by(|a, b| a.deviation.total_cmp(&b.deviation))
273 else {
274 break;
275 };
276 closest = closest.min(candidate.deviation);
277 let mut misses: Vec<f64> = kept_points
278 .iter()
279 .map(|p| candidate.surface.distance_to(*p))
280 .collect();
281 misses.sort_by(f64::total_cmp);
282 if misses[misses.len() / 2] > tolerance * 10.0 {
285 break;
286 }
287 let bound = misses[misses.len() * 9 / 10].max(tolerance);
288 let before = keep.iter().filter(|k| **k).count();
289 for (k, p) in keep.iter_mut().zip(points) {
290 *k = *k && candidate.surface.distance_to(*p) <= bound;
291 }
292 if keep.iter().filter(|k| **k).count() == before {
293 break;
294 }
295 }
296 Err(closest)
297}
298
299pub(crate) fn worst_deviation(candidate: &Canonical, points: &[Point]) -> f64 {
301 points
302 .iter()
303 .map(|p| candidate.distance_to(*p))
304 .fold(0.0, f64::max)
305}
306
307fn centroid(points: &[Point]) -> Vector {
308 let mut sum = Vector::ZERO;
309 for p in points {
310 sum += p.to_vector();
311 }
312 #[allow(
313 clippy::cast_precision_loss,
314 reason = "sample counts are far below 2^52"
315 )]
316 let n = points.len() as f64;
317 sum / n
318}
319
320fn smallest_direction(m: nalgebra::Matrix3<f64>) -> Vector {
322 let eigen = nalgebra::SymmetricEigen::new(m);
323 let mut best = 0;
324 for i in 1..3 {
325 if eigen.eigenvalues[i] < eigen.eigenvalues[best] {
326 best = i;
327 }
328 }
329 let v = eigen.eigenvectors.column(best);
330 Vector::new(v[0], v[1], v[2])
331}
332
333fn covariance(vectors: impl Iterator<Item = Vector>) -> nalgebra::Matrix3<f64> {
334 let mut m = nalgebra::Matrix3::zeros();
335 for v in vectors {
336 let n = nalgebra::Vector3::new(v.x, v.y, v.z);
337 m += n * n.transpose();
338 }
339 m
340}
341
342fn fit_plane(points: &[Point], tol: Tolerances) -> Option<Canonical> {
343 let c = centroid(points);
344 let m = covariance(points.iter().map(|p| p.to_vector() - c));
345 let normal = Direction::new(smallest_direction(m), tol).ok()?;
346 Some(Canonical::Plane(Plane::new(Frame::about(
347 Point::from_vector(c),
348 normal,
349 ))))
350}
351
352fn fit_sphere(points: &[Point], tol: Tolerances) -> Option<Canonical> {
353 let mut a = nalgebra::Matrix4::zeros();
355 let mut b = nalgebra::Vector4::zeros();
356 for p in points {
357 let row = nalgebra::Vector4::new(-2.0 * p.x, -2.0 * p.y, -2.0 * p.z, 1.0);
358 let rhs = -(p.to_vector().dot(p.to_vector()));
359 a += row * row.transpose();
360 b += row * rhs;
361 }
362 let solved = a.lu().solve(&b)?;
363 let centre = Point::new(solved[0], solved[1], solved[2]);
364 let r2 = centre.to_vector().dot(centre.to_vector()) - solved[3];
365 if r2 <= tol.confusion() {
366 return None;
367 }
368 Some(Canonical::Sphere(
369 Sphere::centred(centre, r2.sqrt(), tol).ok()?,
370 ))
371}
372
373fn revolution_axis(
381 points: &[Point],
382 normals: &[Vector],
383 tol: Tolerances,
384) -> Option<(Point, Direction)> {
385 let mut weights = vec![1.0; points.len()];
391 let mut axis = None;
392 for _ in 0..8 {
393 let found = weighted_axis(points, normals, &weights, tol)?;
394 let (through, direction) = found;
395 axis = Some(found);
396 let a = direction.vector();
397 let misses: Vec<f64> = points
398 .iter()
399 .zip(normals)
400 .map(|(p, n)| {
401 let w = *p - through;
402 let across = a.cross(*n);
403 let m = across.magnitude();
404 if m > 1e-9 {
405 w.dot(across).abs() / m
406 } else {
407 w.cross(a).magnitude()
408 }
409 })
410 .collect();
411 let mut sorted = misses.clone();
412 sorted.sort_by(f64::total_cmp);
413 let typical = sorted[sorted.len() / 2].max(tol.confusion());
414 for (w, miss) in weights.iter_mut().zip(&misses) {
415 let r = miss / (2.0 * typical);
416 *w = 1.0 / r.mul_add(r, 1.0);
417 }
418 }
419 axis
420}
421
422fn weighted_axis(
423 points: &[Point],
424 normals: &[Vector],
425 weights: &[f64],
426 tol: Tolerances,
427) -> Option<(Point, Direction)> {
428 let mut dd = nalgebra::Matrix3::<f64>::zeros();
435 let mut dm = nalgebra::Matrix3::<f64>::zeros();
436 let mut mm = nalgebra::Matrix3::<f64>::zeros();
437 for ((p, n), w) in points.iter().zip(normals).zip(weights) {
438 let moment = p.to_vector().cross(*n);
439 let a = nalgebra::Vector3::new(moment.x, moment.y, moment.z);
440 let b = nalgebra::Vector3::new(n.x, n.y, n.z);
441 dd += a * a.transpose() * *w;
442 dm += a * b.transpose() * *w;
443 mm += b * b.transpose() * *w;
444 }
445 let scale = mm.norm().max(f64::MIN_POSITIVE);
446 let pinv = mm.pseudo_inverse(scale * 1e-9).ok()?;
447 let schur = dd - dm * pinv * dm.transpose();
448 let schur = (schur + schur.transpose()) * 0.5;
449 let d = smallest_direction(schur);
450 let dv = nalgebra::Vector3::new(d.x, d.y, d.z);
451 let mv = -(pinv * dm.transpose() * dv);
452 let moment = Vector::new(mv[0], mv[1], mv[2]);
453 let length = d.magnitude();
454 if length <= f64::MIN_POSITIVE {
455 return None;
456 }
457 let d = d / length;
458 let moment = (moment - d * d.dot(moment)) / length;
459 let through = Point::from_vector(d.cross(moment));
460 Some((through, Direction::new(d, tol).ok()?))
461}
462
463fn profile(points: &[Point], through: Point, axis: Direction) -> Vec<(f64, f64)> {
466 let a = axis.vector();
467 points
468 .iter()
469 .map(|p| {
470 let w = *p - through;
471 let h = w.dot(a);
472 ((w - a * h).magnitude(), h)
473 })
474 .collect()
475}
476
477fn centred_on(points: &[Point], through: Point, axis: Direction) -> Point {
480 let c = Point::from_vector(centroid(points));
481 through + axis.vector() * (c - through).dot(axis.vector())
482}
483
484fn fit_cylinder(points: &[Point], normals: &[Vector], tol: Tolerances) -> Option<Canonical> {
485 let (through, axis) = revolution_axis(points, normals, tol)?;
486 let rows = profile(points, through, axis);
487 #[allow(
488 clippy::cast_precision_loss,
489 reason = "sample counts are far below 2^52"
490 )]
491 let radius = rows.iter().map(|(rho, _)| rho).sum::<f64>() / rows.len() as f64;
492 Some(Canonical::Cylinder(
493 Cylinder::new(
494 Frame::about(centred_on(points, through, axis), axis),
495 radius,
496 tol,
497 )
498 .ok()?,
499 ))
500}
501
502fn fit_cone(points: &[Point], normals: &[Vector], tol: Tolerances) -> Option<Canonical> {
503 let (through, axis) = revolution_axis(points, normals, tol)?;
504 let origin = centred_on(points, through, axis);
505 let (mut sh, mut shh, mut sr, mut shr, mut count) = (0.0, 0.0, 0.0, 0.0, 0.0);
507 for (rho, h) in profile(points, origin, axis) {
508 sh += h;
509 shh += h * h;
510 sr += rho;
511 shr += h * rho;
512 count += 1.0;
513 }
514 let det = f64::mul_add(count, shh, -(sh * sh));
515 if det.abs() <= f64::MIN_POSITIVE {
516 return None;
517 }
518 let k = f64::mul_add(count, shr, -(sh * sr)) / det;
519 let rho0 = f64::mul_add(shh, sr, -(sh * shr)) / det;
520 if k.abs() <= tol.angular() {
521 return None;
523 }
524 let (axis, k) = if k < 0.0 { (-axis, -k) } else { (axis, k) };
526 Some(Canonical::Cone(
527 Cone::new(
528 Frame::about(origin, axis),
529 rho0.max(tol.confusion()),
530 k.atan(),
531 tol,
532 )
533 .ok()?,
534 ))
535}
536
537fn circle_through(points: &[Point], tol: Tolerances) -> Option<(Point, Direction, f64, f64)> {
541 let c = centroid(points);
542 let normal = Direction::new(
543 smallest_direction(covariance(points.iter().map(|p| p.to_vector() - c))),
544 tol,
545 )
546 .ok()?;
547 let a = normal.vector();
548 let e1 = normal.any_perpendicular().vector();
549 let e2 = a.cross(e1);
550 let mut m = nalgebra::Matrix3::zeros();
551 let mut b = nalgebra::Vector3::zeros();
552 for p in points {
553 let d = p.to_vector() - c;
554 let (x, y) = (d.dot(e1), d.dot(e2));
555 let row = nalgebra::Vector3::new(-2.0 * x, -2.0 * y, 1.0);
556 let rhs = -x.mul_add(x, y * y);
557 m += row * row.transpose();
558 b += row * rhs;
559 }
560 let solved = m.lu().solve(&b)?;
561 let r2 = solved[0].mul_add(solved[0], solved[1] * solved[1]) - solved[2];
562 if r2 <= 0.0 {
563 return None;
564 }
565 let radius = r2.sqrt();
566 let centre = Point::from_vector(c + e1 * solved[0] + e2 * solved[1]);
567 let mut miss = 0.0;
568 for p in points {
569 let d = *p - centre;
570 let off = d.dot(a);
571 let within = (d - a * off).magnitude() - radius;
572 miss += off.mul_add(off, within * within);
573 }
574 #[allow(
575 clippy::cast_precision_loss,
576 reason = "sample counts are far below 2^52"
577 )]
578 let rms = (miss / points.len() as f64).sqrt();
579 Some((centre, normal, radius, rms))
580}
581
582fn fit_torus(points: &[Point], normals: &[Vector], tol: Tolerances) -> Option<Canonical> {
583 let stride = points.len().div_ceil(160).max(1);
595 let picked: Vec<usize> = (0..points.len()).step_by(stride).collect();
596 let mut turns: Vec<f64> = Vec::with_capacity(picked.len() * picked.len() / 2);
597 for (a, &i) in picked.iter().enumerate() {
598 for &j in &picked[a + 1..] {
599 let gap = points[i].distance(points[j]);
600 if gap > 0.0 {
601 turns.push((normals[i] - normals[j]).magnitude() / gap);
602 }
603 }
604 }
605 if turns.is_empty() {
606 return None;
607 }
608 turns.sort_by(f64::total_cmp);
609 let sharpest = turns[turns.len() * 9 / 10];
610 if sharpest <= 0.0 {
611 return None;
612 }
613 let estimate = 1.0 / sharpest;
614 let miss = |r: f64| -> Option<(Point, Direction, f64, f64)> {
615 let shifted: Vec<Point> = points
616 .iter()
617 .zip(normals)
618 .map(|(p, n)| *p - *n * r)
619 .collect();
620 circle_through(&shifted, tol)
621 };
622 let score = |r: f64| {
626 miss(r)
627 .filter(|m| m.2 > r.abs())
628 .map_or(f64::INFINITY, |m| m.3)
629 };
630 let steps = 60_i32;
631 let ratio = 4.0_f64.powf(1.0 / f64::from(steps));
632 let mut best: Option<(f64, f64)> = None;
633 for sign in [-1.0, 1.0] {
634 for k in 0..=steps {
635 let r = sign * estimate * 0.5 * ratio.powi(k);
636 let rms = score(r);
637 if rms.is_finite() && best.is_none_or(|(held, _)| rms < held) {
638 best = Some((rms, r));
639 }
640 }
641 }
642 let (_, tube) = best?;
643 let (mut lo, mut hi) = (tube / ratio, tube * ratio);
644 if lo > hi {
645 core::mem::swap(&mut lo, &mut hi);
646 }
647 for _ in 0..40 {
648 let m1 = lo + (hi - lo) * 0.382;
649 let m2 = lo + (hi - lo) * 0.618;
650 if score(m1) < score(m2) {
651 hi = m2;
652 } else {
653 lo = m1;
654 }
655 }
656 let tube = f64::midpoint(lo, hi);
657 let (centre, axis, major, _) = miss(tube)?;
658 let minor = tube.abs();
659 if minor <= tol.confusion() || minor >= major {
660 return None;
661 }
662 Some(Canonical::Torus(
663 Torus::new(Frame::about(centre, axis), major, minor, tol).ok()?,
664 ))
665}
666
667fn parameters(surface: &Canonical) -> Option<Vec<f64>> {
670 let flat = |o: Point, d: Direction, rest: &[f64]| {
671 let v = d.vector();
672 let mut out = vec![o.x, o.y, o.z, v.x, v.y, v.z];
673 out.extend_from_slice(rest);
674 out
675 };
676 Some(match surface {
677 Canonical::Plane(_) => return None,
678 Canonical::Cylinder(c) => flat(c.frame().origin(), c.frame().z(), &[c.radius()]),
679 Canonical::Cone(c) => flat(
680 c.frame().origin(),
681 c.frame().z(),
682 &[c.reference_radius(), c.half_angle()],
683 ),
684 Canonical::Sphere(s) => {
685 let o = s.centre();
686 vec![o.x, o.y, o.z, s.radius()]
687 }
688 Canonical::Torus(t) => flat(
689 t.frame().origin(),
690 t.frame().z(),
691 &[t.major_radius(), t.minor_radius()],
692 ),
693 })
694}
695
696fn residual(like: &Canonical, x: &[f64], p: Point) -> f64 {
699 let o = Point::new(x[0], x[1], x[2]);
700 if let Canonical::Sphere(_) = like {
701 return p.distance(o) - x[3];
702 }
703 let d = Vector::new(x[3], x[4], x[5]);
704 let m = d.magnitude();
705 let a = if m > 0.0 { d / m } else { Vector::Z };
706 let w = p - o;
707 let h = w.dot(a);
708 let rho = (w - a * h).magnitude();
709 match like {
710 Canonical::Cylinder(_) => rho - x[6],
711 Canonical::Cone(_) => {
712 let (r0, angle) = (x[6], x[7]);
713 (rho - h.mul_add(angle.tan(), r0)) * angle.cos()
714 }
715 Canonical::Torus(_) => (rho - x[6]).hypot(h) - x[7],
716 Canonical::Plane(_) | Canonical::Sphere(_) => 0.0,
717 }
718}
719
720fn rebuild(like: &Canonical, x: &[f64], tol: Tolerances) -> Option<Canonical> {
721 let o = Point::new(x[0], x[1], x[2]);
722 if let Canonical::Sphere(_) = like {
723 return Some(Canonical::Sphere(Sphere::centred(o, x[3], tol).ok()?));
724 }
725 let axis = Direction::new(Vector::new(x[3], x[4], x[5]), tol).ok()?;
726 let frame = Frame::about(o, axis);
727 Some(match like {
728 Canonical::Cylinder(_) => Canonical::Cylinder(Cylinder::new(frame, x[6], tol).ok()?),
729 Canonical::Cone(_) => {
730 if x[7] <= 0.0 || x[7] >= core::f64::consts::FRAC_PI_2 {
731 return None;
732 }
733 Canonical::Cone(Cone::new(frame, x[6].max(tol.confusion()), x[7], tol).ok()?)
734 }
735 Canonical::Torus(_) => {
736 if x[7] <= 0.0 || x[7] >= x[6] {
737 return None;
738 }
739 Canonical::Torus(Torus::new(frame, x[6], x[7], tol).ok()?)
740 }
741 Canonical::Plane(_) | Canonical::Sphere(_) => return None,
742 })
743}
744
745fn regauged(like: &Canonical, mut x: Vec<f64>, points: &[Point]) -> Vec<f64> {
749 if let Canonical::Sphere(_) = like {
750 return x;
751 }
752 let d = Vector::new(x[3], x[4], x[5]);
753 let m = d.magnitude();
754 if m == 0.0 {
755 return x;
756 }
757 let a = d / m;
758 x[3..6].copy_from_slice(&[a.x, a.y, a.z]);
759 if let Canonical::Torus(_) = like {
761 return x;
762 }
763 let o = Point::new(x[0], x[1], x[2]);
764 let c = Point::from_vector(centroid(points));
765 let shift = (c - o).dot(a);
766 let moved = o + a * shift;
767 if let Canonical::Cone(_) = like {
768 x[6] = shift.mul_add(x[7].tan(), x[6]);
770 }
771 x[..3].copy_from_slice(&[moved.x, moved.y, moved.z]);
772 x
773}
774
775fn refine(seed: Canonical, points: &[Point], hopeless: f64, tol: Tolerances) -> Option<Canonical> {
782 let mut x = parameters(&seed)?;
783 let n = x.len();
784 let cost = |x: &[f64]| -> f64 { points.iter().map(|p| residual(&seed, x, *p).powi(2)).sum() };
785 let scale = points
786 .iter()
787 .map(|p| p.distance(points[0]))
788 .fold(tol.confusion(), f64::max);
789 let mut current = cost(&x);
790 #[allow(
791 clippy::cast_precision_loss,
792 reason = "sample counts are far below 2^52"
793 )]
794 let count = points.len() as f64;
795 let mut slowed = false;
796 let patient = matches!(seed, Canonical::Torus(_));
800 let (steps_allowed, least_gain) = if patient { (60, 1e-10) } else { (30, 1e-4) };
801 for iteration in 0..steps_allowed {
802 if !patient && iteration >= 3 && slowed && (current / count).sqrt() > hopeless {
808 break;
809 }
810 let mut jacobian = nalgebra::DMatrix::<f64>::zeros(points.len(), n);
811 let mut r = nalgebra::DVector::<f64>::zeros(points.len());
812 let steps: Vec<f64> = x.iter().map(|v| 1e-7 * v.abs().max(scale)).collect();
813 for (i, p) in points.iter().enumerate() {
814 r[i] = residual(&seed, &x, *p);
815 for k in 0..n {
816 let mut up = x.clone();
817 let mut down = x.clone();
818 up[k] += steps[k];
819 down[k] -= steps[k];
820 jacobian[(i, k)] =
821 (residual(&seed, &up, *p) - residual(&seed, &down, *p)) / (2.0 * steps[k]);
822 }
823 }
824 let svd = jacobian.svd(true, true);
825 let largest = svd.singular_values.max();
826 let Ok(step) = svd.solve(&(-r), largest * 1e-8) else {
827 break;
828 };
829 let mut scale_step = 1.0;
830 let mut accepted = false;
831 for _ in 0..12 {
832 let trial: Vec<f64> = x
833 .iter()
834 .zip(step.iter())
835 .map(|(a, b)| b.mul_add(scale_step, *a))
836 .collect();
837 let trial = regauged(&seed, trial, points);
838 let c = cost(&trial);
839 if c.is_finite() && c < current {
840 let gain = current - c;
841 slowed = c > current * 0.5;
842 x = trial;
843 current = c;
844 accepted = gain > current * least_gain;
849 break;
850 }
851 scale_step *= 0.5;
852 }
853 if !accepted {
854 break;
855 }
856 }
857 rebuild(&seed, &x, tol)
858}
859
860#[cfg(test)]
861mod tests {
862 #![allow(clippy::unwrap_used, reason = "test code")]
863 use super::*;
864
865 const T: Tolerances = Tolerances::millimetres();
866
867 #[test]
871 fn a_cylinder_is_recognized_from_rough_normals() {
872 let axis = Direction::new(Vector::new(0.2, -0.1, 1.0), T).unwrap();
873 let frame = Frame::about(Point::new(3.0, -2.0, 1.0), axis);
874 let (mut points, mut normals) = (Vec::new(), Vec::new());
875 for i in 0..24 {
876 for j in 0..5 {
877 let u = f64::from(i) * 0.2;
878 let h = f64::from(j) * 2.0;
879 let radial = frame.x().vector() * u.cos() + frame.y().vector() * u.sin();
880 points.push(frame.origin() + radial * 7.5 + axis.vector() * h);
881 let tipped = radial + axis.vector() * 0.05 * f64::from(j % 2);
882 normals.push(tipped / tipped.magnitude());
883 }
884 }
885 let found = recognize_points(&points, &normals, 1e-6, T)
886 .unwrap()
887 .unwrap();
888 let Canonical::Cylinder(c) = found.surface else {
889 panic!("a cylinder: {found:?}");
890 };
891 assert!((c.radius() - 7.5).abs() < 1e-7, "{c:?}");
892 }
893
894 #[test]
899 fn a_small_patch_of_a_thick_torus_is_that_torus() {
900 let torus = Torus::new(Frame::WORLD, 40.0, 10.0, T).unwrap();
901 let (mut points, mut normals) = (Vec::new(), Vec::new());
902 for i in 0..6 {
903 for j in 0..5 {
904 let (u, v) = (0.3 + f64::from(i) * 0.0126, 1.0 + f64::from(j) * 0.0314);
905 let at = ogeom_math::elementary::torus_at(&torus, u, v);
906 points.push(at.point);
907 let n = at.du.cross(at.dv);
908 normals.push(n / n.magnitude() + Vector::new(0.003, 0.0, 0.0));
909 }
910 }
911 let found = recognize_points(&points, &normals, 1e-9, T)
912 .unwrap()
913 .unwrap();
914 let Canonical::Torus(t) = found.surface else {
915 panic!("a torus: {found:?}");
916 };
917 assert!((t.minor_radius() - 10.0).abs() < 1e-6, "{t:?}");
918 assert!((t.major_radius() - 40.0).abs() < 1e-6, "{t:?}");
919 }
920
921 #[test]
922 fn a_free_form_patch_refuses_every_canonical() {
923 let (mut points, mut normals) = (Vec::new(), Vec::new());
924 for i in 0..=9 {
925 for j in 0..=9 {
926 let (x, y) = (f64::from(i) - 4.5, f64::from(j) - 4.5);
927 points.push(Point::new(x, y, x * y));
928 let n = Vector::new(-y, -x, 1.0);
929 normals.push(n / n.magnitude());
930 }
931 }
932 assert!(
933 recognize_points(&points, &normals, 1e-3, T)
934 .unwrap()
935 .is_none()
936 );
937 }
938}