Skip to main content

ogeom_algo/fit/
zone.rs

1//! Form as the standards define it: minimum-zone, maximum-inscribed and
2//! minimum-circumscribed fits.
3//!
4//! A least-squares fit ([`plane`](super::plane), [`cylinder`](super::cylinder)
5//! and the rest) makes the squared distances least, and its residuals'
6//! peak to peak is an upper bound on form. The form a tolerance limits is
7//! a Chebyshev (minimax) zone: the narrowest pair of parallel planes,
8//! concentric circles, coaxial cylinders or concentric spheres that holds
9//! every point. Each minimum zone here starts from the least-squares fit
10//! and is never wider than its peak to peak.
11//!
12//! # Exact and certified
13//!
14//! - [`flatness`], [`circularity`], [`circumscribed_circle`] and
15//!   [`inscribed_circle`] are exact: the global optimum, to rounding.
16//!   The slab and the ring are found over a working subset of the points
17//!   whose zone, tried in every configuration the optimum can take, is
18//!   grown by the points outside it until it holds them all; the
19//!   enclosing circle is Welzl's; the empty circle stands on the points'
20//!   Voronoi diagram.
21//! - [`sphericity`], [`cylindricity`], [`straightness`],
22//!   [`circumscribed_cylinder`] and [`inscribed_cylinder`] are not convex
23//!   problems. They are settled by sequential linear programming in a trust
24//!   region from the least-squares fit, which converges to a point where no
25//!   first-order change of the parameters narrows the zone, and
26//!   [`Zone::optimal`] says whether the result meets that test: the
27//!   contacts' gradients balance, so the zone is least within the
28//!   confusion distance among shapes near it.
29//!
30//! The fits run on the calling thread, in point order, and draw no
31//! randomness but a fixed sequence: the same points give the same answer
32//! bit for bit.
33
34mod exact;
35mod hull;
36mod lp;
37mod slp;
38
39use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
40use ogeom_math::{Axis, Circle2, Cylinder, Direction, Frame, Plane, Point, Point2, Sphere};
41
42use super::FitOptions;
43use exact::Ring;
44use slp::{Centre, Goal, Line};
45
46/// A fitted zone.
47#[derive(Debug, Clone, PartialEq)]
48pub struct Zone<S> {
49    /// The zone's middle: the plane halfway between the two planes, the
50    /// circle, cylinder or sphere halfway between the two. For an inscribed
51    /// or circumscribed fit, the fitted circle or cylinder itself; for
52    /// straightness, the axis of the cylinder.
53    pub shape: S,
54    /// The zone's width: the distance between its two boundaries. For an
55    /// inscribed or circumscribed fit, the points' greatest distance from
56    /// the centre or axis less their least, their form about that
57    /// reference; for straightness, the cylinder's diameter.
58    pub width: f64,
59    /// The points touching the zone's boundaries, within the confusion
60    /// distance, in index order.
61    pub contacts: Vec<usize>,
62    /// Whether the zone is the optimum: always for the exact fits, and for
63    /// the others where the first-order Chebyshev conditions hold.
64    pub optimal: bool,
65}
66
67/// Flatness: the narrowest slab between two parallel planes holding every
68/// point. Exact.
69///
70/// # Errors
71///
72/// As [`plane`](super::plane): too few points, points on one line, or a
73/// point that is not finite.
74pub fn flatness(points: &[Point], tol: Tolerances) -> OgeomResult<Zone<Plane>> {
75    let fit = super::plane(points, &FitOptions::default(), tol)?;
76    let origin = fit.shape.origin();
77    let normal = fit.shape.normal().vector();
78    let mut seeds = extremes(&fit.residuals);
79    seeds.extend(spanning(points));
80    let Some(slab) = exact::narrowest_slab(points, origin, &seeds) else {
81        ogeom_bail!(Construction, "the points lie on one line and span no plane");
82    };
83    let (n, low, high) = if slab.normal.dot(normal) < 0.0 {
84        (-slab.normal, -slab.high, -slab.low)
85    } else {
86        (slab.normal, slab.low, slab.high)
87    };
88    let slack = tol.confusion();
89    let contacts = touching(
90        points.iter().map(|p| n.dot(*p - origin)),
91        Some(low),
92        Some(high),
93        slack,
94    );
95    let middle = origin + n * ((low + high) / 2.0);
96    Ok(Zone {
97        shape: Plane::new(Frame::about(middle, Direction::new(n, tol)?)),
98        width: high - low,
99        contacts,
100        optimal: true,
101    })
102}
103
104/// Straightness of a line in space: the thinnest cylinder about a line
105/// holding every point, its axis and its diameter.
106///
107/// # Errors
108///
109/// As [`line3`](super::line3).
110pub fn straightness(points: &[Point], tol: Tolerances) -> OgeomResult<Zone<Axis>> {
111    let fit = super::line3(points, &FitOptions::default(), tol)?;
112    let start = line(fit.shape.location, fit.shape.direction, points)?;
113    let settled = slp::settle(start, points, Goal::Outer, reach(points), tol.confusion());
114    let (_, most) = span(&settled.distances);
115    let axis = Axis::new(settled.shape.origin, Direction::new(settled.shape.z, tol)?);
116    Ok(Zone {
117        shape: axis,
118        width: 2.0 * most,
119        contacts: touching(
120            settled.distances.iter().copied(),
121            None,
122            Some(most),
123            tol.confusion(),
124        ),
125        optimal: settled.optimal,
126    })
127}
128
129/// Circularity: the narrowest ring between two concentric circles holding
130/// every point. Exact.
131///
132/// # Errors
133///
134/// As [`circle2`](super::circle2), and
135/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) where
136/// the points lie nearer a line than any circle: a strip between two
137/// parallel lines holds them narrower than every ring, and the narrowest
138/// ring is that strip's limit, not reached.
139pub fn circularity(points: &[Point2], tol: Tolerances) -> OgeomResult<Zone<Circle2>> {
140    let fit = super::circle2(points, &FitOptions::default(), tol)?;
141    let mut seeds = extremes(&fit.residuals);
142    let lifted: Vec<Point> = points.iter().map(|p| p.to_3d()).collect();
143    seeds.extend(spanning(&lifted));
144    match exact::narrowest_ring(points, fit.shape.centre(), &seeds) {
145        Ring::Circles {
146            centre,
147            radius,
148            deviations,
149            inner,
150            outer,
151        } => Ok(Zone {
152            shape: Circle2::centred(centre, radius + (inner + outer) / 2.0, tol)?,
153            width: outer - inner,
154            contacts: touching(
155                deviations.into_iter(),
156                Some(inner),
157                Some(outer),
158                tol.confusion(),
159            ),
160            optimal: true,
161        }),
162        Ring::Straight { width } => ogeom_bail!(
163            Construction,
164            "the points lie between two lines {width} apart, narrower than any ring: \
165             they have no narrowest circle"
166        ),
167    }
168}
169
170/// Cylindricity: the narrowest shell between two coaxial cylinders holding
171/// every point.
172///
173/// # Errors
174///
175/// As [`cylinder`](super::cylinder).
176pub fn cylindricity(points: &[Point], tol: Tolerances) -> OgeomResult<Zone<Cylinder>> {
177    revolved(points, Goal::Zone, tol)
178}
179
180/// The largest cylinder holding no point inside, from the least-squares
181/// cylinder: a bore's functional size.
182///
183/// # Errors
184///
185/// As [`cylinder`](super::cylinder).
186pub fn inscribed_cylinder(points: &[Point], tol: Tolerances) -> OgeomResult<Zone<Cylinder>> {
187    revolved(points, Goal::Inner, tol)
188}
189
190/// The smallest cylinder holding every point, from the least-squares
191/// cylinder: a shaft's functional size.
192///
193/// # Errors
194///
195/// As [`cylinder`](super::cylinder).
196pub fn circumscribed_cylinder(points: &[Point], tol: Tolerances) -> OgeomResult<Zone<Cylinder>> {
197    revolved(points, Goal::Outer, tol)
198}
199
200/// Sphericity: the narrowest shell between two concentric spheres holding
201/// every point.
202///
203/// # Errors
204///
205/// As [`sphere`](super::sphere).
206pub fn sphericity(points: &[Point], tol: Tolerances) -> OgeomResult<Zone<Sphere>> {
207    let fit = super::sphere(points, &FitOptions::default(), tol)?;
208    let slack = tol.confusion();
209    let settled = slp::settle(
210        Centre(fit.shape.centre()),
211        points,
212        Goal::Zone,
213        reach(points),
214        slack,
215    );
216    let (least, most) = span(&settled.distances);
217    Ok(Zone {
218        shape: Sphere::centred(settled.shape.0, (least + most) / 2.0, tol)?,
219        width: most - least,
220        contacts: touching(
221            settled.distances.iter().copied(),
222            Some(least),
223            Some(most),
224            slack,
225        ),
226        optimal: settled.optimal,
227    })
228}
229
230/// The largest circle whose centre lies in the points' convex hull and
231/// which holds none of them inside. Exact.
232///
233/// # Errors
234///
235/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) with
236/// fewer than three points or with them all on one line, and
237/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) for a point that
238/// is not finite.
239pub fn inscribed_circle(points: &[Point2], tol: Tolerances) -> OgeomResult<Zone<Circle2>> {
240    finite(points)?;
241    if points.len() < 3 {
242        ogeom_bail!(
243            Construction,
244            "an inscribed circle needs at least 3 points, got {}",
245            points.len()
246        );
247    }
248    let Some(centre) = exact::largest_empty_circle(points) else {
249        ogeom_bail!(
250            Construction,
251            "the points lie on one line and enclose no circle"
252        );
253    };
254    let distances: Vec<f64> = points.iter().map(|p| p.distance(centre)).collect();
255    let (least, most) = span(&distances);
256    Ok(Zone {
257        shape: Circle2::centred(centre, least, tol)?,
258        width: most - least,
259        contacts: touching(
260            distances.iter().copied(),
261            Some(least),
262            None,
263            tol.confusion(),
264        ),
265        optimal: true,
266    })
267}
268
269/// The smallest circle holding every point. Exact.
270///
271/// # Errors
272///
273/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) with no
274/// points or with them all within the confusion distance of one, and
275/// [`OgeomError::Domain`](ogeom_core::OgeomError::Domain) for a point that
276/// is not finite.
277pub fn circumscribed_circle(points: &[Point2], tol: Tolerances) -> OgeomResult<Zone<Circle2>> {
278    finite(points)?;
279    if points.is_empty() {
280        ogeom_bail!(Construction, "a circumscribed circle needs a point");
281    }
282    let (centre, radius) = exact::enclosing_circle(points);
283    let distances: Vec<f64> = points.iter().map(|p| p.distance(centre)).collect();
284    let (least, most) = span(&distances);
285    Ok(Zone {
286        shape: Circle2::centred(centre, radius, tol)?,
287        width: most - least,
288        contacts: touching(distances.iter().copied(), None, Some(most), tol.confusion()),
289        optimal: true,
290    })
291}
292
293/// A cylinder's zone of `goal`, settled from the least-squares cylinder.
294fn revolved(points: &[Point], goal: Goal, tol: Tolerances) -> OgeomResult<Zone<Cylinder>> {
295    let fit = super::cylinder(points, &FitOptions::default(), tol)?;
296    let frame = fit.shape.frame();
297    let start = line(frame.origin(), frame.z(), points)?;
298    let slack = tol.confusion();
299    let settled = slp::settle(start, points, goal, reach(points), slack);
300    let (least, most) = span(&settled.distances);
301    let (radius, low, high) = match goal {
302        Goal::Zone => ((least + most) / 2.0, Some(least), Some(most)),
303        Goal::Outer => (most, None, Some(most)),
304        Goal::Inner => (least, Some(least), None),
305    };
306    let axis = Frame::about(settled.shape.origin, Direction::new(settled.shape.z, tol)?);
307    Ok(Zone {
308        shape: Cylinder::new(axis, radius, tol)?,
309        width: most - least,
310        contacts: touching(settled.distances.iter().copied(), low, high, slack),
311        optimal: settled.optimal,
312    })
313}
314
315/// The line through `location` along `direction`, its origin moved level
316/// with the points' centroid.
317fn line(location: Point, direction: Direction, points: &[Point]) -> OgeomResult<Line> {
318    let z = direction.vector();
319    let centroid = Point::centroid(points)?;
320    let origin = location + z * (centroid - location).dot(z);
321    match Line::new(origin, z, points) {
322        Some(l) => Ok(l),
323        None => ogeom_bail!(Construction, "the fitted axis has no direction"),
324    }
325}
326
327/// The diagonal of the points' bounding box: the widest step an
328/// optimality test need consider.
329fn reach(points: &[Point]) -> f64 {
330    let (mut lo, mut hi) = (points[0], points[0]);
331    for p in points {
332        lo = lo.min(*p);
333        hi = hi.max(*p);
334    }
335    lo.distance(hi)
336}
337
338fn span(distances: &[f64]) -> (f64, f64) {
339    distances
340        .iter()
341        .fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), d| {
342            (lo.min(*d), hi.max(*d))
343        })
344}
345
346/// The points within `slack` of the boundaries `low` and `high`, or past
347/// them.
348fn touching(
349    values: impl Iterator<Item = f64>,
350    low: Option<f64>,
351    high: Option<f64>,
352    slack: f64,
353) -> Vec<usize> {
354    values
355        .enumerate()
356        .filter(|&(_, v)| {
357            low.is_some_and(|l| v <= l + slack) || high.is_some_and(|h| v >= h - slack)
358        })
359        .map(|(i, _)| i)
360        .collect()
361}
362
363/// The points of the four greatest residuals and the four least: where the
364/// zone's contacts are first looked for.
365fn extremes(residuals: &[f64]) -> Vec<usize> {
366    let mut out = slp::largest(residuals.len(), 4, |i| Some(residuals[i]));
367    out.extend(slp::largest(residuals.len(), 4, |i| Some(-residuals[i])));
368    out
369}
370
371/// Three points spanning the most: the farthest from the first, the
372/// farthest from that, and the farthest from the line through those two.
373fn spanning(points: &[Point]) -> Vec<usize> {
374    let farthest = |key: &dyn Fn(Point) -> f64| {
375        slp::largest(points.len(), 1, |i| Some(key(points[i])))
376            .first()
377            .copied()
378            .unwrap_or(0)
379    };
380    let a = farthest(&|p| p.distance(points[0]));
381    let b = farthest(&|p| p.distance(points[a]));
382    let along = points[b] - points[a];
383    let c = farthest(&|p| (p - points[a]).cross(along).magnitude());
384    vec![a, b, c]
385}
386
387fn finite(points: &[Point2]) -> OgeomResult<()> {
388    if let Some(i) = points
389        .iter()
390        .position(|p| !(p.x.is_finite() && p.y.is_finite()))
391    {
392        ogeom_bail!(Domain, "point {i} is not finite");
393    }
394    Ok(())
395}