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}