Skip to main content

ifc_alignment/curve/
tolerance.rs

1//! How far apart two authored lengths may be and still name the same seam.
2//!
3//! Every vertical segment restates where it starts (`StartDistAlong`,
4//! `StartHeight`), and those restatements are compared with where the
5//! previous segment ends. The comparison needs a tolerance, and the honest
6//! source for one is the file itself:
7//! `IfcGeometricRepresentationContext.Precision` is, in the schema's own
8//! words, "the tolerance under which two given points are still assumed to
9//! be identical" (IFC4.3 ADD2, `IfcGeometricRepresentationContext`).
10//!
11//! Exporters that round stations and heights to their working precision
12//! state that precision there. buildingSMART's own alignment reference
13//! datasets (IFC4.x-IF `BC003_*`) declare `Precision = 1E-4` and carry seam
14//! residuals up to 1E-5 m in `StartDistAlong` and 8.5E-6 m in
15//! `StartHeight`; a fixed 1E-9 relative test refuses every one of them.
16//!
17//! The rule here:
18//!
19//! - a length seam (`StartDistAlong`, `StartHeight`) is accepted when the
20//!   two values differ by at most the larger of `1e-9 * max(|a|, |b|, 1)`
21//!   (floating-point rounding of the seam arithmetic itself) and the
22//!   declared precision converted to metres;
23//! - the declared precision is read from the model's 3D
24//!   `IfcGeometricRepresentationContext`s (subcontexts derive theirs); when
25//!   several declare one, the coarsest applies, because the profile is not
26//!   tied to any one representation;
27//! - a declared precision coarser than [`SeamTolerance::MAX_PRECISION`]
28//!   (1 mm) is capped there, so a file can never talk the check into joining
29//!   a visible step;
30//! - with no declared precision the rounding term alone applies, which is
31//!   the behaviour before this tolerance existed;
32//! - gradients are ratios, not lengths, so no length unit or length
33//!   precision applies to them; they keep the `1e-9` absolute test.
34
35use ifc_model::{EntityId, Model};
36
37use crate::error::{AlignmentError, AlignmentResult};
38use crate::horizontal::AlignmentUnits;
39
40/// The tolerance a vertical profile's seams are checked against.
41///
42/// Build it from the model with [`SeamTolerance::for_model`], from a known
43/// precision with [`SeamTolerance::from_precision`], or use
44/// [`SeamTolerance::strict`] for the floating-point rounding term alone.
45#[derive(Debug, Clone, Copy, PartialEq)]
46#[non_exhaustive]
47pub struct SeamTolerance {
48    /// Absolute length tolerance in metres, already capped.
49    length: f64,
50}
51
52impl Default for SeamTolerance {
53    fn default() -> Self {
54        Self::strict()
55    }
56}
57
58impl SeamTolerance {
59    /// The coarsest precision honoured, in metres.
60    ///
61    /// A file declaring a coarser `Precision` is checked at 1 mm instead: a
62    /// step a surveyor could see is never joined on the file's say-so.
63    pub const MAX_PRECISION: f64 = 1e-3;
64
65    /// Only the floating-point rounding term: `1e-9 * max(|a|, |b|, 1)`.
66    #[must_use]
67    pub fn strict() -> Self {
68        Self { length: 0.0 }
69    }
70
71    /// Accept length seams within `precision_metres`, capped at
72    /// [`Self::MAX_PRECISION`].
73    ///
74    /// # Errors
75    ///
76    /// Refuses a negative or non-finite precision with
77    /// [`AlignmentError::InvalidUnits`].
78    pub fn from_precision(precision_metres: f64) -> AlignmentResult<Self> {
79        if !precision_metres.is_finite() || precision_metres < 0.0 {
80            return Err(AlignmentError::InvalidUnits {
81                detail: "seam precision must be finite and non-negative",
82            });
83        }
84        Ok(Self {
85            length: precision_metres.min(Self::MAX_PRECISION),
86        })
87    }
88
89    /// The tolerance the model itself declares.
90    ///
91    /// Reads `Precision` from every `IfcGeometricRepresentationContext`
92    /// whose `CoordinateSpaceDimension` is 3 and takes the coarsest,
93    /// converted from the project length unit with
94    /// `units.length_to_metres`. No declared precision gives
95    /// [`Self::strict`].
96    ///
97    /// # Errors
98    ///
99    /// Refuses invalid `units`, and a declared `Precision` that is not a
100    /// finite, positive number (`InvalidAttribute` naming the context).
101    pub fn for_model(model: &Model, units: AlignmentUnits) -> AlignmentResult<Self> {
102        if !units.length_to_metres.is_finite() || units.length_to_metres <= 0.0 {
103            return Err(AlignmentError::InvalidUnits {
104                detail: "length factor must be finite and positive",
105            });
106        }
107        let mut coarsest: Option<f64> = None;
108        for (id, entity) in model.iter() {
109            // Exact type: a subcontext's Precision is derived (`*`) from its
110            // parent, which this loop already visits.
111            if !entity
112                .type_name
113                .eq_ignore_ascii_case("IFCGEOMETRICREPRESENTATIONCONTEXT")
114            {
115                continue;
116            }
117            if let Some(precision) = declared_3d_precision(id, &entity.attributes)? {
118                let metres = precision * units.length_to_metres;
119                coarsest = Some(coarsest.map_or(metres, |c: f64| c.max(metres)));
120            }
121        }
122        coarsest.map_or(Ok(Self::strict()), Self::from_precision)
123    }
124
125    /// The absolute length tolerance in metres, after the cap.
126    #[must_use]
127    pub fn length(&self) -> f64 {
128        self.length
129    }
130
131    /// Whether two lengths, in metres, name the same seam.
132    pub(crate) fn same_length(&self, left: f64, right: f64) -> bool {
133        (left - right).abs() <= rounding(left, right).max(self.length)
134    }
135
136    /// Whether two gradients name the same seam: rounding only, no length
137    /// precision, because a gradient is a ratio.
138    pub(crate) fn same_gradient(&self, left: f64, right: f64) -> bool {
139        (left - right).abs() <= rounding(left, right)
140    }
141}
142
143/// Floating-point rounding of the seam arithmetic: `1e-9` relative, floor 1.
144fn rounding(left: f64, right: f64) -> f64 {
145    1e-9 * left.abs().max(right.abs()).max(1.0)
146}
147
148/// `Precision` of a 3D context, if declared.
149///
150/// IFC4X3_ADD2 `IfcGeometricRepresentationContext`: `ContextIdentifier`,
151/// `ContextType` (inherited), then `CoordinateSpaceDimension` at slot 2 and
152/// `Precision` at slot 3.
153fn declared_3d_precision(
154    id: EntityId,
155    attributes: &[ifc_model::Value],
156) -> AlignmentResult<Option<f64>> {
157    const DIMENSION: usize = 2;
158    const PRECISION: usize = 3;
159    let dimension = attributes
160        .get(DIMENSION)
161        .and_then(|value| value.unwrap_typed().as_f64());
162    if dimension != Some(3.0) {
163        return Ok(None);
164    }
165    match attributes.get(PRECISION) {
166        None | Some(ifc_model::Value::Null | ifc_model::Value::Derived) => Ok(None),
167        Some(value) => match value.unwrap_typed().as_f64() {
168            Some(precision) if precision.is_finite() && precision > 0.0 => Ok(Some(precision)),
169            _ => Err(AlignmentError::InvalidAttribute {
170                entity: id,
171                index: PRECISION,
172                name: "Precision",
173            }),
174        },
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use ifc_model::{Entity, Value};
182    use std::sync::Arc;
183
184    fn metres() -> AlignmentUnits {
185        AlignmentUnits {
186            length_to_metres: 1.0,
187            angle_to_radians: 1.0,
188        }
189    }
190
191    fn context(model: &mut Model, id: u64, dimension: i64, precision: Value) {
192        model.insert(
193            EntityId(id),
194            Entity::new(
195                "IFCGEOMETRICREPRESENTATIONCONTEXT",
196                vec![
197                    Value::Null,
198                    Value::Text(Arc::from("Model")),
199                    Value::Integer(dimension),
200                    precision,
201                    Value::Null,
202                    Value::Null,
203                ],
204            ),
205        );
206    }
207
208    #[test]
209    fn no_declared_precision_is_strict() {
210        let mut model = Model::new();
211        context(&mut model, 1, 3, Value::Null);
212        assert_eq!(
213            SeamTolerance::for_model(&model, metres()),
214            Ok(SeamTolerance::strict())
215        );
216    }
217
218    #[test]
219    fn the_coarsest_3d_precision_applies_and_2d_contexts_are_ignored() {
220        let mut model = Model::new();
221        context(&mut model, 1, 3, Value::Real(1e-6));
222        context(&mut model, 2, 3, Value::Real(1e-4));
223        context(&mut model, 3, 2, Value::Real(1e-2));
224        let tolerance = SeamTolerance::for_model(&model, metres()).expect("tolerance");
225        assert_eq!(tolerance.length(), 1e-4);
226    }
227
228    #[test]
229    fn precision_is_converted_from_the_project_length_unit() {
230        let mut model = Model::new();
231        // 0.1 mm declared in millimetres.
232        context(&mut model, 1, 3, Value::Real(0.1));
233        let millimetres = AlignmentUnits {
234            length_to_metres: 0.001,
235            angle_to_radians: 1.0,
236        };
237        let tolerance = SeamTolerance::for_model(&model, millimetres).expect("tolerance");
238        assert!((tolerance.length() - 1e-4).abs() < 1e-18);
239    }
240
241    #[test]
242    fn a_coarse_precision_is_capped_at_one_millimetre() {
243        let mut model = Model::new();
244        context(&mut model, 1, 3, Value::Real(0.5));
245        let tolerance = SeamTolerance::for_model(&model, metres()).expect("tolerance");
246        assert_eq!(tolerance.length(), SeamTolerance::MAX_PRECISION);
247        assert!(!tolerance.same_length(52.0, 52.002));
248    }
249
250    #[test]
251    fn a_non_positive_precision_is_refused_by_name() {
252        let mut model = Model::new();
253        context(&mut model, 7, 3, Value::Real(-1e-5));
254        assert_eq!(
255            SeamTolerance::for_model(&model, metres()),
256            Err(AlignmentError::InvalidAttribute {
257                entity: EntityId(7),
258                index: 3,
259                name: "Precision",
260            })
261        );
262    }
263
264    #[test]
265    fn gradients_ignore_the_length_precision() {
266        let tolerance = SeamTolerance::from_precision(1e-4).expect("tolerance");
267        assert!(tolerance.same_length(100.0, 100.00009));
268        assert!(!tolerance.same_gradient(0.02, 0.02009));
269    }
270}