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}