Skip to main content

brep_kernel/geometry/
entity_tolerance.rs

1//! Per-entity measured tolerances — the lazy, capped, per-edge/per-vertex band
2//! `per-entity-tolerances.md` asks for, derived from the entity's own redundant
3//! representations rather than predicted from the model's size.
4//!
5//! # Why an entity needs its own band
6//!
7//! Everything in [`crate::tolerance`] is a *policy*: one spatial base `model`,
8//! size-coupled at the site through [`KernelTolerances::heal_band`], with named
9//! weld floors. That policy answers "how accurate is geometry in this kernel?"
10//! with ONE number, and so it cannot say the thing a dirty import needs said:
11//! *this* edge is known to be 4.7e-4 sloppy while *that* one is exact. A vendor
12//! STEP file commits an edge's 3D curve and its face surfaces as INDEPENDENT
13//! approximations; where they disagree, two intersections computed against the
14//! two surfaces sharing that edge cannot agree better than the disagreement,
15//! no matter how good the marcher is. The residual is a property OF THE EDGE.
16//!
17//! So: `tol(E) = min(cap_E, max(policy floor, d_meas(E)))`, where `d_meas(E)`
18//! is the largest disagreement the edge's own representations show — exactly
19//! the quantity [`crate::measure_edge_against_pcurve_image`] measures, taken
20//! over every coedge that references the edge.
21//!
22//! # The direction rule, and why this type may hand out a band at all
23//!
24//! [`crate::MeasuredTolerance`] deliberately exposes no "band to use" accessor:
25//! a *construction* that measured its own error must never widen the band that
26//! judges it, or the sloppier the fit the more forgiving its grade. That is
27//! invariant **I1**, and it is not weakened here.
28//!
29//! This type is the other case. Its input is not a fit this kernel just
30//! produced but the disagreement already present in geometry the kernel was
31//! HANDED, and the band it returns is spent only on *search / pairing*
32//! questions — "which of these candidates might be the same point?" — never on
33//! an acceptance gate. The acceptance gates ([`crate::BrepSolid::validate`],
34//! the boolean's own assembly gate) still run afterwards on the global policy
35//! band and still refuse, so a wider search band can only change WHICH
36//! candidates are considered, never whether a wrong answer is accepted. Two
37//! structural properties keep that honest:
38//!
39//! * **Capped** (`cap_E`, `cap_V` below). A measurement above the cap does not
40//!   buy a wider band: the entity is *defective*, its band pins at the cap, and
41//!   the acceptance gate still fails it. We refuse; we do not widen.
42//! * **Local.** The band belongs to one edge or one vertex. A poor fit on one
43//!   imported edge cannot authorize an unrelated pair of vertices, or a thin
44//!   feature elsewhere, to merge — which is the failure mode the plan names.
45//!
46//! And **I2**: an entity with no measurable band degrades to the policy floor —
47//! *narrower*, so more refusals, never a wrong weld. Every consumer therefore
48//! reads `max(its own global band, tol(entity))` and behaves exactly as it did
49//! before wherever the measurement is absent or zero. Constructed geometry
50//! measures ~0, so nothing this kernel builds itself changes behaviour.
51//!
52//! # Lazy, not stored — which is also the cache-invalidation answer
53//!
54//! The view borrows a solid and memoizes per id. It is constructed on the
55//! operation's ENTRY snapshot and dropped when the operation ends, so a
56//! measurement can never outlive the geometry it was taken from: there is no
57//! stale residual to carry, because there is no residual carried. A rigid
58//! placement changes neither `d_meas` (distances are invariant) nor the answer,
59//! and an edit simply means the next operation measures the edited geometry.
60//! Persisting a *committed* band on the records — a different question, with
61//! its own grow-only commit rule and a codec obligation — stays future work.
62
63use crate::topology::{BrepSolid, EdgeRecord, FaceRecord};
64use crate::tolerance::PCURVE_ACCEPTANCE_REL;
65use crate::{
66    measure_edge_against_pcurve_image, solid_scale, KernelTolerances, NurbsCurve, NurbsSurface,
67    Vec3,
68};
69use rustc_hash::FxHashMap as HashMap;
70
71/// Fraction of an edge's own length that bounds its measured band.
72///
73/// The band is spent deciding whether two points ON the edge are the same
74/// point, so it must stay far below the edge's extent or a genuine short
75/// feature could be collapsed. A tenth leaves an order of magnitude between
76/// "the vendor's representations disagree here" and "this edge is one point".
77pub const EDGE_CAP_FRACTION: f64 = 0.10;
78
79/// Fraction of the SHORTEST incident edge that bounds a vertex's band.
80///
81/// A quarter is what makes endpoint welding structurally unable to collapse an
82/// edge: both endpoint radii of an edge sum to at most half its length, so the
83/// two ends can never reach each other.
84pub const VERTEX_CAP_FRACTION: f64 = 0.25;
85
86/// Samples used for an edge's polyline length. The length only feeds a cap, so
87/// a coarse chord underestimates it and errs toward a NARROWER band.
88const LENGTH_SAMPLES: usize = 8;
89
90/// Lazily-measured, capped per-entity tolerances for one solid.
91///
92/// Construct once per operation on the operand you are about to consume, ask
93/// for the entities you actually touch, and drop it with the operation. Each
94/// entity is measured at most once.
95///
96/// See the module documentation for the invariants; in particular a caller
97/// takes `max(its own global band, this)`, never this alone.
98pub struct EntityTolerances<'s> {
99    solid: &'s BrepSolid,
100    /// The policy floor `x` every band starts from.
101    floor: f64,
102    /// `PCURVE_ACCEPTANCE_REL * solid_scale` — the size-relative half of the
103    /// edge cap, computed once.
104    size_cap: f64,
105    /// edge id -> the coedges that reference it, as (surface, pcurve, forward).
106    coedges: Option<HashMap<u64, Vec<CoedgeUse<'s>>>>,
107    /// edge id -> the record, for endpoint and length queries.
108    edge_index: Option<HashMap<u64, &'s EdgeRecord>>,
109    edges: HashMap<u64, f64>,
110    vertices: HashMap<u64, f64>,
111}
112
113/// One coedge's contribution to an edge's measurement.
114struct CoedgeUse<'s> {
115    surface: &'s NurbsSurface,
116    pcurve: &'s NurbsCurve,
117    forward: bool,
118}
119
120impl<'s> EntityTolerances<'s> {
121    /// A view over `solid` with `policy`'s spatial tolerance as the floor.
122    pub fn for_solid(solid: &'s BrepSolid, policy: &KernelTolerances) -> Self {
123        Self::with_floor(solid, policy.spatial())
124    }
125
126    /// A view whose floor is given directly — for the several callers that
127    /// carry a bare `tolerance: f64` rather than a whole policy.
128    pub fn with_floor(solid: &'s BrepSolid, floor: f64) -> Self {
129        let floor = if floor.is_finite() && floor > 0.0 {
130            floor
131        } else {
132            0.0
133        };
134        Self {
135            solid,
136            floor,
137            size_cap: PCURVE_ACCEPTANCE_REL * solid_scale(solid),
138            coedges: None,
139            edge_index: None,
140            edges: HashMap::default(),
141            vertices: HashMap::default(),
142        }
143    }
144
145    /// The identity band for edge `id`: `min(cap_E, max(floor, d_meas(E)))`.
146    ///
147    /// Answers the floor for an unknown edge, an edge with no coedge, or a
148    /// measurement that could not be taken — I2's fail-safe direction. Never
149    /// returns a non-finite or negative value.
150    pub fn edge(&mut self, id: u64) -> f64 {
151        if let Some(&memo) = self.edges.get(&id) {
152            return memo;
153        }
154        let band = self.measure_edge(id);
155        self.edges.insert(id, band);
156        band
157    }
158
159    /// The identity band for vertex `id`: `min(cap_V, max(floor, g_meas(V)))`,
160    /// where `g_meas` is the largest gap between the vertex's point and the
161    /// endpoints of the edges that claim it.
162    pub fn vertex(&mut self, id: u64) -> f64 {
163        if let Some(&memo) = self.vertices.get(&id) {
164            return memo;
165        }
166        let band = self.measure_vertex(id);
167        self.vertices.insert(id, band);
168        band
169    }
170
171    /// The largest edge band over `ids` — the band a decision that involves
172    /// several edges at once (a junction sitting where two boundary edges meet)
173    /// must respect, since it can be no more certain than its worst input.
174    pub fn worst_edge(&mut self, ids: impl IntoIterator<Item = u64>) -> f64 {
175        let mut worst = self.floor;
176        for id in ids {
177            worst = worst.max(self.edge(id));
178        }
179        worst
180    }
181
182    /// The policy floor this view was built with — what every band degrades to.
183    pub fn floor(&self) -> f64 {
184        self.floor
185    }
186
187    /// The raw `d_meas(E)` for edge `id`: the largest 3D disagreement between
188    /// the edge's curve and its pcurve images, over every coedge that
189    /// references it — UNCLAMPED, neither floored nor capped, so a defective
190    /// entity (measured above `cap_E`) reads as what it is rather than as the
191    /// cap. `None` for an unknown or degenerate edge, one no coedge
192    /// references, or one whose every measurement failed.
193    ///
194    /// For REPORTING (the import's stated-precision consistency check, a
195    /// census). It is never a band: the band is [`Self::edge`], and nothing a
196    /// caller reads here may widen anything (I1/I2 in the module doc).
197    pub fn measured_edge_deviation(&mut self, id: u64) -> Option<f64> {
198        self.raw_edge(id)
199    }
200
201    /// The raw `g_meas(V)` for vertex `id`: the largest gap between the
202    /// vertex's point and the ends of the edge curves that claim it —
203    /// unclamped, as [`Self::measured_edge_deviation`] is for edges. `None`
204    /// for an unknown vertex or one no non-degenerate edge ends at.
205    ///
206    /// On an imported solid this is the gap AFTER endpoint healing
207    /// (`heal_imported_edge_endpoints`), not the vendor's raw miss.
208    pub fn measured_vertex_gap(&mut self, id: u64) -> Option<f64> {
209        self.raw_vertex(id).map(|(gap, _)| gap)
210    }
211
212    fn measure_edge(&mut self, id: u64) -> f64 {
213        self.ensure_index();
214        let Some(edge) = self.edge_index.as_ref().and_then(|index| index.get(&id)) else {
215            return self.floor;
216        };
217        let edge = *edge;
218        if edge.degenerate {
219            return self.floor;
220        }
221        let cap = self.edge_cap(edge);
222        // A measurement that failed says nothing, so it must not widen
223        // anything: the floor stands (I2).
224        let worst = self.raw_edge(id).unwrap_or(0.0);
225        clamp_band(self.floor, worst, cap)
226    }
227
228    /// The unclamped edge measurement behind [`Self::measure_edge`]; see
229    /// [`Self::measured_edge_deviation`] for the contract.
230    fn raw_edge(&mut self, id: u64) -> Option<f64> {
231        self.ensure_index();
232        let edge = *self.edge_index.as_ref()?.get(&id)?;
233        if edge.degenerate {
234            return None;
235        }
236        // The band handed to the sampler DRIVES ITS REFINEMENT (it subdivides
237        // while the deviation is non-linear relative to this number), it is not
238        // a verdict the measurement is judged against. So it must be the TIGHT
239        // policy floor: passing the cap here would stop subdivision at depth 0
240        // on a smooth curve and alias a real 4e-4 residual down to nothing —
241        // which is the same aliasing `offset/measure.rs` documents, arriving
242        // through the band instead of through the sample grid.
243        let probe_band = self.floor;
244        let uses = self.coedges.as_ref()?.get(&id)?;
245        let mut worst: Option<f64> = None;
246        for use_record in uses {
247            match measure_edge_against_pcurve_image(
248                use_record.surface,
249                use_record.pcurve,
250                edge,
251                use_record.forward,
252                probe_band,
253            ) {
254                Err(_) => continue,
255                Ok(measured) => {
256                    let deviation = measured.deviation();
257                    if deviation.is_finite() {
258                        worst = Some(worst.map_or(deviation, |w| w.max(deviation)));
259                    }
260                }
261            }
262        }
263        worst
264    }
265
266    /// O(edges) per query and deliberately index-free: the vertex band needs
267    /// only curve endpoints, not the coedge index the edge band builds, and a
268    /// caller that asks for one vertex should not pay for the other structure.
269    fn measure_vertex(&mut self, id: u64) -> f64 {
270        match self.raw_vertex(id) {
271            None => self.floor,
272            Some((gap, shortest)) => clamp_band(self.floor, gap, VERTEX_CAP_FRACTION * shortest),
273        }
274    }
275
276    /// `(g_meas(V), shortest incident edge length)`, unclamped; `None` when
277    /// the vertex is unknown or no non-degenerate edge ends at it.
278    fn raw_vertex(&mut self, id: u64) -> Option<(f64, f64)> {
279        let point = self
280            .solid
281            .vertices
282            .iter()
283            .find(|vertex| vertex.id == id)
284            .map(|vertex| vertex.point)?;
285        let mut gap = 0.0f64;
286        let mut shortest = f64::INFINITY;
287        for edge in &self.solid.edges {
288            if edge.degenerate {
289                continue;
290            }
291            for (vertex_id, parameter) in [
292                (edge.start_vertex_id, edge.t0),
293                (edge.end_vertex_id, edge.t1),
294            ] {
295                if vertex_id != id {
296                    continue;
297                }
298                shortest = shortest.min(edge_length(edge));
299                if let Ok(end) = edge.curve.evaluate(parameter) {
300                    let distance = point.sub(end).length();
301                    if distance.is_finite() {
302                        gap = gap.max(distance);
303                    }
304                }
305            }
306        }
307        if !shortest.is_finite() {
308            return None;
309        }
310        Some((gap, shortest))
311    }
312
313    /// `cap_E = min(EDGE_CAP_FRACTION * len(E), PCURVE_ACCEPTANCE_REL * D)`.
314    fn edge_cap(&self, edge: &EdgeRecord) -> f64 {
315        (EDGE_CAP_FRACTION * edge_length(edge)).min(self.size_cap)
316    }
317
318    fn ensure_index(&mut self) {
319        if self.coedges.is_some() {
320            return;
321        }
322        let mut coedges: HashMap<u64, Vec<CoedgeUse<'s>>> = HashMap::default();
323        for face in self.solid.shells.iter().flat_map(|shell| &shell.faces) {
324            let face: &'s FaceRecord = face;
325            for loop_record in &face.loops {
326                for coedge in &loop_record.coedges {
327                    coedges
328                        .entry(coedge.edge_id)
329                        .or_default()
330                        .push(CoedgeUse {
331                            surface: &face.surface,
332                            pcurve: &coedge.pcurve,
333                            forward: coedge.forward,
334                        });
335                }
336            }
337        }
338        self.coedges = Some(coedges);
339        self.edge_index = Some(
340            self.solid
341                .edges
342                .iter()
343                .map(|edge| (edge.id, edge))
344                .collect(),
345        );
346    }
347}
348
349/// `min(cap, max(floor, measured))`, guarded so no non-finite input escapes and
350/// so the result is never below the floor: the fail-safe direction is NARROW.
351fn clamp_band(floor: f64, measured: f64, cap: f64) -> f64 {
352    let measured = if measured.is_finite() && measured > 0.0 {
353        measured
354    } else {
355        0.0
356    };
357    let cap = if cap.is_finite() && cap > 0.0 {
358        cap
359    } else {
360        floor
361    };
362    floor.max(measured.min(cap.max(floor)))
363}
364
365/// Chord length of `LENGTH_SAMPLES` uniform samples across the edge's live
366/// span. Underestimates a curved edge, which narrows its cap — the safe way to
367/// be wrong.
368fn edge_length(edge: &EdgeRecord) -> f64 {
369    let mut total = 0.0f64;
370    let mut previous: Option<Vec3> = None;
371    for step in 0..=LENGTH_SAMPLES {
372        let fraction = step as f64 / LENGTH_SAMPLES as f64;
373        let Ok(point) = edge.curve.evaluate(edge.t0 + (edge.t1 - edge.t0) * fraction) else {
374            return 0.0;
375        };
376        if let Some(last) = previous {
377            total += point.sub(last).length();
378        }
379        previous = Some(point);
380    }
381    if total.is_finite() {
382        total
383    } else {
384        0.0
385    }
386}
387