Skip to main content

brep_kernel/geometry/
entity_tolerance.rs

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