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