axiolid_brep_audit/
lib.rs

1//! Geometric consistency auditing for exact boundary representations.
2//!
3//! # Why this exists next to `audit_brep`
4//!
5//! [`audit_brep`](axiolid_topology::audit_brep) is pure topology: handles and
6//! adjacency, no coordinates and no tolerance. That makes it exact and
7//! reproducible, and it is deliberately kept that way.
8//!
9//! It also means a whole class of defect passes it silently. A pcurve that
10//! has nothing to do with the 3D edge it trims still resolves, still closes
11//! its loop, and still balances its edge uses. Every variant of
12//! `ExactBRepError` is likewise about PRESENCE -- is there a pcurve, does the
13//! handle resolve -- never about AGREEMENT.
14//!
15//! That gap is not hypothetical. An earlier arc-aware overlay built cap loops
16//! whose pcurves were straight chords across arc edges. The solid closed,
17//! validated, audited clean, and reported a plausible area, while the cap
18//! boundary disagreed with the wall boundary along the same edge.
19//!
20//! This module closes that gap by EVALUATING. Each check maps parameters to
21//! points through the real evaluators and compares positions, so it is
22//! necessarily tolerance-dependent -- which is exactly why it is separate
23//! from the exact topological audit rather than folded into it.
24
25#![forbid(unsafe_code)]
26
27mod report;
28
29pub use report::{GeometricDefect, GeometricHealth};
30
31use axiolid_brep::ExactBRep;
32use axiolid_core::{Point3, Scalar, Tolerance};
33use axiolid_evaluate::{curve, surface};
34use axiolid_topology::Orientation;
35
36/// Audit the geometric consistency of an exact B-rep.
37///
38/// Complements the topological audit: this one evaluates curves and surfaces
39/// and compares positions, so it needs a tolerance and can only report
40/// agreement to within it.
41///
42/// Checks performed:
43///
44/// - every edge's start and end vertex lies on the edge's own 3D curve;
45/// - every pcurve, mapped through its face's surface, lands on the 3D curve
46///   of the edge it trims.
47#[must_use]
48pub fn geometric_audit(brep: &ExactBRep, tolerance: Tolerance) -> GeometricHealth {
49    let mut health = GeometricHealth::default();
50    check_vertices_on_curves(brep, tolerance, &mut health);
51    check_pcurves_against_curves(brep, tolerance, &mut health);
52    health
53}
54
55/// Sample parameters used to compare a pcurve against its 3D curve.
56///
57/// Endpoints catch a pcurve that trims the wrong portion; the interior
58/// samples catch one that shares endpoints but takes a different path --
59/// a straight chord standing in for an arc, for instance, which agrees
60/// exactly at both ends.
61const SAMPLES: [Scalar; 5] = [0.0, 0.25, 0.5, 0.75, 1.0];
62
63/// The worst deviation of a pcurve from its edge when the two share no
64/// parameterisation: each lifted sample is projected onto the edge (by
65/// inversion), and must land within the edge's span, at the use's start and
66/// end for the end samples, and in order along the use between them. A
67/// sample that fails any of these counts as infinitely far.
68fn follows_in_order(
69    pcurve: &axiolid_curve::Curve2,
70    pcurve_interval: axiolid_core::Interval,
71    support: &axiolid_surface::Surface,
72    curve3: &axiolid_curve::Curve3,
73    edge_interval: axiolid_core::Interval,
74    orientation: Orientation,
75    tolerance: Tolerance,
76) -> Scalar {
77    const DENSE: usize = 16;
78    let (lo, hi) = (
79        edge_interval.start.min(edge_interval.end),
80        edge_interval.start.max(edge_interval.end),
81    );
82    let periodic = matches!(
83        curve3,
84        axiolid_curve::Curve3::Circle(_) | axiolid_curve::Curve3::Ellipse(_)
85    );
86    // An edge that closes on itself (one vertex) reads its end as its start.
87    let closed = match (curve::evaluate3(curve3, lo), curve::evaluate3(curve3, hi)) {
88        (Ok(a), Ok(b)) => distance(a, b) <= tolerance.linear(),
89        _ => false,
90    };
91    let slack = 1e-9 * (1.0 + lo.abs().max(hi.abs()));
92    let mut worst: Scalar = 0.0;
93    let mut previous: Option<Scalar> = None;
94    for i in 0..=DENSE {
95        let sample = i as Scalar / DENSE as Scalar;
96        let Ok(uv) = curve::evaluate2(pcurve, lerp(pcurve_interval, sample)) else {
97            return Scalar::INFINITY;
98        };
99        let Ok(lifted) = surface::evaluate(support, uv.x, uv.y) else {
100            return Scalar::INFINITY;
101        };
102        let Ok(t) = curve::locate3(curve3, lifted, tolerance) else {
103            return Scalar::INFINITY;
104        };
105        // Into the edge's span, by whole turns for a closed conic.
106        let candidates: &[Scalar] = if periodic {
107            &[0.0, 1.0, -1.0, 2.0]
108        } else {
109            &[0.0]
110        };
111        let Some(t) = candidates
112            .iter()
113            .map(|k| t + k * core::f64::consts::TAU)
114            .find(|t| *t >= lo - slack && *t <= hi + slack)
115        else {
116            return Scalar::INFINITY;
117        };
118        // Where along the use, from 0 at its start to 1 at its end.
119        let along = (t - edge_interval.start) / (edge_interval.end - edge_interval.start);
120        let along = match orientation {
121            Orientation::Forward => along,
122            Orientation::Reversed => 1.0 - along,
123        };
124        // The end samples at the use's ends (a closed edge's end may read
125        // as its start).
126        let at_end = |target: Scalar| {
127            (along - target).abs() <= 1e-6 || (closed && (along - (1.0 - target)).abs() <= 1e-6)
128        };
129        if (i == 0 && !at_end(0.0)) || (i == DENSE && !at_end(1.0)) {
130            return Scalar::INFINITY;
131        }
132        if i > 0 && i < DENSE {
133            if let Some(last) = previous {
134                if along < last - 1e-9 {
135                    return Scalar::INFINITY;
136                }
137            }
138            previous = Some(along);
139        } else if i == 0 {
140            previous = Some(0.0);
141        }
142        let Ok(on) = curve::evaluate3(curve3, t) else {
143            return Scalar::INFINITY;
144        };
145        worst = worst.max(distance(lifted, on));
146    }
147    worst
148}
149
150fn lerp(interval: axiolid_core::Interval, t: Scalar) -> Scalar {
151    interval.start + (interval.end - interval.start) * t
152}
153
154fn distance(a: Point3, b: Point3) -> Scalar {
155    (a - b).length()
156}
157
158/// Every edge's endpoints must lie on the curve that edge claims to follow.
159fn check_vertices_on_curves(brep: &ExactBRep, tolerance: Tolerance, health: &mut GeometricHealth) {
160    let topology = brep.topology();
161    for (index, edge) in topology.edges().iter().enumerate() {
162        let Some(curve_id) = edge.curve else { continue };
163        let Some(curve3) = brep.curves3().get(curve_id.index()) else {
164            continue;
165        };
166        let Some(edge_id) = topology.edge_id_at(index) else {
167            continue;
168        };
169        let Some(interval) = brep.edge_interval(edge_id) else {
170            continue;
171        };
172        let vertices = topology.vertices();
173        let (Some(start), Some(end)) = (
174            vertices.get(edge.start.index()),
175            vertices.get(edge.end.index()),
176        ) else {
177            continue;
178        };
179        for (parameter, vertex) in [(interval.start, start), (interval.end, end)] {
180            let Ok(point) = curve::evaluate3(curve3, parameter) else {
181                health.push(GeometricDefect::UnevaluableCurve { edge: index });
182                continue;
183            };
184            let error = distance(point, vertex.position);
185            if error > tolerance.linear() {
186                health.push(GeometricDefect::VertexOffCurve { edge: index, error });
187            }
188        }
189    }
190}
191
192/// Every pcurve, lifted through its face surface, must follow the 3D edge.
193fn check_pcurves_against_curves(
194    brep: &ExactBRep,
195    tolerance: Tolerance,
196    health: &mut GeometricHealth,
197) {
198    let topology = brep.topology();
199    for face in topology.faces() {
200        let Some(surface_id) = face.surface else {
201            continue;
202        };
203        let Some(support) = brep.surfaces().get(surface_id.index()) else {
204            continue;
205        };
206        for bound in &face.bounds {
207            let Some(lp) = topology.loops().get(bound.loop_id.index()) else {
208                continue;
209            };
210            for (use_index, use_) in lp.edges.iter().enumerate() {
211                let Some(pcurve_id) = use_.pcurve else {
212                    continue;
213                };
214                let Some(pcurve) = brep.curves2().get(pcurve_id.index()) else {
215                    continue;
216                };
217                let Some(pcurve_interval) = brep.pcurve_interval(bound.loop_id, use_index) else {
218                    continue;
219                };
220                let Some(edge) = topology.edges().get(use_.edge.index()) else {
221                    continue;
222                };
223                let Some(curve_id) = edge.curve else { continue };
224                let Some(curve3) = brep.curves3().get(curve_id.index()) else {
225                    continue;
226                };
227                let Some(edge_interval) = brep.edge_interval(use_.edge) else {
228                    continue;
229                };
230
231                let mut worst: Scalar = 0.0;
232                for sample in SAMPLES {
233                    // The pcurve runs in loop-traversal order; the 3D curve
234                    // runs from its own start vertex. A reversed use walks
235                    // the edge backwards, so the sample must be mirrored or
236                    // every reversed edge would look like a mismatch.
237                    let along = match use_.orientation {
238                        Orientation::Forward => sample,
239                        Orientation::Reversed => 1.0 - sample,
240                    };
241                    let parametric = lerp(pcurve_interval, sample);
242                    let spatial = lerp(edge_interval, along);
243
244                    let Ok(uv) = curve::evaluate2(pcurve, parametric) else {
245                        health.push(GeometricDefect::UnevaluablePcurve {
246                            loop_id: bound.loop_id.index(),
247                            use_index,
248                        });
249                        break;
250                    };
251                    let Ok(lifted) = surface::evaluate(support, uv.x, uv.y) else {
252                        health.push(GeometricDefect::UnevaluableSurface {
253                            loop_id: bound.loop_id.index(),
254                            use_index,
255                        });
256                        break;
257                    };
258                    let Ok(expected) = curve::evaluate3(curve3, spatial) else {
259                        health.push(GeometricDefect::UnevaluableCurve {
260                            edge: use_.edge.index(),
261                        });
262                        break;
263                    };
264                    // Track the WORST sample rather than reporting the first
265                    // failure: the first sample over tolerance is rarely the
266                    // largest deviation, and an under-reported error invites
267                    // someone to widen the tolerance just past it.
268                    let error = distance(lifted, expected);
269                    if error > worst {
270                        worst = error;
271                    }
272                }
273                // An implicit pcurve (ADR 0077) is parameterised by its own
274                // cells, not in proportion to the edge: it follows the edge
275                // when every lifted sample lies on the edge's span, in the
276                // order the use runs, from the use's start to its end.
277                if worst > tolerance.linear()
278                    && matches!(pcurve, axiolid_curve::Curve2::Implicit(_))
279                {
280                    worst = follows_in_order(
281                        pcurve,
282                        pcurve_interval,
283                        support,
284                        curve3,
285                        edge_interval,
286                        use_.orientation,
287                        tolerance,
288                    );
289                }
290                if worst > tolerance.linear() {
291                    health.push(GeometricDefect::PcurveOffCurve {
292                        loop_id: bound.loop_id.index(),
293                        use_index,
294                        error: worst,
295                    });
296                }
297            }
298        }
299    }
300}