Skip to main content

ogeom_core/
tolerance.rs

1//! Tolerances.
2//!
3//! See `docs/DATA_MODEL.md` §5 and §9. Two separate things live here:
4//!
5//! - **Global constants**: the thresholds at which the kernel decides two
6//!   things are "the same". [`Tolerances`] carries them, parameterised by the
7//!   model's unit scale.
8//! - **Per-entity tolerances**: [`Tolerance`], the radius attached to an
9//!   individual vertex, edge or face, together with the containment rule that
10//!   relates them.
11//!
12//! The unit scale is **explicit**. A confusion tolerance hard-coded at `1e-7`
13//! carries an unstated assumption that models are in millimetres, and
14//! misbehaves silently on a model authored in metres or inches.
15
16use crate::{OgeomResult, ogeom_bail};
17
18/// The threshold constants the kernel decides identity by, for a given model
19/// scale.
20///
21/// `linear_scale` is the length of one model unit in millimetres: `1.0` for a
22/// model in millimetres, `1000.0` for metres, `25.4` for inches. Linear
23/// tolerances scale with it. Angular and parametric ones do not.
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct Tolerances {
26    linear_scale: f64,
27}
28
29/// Two points closer than this are the same point, at unit scale.
30pub const CONFUSION: f64 = 1e-7;
31/// Two directions closer than this in angle are parallel. Dimensionless.
32///
33/// Deliberately tight: near the limit of what `f64` can distinguish. It is the
34/// right threshold for comparing directions that were *stored*, such as two
35/// surface axes read back from a model. It is the wrong one for comparing
36/// directions *computed* through subtraction of nearby coordinates, where the
37/// input's own rounding is already larger. Such a comparison needs a bound
38/// derived from the magnitudes involved.
39pub const ANGULAR: f64 = 1e-12;
40/// Convergence target for intersection algorithms, at unit scale.
41pub const INTERSECTION: f64 = CONFUSION * 1e-2;
42/// Accuracy target when fitting a curve or surface to data, at unit scale.
43pub const APPROXIMATION: f64 = CONFUSION * 1e1;
44/// Confusion in parametric space. Dimensionless.
45pub const P_CONFUSION: f64 = CONFUSION * 1e-2;
46/// A length below which an entity is degenerate rather than merely small, at
47/// unit scale.
48pub const DEGENERATE: f64 = CONFUSION * 1e-1;
49
50impl Default for Tolerances {
51    fn default() -> Self {
52        Self::millimetres()
53    }
54}
55
56impl Tolerances {
57    /// Tolerances for a model whose unit is one millimetre.
58    #[must_use]
59    pub const fn millimetres() -> Self {
60        Self { linear_scale: 1.0 }
61    }
62
63    /// Tolerances for a model whose unit is one metre.
64    #[must_use]
65    pub const fn metres() -> Self {
66        Self {
67            linear_scale: 1000.0,
68        }
69    }
70
71    /// Tolerances for a model whose unit is one inch.
72    #[must_use]
73    pub const fn inches() -> Self {
74        Self { linear_scale: 25.4 }
75    }
76
77    /// Tolerances for a model whose unit is `mm_per_unit` millimetres.
78    ///
79    /// Every linear tolerance derived from the scale, and its square, must be
80    /// a normal `f64`: comparisons are made both on distances and on squared
81    /// distances, and an infinite, zero or subnormal threshold would decide
82    /// them by overflow or underflow. That accepts scales from about `7.5e-161`
83    /// to about `6.7e144` millimetres per unit.
84    ///
85    /// # Errors
86    ///
87    /// [`OgeomError::Construction`](crate::OgeomError::Construction) if the scale is
88    /// not finite and positive, or is outside that range.
89    pub fn with_scale(mm_per_unit: f64) -> OgeomResult<Self> {
90        if !mm_per_unit.is_finite() || mm_per_unit <= 0.0 {
91            ogeom_bail!(
92                Construction,
93                "unit scale {mm_per_unit} must be finite and positive"
94            );
95        }
96        let scaled = Self {
97            linear_scale: mm_per_unit,
98        };
99        let linear = [
100            scaled.confusion(),
101            scaled.intersection(),
102            scaled.approximation(),
103            scaled.degenerate(),
104        ];
105        if !linear.iter().all(|t| t.is_normal() && (t * t).is_normal()) {
106            ogeom_bail!(
107                Construction,
108                "unit scale {mm_per_unit} puts a linear tolerance or its square \
109                 outside the normal range of f64"
110            );
111        }
112        Ok(scaled)
113    }
114
115    /// Millimetres per model unit.
116    #[must_use]
117    pub const fn scale(self) -> f64 {
118        self.linear_scale
119    }
120
121    /// Distance below which two points are the same point.
122    #[must_use]
123    pub fn confusion(self) -> f64 {
124        CONFUSION / self.linear_scale
125    }
126
127    /// Angle below which two directions are parallel. Independent of scale.
128    #[must_use]
129    pub const fn angular(self) -> f64 {
130        ANGULAR
131    }
132
133    /// Convergence target for intersection algorithms.
134    #[must_use]
135    pub fn intersection(self) -> f64 {
136        INTERSECTION / self.linear_scale
137    }
138
139    /// Accuracy target when fitting curves and surfaces.
140    #[must_use]
141    pub fn approximation(self) -> f64 {
142        APPROXIMATION / self.linear_scale
143    }
144
145    /// Confusion in parametric space. Independent of scale.
146    #[must_use]
147    pub const fn parametric(self) -> f64 {
148        P_CONFUSION
149    }
150
151    /// Length below which an entity is degenerate.
152    #[must_use]
153    pub fn degenerate(self) -> f64 {
154        DEGENERATE / self.linear_scale
155    }
156
157    /// Whether two lengths are indistinguishable.
158    #[must_use]
159    pub fn same_length(self, a: f64, b: f64) -> bool {
160        (a - b).abs() <= self.confusion()
161    }
162
163    /// Whether two parameters are indistinguishable.
164    #[must_use]
165    pub fn same_parameter(self, a: f64, b: f64) -> bool {
166        (a - b).abs() <= self.parametric()
167    }
168}
169
170/// The tolerance carried by a single vertex, edge or face: the radius within
171/// which the entity is considered to lie.
172///
173/// Always finite and non-negative. Operations may only widen it (see
174/// [`Tolerance::widen`]) because narrowing a tolerance asserts an accuracy the
175/// geometry does not have.
176#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
177pub struct Tolerance(f64);
178
179impl Tolerance {
180    /// The smallest meaningful tolerance, at unit scale.
181    pub const MIN: Self = Self(CONFUSION);
182
183    /// A tolerance of `value`, clamped up to [`Tolerance::MIN`].
184    ///
185    /// # Errors
186    ///
187    /// [`OgeomError::Construction`](crate::OgeomError::Construction) if `value` is not
188    /// finite or is negative. A NaN tolerance poisons every comparison it
189    /// reaches, so it is rejected at the boundary rather than propagated.
190    pub fn new(value: f64) -> OgeomResult<Self> {
191        if !value.is_finite() || value < 0.0 {
192            ogeom_bail!(
193                Construction,
194                "tolerance {value} must be finite and non-negative"
195            );
196        }
197        Ok(Self(value.max(CONFUSION)))
198    }
199
200    /// The tolerance as a length.
201    #[must_use]
202    pub const fn get(self) -> f64 {
203        self.0
204    }
205
206    /// This tolerance widened to at least `other`.
207    ///
208    /// The only sanctioned way to change a tolerance. Boolean operations grow
209    /// tolerances as they go. Nothing shrinks them.
210    #[must_use]
211    pub fn widen(self, other: Self) -> Self {
212        Self(self.0.max(other.0))
213    }
214
215    /// This tolerance widened to at least `value`, ignoring non-finite input.
216    #[must_use]
217    pub fn widen_to(self, value: f64) -> Self {
218        if value.is_finite() {
219            Self(self.0.max(value))
220        } else {
221            self
222        }
223    }
224
225    /// Whether a separation of `distance` is within this tolerance.
226    #[must_use]
227    pub fn covers(self, distance: f64) -> bool {
228        distance.abs() <= self.0
229    }
230}
231
232impl Default for Tolerance {
233    fn default() -> Self {
234        Self::MIN
235    }
236}
237
238/// Check the containment rule `tol(vertex) >= tol(edge) >= tol(face)` for a
239/// boundary relationship.
240///
241/// `docs/DATA_MODEL.md` §5. A parent entity's boundary must be at least as
242/// uncertain as the entity it bounds, or the boundary does not reliably lie on
243/// it.
244///
245/// # Errors
246///
247/// [`OgeomError::Invariant`](crate::OgeomError::Invariant) if `bounding` is tighter
248/// than `bounded`.
249pub fn check_containment(bounding: Tolerance, bounded: Tolerance) -> OgeomResult<()> {
250    if bounding.get() < bounded.get() {
251        ogeom_bail!(
252            Invariant,
253            "tolerance containment violated: bounding {} < bounded {}",
254            bounding.get(),
255            bounded.get()
256        );
257    }
258    Ok(())
259}
260
261#[cfg(test)]
262#[allow(clippy::unwrap_used)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn linear_tolerances_scale_but_angular_ones_do_not() {
268        let mm = Tolerances::millimetres();
269        let m = Tolerances::metres();
270        // A model in metres has 1000x coarser numbers for the same physical
271        // distance, so the tolerance expressed in model units is 1000x smaller.
272        assert!((m.confusion() * 1000.0 - mm.confusion()).abs() < 1e-18);
273        assert_eq!(m.angular(), mm.angular());
274        assert_eq!(m.parametric(), mm.parametric());
275    }
276
277    #[test]
278    fn rejects_nonsense_scales() {
279        assert!(Tolerances::with_scale(0.0).is_err());
280        assert!(Tolerances::with_scale(-1.0).is_err());
281        assert!(Tolerances::with_scale(f64::NAN).is_err());
282        assert!(Tolerances::with_scale(f64::INFINITY).is_err());
283        assert!(Tolerances::with_scale(25.4).is_ok());
284    }
285
286    /// Micrometres to kilometres, every derived linear tolerance is the
287    /// constant over the scale.
288    #[test]
289    fn every_working_unit_derives_its_tolerances() {
290        for scale in [1e-3, 1.0, 25.4, 1e3, 1e6] {
291            let t = Tolerances::with_scale(scale).unwrap();
292            assert_eq!(t.confusion(), CONFUSION / scale);
293            assert_eq!(t.intersection(), INTERSECTION / scale);
294            assert_eq!(t.approximation(), APPROXIMATION / scale);
295            assert_eq!(t.degenerate(), DEGENERATE / scale);
296            assert!(t.same_length(1.0, 1.0 + 0.5 * CONFUSION / scale));
297            assert!(!t.same_length(1.0, 1.0 + 2.0 * CONFUSION / scale));
298        }
299    }
300
301    /// A scale whose tolerances overflow, underflow or square out of range
302    /// is refused by name; the extremes it accepts still compare distances.
303    #[test]
304    fn a_scale_whose_tolerances_leave_the_normal_range_is_refused() {
305        for scale in [f64::MIN_POSITIVE, 1e-200, 1e-161, 1e145, 1e200, f64::MAX] {
306            assert!(Tolerances::with_scale(scale).is_err(), "{scale}");
307        }
308        for scale in [1e-159, 1e143] {
309            let t = Tolerances::with_scale(scale).unwrap();
310            let c = t.confusion();
311            assert!(t.same_length(0.0, 0.5 * c) && !t.same_length(0.0, 2.0 * c));
312            let (near, far) = (0.5 * c, 2.0 * c);
313            assert!(near * near <= c * c && far * far > c * c);
314        }
315    }
316
317    #[test]
318    fn tolerance_never_drops_below_min() {
319        assert_eq!(Tolerance::new(0.0).unwrap(), Tolerance::MIN);
320        assert_eq!(Tolerance::new(1e-30).unwrap(), Tolerance::MIN);
321    }
322
323    #[test]
324    fn nan_tolerance_is_rejected_not_propagated() {
325        assert!(Tolerance::new(f64::NAN).is_err());
326        assert!(Tolerance::new(-1.0).is_err());
327        assert!(Tolerance::new(f64::INFINITY).is_err());
328    }
329
330    #[test]
331    fn widening_is_monotone() {
332        let a = Tolerance::new(1e-4).unwrap();
333        let b = Tolerance::new(1e-2).unwrap();
334        assert_eq!(a.widen(b), b);
335        assert_eq!(b.widen(a), b, "widen must never shrink");
336        assert_eq!(a.widen_to(f64::NAN), a, "non-finite input must not poison");
337    }
338
339    #[test]
340    fn containment_rule() {
341        let vertex = Tolerance::new(1e-3).unwrap();
342        let edge = Tolerance::new(1e-4).unwrap();
343        let face = Tolerance::new(1e-5).unwrap();
344        assert!(check_containment(vertex, edge).is_ok());
345        assert!(check_containment(edge, face).is_ok());
346        assert!(check_containment(face, vertex).is_err());
347    }
348}