Skip to main content

geop_core_topology/validation/
numerical_accuracy.rs

1use crate::{Model, validation::ValidationParameters};
2use geop_core_math::{geop_error::GeopError, scalars::Scalar};
3
4/// The widest a single coordinate of any stored entity may be.
5///
6/// Not a comparison tolerance — nothing is ever tested for equality against
7/// it. It is a bound on how much uncertainty an entity is allowed to *carry*,
8/// and every search and comparison downstream assumes something like it: a
9/// containment search resolves to `min_subdivision_size` (also `1e-4`), so a
10/// vertex already wider than that cannot be located on anything, and a
11/// control point that wide makes a curve whose convex hull no longer pins the
12/// curve down. Past this point the arithmetic stops meaning what the
13/// algorithms assume it means.
14///
15/// Public so that what *builds* an entity can tell whether a
16/// representation it may choose fits the budget: the boolean's exact arc
17/// (`geop-lib-booleans`) is one of two enclosures of the same curve, and
18/// is taken only where it does.
19pub const MAX_WIDTH: f64 = 1e-4;
20
21/// Checks that no stored entity carries more than [`MAX_WIDTH`] of numerical
22/// uncertainty in any coordinate: every vertex position, every edge curve
23/// control point, every face surface control point.
24///
25/// Runs before every other check, because it explains them. A too-wide entity
26/// does not fail in place — it fails somewhere downstream, as a containment
27/// search that finds nothing, a pcurve that will not match its edge, or an
28/// intersection that is missed entirely, and the report names that distant
29/// symptom rather than the cause.
30///
31/// A failure here almost always means a **missing refinement**: some operation
32/// returned a subdivision search's raw enclosure (as wide as its tolerance)
33/// and stored it, where it should have polished it with Newton first — see
34/// `NurbCurve::refine_parameter_at_point` and
35/// `intersection::curve_surface::refine_crossing`. Widening is monotone
36/// through arithmetic, so the first entity to exceed the bound is close to
37/// wherever that refinement was skipped.
38///
39/// The same bound doubles as a *minimum* on how long an edge may be. An edge
40/// shorter than the accuracy its own endpoints carry is not a feature of the
41/// model, it is noise: nothing can be located along it, the searches cannot
42/// tell its two ends apart, and splicing anything into a face across it
43/// produces a region of no area. Such an edge always means an operation split
44/// something it should have recognised as already coincident.
45///
46/// The placeholder surface a face carries before it is given real geometry
47/// (`NurbSurface::everything`, every coordinate `ENTIRE`) is deliberately not
48/// exempted: a finished model must not contain one, and reporting it here as
49/// an unbounded width is exactly right.
50pub fn check_numerical_accuracy(
51    _params: &ValidationParameters,
52    errors: &mut Vec<GeopError>,
53    model: &Model,
54) {
55    let limit = Scalar::from_f64(MAX_WIDTH);
56    // `width` is sharp by construction, and so is `limit`, so this comparison
57    // is always decidable — which is the whole reason the bound is expressed
58    // as a width rather than as a comparison between two uncertain values.
59    let too_wide = |x: Scalar| x.width().definitely_greater(limit);
60
61    for (&vertex_id, vertex) in &model.vertices {
62        for c in 0..3 {
63            if too_wide(vertex.point[c]) {
64                errors.push(GeopError::new(format!(
65                    "vertex {vertex_id}'s coordinate {c} is {:?}, {:?} wide — wider than the {MAX_WIDTH:e} every search downstream assumes; something that produced it skipped a refinement",
66                    vertex.point[c],
67                    vertex.point[c].width()
68                )));
69            }
70        }
71    }
72
73    for (&edge_id, edge) in &model.edges {
74        for (i, p) in edge.curve.control_points.iter().enumerate() {
75            for c in 0..4 {
76                if too_wide(p[c]) {
77                    errors.push(GeopError::new(format!(
78                        "edge {edge_id}'s curve control point {i}, component {c} is {:?}, {:?} wide — wider than the {MAX_WIDTH:e} every search downstream assumes; something that produced it skipped a refinement",
79                        p[c],
80                        p[c].width()
81                    )));
82                }
83            }
84        }
85
86        let (t0, t1) = edge.curve.domain();
87        let (Ok(start), Ok(end)) = (edge.curve.evaluate(t0), edge.curve.evaluate(t1)) else {
88            continue;
89        };
90        // Chord, not arc length: a curve whose two ends are closer together
91        // than this is degenerate however it wanders in between, and the
92        // chord is exactly what a subdivision search's convergence test sees.
93        let chord = end.sub(&start).norm();
94        if limit.definitely_greater(chord) {
95            errors.push(GeopError::new(format!(
96                "edge {edge_id} runs from {start:?} to {end:?}, a chord of {chord:?} — shorter than the {MAX_WIDTH:e} the searches can resolve, so it is noise rather than geometry; whatever produced it split something it should have recognised as already coincident"
97            )));
98        }
99    }
100
101    for (&face_id, face) in &model.faces {
102        for (i, p) in face.surface.control_points.iter().enumerate() {
103            for c in 0..4 {
104                if too_wide(p[c]) {
105                    errors.push(GeopError::new(format!(
106                        "face {face_id}'s surface control point {i}, component {c} is {:?}, {:?} wide — wider than the {MAX_WIDTH:e} every search downstream assumes; something that produced it skipped a refinement",
107                        p[c],
108                        p[c].width()
109                    )));
110                }
111            }
112        }
113    }
114}