Skip to main content

ogeom_algo/fit/
primitive.rs

1//! Best fits of primitives to point sets, with their residuals.
2//!
3//! Where [`recognize_points`](crate::recognize_points) decides whether points
4//! *are* a surface, these fit the surface regardless and report how well it
5//! fits: every point's signed residual, their RMS over the points kept, and
6//! the signed extremes. Each fit is orthogonal least squares, the sum of the
7//! squared distances from the points to the shape made least.
8//!
9//! A plane and a line are closed forms: the points' centroid and the
10//! covariance's smallest or largest direction. Every other kind starts from
11//! a closed-form estimate and is refined by damped Gauss-Newton steps on the
12//! distances themselves: a sphere and a circle from the algebraic fit
13//! through the `|c|² − r²` substitution, and a cylinder, a cone and a torus
14//! from the axis every normal line meets, estimated from the normals when
15//! the caller gives them and from each point's neighbours when not.
16//!
17//! The fits run on the calling thread, in point order, and draw no
18//! randomness but a fixed sequence: the same points give the same answer
19//! bit for bit on any machine and any thread count.
20
21use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
22use ogeom_math::{
23    Axis, Axis2, Circle, Circle2, Cone, Cylinder, Direction, Direction2, Frame, Plane, Point,
24    Point2, Sphere, Torus, Vector,
25};
26
27use crate::Cutoff;
28use crate::recognize::{self, Canonical};
29
30/// Which side of a closed surface or curve the material lies on.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Side {
33    /// The material is inside: a boss, a shaft, a ball. A residual is
34    /// positive outside the shape.
35    Inside,
36    /// The material is outside: a bore, a socket. A residual is positive
37    /// inside the shape.
38    Outside,
39}
40
41/// What a fit holds fixed instead of fitting.
42#[derive(Debug, Clone, Copy, PartialEq, Default)]
43pub struct FitFixed {
44    /// A sphere's, a cylinder's or a circle's radius, or a torus's tube
45    /// (minor) radius: a precision ball partly scanned fits its centre only.
46    pub radius: Option<f64>,
47    /// A plane's normal, a line's direction, or the axis direction of a
48    /// cylinder, a cone, a torus or a circle in space; its sense is free.
49    pub direction: Option<Direction>,
50    /// The whole axis of a cylinder, a cone, a torus or a circle in space,
51    /// as a line; its sense is free.
52    pub axis: Option<Axis>,
53    /// A cone's half angle, in radians.
54    pub half_angle: Option<f64>,
55}
56
57/// How to fit.
58///
59/// `S` is the shape fitted, the type of the starting guess.
60#[derive(Debug, Clone, PartialEq)]
61pub struct FitOptions<'a, S> {
62    /// A normal for each point, used for the start and to tell which side
63    /// the material is on; never in the least squares itself, which
64    /// measures the points alone. Their sense is the material's outside.
65    pub normals: Option<&'a [Vector]>,
66    /// Which points are kept between rounds.
67    pub cutoff: Cutoff,
68    /// Start from a consensus over small subsets of the points, scored by
69    /// their median residual, rather than from all of them: a start that
70    /// many outliers cannot pull away, and whose residuals choose the
71    /// first round's points.
72    pub robust_start: bool,
73    /// What is held instead of fitted.
74    pub fixed: FitFixed,
75    /// Start from this shape rather than from the estimate; a robust start
76    /// then only chooses the first round's points, by their residuals from
77    /// it. A closed-form fit (a plane, a line) has no start, and ignores it.
78    pub guess: Option<S>,
79    /// Which side the material is on. Where it is not given and normals
80    /// are, the normals decide.
81    pub side: Option<Side>,
82    /// The radius a fit must land within, inclusive: the radius
83    /// [`FitFixed::radius`] would hold.
84    pub radius_range: Option<(f64, f64)>,
85}
86
87impl<S> Default for FitOptions<'_, S> {
88    fn default() -> Self {
89        Self {
90            normals: None,
91            cutoff: Cutoff::All,
92            robust_start: false,
93            fixed: FitFixed::default(),
94            guess: None,
95            side: None,
96            radius_range: None,
97        }
98    }
99}
100
101/// A fitted shape and how well it fits.
102#[derive(Debug, Clone, PartialEq)]
103pub struct Fit<S> {
104    /// The shape.
105    pub shape: S,
106    /// The RMS of the residuals of the points kept.
107    pub sigma: f64,
108    /// The least and the greatest residual of the points kept; their
109    /// difference is the form error, peak to peak.
110    pub form: (f64, f64),
111    /// Whether each point was kept by the last round.
112    pub used: Vec<bool>,
113    /// Each point's signed distance to the shape: positive off the
114    /// material's side where the side is known, else away from the centre,
115    /// the axis or the spine; along the normal for a plane, to the left for
116    /// a line in the plane. A line in space has no side, and its residual
117    /// is the distance.
118    pub residuals: Vec<f64>,
119    /// The least-squares steps taken, over every round.
120    pub iterations: u32,
121    /// The rounds fitted: one, unless the cutoff dropped points.
122    pub rounds: u32,
123    /// Whether the last round's steps converged and the kept points stopped
124    /// changing.
125    pub converged: bool,
126    /// The side the residuals are signed by, given or read from the
127    /// normals; `None` for a kind without one or where neither said.
128    pub side: Option<Side>,
129}
130
131/// The plane through points in space.
132///
133/// # Errors
134///
135/// As every fit: [`OgeomError::Construction`](ogeom_core::OgeomError::Construction)
136/// with too few points (three, or one with the normal fixed) or with them
137/// all within the confusion distance of one line, and
138/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) for an option this
139/// kind has no use for (a side, a radius, an axis) or a point that is not
140/// finite.
141pub fn plane(
142    points: &[Point],
143    options: &FitOptions<'_, Plane>,
144    tol: Tolerances,
145) -> OgeomResult<Fit<Plane>> {
146    let guess = options.guess.map(|p| State::about(p.origin(), p.normal()));
147    run(Kind::Plane, points, options, guess, tol)?
148        .shaped(|s| Ok(Plane::new(Frame::about(s.origin, s.axis))))
149}
150
151/// The line through points in space.
152///
153/// # Errors
154///
155/// As [`plane`], with two points needed, or one with the direction fixed,
156/// and refused where they all lie within the confusion distance of one
157/// point.
158pub fn line3(
159    points: &[Point],
160    options: &FitOptions<'_, Axis>,
161    tol: Tolerances,
162) -> OgeomResult<Fit<Axis>> {
163    let guess = options.guess.map(|a| State::about(a.location, a.direction));
164    run(Kind::Line3, points, options, guess, tol)?.shaped(|s| Ok(Axis::new(s.origin, s.axis)))
165}
166
167/// The sphere through points.
168///
169/// # Errors
170///
171/// As [`plane`], with four points needed, or three with the radius fixed;
172/// refused where they lie on one plane, and with
173/// [`OgeomError::Range`](ogeom_core::OgeomError::Range) where the radius
174/// lands outside [`FitOptions::radius_range`].
175pub fn sphere(
176    points: &[Point],
177    options: &FitOptions<'_, Sphere>,
178    tol: Tolerances,
179) -> OgeomResult<Fit<Sphere>> {
180    let guess = options.guess.map(|s| State {
181        radius: s.radius(),
182        ..State::about(s.centre(), Direction::Z)
183    });
184    run(Kind::Sphere, points, options, guess, tol)?
185        .shaped(|s| Sphere::centred(s.origin, s.radius, tol))
186}
187
188/// The cylinder through points.
189///
190/// # Errors
191///
192/// As [`sphere`], with five points needed, fewer with parts of it fixed;
193/// refused where no axis is determined.
194pub fn cylinder(
195    points: &[Point],
196    options: &FitOptions<'_, Cylinder>,
197    tol: Tolerances,
198) -> OgeomResult<Fit<Cylinder>> {
199    let guess = options.guess.map(|c| State {
200        radius: c.radius(),
201        ..State::about(c.frame().origin(), c.frame().z())
202    });
203    run(Kind::Cylinder, points, options, guess, tol)?
204        .shaped(|s| Cylinder::new(Frame::about(s.origin, s.axis), s.radius, tol))
205}
206
207/// The cone through points.
208///
209/// The cone's frame sits on its axis level with the points' centroid, and
210/// widens along its axis.
211///
212/// # Errors
213///
214/// As [`sphere`], with six points needed, fewer with parts of it fixed;
215/// refused where no axis is determined or where the points have no taper,
216/// lying on a cylinder. A radius range does not apply: a cone's radius
217/// changes along it.
218pub fn cone(
219    points: &[Point],
220    options: &FitOptions<'_, Cone>,
221    tol: Tolerances,
222) -> OgeomResult<Fit<Cone>> {
223    let guess = options.guess.map(|c| State {
224        radius: c.reference_radius(),
225        second: c.half_angle(),
226        ..State::about(c.frame().origin(), c.frame().z())
227    });
228    run(Kind::Cone, points, options, guess, tol)?.shaped(|s| {
229        Cone::new(Frame::about(s.origin, s.axis), s.radius, s.second, tol).map_err(|_| {
230            ogeom_core::ogeom_err!(
231                Construction,
232                "the points have no taper a cone can take: half angle {} at radius {}",
233                s.second,
234                s.radius
235            )
236        })
237    })
238}
239
240/// The torus through points.
241///
242/// [`FitFixed::radius`] and [`FitOptions::radius_range`] are the tube's
243/// (minor) radius.
244///
245/// # Errors
246///
247/// As [`sphere`], with seven points needed, fewer with parts of it fixed;
248/// refused where no axis is determined.
249pub fn torus(
250    points: &[Point],
251    options: &FitOptions<'_, Torus>,
252    tol: Tolerances,
253) -> OgeomResult<Fit<Torus>> {
254    let guess = options.guess.map(|t| State {
255        radius: t.minor_radius(),
256        second: t.major_radius(),
257        ..State::about(t.centre(), t.frame().z())
258    });
259    run(Kind::Torus, points, options, guess, tol)?
260        .shaped(|s| Torus::new(Frame::about(s.origin, s.axis), s.second, s.radius, tol))
261}
262
263/// The circle through points in space: the distance a residual measures
264/// is to the circle itself, out of its plane as well as across it, and is
265/// signed by which side of the cylinder through the circle the point lies.
266///
267/// # Errors
268///
269/// As [`sphere`], with three points needed; refused where they lie on one
270/// line.
271pub fn circle3(
272    points: &[Point],
273    options: &FitOptions<'_, Circle>,
274    tol: Tolerances,
275) -> OgeomResult<Fit<Circle>> {
276    let guess = options.guess.map(|c| State {
277        radius: c.radius(),
278        ..State::about(c.centre(), c.frame().z())
279    });
280    run(Kind::Circle3, points, options, guess, tol)?
281        .shaped(|s| Circle::new(Frame::about(s.origin, s.axis), s.radius, tol))
282}
283
284/// The circle through points in the plane.
285///
286/// # Errors
287///
288/// As [`sphere`], with three points needed; refused where they lie on one
289/// line. Normals, a direction and an axis do not apply.
290pub fn circle2(
291    points: &[Point2],
292    options: &FitOptions<'_, Circle2>,
293    tol: Tolerances,
294) -> OgeomResult<Fit<Circle2>> {
295    let lifted: Vec<Point> = points.iter().map(|p| p.to_3d()).collect();
296    let guess = options.guess.map(|c| State {
297        radius: c.radius(),
298        ..State::about(c.centre().to_3d(), Direction::Z)
299    });
300    run(Kind::Circle2, &lifted, options, guess, tol)?
301        .shaped(|s| Circle2::centred(s.origin.xy(), s.radius, tol))
302}
303
304/// The line through points in the plane.
305///
306/// # Errors
307///
308/// As [`line3`]. Normals, a side and anything fixed do not apply.
309pub fn line2(
310    points: &[Point2],
311    options: &FitOptions<'_, Axis2>,
312    tol: Tolerances,
313) -> OgeomResult<Fit<Axis2>> {
314    let lifted: Vec<Point> = points.iter().map(|p| p.to_3d()).collect();
315    run(Kind::Line2, &lifted, options, None, tol)?.shaped(|s| {
316        Ok(Axis2 {
317            location: s.origin.xy(),
318            direction: Direction2::new(s.axis.vector().xy(), tol)?,
319        })
320    })
321}
322
323/// The kinds of shape, as the fitting machinery tells them apart.
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
325pub(super) enum Kind {
326    Plane,
327    Line3,
328    Line2,
329    Sphere,
330    Circle2,
331    Circle3,
332    Cylinder,
333    Cone,
334    Torus,
335}
336
337impl Kind {
338    pub(super) const fn name(self) -> &'static str {
339        match self {
340            Self::Plane => "plane",
341            Self::Line3 => "line",
342            Self::Line2 => "line in the plane",
343            Self::Sphere => "sphere",
344            Self::Circle2 => "circle in the plane",
345            Self::Circle3 => "circle",
346            Self::Cylinder => "cylinder",
347            Self::Cone => "cone",
348            Self::Torus => "torus",
349        }
350    }
351
352    /// Fitted in closed form, with no start and no steps.
353    const fn closed(self) -> bool {
354        matches!(self, Self::Plane | Self::Line3 | Self::Line2)
355    }
356
357    /// A kind in the plane, which has no normals to take.
358    const fn planar(self) -> bool {
359        matches!(self, Self::Line2 | Self::Circle2)
360    }
361
362    /// A kind that encloses a side, and so has residuals signed by it.
363    const fn sided(self) -> bool {
364        !matches!(self, Self::Plane | Self::Line3 | Self::Line2)
365    }
366
367    /// A kind with the one radius [`FitFixed::radius`] holds.
368    const fn radial(self) -> bool {
369        matches!(
370            self,
371            Self::Sphere | Self::Circle2 | Self::Circle3 | Self::Cylinder | Self::Torus
372        )
373    }
374
375    /// A kind with a direction to hold.
376    const fn directed(self) -> bool {
377        matches!(
378            self,
379            Self::Plane | Self::Line3 | Self::Circle3 | Self::Cylinder | Self::Cone | Self::Torus
380        )
381    }
382
383    /// A kind with an axis line to hold.
384    const fn axial(self) -> bool {
385        matches!(
386            self,
387            Self::Circle3 | Self::Cylinder | Self::Cone | Self::Torus
388        )
389    }
390
391    /// How many distances each point contributes: a circle in space is
392    /// missed across its plane and within it.
393    const fn components(self) -> usize {
394        if matches!(self, Self::Circle3) { 2 } else { 1 }
395    }
396
397    /// A surface of revolution, whose start is read from normals.
398    const fn revolved(self) -> bool {
399        matches!(self, Self::Cylinder | Self::Cone | Self::Torus)
400    }
401}
402
403/// A shape of any kind as the fits move it: a point (a centre, or a point
404/// on the axis or the plane), a direction (a normal or an axis), a radius
405/// (a cone's at the point, a torus's tube) and a second size (a cone's half
406/// angle, a torus's major radius).
407#[derive(Debug, Clone, Copy, PartialEq)]
408struct State {
409    origin: Point,
410    axis: Direction,
411    radius: f64,
412    second: f64,
413}
414
415impl State {
416    const fn about(origin: Point, axis: Direction) -> Self {
417        Self {
418            origin,
419            axis,
420            radius: 0.0,
421            second: 0.0,
422        }
423    }
424}
425
426/// The parameters a step moves: two tilts of the direction, two moves of
427/// the point across it, one along it, the radius and the second size. A
428/// sphere and a circle in the plane move their centre along the world's
429/// axes through the same three moves.
430const PARAMETERS: usize = 7;
431const TILT: [usize; 2] = [0, 1];
432const ACROSS: [usize; 2] = [2, 3];
433const ALONG: usize = 4;
434const RADIUS: usize = 5;
435const SECOND: usize = 6;
436
437/// Which parameters a kind fits, with what the caller holds left out.
438fn free(kind: Kind, fixed: &FitFixed) -> [bool; PARAMETERS] {
439    let mut on = [false; PARAMETERS];
440    let tilt = fixed.direction.is_none() && fixed.axis.is_none();
441    let across = fixed.axis.is_none();
442    let radius = fixed.radius.is_none();
443    match kind {
444        Kind::Plane | Kind::Line3 | Kind::Line2 => {}
445        Kind::Sphere => {
446            on[ACROSS[0]] = true;
447            on[ACROSS[1]] = true;
448            on[ALONG] = true;
449            on[RADIUS] = radius;
450        }
451        Kind::Circle2 => {
452            on[ACROSS[0]] = true;
453            on[ACROSS[1]] = true;
454            on[RADIUS] = radius;
455        }
456        Kind::Cylinder | Kind::Cone | Kind::Torus | Kind::Circle3 => {
457            on[TILT[0]] = tilt;
458            on[TILT[1]] = tilt;
459            on[ACROSS[0]] = across;
460            on[ACROSS[1]] = across;
461            on[ALONG] = matches!(kind, Kind::Torus | Kind::Circle3);
462            on[RADIUS] = radius;
463            on[SECOND] = match kind {
464                Kind::Cone => fixed.half_angle.is_none(),
465                Kind::Torus => true,
466                _ => false,
467            };
468        }
469    }
470    on
471}
472
473/// The fewest points that determine the fit.
474pub(super) fn needed(kind: Kind, fixed: &FitFixed) -> usize {
475    let held = fixed.direction.is_some();
476    match kind {
477        Kind::Plane => {
478            if held {
479                1
480            } else {
481                3
482            }
483        }
484        Kind::Line3 | Kind::Line2 => {
485            if held {
486                1
487            } else {
488                2
489            }
490        }
491        _ => {
492            let count = free(kind, fixed).iter().filter(|on| **on).count();
493            count.div_ceil(kind.components()).max(1)
494        }
495    }
496}
497
498/// A fitted surface asked about any point: its residual there, signed as
499/// the fit signed its own, and the direction that residual grows in.
500#[derive(Debug, Clone, Copy, PartialEq)]
501pub(super) struct Model {
502    kind: Kind,
503    state: State,
504    /// −1 where the material is outside the shape, so the residual and the
505    /// normal are the material's; 1 otherwise.
506    sense: f64,
507    /// Whether the fit knew the material's side, or for a plane, its normal
508    /// was turned by the normals the fit was given.
509    oriented: bool,
510    /// The parameters the fit moved.
511    free: [bool; PARAMETERS],
512    /// The inverse of the fit's normal matrix over the parameters it moved,
513    /// over its kept points: the parameters' covariance in units of the
514    /// noise's variance. Zero until [`Model::measured_over`].
515    spread: [[f64; PARAMETERS]; PARAMETERS],
516}
517
518impl Model {
519    fn sided<S>(kind: Kind, state: State, fit: &Fit<S>) -> Self {
520        Self {
521            kind,
522            state,
523            sense: if fit.side == Some(Side::Outside) {
524                -1.0
525            } else {
526                1.0
527            },
528            oriented: fit.side.is_some(),
529            free: [false; PARAMETERS],
530            spread: [[0.0; PARAMETERS]; PARAMETERS],
531        }
532    }
533
534    /// A plane fitted with normals: its normal faces them.
535    pub(super) fn plane(fit: &Fit<Plane>) -> Self {
536        Self {
537            kind: Kind::Plane,
538            state: State::about(fit.shape.origin(), fit.shape.normal()),
539            sense: 1.0,
540            oriented: true,
541            free: [false; PARAMETERS],
542            spread: [[0.0; PARAMETERS]; PARAMETERS],
543        }
544    }
545
546    pub(super) fn sphere(fit: &Fit<Sphere>) -> Self {
547        let s = &fit.shape;
548        let state = State {
549            radius: s.radius(),
550            ..State::about(s.centre(), Direction::Z)
551        };
552        Self::sided(Kind::Sphere, state, fit)
553    }
554
555    pub(super) fn cylinder(fit: &Fit<Cylinder>) -> Self {
556        let c = &fit.shape;
557        let state = State {
558            radius: c.radius(),
559            ..State::about(c.frame().origin(), c.frame().z())
560        };
561        Self::sided(Kind::Cylinder, state, fit)
562    }
563
564    pub(super) fn cone(fit: &Fit<Cone>) -> Self {
565        let c = &fit.shape;
566        let state = State {
567            radius: c.reference_radius(),
568            second: c.half_angle(),
569            ..State::about(c.frame().origin(), c.frame().z())
570        };
571        Self::sided(Kind::Cone, state, fit)
572    }
573
574    pub(super) fn torus(fit: &Fit<Torus>) -> Self {
575        let t = &fit.shape;
576        let state = State {
577            radius: t.minor_radius(),
578            second: t.major_radius(),
579            ..State::about(t.centre(), t.frame().z())
580        };
581        Self::sided(Kind::Torus, state, fit)
582    }
583
584    /// A circle in space: its residual is the distance to the circle,
585    /// signed by the side of the cylinder through it.
586    pub(super) fn circle3(fit: &Fit<Circle>) -> Self {
587        let c = &fit.shape;
588        let state = State {
589            radius: c.radius(),
590            ..State::about(c.centre(), c.frame().z())
591        };
592        Self::sided(Kind::Circle3, state, fit)
593    }
594
595    /// A line in space: its residual is the distance to the line, which
596    /// has no side.
597    pub(super) fn line3(fit: &Fit<Axis>) -> Self {
598        Self {
599            kind: Kind::Line3,
600            state: State::about(fit.shape.location, fit.shape.direction),
601            sense: 1.0,
602            oriented: false,
603            free: [false; PARAMETERS],
604            spread: [[0.0; PARAMETERS]; PARAMETERS],
605        }
606    }
607
608    /// How many distances each point's residual combines: two for a curve
609    /// in space, which a point misses in two directions, one for a
610    /// surface.
611    pub(super) const fn components(&self) -> usize {
612        match self.kind {
613            Kind::Line3 => 2,
614            kind => kind.components(),
615        }
616    }
617
618    /// The most the residual at `p` moves when each coordinate moves by at
619    /// most the matching coordinate of `half`: along a surface's normal,
620    /// the box's extent there; for a curve in space, whose residual grows
621    /// in a direction its nearest point decides, the box's half diagonal.
622    pub(super) fn moved_by(&self, p: Point, half: Vector) -> f64 {
623        if self.components() == 2 {
624            return half.magnitude();
625        }
626        let n = self.normal(p);
627        n.x.abs() * half.x + n.y.abs() * half.y + n.z.abs() * half.z
628    }
629
630    /// The point's residual, signed as the fit's residuals are.
631    pub(super) fn residual(&self, p: Point) -> f64 {
632        self.sense * residual(self.kind, &self.state, p)
633    }
634
635    /// The unit direction the point's residual grows in: the material's
636    /// outward normal where the model is oriented.
637    pub(super) fn normal(&self, p: Point) -> Vector {
638        outward(self.kind, &self.state, p) * self.sense
639    }
640
641    /// Whether [`Model::normal`] has the material's sense rather than an
642    /// arbitrary one.
643    pub(super) const fn oriented(&self) -> bool {
644        self.oriented
645    }
646
647    /// The model with the spread of its parameters measured over the
648    /// points its fit kept, `fixed` as the fit held it.
649    ///
650    /// The normal matrix is inverted over its eigenvalues above the
651    /// rounding floor, as the fit's own steps are: a direction the points
652    /// do not determine is one the fit did not move either.
653    pub(super) fn measured_over(
654        mut self,
655        points: &[Point],
656        used: &[bool],
657        fixed: &FitFixed,
658    ) -> Self {
659        let free = if self.kind == Kind::Plane {
660            let mut on = [false; PARAMETERS];
661            on[ALONG] = true;
662            let tilt = fixed.direction.is_none();
663            on[TILT[0]] = tilt;
664            on[TILT[1]] = tilt;
665            on
666        } else if self.kind == Kind::Line3 {
667            let mut on = [false; PARAMETERS];
668            let tilt = fixed.direction.is_none();
669            on[TILT[0]] = tilt;
670            on[TILT[1]] = tilt;
671            on[ACROSS[0]] = true;
672            on[ACROSS[1]] = true;
673            on
674        } else {
675            free(self.kind, fixed)
676        };
677        let index: Vec<usize> = (0..PARAMETERS).filter(|k| free[*k]).collect();
678        let size = index.len();
679        self.free = free;
680        self.spread = [[0.0; PARAMETERS]; PARAMETERS];
681        if size == 0 {
682            return self;
683        }
684        let frame = basis(self.kind, &self.state);
685        let mut normal = nalgebra::DMatrix::<f64>::zeros(size, size);
686        for (p, keep) in points.iter().zip(used) {
687            if !*keep {
688                continue;
689            }
690            let (rows, count) = self.rows(frame, *p);
691            for (_, d) in &rows[..count] {
692                for (i, &ki) in index.iter().enumerate() {
693                    for (j, &kj) in index.iter().enumerate() {
694                        normal[(i, j)] += d[ki] * d[kj];
695                    }
696                }
697            }
698        }
699        let eigen = nalgebra::SymmetricEigen::new(normal);
700        let largest = eigen.eigenvalues.iter().copied().fold(0.0, f64::max);
701        #[allow(clippy::cast_precision_loss, reason = "a handful of parameters")]
702        let floor = largest * f64::EPSILON * size as f64;
703        for (k, value) in eigen.eigenvalues.iter().enumerate() {
704            if *value > floor {
705                let v = eigen.eigenvectors.column(k);
706                for (i, &ki) in index.iter().enumerate() {
707                    for (j, &kj) in index.iter().enumerate() {
708                        self.spread[ki][kj] += v[i] * v[j] / value;
709                    }
710                }
711            }
712        }
713        self
714    }
715
716    /// How many parameters the fit moved.
717    pub(super) fn parameters(&self) -> usize {
718        self.free.iter().filter(|on| **on).count()
719    }
720
721    /// How much the fit's own uncertainty moves its residual at `p`, in
722    /// units of the noise's variance: the residual of a point the fit did
723    /// not see varies as the noise's variance times one plus this.
724    pub(super) fn leverage(&self, p: Point) -> f64 {
725        let (rows, count) = self.rows(basis(self.kind, &self.state), p);
726        let mut sum = 0.0;
727        for (_, d) in &rows[..count] {
728            for i in 0..PARAMETERS {
729                if !self.free[i] {
730                    continue;
731                }
732                for j in 0..PARAMETERS {
733                    if self.free[j] {
734                        sum += d[i] * self.spread[i][j] * d[j];
735                    }
736                }
737            }
738        }
739        sum
740    }
741
742    /// A point's distances and their derivatives, a plane's and a line's
743    /// included: a plane's point moved along its normal and its normal
744    /// tilted; a line's offsets across it, each moved by its point moved
745    /// that way and by its direction tilted that way.
746    fn rows(&self, frame: [Vector; 3], p: Point) -> ([(f64, [f64; PARAMETERS]); 2], usize) {
747        let [e1, e2, a] = frame;
748        let w = p - self.state.origin;
749        match self.kind {
750            Kind::Plane => {
751                let mut d = [0.0; PARAMETERS];
752                d[TILT[0]] = w.dot(e1);
753                d[TILT[1]] = w.dot(e2);
754                d[ALONG] = -1.0;
755                ([(w.dot(a), d), (0.0, [0.0; PARAMETERS])], 1)
756            }
757            Kind::Line3 => {
758                let h = w.dot(a);
759                let mut out = [(0.0, [0.0; PARAMETERS]); 2];
760                for (i, e) in [e1, e2].into_iter().enumerate() {
761                    out[i].0 = w.dot(e);
762                    out[i].1[TILT[i]] = -h;
763                    out[i].1[ACROSS[i]] = -1.0;
764                }
765                (out, 2)
766            }
767            _ => distances(self.kind, &self.state, frame, p),
768        }
769    }
770}
771
772/// A fit's state and the bookkeeping the public result is made from.
773struct Outcome {
774    state: State,
775    sigma: f64,
776    form: (f64, f64),
777    used: Vec<bool>,
778    residuals: Vec<f64>,
779    iterations: u32,
780    rounds: u32,
781    converged: bool,
782    side: Option<Side>,
783}
784
785impl Outcome {
786    fn shaped<S>(self, build: impl FnOnce(&State) -> OgeomResult<S>) -> OgeomResult<Fit<S>> {
787        Ok(Fit {
788            shape: build(&self.state)?,
789            sigma: self.sigma,
790            form: self.form,
791            used: self.used,
792            residuals: self.residuals,
793            iterations: self.iterations,
794            rounds: self.rounds,
795            converged: self.converged,
796            side: self.side,
797        })
798    }
799}
800
801/// The most rounds a cutoff fits before giving up on the kept points
802/// settling: each round costs a whole fit, and a set still changing after
803/// this many is oscillating between two, reported as not converged.
804const MOST_ROUNDS: u32 = 50;
805
806/// The most points a start is estimated from, taken evenly through the
807/// set: the start needs only to land where the least squares converges
808/// from, which a few thousand points fix as well as millions, and this
809/// bounds its cost.
810const START_POINTS: usize = 4096;
811
812/// The size of a Gaussian's median absolute residual in its standard
813/// deviations, `Φ⁻¹(3/4)`: a robust start's spread is its median residual
814/// divided by this.
815const MEDIAN_IN_SIGMAS: f64 = 0.674_489_750_196_081_7;
816
817fn run<S>(
818    kind: Kind,
819    points: &[Point],
820    options: &FitOptions<'_, S>,
821    guess: Option<State>,
822    tol: Tolerances,
823) -> OgeomResult<Outcome> {
824    let normals = checked(kind, points, options)?;
825    let fixed = &options.fixed;
826    let needed = needed(kind, fixed);
827    if points.len() < needed {
828        ogeom_bail!(
829            Construction,
830            "a {} fit needs at least {needed} points, got {}",
831            kind.name(),
832            points.len()
833        );
834    }
835
836    let stride = points.len().div_ceil(START_POINTS).max(1);
837    let sample: Vec<Point> = points.iter().step_by(stride).copied().collect();
838    let sample_normals: Option<Vec<Vector>> = match &normals {
839        Some(n) => Some(n.iter().step_by(stride).copied().collect()),
840        None if kind.revolved() && start_reads_normals(kind, fixed) => {
841            Some(estimated_normals(&sample)?)
842        }
843        None => None,
844    };
845    let sample_normals = sample_normals.as_deref();
846    let given = normals.is_some();
847
848    let mut state = match guess {
849        Some(g) if !kind.closed() => pinned(kind, g, fixed),
850        _ if options.robust_start => consensus(kind, &sample, sample_normals, given, fixed, tol)?,
851        _ => start(kind, &sample, sample_normals, given, fixed, tol)?,
852    };
853
854    let mut used = vec![true; points.len()];
855    if options.robust_start && options.cutoff != Cutoff::All {
856        let sizes: Vec<f64> = points.iter().map(|p| residual(kind, &state, *p)).collect();
857        let threshold = robust_threshold(options.cutoff, &sizes);
858        for (keep, r) in used.iter_mut().zip(&sizes) {
859            *keep = r.abs() <= threshold;
860        }
861        refuse_too_few_kept(kind, &used, needed)?;
862    }
863
864    let mut iterations = 0_u32;
865    let mut rounds = 0_u32;
866    let mut converged;
867    let mut residuals;
868    loop {
869        rounds += 1;
870        let (next, steps, settled) = if kind.closed() {
871            (
872                closed_form(kind, &state, points, &used, fixed, tol)?,
873                0,
874                true,
875            )
876        } else {
877            least_squares(kind, state, &free(kind, fixed), points, &used, tol)
878        };
879        state = next;
880        iterations += steps;
881        converged = settled;
882        residuals = points
883            .iter()
884            .map(|p| residual(kind, &state, *p))
885            .collect::<Vec<_>>();
886        if options.cutoff == Cutoff::All {
887            break;
888        }
889        let kept: Vec<f64> = residuals
890            .iter()
891            .zip(&used)
892            .filter_map(|(r, keep)| keep.then_some(*r))
893            .collect();
894        let threshold = options.cutoff.threshold(&kept);
895        let next_used: Vec<bool> = residuals.iter().map(|r| r.abs() <= threshold).collect();
896        if next_used == used {
897            break;
898        }
899        refuse_too_few_kept(kind, &next_used, needed)?;
900        if rounds >= MOST_ROUNDS {
901            converged = false;
902            break;
903        }
904        used = next_used;
905    }
906    if kind == Kind::Cone && state.second < 0.0 {
907        // The same cone seen from its other end.
908        state.axis = -state.axis;
909        state.second = -state.second;
910    }
911
912    let mut side = None;
913    if kind.sided() {
914        side = options.side.or_else(|| {
915            normals.as_deref().and_then(|n| {
916                let vote = facing(kind, &state, points, n, &used);
917                if vote > 0.0 {
918                    Some(Side::Inside)
919                } else if vote < 0.0 {
920                    Some(Side::Outside)
921                } else {
922                    None
923                }
924            })
925        });
926        if side == Some(Side::Outside) {
927            for r in &mut residuals {
928                *r = -*r;
929            }
930        }
931    } else if kind == Kind::Plane
932        && let Some(n) = normals.as_deref()
933        && facing(kind, &state, points, n, &used) < 0.0
934    {
935        state.axis = -state.axis;
936        for r in &mut residuals {
937            *r = -*r;
938        }
939    }
940
941    let (mut sum, mut count) = (0.0, 0.0);
942    let mut form = (f64::INFINITY, f64::NEG_INFINITY);
943    for (r, keep) in residuals.iter().zip(&used) {
944        if *keep {
945            sum += r * r;
946            count += 1.0;
947            form = (form.0.min(*r), form.1.max(*r));
948        }
949    }
950    let sigma = (sum / count).sqrt();
951
952    if let Some((lo, hi)) = options.radius_range
953        && !(lo..=hi).contains(&state.radius)
954    {
955        ogeom_bail!(
956            Range,
957            "the fitted {} radius {} lies outside the range [{lo}, {hi}]",
958            kind.name(),
959            state.radius
960        );
961    }
962    Ok(Outcome {
963        state,
964        sigma,
965        form,
966        used,
967        residuals,
968        iterations,
969        rounds,
970        converged,
971        side,
972    })
973}
974
975/// The options checked against the kind and the points, and the normals
976/// made unit.
977fn checked<S>(
978    kind: Kind,
979    points: &[Point],
980    options: &FitOptions<'_, S>,
981) -> OgeomResult<Option<Vec<Vector>>> {
982    let name = kind.name();
983    if let Some(i) = points.iter().position(|p| !p.is_finite()) {
984        ogeom_bail!(Domain, "point {i} is not finite");
985    }
986    options.cutoff.check()?;
987    let fixed = &options.fixed;
988    let refuse = |what: &str| -> OgeomResult<()> {
989        ogeom_bail!(Domain, "{what} does not apply to a {name} fit")
990    };
991    if fixed.radius.is_some() && !kind.radial() {
992        refuse("a fixed radius")?;
993    }
994    if fixed.direction.is_some() && !kind.directed() {
995        refuse("a fixed direction")?;
996    }
997    if fixed.axis.is_some() && !kind.axial() {
998        refuse("a fixed axis")?;
999    }
1000    if fixed.half_angle.is_some() && kind != Kind::Cone {
1001        refuse("a fixed half angle")?;
1002    }
1003    if options.side.is_some() && !kind.sided() {
1004        refuse("a side")?;
1005    }
1006    if options.radius_range.is_some() && !kind.radial() {
1007        refuse("a radius range")?;
1008    }
1009    if options.normals.is_some() && kind.planar() {
1010        refuse("normals")?;
1011    }
1012    if fixed.direction.is_some() && fixed.axis.is_some() {
1013        ogeom_bail!(
1014            Domain,
1015            "a fixed axis holds its direction; give the axis or the direction, not both"
1016        );
1017    }
1018    if let Some(r) = fixed.radius
1019        && !(r.is_finite() && r > 0.0)
1020    {
1021        ogeom_bail!(Domain, "the fixed radius {r} must be finite and positive");
1022    }
1023    if let Some(a) = fixed.half_angle
1024        && !(a.is_finite() && a > 0.0 && a < core::f64::consts::FRAC_PI_2)
1025    {
1026        ogeom_bail!(
1027            Domain,
1028            "the fixed half angle {a} must lie strictly between 0 and pi/2"
1029        );
1030    }
1031    if let Some((lo, hi)) = options.radius_range
1032        && !(lo.is_finite() && hi.is_finite() && 0.0 <= lo && lo <= hi)
1033    {
1034        ogeom_bail!(
1035            Domain,
1036            "the radius range [{lo}, {hi}] must be finite, non-negative and in order"
1037        );
1038    }
1039    let Some(normals) = options.normals else {
1040        return Ok(None);
1041    };
1042    if normals.len() != points.len() {
1043        ogeom_bail!(
1044            Dimension,
1045            "{} normals for {} points",
1046            normals.len(),
1047            points.len()
1048        );
1049    }
1050    normals
1051        .iter()
1052        .enumerate()
1053        .map(|(i, n)| {
1054            let m = n.magnitude();
1055            if !(m.is_finite() && m > 0.0) {
1056                ogeom_bail!(Domain, "normal {i} has no direction");
1057            }
1058            Ok(*n / m)
1059        })
1060        .collect::<OgeomResult<Vec<_>>>()
1061        .map(Some)
1062}
1063
1064fn refuse_too_few_kept(kind: Kind, used: &[bool], needed: usize) -> OgeomResult<()> {
1065    let kept = used.iter().filter(|u| **u).count();
1066    if kept < needed {
1067        ogeom_bail!(
1068            Construction,
1069            "the cutoff keeps {kept} points, fewer than the {needed} a {} fit needs",
1070            kind.name()
1071        );
1072    }
1073    Ok(())
1074}
1075
1076/// A cutoff's first threshold over a robust start's residuals: the spread
1077/// read from their median, which the outliers a consensus start ignores
1078/// cannot inflate the way they inflate an RMS.
1079fn robust_threshold(cutoff: Cutoff, residuals: &[f64]) -> f64 {
1080    let median = Cutoff::Median(1.0).threshold(residuals);
1081    match cutoff {
1082        Cutoff::Sigma(k) => k * median / MEDIAN_IN_SIGMAS,
1083        other => other.threshold(residuals),
1084    }
1085}
1086
1087/// How the normals face the shape: the sum over the kept points of each
1088/// normal's component along the direction its residual grows.
1089fn facing(kind: Kind, state: &State, points: &[Point], normals: &[Vector], used: &[bool]) -> f64 {
1090    let mut sum = 0.0;
1091    for ((p, n), keep) in points.iter().zip(normals).zip(used) {
1092        if *keep {
1093            sum += n.dot(outward(kind, state, *p));
1094        }
1095    }
1096    sum
1097}
1098
1099/// The direction a point's residual grows in.
1100fn outward(kind: Kind, state: &State, p: Point) -> Vector {
1101    let a = state.axis.vector();
1102    let w = p - state.origin;
1103    let h = w.dot(a);
1104    let q = w - a * h;
1105    let unit = |v: Vector| {
1106        let m = v.magnitude();
1107        if m > 0.0 { v / m } else { Vector::ZERO }
1108    };
1109    match kind {
1110        Kind::Plane => a,
1111        Kind::Sphere | Kind::Circle2 => unit(w),
1112        Kind::Line2 => Vector::new(-a.y, a.x, 0.0),
1113        Kind::Line3 | Kind::Cylinder | Kind::Circle3 => unit(q),
1114        Kind::Cone => {
1115            let (s, c) = state.second.sin_cos();
1116            unit(q) * c - a * s
1117        }
1118        Kind::Torus => {
1119            let rho = q.magnitude();
1120            let radial = unit(q);
1121            unit(radial * (rho - state.second) + a * h)
1122        }
1123    }
1124}
1125
1126/// A point's signed distance to the shape, positive away from its centre,
1127/// its axis or its spine, along a plane's normal, to the left of a line in
1128/// the plane; a line in space's distance unsigned.
1129fn residual(kind: Kind, state: &State, p: Point) -> f64 {
1130    let a = state.axis.vector();
1131    let w = p - state.origin;
1132    let h = w.dot(a);
1133    let rho = (w - a * h).magnitude();
1134    match kind {
1135        Kind::Plane => h,
1136        Kind::Line3 => rho,
1137        Kind::Line2 => a.x.mul_add(w.y, -(a.y * w.x)),
1138        Kind::Sphere | Kind::Circle2 => w.magnitude() - state.radius,
1139        Kind::Cylinder => rho - state.radius,
1140        Kind::Cone => {
1141            let (s, c) = state.second.sin_cos();
1142            (rho - state.radius).mul_add(c, -(h * s))
1143        }
1144        Kind::Torus => (rho - state.second).hypot(h) - state.radius,
1145        Kind::Circle3 => {
1146            let across = rho - state.radius;
1147            across.hypot(h).copysign(across)
1148        }
1149    }
1150}
1151
1152/// The directions a step moves the point along: two across the axis, and
1153/// the axis itself; the world's axes for a sphere and a circle in the
1154/// plane.
1155fn basis(kind: Kind, state: &State) -> [Vector; 3] {
1156    if matches!(kind, Kind::Sphere | Kind::Circle2) {
1157        return [Vector::X, Vector::Y, Vector::Z];
1158    }
1159    let a = state.axis;
1160    let e1 = a.any_perpendicular().vector();
1161    [e1, a.vector().cross(e1), a.vector()]
1162}
1163
1164/// A point's distances to the shape (one, or two for a circle in space),
1165/// each with its derivative in every parameter.
1166fn distances(
1167    kind: Kind,
1168    state: &State,
1169    [e1, e2, a]: [Vector; 3],
1170    p: Point,
1171) -> ([(f64, [f64; PARAMETERS]); 2], usize) {
1172    let mut out = [(0.0, [0.0; PARAMETERS]); 2];
1173    let w = p - state.origin;
1174    if matches!(kind, Kind::Sphere | Kind::Circle2) {
1175        let length = w.magnitude();
1176        let u = if length > 0.0 { w / length } else { Vector::X };
1177        let d = &mut out[0].1;
1178        d[ACROSS[0]] = -u.x;
1179        d[ACROSS[1]] = -u.y;
1180        d[ALONG] = -u.z;
1181        d[RADIUS] = -1.0;
1182        out[0].0 = length - state.radius;
1183        return (out, 1);
1184    }
1185    let h = w.dot(a);
1186    let q = w - a * h;
1187    let rho = q.magnitude();
1188    let n = if rho > 0.0 { q / rho } else { e1 };
1189    let es = [e1, e2];
1190    // How the distance from the axis and the height along it move: the
1191    // point moved across by `e` takes the distance by `-n·e`, and the axis
1192    // tilted toward `e` takes it by `-h n·e` and the height by `w·e`.
1193    let rho_across = es.map(|e| -n.dot(e));
1194    let rho_tilt = es.map(|e| -h * n.dot(e));
1195    let h_tilt = es.map(|e| w.dot(e));
1196    match kind {
1197        Kind::Cylinder => {
1198            let d = &mut out[0].1;
1199            for i in 0..2 {
1200                d[TILT[i]] = rho_tilt[i];
1201                d[ACROSS[i]] = rho_across[i];
1202            }
1203            d[RADIUS] = -1.0;
1204            out[0].0 = rho - state.radius;
1205        }
1206        Kind::Cone => {
1207            let (s, c) = state.second.sin_cos();
1208            let d = &mut out[0].1;
1209            for i in 0..2 {
1210                d[TILT[i]] = c.mul_add(rho_tilt[i], -(s * h_tilt[i]));
1211                d[ACROSS[i]] = c * rho_across[i];
1212            }
1213            d[RADIUS] = -c;
1214            d[SECOND] = (state.radius - rho).mul_add(s, -(h * c));
1215            out[0].0 = (rho - state.radius).mul_add(c, -(h * s));
1216        }
1217        Kind::Torus => {
1218            let off = rho - state.second;
1219            let tube = off.hypot(h);
1220            let (m, k) = if tube > 0.0 {
1221                (off / tube, h / tube)
1222            } else {
1223                (1.0, 0.0)
1224            };
1225            let d = &mut out[0].1;
1226            for i in 0..2 {
1227                d[TILT[i]] = m.mul_add(rho_tilt[i], k * h_tilt[i]);
1228                d[ACROSS[i]] = m * rho_across[i];
1229            }
1230            d[ALONG] = -k;
1231            d[RADIUS] = -1.0;
1232            d[SECOND] = -m;
1233            out[0].0 = tube - state.radius;
1234        }
1235        Kind::Circle3 => {
1236            let (height, across) = out.split_at_mut(1);
1237            let dh = &mut height[0].1;
1238            let dr = &mut across[0].1;
1239            for i in 0..2 {
1240                dh[TILT[i]] = h_tilt[i];
1241                dr[TILT[i]] = rho_tilt[i];
1242                dr[ACROSS[i]] = rho_across[i];
1243            }
1244            dh[ALONG] = -1.0;
1245            dr[RADIUS] = -1.0;
1246            height[0].0 = h;
1247            across[0].0 = rho - state.radius;
1248            return (out, 2);
1249        }
1250        Kind::Plane | Kind::Line3 | Kind::Line2 | Kind::Sphere | Kind::Circle2 => {}
1251    }
1252    (out, 1)
1253}
1254
1255/// The state moved by a step in the parameters, then its point slid along
1256/// the axis level with the kept points' centroid where nothing pins it
1257/// there (a cylinder's, a cone's), so the steps cannot wander along it.
1258fn stepped(
1259    kind: Kind,
1260    state: &State,
1261    [e1, e2, a]: [Vector; 3],
1262    step: &[f64; PARAMETERS],
1263    centroid: Point,
1264    tol: Tolerances,
1265) -> State {
1266    let mut next = *state;
1267    next.origin = state.origin + e1 * step[ACROSS[0]] + e2 * step[ACROSS[1]] + a * step[ALONG];
1268    if step[TILT[0]] != 0.0 || step[TILT[1]] != 0.0 {
1269        let tilted = a + e1 * step[TILT[0]] + e2 * step[TILT[1]];
1270        if let Ok(d) = Direction::new(tilted, tol) {
1271            next.axis = d;
1272        }
1273    }
1274    next.radius += step[RADIUS];
1275    next.second += step[SECOND];
1276    if matches!(kind, Kind::Cylinder | Kind::Cone) {
1277        let a = next.axis.vector();
1278        let shift = (centroid - next.origin).dot(a);
1279        next.origin += a * shift;
1280        if kind == Kind::Cone {
1281            // The radius is the cone's at its point.
1282            next.radius = shift.mul_add(next.second.tan(), next.radius);
1283        }
1284    }
1285    next
1286}
1287
1288/// The most Gauss-Newton steps a round takes. Converging steps shrink
1289/// fast, by orders of magnitude each, from any start in the basin; a fit
1290/// still stepping after this many is reported as not converged.
1291const MOST_STEPS: u32 = 100;
1292
1293/// Orthogonal least squares on the kept points, from `state`: Gauss-Newton
1294/// steps through the normal equations' eigenvectors, the directions the
1295/// points determine no better than the equations' rounding left where
1296/// they are, and each step halved until it lowers the cost. Converged once
1297/// a step moves the points' distances by less than the confusion distance.
1298fn least_squares(
1299    kind: Kind,
1300    mut state: State,
1301    free: &[bool; PARAMETERS],
1302    points: &[Point],
1303    used: &[bool],
1304    tol: Tolerances,
1305) -> (State, u32, bool) {
1306    let index: Vec<usize> = (0..PARAMETERS).filter(|k| free[*k]).collect();
1307    let size = index.len();
1308    if size == 0 {
1309        return (state, 0, true);
1310    }
1311    let kept: Vec<Point> = points
1312        .iter()
1313        .zip(used)
1314        .filter_map(|(p, keep)| keep.then_some(*p))
1315        .collect();
1316    let centroid = Point::from_vector(recognize::centroid(&kept));
1317    #[allow(
1318        clippy::cast_precision_loss,
1319        reason = "point counts are far below 2^52"
1320    )]
1321    let measured = (kept.len() * kind.components()) as f64;
1322    let cost = |s: &State| -> f64 {
1323        let frame = basis(kind, s);
1324        let mut sum = 0.0;
1325        for p in &kept {
1326            let (rows, count) = distances(kind, s, frame, *p);
1327            for (r, _) in &rows[..count] {
1328                sum += r * r;
1329            }
1330        }
1331        sum
1332    };
1333    let mut current = cost(&state);
1334    for step in 0..MOST_STEPS {
1335        let frame = basis(kind, &state);
1336        let mut normal = nalgebra::DMatrix::<f64>::zeros(size, size);
1337        let mut gradient = nalgebra::DVector::<f64>::zeros(size);
1338        for p in &kept {
1339            let (rows, count) = distances(kind, &state, frame, *p);
1340            for (r, d) in &rows[..count] {
1341                for (i, &ki) in index.iter().enumerate() {
1342                    gradient[i] += d[ki] * r;
1343                    for (j, &kj) in index.iter().enumerate().skip(i) {
1344                        normal[(i, j)] += d[ki] * d[kj];
1345                    }
1346                }
1347            }
1348        }
1349        for i in 0..size {
1350            for j in 0..i {
1351                normal[(i, j)] = normal[(j, i)];
1352            }
1353        }
1354        let eigen = nalgebra::SymmetricEigen::new(normal.clone());
1355        let largest = eigen.eigenvalues.iter().copied().fold(0.0, f64::max);
1356        #[allow(clippy::cast_precision_loss, reason = "a handful of parameters")]
1357        let floor = largest * f64::EPSILON * size as f64;
1358        let mut delta = nalgebra::DVector::<f64>::zeros(size);
1359        for (k, value) in eigen.eigenvalues.iter().enumerate() {
1360            if *value > floor {
1361                let v = eigen.eigenvectors.column(k);
1362                delta -= v * (v.dot(&gradient) / value);
1363            }
1364        }
1365        let movement = ((delta.dot(&(&normal * &delta))) / measured).sqrt();
1366        if !movement.is_finite() {
1367            return (state, step, false);
1368        }
1369        let mut scale = 1.0;
1370        let mut accepted = false;
1371        loop {
1372            let mut full = [0.0; PARAMETERS];
1373            for (i, &k) in index.iter().enumerate() {
1374                full[k] = delta[i] * scale;
1375            }
1376            let trial = stepped(kind, &state, frame, &full, centroid, tol);
1377            let c = cost(&trial);
1378            if c.is_finite() && c < current {
1379                state = trial;
1380                current = c;
1381                accepted = true;
1382                break;
1383            }
1384            if movement * scale <= tol.confusion() {
1385                break;
1386            }
1387            scale *= 0.5;
1388        }
1389        // A step too small to move a distance measurably, or one no
1390        // shortening of which lowers the cost, leaves the fit at its least
1391        // to rounding.
1392        if movement <= tol.confusion() || !accepted {
1393            return (state, step + 1, true);
1394        }
1395    }
1396    (state, MOST_STEPS, false)
1397}
1398
1399/// A plane or a line over the kept points, in closed form: the centroid,
1400/// and the covariance's smallest direction (a plane's normal) or largest
1401/// (a line's direction), or the held direction. The sense follows the
1402/// last round's.
1403fn closed_form(
1404    kind: Kind,
1405    last: &State,
1406    points: &[Point],
1407    used: &[bool],
1408    fixed: &FitFixed,
1409    tol: Tolerances,
1410) -> OgeomResult<State> {
1411    let kept: Vec<Point> = points
1412        .iter()
1413        .zip(used)
1414        .filter_map(|(p, keep)| keep.then_some(*p))
1415        .collect();
1416    let c = recognize::centroid(&kept);
1417    let origin = Point::from_vector(c);
1418    let axis = if let Some(d) = fixed.direction {
1419        d
1420    } else {
1421        let m = recognize::covariance(kept.iter().map(|p| p.to_vector() - c));
1422        let eigen = nalgebra::SymmetricEigen::new(m);
1423        let mut order = [0, 1, 2];
1424        order.sort_by(|i, j| eigen.eigenvalues[*i].total_cmp(&eigen.eigenvalues[*j]));
1425        #[allow(
1426            clippy::cast_precision_loss,
1427            reason = "point counts are far below 2^52"
1428        )]
1429        let count = kept.len() as f64;
1430        let spread = |k: usize| (eigen.eigenvalues[order[k]].max(0.0) / count).sqrt();
1431        let (pick, flat, what) = if kind == Kind::Plane {
1432            (order[0], spread(1), "one line")
1433        } else {
1434            (order[2], spread(2), "one point")
1435        };
1436        if flat <= tol.confusion() {
1437            ogeom_bail!(
1438                Construction,
1439                "the points' spread is degenerate for a {} fit: they lie within {} of {what}",
1440                kind.name(),
1441                tol.confusion()
1442            );
1443        }
1444        let v = eigen.eigenvectors.column(pick);
1445        Direction::new(Vector::new(v[0], v[1], v[2]), tol)?
1446    };
1447    let axis = if axis.vector().dot(last.axis.vector()) < 0.0 {
1448        -axis
1449    } else {
1450        axis
1451    };
1452    Ok(State::about(origin, axis))
1453}
1454
1455/// Whether a surface of revolution's start reads normals: one whose axis
1456/// is not given whole, and a cylinder only where not even its direction is.
1457fn start_reads_normals(kind: Kind, fixed: &FitFixed) -> bool {
1458    fixed.axis.is_none() && !(kind == Kind::Cylinder && fixed.direction.is_some())
1459}
1460
1461/// How many neighbours a point's normal is estimated from, by the plane
1462/// through them: a few more than the three a plane needs, for noise to
1463/// average, and few enough to stay within a patch's curvature.
1464const NORMAL_NEIGHBOURS: usize = 12;
1465
1466/// Each point's normal, in no particular sense, as the smallest direction
1467/// of its nearest neighbours' covariance.
1468fn estimated_normals(points: &[Point]) -> OgeomResult<Vec<Vector>> {
1469    let index = ogeom_mesh::PointIndex::build(points)?;
1470    Ok(points
1471        .iter()
1472        .map(|p| {
1473            let near: Vec<Point> = index
1474                .k_nearest(*p, NORMAL_NEIGHBOURS, f64::INFINITY)
1475                .iter()
1476                .map(|n| points[n.index as usize])
1477                .collect();
1478            let c = recognize::centroid(&near);
1479            recognize::smallest_direction(recognize::covariance(
1480                near.iter().map(|q| q.to_vector() - c),
1481            ))
1482        })
1483        .collect())
1484}
1485
1486/// The state with what the caller holds put in: the radius, the half
1487/// angle, and the axis or its direction, in whichever sense the state's
1488/// own runs.
1489fn pinned(kind: Kind, mut state: State, fixed: &FitFixed) -> State {
1490    if let Some(r) = fixed.radius {
1491        state.radius = r;
1492    }
1493    if let Some(a) = fixed.half_angle {
1494        state.second = a;
1495    }
1496    let along = |d: Direction, current: Direction| {
1497        if d.vector().dot(current.vector()) < 0.0 {
1498            -d
1499        } else {
1500            d
1501        }
1502    };
1503    if let Some(d) = fixed.direction {
1504        state.axis = along(d, state.axis);
1505    }
1506    if let Some(axis) = fixed.axis {
1507        let d = along(axis.direction, state.axis);
1508        let foot = axis.location + d.vector() * (state.origin - axis.location).dot(d.vector());
1509        state.axis = d;
1510        state.origin = foot;
1511    }
1512    if kind == Kind::Cone && state.second < 0.0 {
1513        state.axis = -state.axis;
1514        state.second = -state.second;
1515    }
1516    state
1517}
1518
1519fn degenerate(kind: Kind, why: &str) -> ogeom_core::OgeomError {
1520    ogeom_core::ogeom_err!(
1521        Construction,
1522        "the points' spread is degenerate for a {} fit: {why}",
1523        kind.name()
1524    )
1525}
1526
1527/// The start a fit refines, from points and (for a surface of revolution)
1528/// their normals: `given` where the caller's, else estimated.
1529fn start(
1530    kind: Kind,
1531    points: &[Point],
1532    normals: Option<&[Vector]>,
1533    given: bool,
1534    fixed: &FitFixed,
1535    tol: Tolerances,
1536) -> OgeomResult<State> {
1537    let c = Point::from_vector(recognize::centroid(points));
1538    let state = match kind {
1539        Kind::Plane | Kind::Line3 | Kind::Line2 => {
1540            let all = vec![true; points.len()];
1541            let seed = State::about(c, Direction::Z);
1542            let seed = closed_form(kind, &seed, points, &all, fixed, tol)?;
1543            let v = seed.axis.vector();
1544            // A sense of the covariance's own choosing would leave the
1545            // answer to the eigen solver; the largest component is made
1546            // positive instead.
1547            let largest = [v.x, v.y, v.z]
1548                .into_iter()
1549                .fold(0.0_f64, |m, x| if x.abs() > m.abs() { x } else { m });
1550            if largest < 0.0 {
1551                State::about(seed.origin, -seed.axis)
1552            } else {
1553                seed
1554            }
1555        }
1556        Kind::Sphere => match recognize::fit_sphere(points, tol) {
1557            Some(Canonical::Sphere(s)) => State {
1558                radius: s.radius(),
1559                ..State::about(s.centre(), Direction::Z)
1560            },
1561            _ => return Err(degenerate(kind, "they lie on one plane")),
1562        },
1563        Kind::Circle2 => {
1564            let (centre, radius) = circle_in_plane(points, c, Direction::Z, tol)
1565                .ok_or_else(|| degenerate(kind, "they lie on one line"))?;
1566            State {
1567                radius,
1568                ..State::about(centre, Direction::Z)
1569            }
1570        }
1571        Kind::Circle3 => {
1572            if let Some(axis) = fixed.axis {
1573                let foot = on_line(axis, c);
1574                let radius = mean_distance(points, axis);
1575                State {
1576                    radius,
1577                    ..State::about(foot, axis.direction)
1578                }
1579            } else if let Some(d) = fixed.direction {
1580                let (centre, radius) = circle_in_plane(points, c, d, tol)
1581                    .ok_or_else(|| degenerate(kind, "they lie on one line"))?;
1582                State {
1583                    radius,
1584                    ..State::about(centre, d)
1585                }
1586            } else {
1587                let (centre, normal, radius, _) = recognize::circle_through(points, tol)
1588                    .ok_or_else(|| degenerate(kind, "they lie on one line"))?;
1589                State {
1590                    radius,
1591                    ..State::about(centre, normal)
1592                }
1593            }
1594        }
1595        Kind::Cylinder => {
1596            if let Some(axis) = fixed.axis {
1597                State {
1598                    radius: mean_distance(points, axis),
1599                    ..State::about(on_line(axis, c), axis.direction)
1600                }
1601            } else if let Some(d) = fixed.direction {
1602                let (centre, radius) = circle_in_plane(points, c, d, tol)
1603                    .ok_or_else(|| degenerate(kind, "their shadow along the axis is a line"))?;
1604                State {
1605                    radius,
1606                    ..State::about(centre, d)
1607                }
1608            } else {
1609                let normals = normals.ok_or_else(|| degenerate(kind, "no normals"))?;
1610                match recognize::fit_cylinder(points, normals, tol) {
1611                    Some(Canonical::Cylinder(cyl)) => State {
1612                        radius: cyl.radius(),
1613                        ..State::about(cyl.frame().origin(), cyl.frame().z())
1614                    },
1615                    _ => return Err(degenerate(kind, "their normals meet no one axis")),
1616                }
1617            }
1618        }
1619        Kind::Cone => {
1620            let (through, axis) = revolution_axis(kind, points, normals, fixed, tol)?;
1621            match recognize::ruled_about(points, through, axis, 0.0, tol) {
1622                Some(Canonical::Cone(cone)) => State {
1623                    radius: cone.reference_radius(),
1624                    second: cone.half_angle(),
1625                    ..State::about(cone.frame().origin(), cone.frame().z())
1626                },
1627                Some(_) => {
1628                    return Err(degenerate(
1629                        kind,
1630                        "they have no taper, and lie on a cylinder",
1631                    ));
1632                }
1633                None => return Err(degenerate(kind, "they span no height along the axis")),
1634            }
1635        }
1636        Kind::Torus => torus_start(points, normals, given, fixed, tol)?,
1637    };
1638    Ok(pinned(kind, state, fixed))
1639}
1640
1641/// A cone's or a torus's axis: the one held, or the line every normal line
1642/// meets, through the held direction where that is held.
1643fn revolution_axis(
1644    kind: Kind,
1645    points: &[Point],
1646    normals: Option<&[Vector]>,
1647    fixed: &FitFixed,
1648    tol: Tolerances,
1649) -> OgeomResult<(Point, Direction)> {
1650    if let Some(axis) = fixed.axis {
1651        return Ok((axis.location, axis.direction));
1652    }
1653    let normals = normals.ok_or_else(|| degenerate(kind, "no normals"))?;
1654    let found = match fixed.direction {
1655        Some(d) => axis_along(points, normals, d).map(|p| (p, d)),
1656        None => recognize::revolution_axis(points, normals, tol),
1657    };
1658    found.ok_or_else(|| degenerate(kind, "their normals meet no one axis"))
1659}
1660
1661/// The point where the line of direction `d` that every normal line meets
1662/// best crosses the plane through the origin across `d`: the line through
1663/// `x` meets the normal line through `p` along `n` where
1664/// `(p − x)·(d × n) = 0`, linear in `x`.
1665fn axis_along(points: &[Point], normals: &[Vector], d: Direction) -> Option<Point> {
1666    let reference = *points.first()?;
1667    let mut m = nalgebra::Matrix3::<f64>::zeros();
1668    let mut b = nalgebra::Vector3::<f64>::zeros();
1669    for (p, n) in points.iter().zip(normals) {
1670        let row = d.vector().cross(*n);
1671        let r = nalgebra::Vector3::new(row.x, row.y, row.z);
1672        m += r * r.transpose();
1673        b += r * (*p - reference).dot(row);
1674    }
1675    let scale = m.norm().max(f64::MIN_POSITIVE);
1676    let x = m.pseudo_inverse(scale * f64::EPSILON * 3.0).ok()? * b;
1677    Some(reference + Vector::new(x[0], x[1], x[2]))
1678}
1679
1680/// A torus's start: its axis, then the circle the points trace in the
1681/// half-plane through it; and where the caller's own normals are given and
1682/// the axis is free, also the spine the normals point back to, whichever
1683/// fits the points closer.
1684fn torus_start(
1685    points: &[Point],
1686    normals: Option<&[Vector]>,
1687    given: bool,
1688    fixed: &FitFixed,
1689    tol: Tolerances,
1690) -> OgeomResult<State> {
1691    let kind = Kind::Torus;
1692    let c = Point::from_vector(recognize::centroid(points));
1693    let mut best: Option<(f64, State)> = None;
1694    let mut consider = |state: State| {
1695        let rms: f64 = points
1696            .iter()
1697            .map(|p| residual(kind, &state, *p).powi(2))
1698            .sum();
1699        if rms.is_finite() && best.is_none_or(|(held, _)| rms < held) {
1700            best = Some((rms, state));
1701        }
1702    };
1703    if let Ok((through, axis)) = revolution_axis(kind, points, normals, fixed, tol) {
1704        let line = Axis::new(through, axis);
1705        let base = on_line(line, c);
1706        if let Some((major, height, minor)) = recognize::profile_circle(points, base, line) {
1707            consider(State {
1708                radius: minor,
1709                second: major,
1710                ..State::about(base + axis.vector() * height, axis)
1711            });
1712        }
1713    }
1714    if given
1715        && fixed.axis.is_none()
1716        && fixed.direction.is_none()
1717        && let Some(n) = normals
1718        && let Some(Canonical::Torus(t)) = recognize::fit_torus(points, n, tol)
1719    {
1720        consider(State {
1721            radius: t.minor_radius(),
1722            second: t.major_radius(),
1723            ..State::about(t.centre(), t.frame().z())
1724        });
1725    }
1726    best.map(|(_, s)| s)
1727        .ok_or_else(|| degenerate(kind, "they trace no circle about any axis"))
1728}
1729
1730/// The circle through points' shadows on the plane through `through`
1731/// across `normal`, by the algebraic fit: its centre, on that plane, and
1732/// its radius.
1733fn circle_in_plane(
1734    points: &[Point],
1735    through: Point,
1736    normal: Direction,
1737    tol: Tolerances,
1738) -> Option<(Point, f64)> {
1739    let a = normal.vector();
1740    let e1 = normal.any_perpendicular().vector();
1741    let e2 = a.cross(e1);
1742    let mut m = nalgebra::Matrix3::zeros();
1743    let mut b = nalgebra::Vector3::zeros();
1744    for p in points {
1745        let d = *p - through;
1746        let (x, y) = (d.dot(e1), d.dot(e2));
1747        let row = nalgebra::Vector3::new(-2.0 * x, -2.0 * y, 1.0);
1748        m += row * row.transpose();
1749        b += row * -x.mul_add(x, y * y);
1750    }
1751    let solved = m.lu().solve(&b)?;
1752    let r2 = solved[0].mul_add(solved[0], solved[1] * solved[1]) - solved[2];
1753    (r2 > tol.confusion() * tol.confusion())
1754        .then(|| (through + e1 * solved[0] + e2 * solved[1], r2.sqrt()))
1755}
1756
1757/// The point of the axis nearest `p`.
1758fn on_line(axis: Axis, p: Point) -> Point {
1759    let d = axis.direction.vector();
1760    axis.location + d * (p - axis.location).dot(d)
1761}
1762
1763/// The points' mean distance from an axis.
1764fn mean_distance(points: &[Point], axis: Axis) -> f64 {
1765    let d = axis.direction.vector();
1766    let sum: f64 = points
1767        .iter()
1768        .map(|p| {
1769            let w = *p - axis.location;
1770            (w - d * w.dot(d)).magnitude()
1771        })
1772        .sum();
1773    #[allow(
1774        clippy::cast_precision_loss,
1775        reason = "point counts are far below 2^52"
1776    )]
1777    let n = points.len() as f64;
1778    sum / n
1779}
1780
1781/// The probability that a consensus start draws at least one subset free
1782/// of outliers is at least this, where half the points are outliers.
1783const CONSENSUS_MISS: f64 = 1e-3;
1784
1785/// A start by consensus: the kind's start on many small subsets of the
1786/// points, drawn in a fixed sequence, and the one whose median residual
1787/// over all of them is least. The draws are as many as make a subset with
1788/// no outlier all but certain, with half the points outliers.
1789fn consensus(
1790    kind: Kind,
1791    points: &[Point],
1792    normals: Option<&[Vector]>,
1793    given: bool,
1794    fixed: &FitFixed,
1795    tol: Tolerances,
1796) -> OgeomResult<State> {
1797    let size = needed(kind, &FitFixed::default()).max(needed(kind, fixed));
1798    if points.len() <= size {
1799        return start(kind, points, normals, given, fixed, tol);
1800    }
1801    let clean = 0.5_f64.powi(i32::try_from(size).unwrap_or(i32::MAX));
1802    #[allow(
1803        clippy::cast_possible_truncation,
1804        clippy::cast_sign_loss,
1805        reason = "a positive count of a few thousand"
1806    )]
1807    let draws = (CONSENSUS_MISS.ln() / (-clean).ln_1p()).ceil() as usize;
1808    let mut sequence = Draws::new();
1809    let mut best: Option<(f64, State)> = None;
1810    let mut picked: Vec<usize> = Vec::with_capacity(size);
1811    let mut sizes = vec![0.0; points.len()];
1812    for _ in 0..draws {
1813        picked.clear();
1814        while picked.len() < size {
1815            let i = sequence.below(points.len());
1816            if !picked.contains(&i) {
1817                picked.push(i);
1818            }
1819        }
1820        let subset: Vec<Point> = picked.iter().map(|i| points[*i]).collect();
1821        let subset_normals: Option<Vec<Vector>> =
1822            normals.map(|n| picked.iter().map(|i| n[*i]).collect());
1823        let Ok(candidate) = start(kind, &subset, subset_normals.as_deref(), given, fixed, tol)
1824        else {
1825            continue;
1826        };
1827        for (s, p) in sizes.iter_mut().zip(points) {
1828            *s = residual(kind, &candidate, *p);
1829        }
1830        let median = Cutoff::Median(1.0).threshold(&sizes);
1831        if median.is_finite() && best.is_none_or(|(held, _)| median < held) {
1832            best = Some((median, candidate));
1833        }
1834    }
1835    match best {
1836        Some((_, state)) => Ok(state),
1837        None => start(kind, points, normals, given, fixed, tol),
1838    }
1839}
1840
1841/// A fixed sequence of draws (xorshift64*), so a consensus start is the
1842/// same on every run.
1843pub(super) struct Draws(u64);
1844
1845impl Draws {
1846    /// The sequence every fit draws from.
1847    pub(super) const fn new() -> Self {
1848        Self(0x9E37_79B9_7F4A_7C15)
1849    }
1850
1851    /// A draw below `n`.
1852    pub(super) fn below(&mut self, n: usize) -> usize {
1853        let mut x = self.0;
1854        x ^= x >> 12;
1855        x ^= x << 25;
1856        x ^= x >> 27;
1857        self.0 = x;
1858        let wide = u128::from(x.wrapping_mul(0x2545_F491_4F6C_DD1D)) * n as u128;
1859        usize::try_from(wide >> 64).unwrap_or(0)
1860    }
1861}
1862
1863#[cfg(test)]
1864mod tests {
1865    #![allow(clippy::unwrap_used, reason = "test code")]
1866    use super::*;
1867
1868    const T: Tolerances = Tolerances::millimetres();
1869
1870    /// Every kind's derivatives in the parameters it fits are those of its
1871    /// distances: each against a central difference through the same step the least squares takes,
1872    /// at points off the shape on every side.
1873    #[test]
1874    fn the_derivatives_are_the_distances_own() {
1875        let axis = Direction::new(Vector::new(0.2, -0.3, 1.0), T).unwrap();
1876        let state = State {
1877            origin: Point::new(1.0, 2.0, -1.0),
1878            axis,
1879            radius: 3.0,
1880            second: 0.4,
1881        };
1882        let torus = State {
1883            second: 9.0,
1884            ..state
1885        };
1886        let points = [
1887            Point::new(4.0, 1.0, 2.0),
1888            Point::new(-3.0, 5.0, 0.5),
1889            Point::new(2.5, -6.0, -4.0),
1890            Point::new(9.0, 3.0, 1.0),
1891        ];
1892        for (kind, at) in [
1893            (Kind::Sphere, state),
1894            (Kind::Circle3, state),
1895            (Kind::Cylinder, state),
1896            (Kind::Cone, state),
1897            (Kind::Torus, torus),
1898        ] {
1899            let frame = basis(kind, &at);
1900            for p in points {
1901                let (rows, count) = distances(kind, &at, frame, p);
1902                let on = free(kind, &FitFixed::default());
1903                for k in (0..PARAMETERS).filter(|k| on[*k]) {
1904                    let h = 1e-6;
1905                    let moved = |sign: f64| {
1906                        let mut step = [0.0; PARAMETERS];
1907                        step[k] = sign * h;
1908                        // Slid along the axis to where the point already
1909                        // stands, so only the step moves it.
1910                        let s = stepped(kind, &at, frame, &step, at.origin, T);
1911                        distances(kind, &s, basis(kind, &s), p).0
1912                    };
1913                    let (up, down) = (moved(1.0), moved(-1.0));
1914                    for c in 0..count {
1915                        let numeric = (up[c].0 - down[c].0) / (2.0 * h);
1916                        let analytic = rows[c].1[k];
1917                        assert!(
1918                            (numeric - analytic).abs() < 1e-6,
1919                            "{kind:?} parameter {k} component {c}: {analytic} against {numeric}"
1920                        );
1921                    }
1922                }
1923            }
1924        }
1925    }
1926
1927    /// A plane's leverage at a point is the closed form of linear least
1928    /// squares: `1/n` for its offset and `wᵀ S⁻¹ w` for its tilt, `w` the
1929    /// point's offset from the centroid across the plane and `S` the
1930    /// points' scatter there.
1931    #[test]
1932    #[allow(clippy::cast_precision_loss, reason = "a few points")]
1933    fn a_planes_leverage_is_the_closed_form() {
1934        let points: Vec<Point> = (0..7)
1935            .flat_map(|i| (0..5).map(move |j| (i, j)))
1936            .map(|(i, j)| {
1937                let (x, y) = (f64::from(i) * 0.7, f64::from(j) * 1.3 - 0.4);
1938                Point::new(x, y, 0.01 * f64::from((i * 3 + j * 5) % 7) - 0.03)
1939            })
1940            .collect();
1941        let normals = vec![Vector::Z; points.len()];
1942        let options = FitOptions {
1943            normals: Some(&normals),
1944            ..FitOptions::default()
1945        };
1946        let fit = plane(&points, &options, T).unwrap();
1947        let model = Model::plane(&fit).measured_over(&points, &fit.used, &FitFixed::default());
1948        assert_eq!(model.parameters(), 3);
1949
1950        let n = fit.shape.normal().vector();
1951        let e1 = fit.shape.normal().any_perpendicular().vector();
1952        let e2 = n.cross(e1);
1953        let count = points.len() as f64;
1954        let centroid = points
1955            .iter()
1956            .fold(Vector::ZERO, |sum, p| sum + p.to_vector())
1957            / count;
1958        let across = |p: Point| {
1959            let w = p.to_vector() - centroid;
1960            (w.dot(e1), w.dot(e2))
1961        };
1962        let (mut a, mut b, mut c) = (0.0, 0.0, 0.0);
1963        for p in &points {
1964            let (u, v) = across(*p);
1965            a += u * u;
1966            b += u * v;
1967            c += v * v;
1968        }
1969        let det = a.mul_add(c, -(b * b));
1970        for p in [Point::new(9.0, -4.0, 0.2), Point::new(2.1, 2.2, 0.0)] {
1971            let (u, v) = across(p);
1972            let closed = 1.0 / count + (c * u * u - 2.0 * b * u * v + a * v * v) / det;
1973            let got = model.leverage(p);
1974            assert!(
1975                (got - closed).abs() <= 1e-12 * closed,
1976                "{got} against {closed}"
1977            );
1978        }
1979    }
1980
1981    /// A line's leverage at a point is the closed form of linear least
1982    /// squares, once for each of the two directions across it: `1/n` for
1983    /// its offset and `s² / S` for its tilt, `s` the point's height along
1984    /// it from the centroid and `S` the points' scatter along it.
1985    #[test]
1986    #[allow(clippy::cast_precision_loss, reason = "a few points")]
1987    fn a_lines_leverage_is_the_closed_form() {
1988        let points: Vec<Point> = (0..11)
1989            .map(|i| {
1990                let t = f64::from(i) * 0.9 - 2.0;
1991                let wobble = 0.01 * f64::from((i * 3) % 7) - 0.03;
1992                Point::new(1.0 + 0.6 * t, -2.0 + 0.8 * t + wobble, 0.5 - wobble)
1993            })
1994            .collect();
1995        let fit = line3(&points, &FitOptions::default(), T).unwrap();
1996        let model = Model::line3(&fit).measured_over(&points, &fit.used, &FitFixed::default());
1997        assert_eq!(model.parameters(), 4);
1998        assert_eq!(model.components(), 2);
1999
2000        let d = fit.shape.direction.vector();
2001        let count = points.len() as f64;
2002        let centroid = points
2003            .iter()
2004            .fold(Vector::ZERO, |sum, p| sum + p.to_vector())
2005            / count;
2006        let along = |p: Point| (p.to_vector() - centroid).dot(d);
2007        let scatter: f64 = points.iter().map(|p| along(*p).powi(2)).sum();
2008        for p in [Point::new(9.0, -4.0, 0.2), Point::new(1.1, -1.9, 0.6)] {
2009            let s = along(p);
2010            let closed = 2.0 * (1.0 / count + s * s / scatter);
2011            let got = model.leverage(p);
2012            assert!(
2013                (got - closed).abs() <= 1e-12 * closed,
2014                "{got} against {closed}"
2015            );
2016        }
2017    }
2018}