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}