Skip to main content

ogeom_algo/
recognize.rs

1//! Canonical recognition: deciding that a set of points *is* a plane, a
2//! cylinder, a cone, a sphere or a torus, not that it resembles one.
3//!
4//! The input is samples with normals; the output is the canonical surface
5//! and the worst deviation actually measured, or nothing. A fit is easy;
6//! the decision is the product, and a wrong yes gives a solid that measures
7//! nearly right with the wrong surface under every later operation. So
8//! every candidate is verified against all the samples at the caller's
9//! stated tolerance, and the reported deviation is the certificate.
10//!
11//! The first estimates are closed forms. A plane is the point covariance's
12//! smallest direction, and a sphere linear least squares through the
13//! `|c|² − r²` substitution. A cylinder, a cone and a torus are surfaces of
14//! revolution, every normal line of which meets the axis: the axis is the
15//! line that best meets them all, found linearly in Plücker coordinates and
16//! reweighted against the few samples off the surface, and the kind's
17//! profile (a line, a slanted line) is then fitted in the plane through
18//! it. A torus's tube radius is read from how fast its normals turn, and its
19//! spine as the circle the samples land on when shifted back along their
20//! normals by that radius. Normals estimated from a mesh's facets are only
21//! good to a fraction of the facet angle, so each estimate is refined by
22//! least squares on the samples' own distances to the surface, which is
23//! what the verification measures.
24
25use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
26use ogeom_math::{Cone, Cylinder, Direction, Frame, Plane, Point, Sphere, Torus, Vector};
27
28/// A canonical surface a set of samples was recognized as.
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub enum Canonical {
31    /// A plane.
32    Plane(Plane),
33    /// A cylinder.
34    Cylinder(Cylinder),
35    /// A cone.
36    Cone(Cone),
37    /// A sphere.
38    Sphere(Sphere),
39    /// A torus.
40    Torus(Torus),
41}
42
43impl Canonical {
44    /// The distance from `p` to the surface.
45    #[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    /// The distance from `p` to the surface, positive on the side its
57    /// radius grows toward (outside a cylinder, sphere or torus, off the
58    /// axis of a cone) and along a plane's normal. Near the surface it
59    /// grows as the distance does, which is what a solve onto the surface
60    /// asks of it; a cone's is taken to its nearer nappe.
61    #[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                // In the half-plane through the axis the nappe is the line
75                // through (reference radius, 0) at the half angle.
76                (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/// A recognition with its certificate.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub struct Recognized {
90    /// What the samples are.
91    pub surface: Canonical,
92    /// The worst distance from any sample to it: measured, not promised.
93    pub deviation: f64,
94}
95
96/// Recognize a canonical surface from samples with unit normals.
97///
98/// A plane is tried first; otherwise every curved kind is fitted, and the
99/// closest whose *measured* worst deviation meets `tolerance` is taken, a
100/// simpler kind winning a tie. `None` says the samples are free-form at
101/// that tolerance, which is an answer, not a failure. Each kind has its own
102/// sample floor, below which the fit is underdetermined and verification
103/// would rubber-stamp whatever the algebra produced.
104///
105/// # Errors
106///
107/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
108/// fewer than three samples arrive, the normals do not match the points,
109/// or the tolerance is not a positive distance.
110pub 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
137/// Whether the samples lie within `tolerance` of one plane.
138pub(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
142/// As [`recognize_points`], among the curved kinds only.
143///
144/// `chords` are straight segments between samples (a mesh's edges),
145/// which break ties among fits: samples on two equal coaxial circles lie
146/// as exactly on a sphere as on a cylinder, but the cylinder's rulings run
147/// between them and the mesh draws those as edges on the surface, midpoint
148/// and all, where no chord of a sphere lies on it.
149pub(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
163/// Every curved kind fitted and refined, with its worst deviation over all
164/// the samples. Fitted on an even subsample of a large set and verified on
165/// all of it: the fit's cost grows with every point, the certificate's
166/// only by a distance each.
167fn 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    // Each kind is fitted only to more samples than it has parameters, by
184    // half as many again: a torus's seven fit eight points on a sphere and
185    // a cylinder's end together exactly, and says nothing by it.
186    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
209/// The fit to take among those within the tolerance: the closest (a
210/// small patch of a torus may sit within the tolerance of a sphere too,
211/// and the sphere would not extrapolate) unless a simpler kind ties it,
212/// within twice the best or at the noise floor of a thousandth of the
213/// tolerance, since a torus can mimic a cylinder as closely as it likes.
214///
215/// Before either, the fit that lays the most of the chords' midpoints on
216/// itself: a mesh's edges along a surface's rulings are on the surface.
217fn 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
240/// A curved surface through samples of which a few are not on it: fitted,
241/// the tenth that miss the closest fit farthest dropped (a few corners
242/// far off the surface pull a fit a long way, so a median cut would not
243/// isolate them), and fitted again, until what is left fits within the
244/// tolerance or too little is left. Returns the surface and which samples
245/// it keeps, or, when nothing fits, how close the closest fit came.
246pub(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        // Trimming drops a few samples off a surface the rest are on; when
283        // the typical sample misses too, the rest are on no surface.
284        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
299/// The worst distance from any sample to the candidate.
300pub(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
320/// The eigenvector of a 3×3 symmetric matrix for its smallest eigenvalue.
321fn 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    // |p|² − 2p·c + Q = 0 with Q = |c|² − r²: linear in (c, Q).
354    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
373/// The axis of a surface of revolution, from its normals: every normal
374/// line of such a surface lies in a plane through the axis, so meets it.
375/// In Plücker coordinates a line `(d, m)` meets the normal line `(n, p×n)`
376/// exactly when `d·(p×n) + m·n = 0`, which is linear; the axis is the
377/// least-squares null vector of those conditions, put back on the Klein
378/// quadric. A sphere's normals all meet at one point and determine no
379/// axis, which the fits after this one catch.
380fn revolution_axis(
381    points: &[Point],
382    normals: &[Vector],
383    tol: Tolerances,
384) -> Option<(Point, Direction)> {
385    // Reweighted: a few samples off the surface (a flat face's corner met
386    // along a tangent line) have normal lines far from the axis, and in
387    // plain least squares those few decide it. Each round weighs a line by
388    // how far it passes from the last round's axis, relative to the
389    // typical miss.
390    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    // Minimized with the direction held to unit length, not the whole
429    // six-vector: a cylinder's normals are all perpendicular to its axis,
430    // and the direction-free "line at infinity" meets every one of them.
431    // For a fixed direction the best moment is linear in it, so the
432    // moment is eliminated and the direction is the Schur complement's
433    // smallest eigenvector.
434    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
463/// Each point's height along the axis and distance from it: the profile
464/// the surface of revolution sweeps.
465fn 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
477/// The axis point at the samples' mean height, so the frame sits among
478/// them rather than wherever the axis estimate happened to pass.
479fn 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    // Radius against height is a line: ρ = k·h + ρ₀.
506    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        // No taper is a cylinder.
522        return None;
523    }
524    // A negative taper is the same cone seen from its other end.
525    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
537/// A circle through points in space: its plane from their covariance, its
538/// centre and radius from an algebraic fit in that plane, and the rms of
539/// how far the points miss it.
540fn 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    // Every normal line of a torus passes through its spine circle, a tube
584    // radius from the surface: at the right radius the points shifted back
585    // along their normals lie on one circle, whose plane gives the axis.
586    // Searched log-spaced over both signs, since the normals may face
587    // either way and the tube may be many times the patch's size.
588    // The tube is the tighter of the torus's two curvatures, so its radius
589    // is read from how fast the normals turn between samples: the largest
590    // turn per unit distance, taken robustly as a high quantile over pairs.
591    // The search then runs finely within a factor of two of it: the
592    // spine's fit sharpens to a narrow valley at the true radius, which a
593    // coarse sweep over every scale steps across.
594    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    // Only a spine wider than the tube makes a torus: shifted far enough,
623    // the points crowd onto a small circle near the axis, which fits well
624    // and describes nothing.
625    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
667/// A surface's defining numbers, as the refinement moves them: a point, a
668/// direction (not kept unit while it moves), and the kind's radii.
669fn 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
696/// A point's signed distance to the surface `x` describes, of the kind
697/// `like` is.
698fn 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
745/// The same surface with its axis direction unit and its origin the axis
746/// point nearest the samples' centroid: the two freedoms the residual does
747/// not see, pinned so the solve cannot wander along them.
748fn 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    // A torus's centre is a point, not a place along a line.
760    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        // The reference radius is the radius at the origin.
769        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
775/// Least squares on the points' distances to the surface, from the
776/// estimate: Gauss–Newton steps through the Jacobian's singular value
777/// decomposition, the directions the samples barely determine truncated
778/// rather than amplified, and each step halved until it lowers the cost.
779/// The Jacobian is by central differences: a handful of numbers, and a
780/// residual cheap enough that exactness in it buys nothing.
781fn 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    // A torus on a small patch is ill-conditioned (its tube and its sweep
797    // trade off against each other) and closes in slowly before it closes
798    // in fast: it gets the steps, and is never given up for slowing.
799    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        // Converging onto samples that are this surface, a step divides
803        // the cost many times over; onto samples that are some other
804        // surface, the steps stall at a floor. A fit that has stopped
805        // halving its cost while still missing by more than the tolerance
806        // will not get there.
807        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                // Converged once a step gains less than a ten-thousandth:
845                // exact samples fall by orders of magnitude a step until
846                // they reach the rounding floor, and samples on no such
847                // surface creep toward a miss the tolerance will refuse.
848                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    /// Points on a cylinder with normals tipped a few degrees off true, as
868    /// a mesh's averaged facet normals are: the refinement recovers the
869    /// cylinder from the points to well within a micron.
870    #[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    /// A small patch of a thick torus (a few millimetres of a tube ten
895    /// millimetres in radius, its normals tipped off true) is recognized
896    /// as that torus, exactly: the tube radius lies far outside the patch's
897    /// own size, and the fit has to find it anyway.
898    #[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}