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