Skip to main content

brep_cam/
numeric.rs

1//! The single numeric policy every CAM algorithm reads.
2//!
3//! No algorithm hard-codes a tolerance: it takes a [`Tolerance`] and uses the
4//! named epsilon for the comparison it is making. Tolerances scale with the
5//! model's bounding-box diagonal (see [`Tolerance::scaled_for_diagonal`]) so
6//! very large or very small models keep sensible relative precision.
7
8use crate::model::Point3;
9use serde::{Deserialize, Serialize};
10
11/// Named epsilons. Lengths are in model units.
12#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
13#[serde(rename_all = "camelCase")]
14pub struct Tolerance {
15    /// Point equality.
16    pub point_eps: f64,
17    /// Geometric distance comparisons (contact heights, offsets, clipping).
18    pub distance_eps: f64,
19    /// Zero-area loop/triangle checks.
20    pub area_eps: f64,
21    /// Dot/cross comparisons (unitless).
22    pub angle_eps: f64,
23    /// Minimum accepted span/sample distance.
24    pub sample_eps: f64,
25}
26
27impl Default for Tolerance {
28    fn default() -> Self {
29        Self {
30            point_eps: 1e-6,
31            distance_eps: 1e-6,
32            area_eps: 1e-10,
33            angle_eps: 1e-9,
34            sample_eps: 1e-4,
35        }
36    }
37}
38
39impl Tolerance {
40    /// Bounding-box diagonal the defaults are tuned for (a part about a metre across).
41    pub const REFERENCE_DIAGONAL: f64 = 1000.0;
42    /// Scale factor clamp: tolerances never move more than this far from the defaults.
43    pub const MAX_SCALE: f64 = 1e3;
44
45    /// Defaults scaled for a model whose bounding-box diagonal is `diagonal`.
46    /// Length epsilons scale linearly, the area epsilon quadratically, the
47    /// angle epsilon not at all. A non-positive or non-finite diagonal yields
48    /// the defaults.
49    pub fn scaled_for_diagonal(diagonal: f64) -> Self {
50        let base = Self::default();
51        if !(diagonal > 0.0) || !diagonal.is_finite() {
52            return base;
53        }
54        let s = (diagonal / Self::REFERENCE_DIAGONAL).clamp(1.0 / Self::MAX_SCALE, Self::MAX_SCALE);
55        Self {
56            point_eps: base.point_eps * s,
57            distance_eps: base.distance_eps * s,
58            area_eps: base.area_eps * s * s,
59            angle_eps: base.angle_eps,
60            sample_eps: base.sample_eps * s,
61        }
62    }
63
64    /// Every epsilon finite and positive.
65    pub fn validate(&self) -> Result<(), NumericError> {
66        let fields = [
67            ("pointEps", self.point_eps),
68            ("distanceEps", self.distance_eps),
69            ("areaEps", self.area_eps),
70            ("angleEps", self.angle_eps),
71            ("sampleEps", self.sample_eps),
72        ];
73        for (name, value) in fields {
74            if !value.is_finite() {
75                return Err(NumericError::NonFinite { field: name });
76            }
77            if value <= 0.0 {
78                return Err(NumericError::NotPositive { field: name });
79            }
80        }
81        Ok(())
82    }
83
84    pub fn points_equal(&self, a: Point3, b: Point3) -> bool {
85        a.distance(b) <= self.point_eps
86    }
87
88    pub fn distances_equal(&self, a: f64, b: f64) -> bool {
89        (a - b).abs() <= self.distance_eps
90    }
91
92    /// `a` is below `b` by more than the distance epsilon.
93    pub fn distance_less(&self, a: f64, b: f64) -> bool {
94        a < b - self.distance_eps
95    }
96
97    pub fn is_zero_area(&self, area: f64) -> bool {
98        area.abs() <= self.area_eps
99    }
100
101    /// Two unit vectors are parallel (same or opposite sense).
102    pub fn parallel(&self, a: Point3, b: Point3) -> bool {
103        a.cross(b).length() <= self.angle_eps
104    }
105
106    /// Two unit vectors are perpendicular.
107    pub fn perpendicular(&self, a: Point3, b: Point3) -> bool {
108        a.dot(b).abs() <= self.angle_eps
109    }
110
111    /// A unit direction is vertical (along ±Z) within the angle epsilon.
112    pub fn is_vertical(&self, dir: Point3) -> bool {
113        self.parallel(dir, Point3::Z)
114    }
115
116    /// A unit direction is horizontal (perpendicular to Z) within the angle epsilon.
117    pub fn is_horizontal(&self, dir: Point3) -> bool {
118        self.perpendicular(dir, Point3::Z)
119    }
120
121    /// A span shorter than the sample epsilon is not worth a sample.
122    pub fn span_too_short(&self, length: f64) -> bool {
123        length < self.sample_eps
124    }
125}
126
127/// A refused tolerance field.
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub enum NumericError {
130    NonFinite { field: &'static str },
131    NotPositive { field: &'static str },
132}
133
134impl std::fmt::Display for NumericError {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            NumericError::NonFinite { field } => write!(f, "tolerance {field} is not finite"),
138            NumericError::NotPositive { field } => write!(f, "tolerance {field} must be positive"),
139        }
140    }
141}
142
143impl std::error::Error for NumericError {}
144
145/// Finite check for a coordinate triple; the caller turns `false` into a warning.
146pub fn finite_point(p: Point3) -> bool {
147    p.is_finite()
148}
149