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::{Axis, 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, 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    /// A surface fitted to a region none of the others fits: a profile
42    /// swept round an axis or along a direction (a surface of revolution or
43    /// an extrusion), or a B-spline patch.
44    Swept(Box<SweptShape>),
45}
46
47/// A fitted surface, with the tolerances its distances are measured at.
48#[derive(Debug, Clone, PartialEq)]
49pub struct SweptShape {
50    /// The surface: a [`ogeom_geom::SurfaceGeometry::Revolution`], an
51    /// [`ogeom_geom::SurfaceGeometry::Extrusion`] or a
52    /// [`ogeom_geom::SurfaceGeometry::BSpline`].
53    pub surface: ogeom_geom::SurfaceGeometry,
54    /// The tolerances a point's foot on it is found to.
55    pub tol: Tolerances,
56    /// The profile sampled evenly in its parameter, where a foot is first
57    /// sought.
58    profile: Vec<(f64, Point)>,
59    /// A patch sampled on an even grid of its parameters, where a foot is
60    /// first sought.
61    grid: Vec<((f64, f64), Point)>,
62    /// The grid's samples in square blocks: each block's centre, the
63    /// radius of the ball about it holding its samples, and their indices.
64    /// A block whose ball is farther from a point than the samples already
65    /// found is passed over whole.
66    blocks: Vec<(Point, f64, Vec<usize>)>,
67}
68
69/// How many samples a swept shape's profile is held at.
70const PROFILE_SAMPLES: usize = 256;
71
72/// How many samples a patch's grid holds along each direction, at the
73/// middles of as many equal cells.
74const GRID_SAMPLES: usize = 40;
75
76/// How many of a patch's nearest grid samples a foot is sought from.
77const GRID_SEEDS: usize = 3;
78
79/// How many samples a block of a patch's grid spans along each direction.
80const GRID_BLOCK: usize = 5;
81
82impl SweptShape {
83    /// A swept shape on `surface`: a sweep's profile sampled, or a patch
84    /// sampled on a grid.
85    #[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    /// The parameter and distance of `p`'s foot on the profile `curve`:
152    /// the nearest sample, sought coarsely and then among its neighbours,
153    /// then Newton's method on the squared distance held between the
154    /// sample's own neighbours.
155    fn on_profile(&self, curve: &ogeom_geom::Curve, p: Point) -> Option<(f64, f64)> {
156        use ogeom_geom::Curve3d as _;
157        // Every sixteenth sample, then the ones round the nearest of those.
158        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    /// The foot of `p` on the surface: its parameters, the point there and
194    /// the distance to it.
195    #[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            // The nearest point of a revolution lies in the point's own
200            // half-plane through the axis: the point turned back into the
201            // profile's, then its foot on the profile.
202            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            // An extrusion's nearest point is on the point's shadow's foot,
226            // as far along the direction as the point.
227            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            // A patch's nearest point is sought from the nearest of its
242            // grid's samples.
243            // A patch's nearest point is sought from the few nearest of its
244            // grid's samples, each refined and the nearest foot kept: a
245            // patch that turns back on itself can bring a sample of another
246            // stretch of it nearer than those of the stretch under the point.
247            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    /// Whether distances to the surface are resolved to `distance`: a
287    /// sphere fitted to a nearly flat patch can have its centre so far off
288    /// that the distance to it, taken from points near the patch, rounds
289    /// by more than the distance, and every point of the patch and well
290    /// beyond it then measures as on it.
291    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    /// The distance from `p` to the surface.
304    #[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    /// The distance from `p` to the surface, positive on the side its
317    /// radius grows toward (outside a cylinder, sphere or torus, off the
318    /// axis of a cone) and along a plane's normal. Near the surface it
319    /// grows as the distance does, which is what a solve onto the surface
320    /// asks of it; a cone's is taken to its nearer nappe.
321    #[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                // In the half-plane through the axis the nappe is the line
335                // through (reference radius, 0) at the half angle.
336                (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/// A recognition with its certificate.
359#[derive(Debug, Clone, PartialEq)]
360pub struct Recognized {
361    /// What the samples are.
362    pub surface: Canonical,
363    /// The worst distance from any sample to it: measured, not promised.
364    pub deviation: f64,
365}
366
367/// Recognize a canonical surface from samples with unit normals.
368///
369/// A plane is tried first; otherwise every curved kind is fitted, and the
370/// closest whose *measured* worst deviation meets `tolerance` is taken, a
371/// simpler kind winning a tie. `None` says the samples are free-form at
372/// that tolerance, which is an answer, not a failure. Each kind has its own
373/// sample floor, below which the fit is underdetermined and verification
374/// would rubber-stamp whatever the algebra produced.
375///
376/// # Errors
377///
378/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if
379/// fewer than three samples arrive, the normals do not match the points,
380/// or the tolerance is not a positive distance.
381pub 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
408/// Whether the samples lie within `tolerance` of one plane.
409pub(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
413/// As [`recognize_points`], among the curved kinds only.
414///
415/// `chords` are straight segments between samples (a mesh's edges),
416/// which break ties among fits: samples on two equal coaxial circles lie
417/// as exactly on a sphere as on a cylinder, but the cylinder's rulings run
418/// between them and the mesh draws those as edges on the surface, midpoint
419/// and all, where no chord of a sphere lies on it.
420pub(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
434/// The fewest samples a torus is fitted to: its seven parameters and half
435/// as many again, the most any kind asks.
436pub(crate) const TORUS_SAMPLES: usize = 11;
437
438/// Every curved kind fitted and refined, with its worst deviation over all
439/// the samples. Fitted on an even subsample of a large set and verified on
440/// all of it: the fit's cost grows with every point, the certificate's
441/// only by a distance each.
442fn 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    // Each kind is fitted only to more samples than it has parameters, by
459    // half as many again: a torus's seven fit eight points on a sphere and
460    // a cylinder's end together exactly, and says nothing by it.
461    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    // A torus far larger than the samples' span is a cylinder there: about
483    // its axis where its tube's radius dwarfs the span, along its tube
484    // where its major radius does. The cylinder fit can miss such a strip
485    // (a few rows of samples tell its axis poorly), and the torus would
486    // stand in for it. Where the torus fits, the cylinder it points to is
487    // fitted too, and kept where it ties the torus.
488    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    // Samples on a band's two rims fit a whole family of spheres and tori,
517    // each as closely as the cone the band's rungs draw: the round fits say
518    // nothing the mesh does, and give way to the ruled one.
519    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
526/// How far a fit may miss the samples and still tie the best one, missing
527/// them by `best`: twice that, or a thousandth of the tolerance.
528fn tie(best: f64, tolerance: f64) -> f64 {
529    (2.0 * best).max(tolerance * 1e-3)
530}
531
532/// The cylinder a torus far larger than the samples' span stands in for,
533/// where one of its radii is ten times the span or more: about its axis
534/// through the samples' mean distance from it where the tube's radius is,
535/// along the tube's spine at the samples' mean angle where the major
536/// radius is.
537fn 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
582/// The fit to take among those within the tolerance: the closest (a
583/// small patch of a torus may sit within the tolerance of a sphere too,
584/// and the sphere would not extrapolate) unless a simpler kind ties it,
585/// within twice the best or at the noise floor of a thousandth of the
586/// tolerance, since a torus can mimic a cylinder as closely as it likes.
587///
588/// Before either, the fit that lays the most of the chords' midpoints on
589/// itself: a mesh's edges along a surface's rulings are on the surface.
590/// The chords favour a ruled fit over a round one the samples cannot tell
591/// it from, and never the other way: a sphere or a torus the samples tell
592/// from a closer fit takes no part. A sphere of a radius far beyond the
593/// samples' span, through a strip of chord facets across a narrow round,
594/// lays the facets' cross chords on itself as their plane does, and misses
595/// the samples by orders more than the round's own cylinder.
596fn 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    // Among fits the samples cannot tell apart, the ruled kinds first: two
629    // rows of samples fix a cylinder or a cone, and lie on a whole family
630    // of spheres and tori besides. A round patch the samples do fix leaves
631    // no ruled fit that close.
632    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
646/// A curved surface through samples of which a few are not on it: fitted,
647/// the tenth that miss the closest fit farthest dropped (a few corners
648/// far off the surface pull a fit a long way, so a median cut would not
649/// isolate them), and fitted again, until what is left fits within the
650/// tolerance or too little is left. Returns the surface and which samples
651/// it keeps, or, when nothing fits, how close the closest fit came.
652pub(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        // Trimming drops a few samples off a surface the rest are on; when
689        // the typical sample misses too, the rest are on no surface.
690        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
705/// The ruled surface of a band whose samples lie on two rows: a sphere's
706/// or a torus's profile is a circle, fixed only by three rows, and two rows
707/// (the rims of a band one triangle high) lie on a whole family of both.
708/// The torus fit passes through both rims, and its tube angle sorts the
709/// samples onto them; each rim's circle then gives the axis, square to
710/// its plane through its centre, and the cone or cylinder about that axis
711/// through the samples is the band. A few stray samples beyond the rows (a
712/// corner of the next facet taken in) are left for trimming to drop.
713/// `None` where no torus fits or the samples stand on more than two rows.
714fn 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
763/// The samples' two largest rows by a torus's tube angle, the largest
764/// first.
765fn 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
796/// The samples' two largest rows as the two parallel planes holding the
797/// most of them: a sphere through a band's rims has no axis of its own to
798/// sort by, but each rim is a circle, and a circle lies in a plane. Planes
799/// through triples of samples: every triple of a small sample, and of a
800/// larger one a fixed spread of triples, a quarter of which fall on one
801/// rim and give its plane.
802fn 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        // A fixed sequence, so the same samples always give the same rows.
815        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    // The other rim: the plane parallel to the first through the most of
846    // the rest.
847    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
854/// The worst distance from any sample to the candidate.
855pub(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
875/// The eigenvector of a 3×3 symmetric matrix for its smallest eigenvalue.
876pub(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    // |p|² − 2p·c + Q = 0 with Q = |c|² − r²: linear in (c, Q). Taken
909    // about a point of the samples: the fit is the same about any point,
910    // and about a far one the squares are huge and cancel to rounding.
911    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
933/// The axis of a surface of revolution, from its normals: every normal
934/// line of such a surface lies in a plane through the axis, so meets it.
935/// In Plücker coordinates a line `(d, m)` meets the normal line `(n, p×n)`
936/// exactly when `d·(p×n) + m·n = 0`, which is linear; the axis is the
937/// least-squares null vector of those conditions, put back on the Klein
938/// quadric. A sphere's normals all meet at one point and determine no
939/// axis, which the fits after this one catch.
940pub(crate) fn revolution_axis(
941    points: &[Point],
942    normals: &[Vector],
943    tol: Tolerances,
944) -> Option<(Point, Direction)> {
945    // Reweighted: a few samples off the surface (a flat face's corner met
946    // along a tangent line) have normal lines far from the axis, and in
947    // plain least squares those few decide it. Each round weighs a line by
948    // how far it passes from the last round's axis, relative to the
949    // typical miss.
950    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    // Minimized with the direction held to unit length, not the whole
989    // six-vector: a cylinder's normals are all perpendicular to its axis,
990    // and the direction-free "line at infinity" meets every one of them.
991    // For a fixed direction the best moment is linear in it, so the
992    // moment is eliminated and the direction is the Schur complement's
993    // smallest eigenvector.
994    // Moments about a point of the samples: the fit is the same about any
995    // point, and about a far one every moment is huge and the elimination
996    // cancels them to rounding.
997    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
1027/// Each point's height along the axis and distance from it: the profile
1028/// the surface of revolution sweeps.
1029fn 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
1041/// The axis point at the samples' mean height, so the frame sits among
1042/// them rather than wherever the axis estimate happened to pass.
1043fn 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
1078/// The ruled surface of revolution about a known axis through the samples:
1079/// radius against height fitted as a line, a cone where it leans and a
1080/// cylinder where it does not, or where its lean changes the radius by no
1081/// more than `flat` over the samples' own height.
1082pub(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    // Radius against height is a line: ρ = k·h + ρ₀.
1091    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        // No taper is a cylinder.
1113        let radius = sr / count;
1114        return Some(Canonical::Cylinder(
1115            Cylinder::new(Frame::about(origin, axis), radius, tol).ok()?,
1116        ));
1117    }
1118    // A negative taper is the same cone seen from its other end.
1119    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
1131/// A circle through points in space: its plane from their covariance, its
1132/// centre and radius from an algebraic fit in that plane, and the rms of
1133/// how far the points miss it.
1134pub(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
1179/// The circle the points trace in the half-plane through the axis (their
1180/// distance from it against their height along it from `base`): its
1181/// centre's distance and height, and its radius. A torus about the axis
1182/// is that circle swept round it.
1183pub(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    // Every normal line of a torus passes through its spine circle, a tube
1206    // radius from the surface: at the right radius the points shifted back
1207    // along their normals lie on one circle, whose plane gives the axis.
1208    // Searched log-spaced over both signs, since the normals may face
1209    // either way and the tube may be many times the patch's size.
1210    // The tube is the tighter of the torus's two curvatures, so its radius
1211    // is read from how fast the normals turn between samples: the largest
1212    // turn per unit distance, taken robustly as a high quantile over pairs.
1213    // The search then runs finely within a factor of two of it: the
1214    // spine's fit sharpens to a narrow valley at the true radius, which a
1215    // coarse sweep over every scale steps across.
1216    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    // Only a spine wider than the tube makes a torus: shifted far enough,
1245    // the points crowd onto a small circle near the axis, which fits well
1246    // and describes nothing.
1247    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
1289/// A surface's defining numbers, as the refinement moves them: a point, a
1290/// direction (not kept unit while it moves), and the kind's radii.
1291fn 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
1318/// A point's signed distance to the surface `x` describes, of the kind
1319/// `like` is.
1320pub(crate) fn residual(like: &Canonical, x: &[f64], p: Point) -> f64 {
1321    Gauge::of(like, x).residual(p)
1322}
1323
1324/// The surface `x` describes, of the kind `like` is, read once for
1325/// measuring many points against it: its axis made unit, its cone's
1326/// slope taken.
1327enum 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    /// The point's signed distance to the surface.
1382    fn residual(&self, p: Point) -> f64 {
1383        // The height along the axis and the distance from it.
1384        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
1430/// The same surface with its axis direction unit and its origin the axis
1431/// point nearest the samples' centroid: the two freedoms the residual does
1432/// not see, pinned so the solve cannot wander along them.
1433fn 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    // A torus's centre is a point, not a place along a line.
1445    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        // The reference radius is the radius at the origin.
1454        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
1460/// Least squares on the points' distances to the surface, from the
1461/// estimate: Gauss-Newton steps through the Jacobian's singular value
1462/// decomposition, the directions the samples barely determine truncated
1463/// rather than amplified, and each step halved until it lowers the cost.
1464/// The Jacobian is by central differences: a handful of numbers, and a
1465/// residual cheap enough that exactness in it buys nothing.
1466pub(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    // A torus on a small patch is ill-conditioned (its tube and its sweep
1490    // trade off against each other) and closes in slowly before it closes
1491    // in fast: it gets the steps, and is never given up for slowing.
1492    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        // Converging onto samples that are this surface, a step divides
1496        // the cost many times over; onto samples that are some other
1497        // surface, the steps stall at a floor. A fit that has stopped
1498        // halving its cost while still missing by more than the tolerance
1499        // will not get there.
1500        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                // Converged once a step gains less than a ten-thousandth:
1541                // exact samples fall by orders of magnitude a step until
1542                // they reach the rounding floor, and samples on no such
1543                // surface creep toward a miss the tolerance will refuse.
1544                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    /// A sphere centred so far off that a point's distance to it rounds by
1564    /// more than the tolerance: a point five off it measures as on it or
1565    /// many times farther. It is no fit. A sphere of the part's own size
1566    /// is.
1567    #[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    /// Points on a cylinder with normals tipped a few degrees off true, as
1587    /// a mesh's averaged facet normals are: the refinement recovers the
1588    /// cylinder from the points to well within a micron.
1589    #[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    /// A small patch of a thick torus (a few millimetres of a tube ten
1614    /// millimetres in radius, its normals tipped off true) is recognized
1615    /// as that torus, exactly: the tube radius lies far outside the patch's
1616    /// own size, and the fit has to find it anyway.
1617    #[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    /// A strip of a narrow round meshed coarsely: two rulings of a radius
1641    /// 0.432 cylinder twelve long, cut into cells, and one corner on a
1642    /// third ruling sixty along, where a fan of long facets meets them.
1643    /// A sphere of a radius in the tens of thousands passes within a
1644    /// thousandth of every sample and lays the cells' cross chords on
1645    /// itself; the samples lie on the cylinder to rounding, which is what
1646    /// is recognized.
1647    #[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}