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}