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/// Fraction of an edge's own length that bounds its measured band.
68///
69/// The band is spent deciding whether two points ON the edge are the same
70/// point, so it must stay far below the edge's extent or a genuine short
71/// feature could be collapsed. A tenth leaves an order of magnitude between
72/// "the vendor's representations disagree here" and "this edge is one point".
73pub const EDGE_CAP_FRACTION: f64 = 0.10;
74
75/// Fraction of the SHORTEST incident edge that bounds a vertex's band.
76///
77/// A quarter is what makes endpoint welding structurally unable to collapse an
78/// edge: both endpoint radii of an edge sum to at most half its length, so the
79/// two ends can never reach each other.
80pub const VERTEX_CAP_FRACTION: f64 = 0.25;
81
82/// Samples used for an edge's polyline length. The length only feeds a cap, so
83/// a coarse chord underestimates it and errs toward a NARROWER band.
84const LENGTH_SAMPLES: usize = 8;
85
86/// Lazily-measured, capped per-entity tolerances for one solid.
87///
88/// Construct once per operation on the operand you are about to consume, ask
89/// for the entities you actually touch, and drop it with the operation. Each
90/// entity is measured at most once.
91///
92/// See the module documentation for the invariants; in particular a caller
93/// takes `max(its own global band, this)`, never this alone.
94pub struct EntityTolerances<'s> {
95 solid: &'s BrepSolid,
96 /// The policy floor `x` every band starts from.
97 floor: f64,
98 /// `PCURVE_ACCEPTANCE_REL * solid_scale` — the size-relative half of the
99 /// edge cap, computed once.
100 size_cap: f64,
101 /// edge id -> the coedges that reference it, as (surface, pcurve, forward).
102 coedges: Option<HashMap<u64, Vec<CoedgeUse<'s>>>>,
103 /// edge id -> the record, for endpoint and length queries.
104 edge_index: Option<HashMap<u64, &'s EdgeRecord>>,
105 edges: HashMap<u64, f64>,
106 vertices: HashMap<u64, f64>,
107}
108
109/// One coedge's contribution to an edge's measurement.
110struct CoedgeUse<'s> {
111 surface: &'s NurbsSurface,
112 pcurve: &'s NurbsCurve,
113 forward: bool,
114}
115
116impl<'s> EntityTolerances<'s> {
117 /// A view over `solid` with `policy`'s spatial tolerance as the floor.
118 pub fn for_solid(solid: &'s BrepSolid, policy: &KernelTolerances) -> Self {
119 Self::with_floor(solid, policy.spatial())
120 }
121
122 /// A view whose floor is given directly — for the several callers that
123 /// carry a bare `tolerance: f64` rather than a whole policy.
124 pub fn with_floor(solid: &'s BrepSolid, floor: f64) -> Self {
125 let floor = if floor.is_finite() && floor > 0.0 {
126 floor
127 } else {
128 0.0
129 };
130 Self {
131 solid,
132 floor,
133 size_cap: PCURVE_ACCEPTANCE_REL * solid_scale(solid),
134 coedges: None,
135 edge_index: None,
136 edges: HashMap::default(),
137 vertices: HashMap::default(),
138 }
139 }
140
141 /// The identity band for edge `id`: `min(cap_E, max(floor, d_meas(E)))`.
142 ///
143 /// Answers the floor for an unknown edge, an edge with no coedge, or a
144 /// measurement that could not be taken — I2's fail-safe direction. Never
145 /// returns a non-finite or negative value.
146 pub fn edge(&mut self, id: u64) -> f64 {
147 if let Some(&memo) = self.edges.get(&id) {
148 return memo;
149 }
150 let band = self.measure_edge(id);
151 self.edges.insert(id, band);
152 band
153 }
154
155 /// The identity band for vertex `id`: `min(cap_V, max(floor, g_meas(V)))`,
156 /// where `g_meas` is the largest gap between the vertex's point and the
157 /// endpoints of the edges that claim it.
158 pub fn vertex(&mut self, id: u64) -> f64 {
159 if let Some(&memo) = self.vertices.get(&id) {
160 return memo;
161 }
162 let band = self.measure_vertex(id);
163 self.vertices.insert(id, band);
164 band
165 }
166
167 /// The largest edge band over `ids` — the band a decision that involves
168 /// several edges at once (a junction sitting where two boundary edges meet)
169 /// must respect, since it can be no more certain than its worst input.
170 pub fn worst_edge(&mut self, ids: impl IntoIterator<Item = u64>) -> f64 {
171 let mut worst = self.floor;
172 for id in ids {
173 worst = worst.max(self.edge(id));
174 }
175 worst
176 }
177
178 /// The policy floor this view was built with — what every band degrades to.
179 pub fn floor(&self) -> f64 {
180 self.floor
181 }
182
183 /// The raw `d_meas(E)` for edge `id`: the largest 3D disagreement between
184 /// the edge's curve and its pcurve images, over every coedge that
185 /// references it — UNCLAMPED, neither floored nor capped, so a defective
186 /// entity (measured above `cap_E`) reads as what it is rather than as the
187 /// cap. `None` for an unknown or degenerate edge, one no coedge
188 /// references, or one whose every measurement failed.
189 ///
190 /// For REPORTING (the import's stated-precision consistency check, a
191 /// census). It is never a band: the band is [`Self::edge`], and nothing a
192 /// caller reads here may widen anything (I1/I2 in the module doc).
193 pub fn measured_edge_deviation(&mut self, id: u64) -> Option<f64> {
194 self.raw_edge(id)
195 }
196
197 /// The raw `g_meas(V)` for vertex `id`: the largest gap between the
198 /// vertex's point and the ends of the edge curves that claim it —
199 /// unclamped, as [`Self::measured_edge_deviation`] is for edges. `None`
200 /// for an unknown vertex or one no non-degenerate edge ends at.
201 ///
202 /// On an imported solid this is the gap AFTER endpoint healing
203 /// (`heal_imported_edge_endpoints`), not the vendor's raw miss.
204 pub fn measured_vertex_gap(&mut self, id: u64) -> Option<f64> {
205 self.raw_vertex(id).map(|(gap, _)| gap)
206 }
207
208 fn measure_edge(&mut self, id: u64) -> f64 {
209 self.ensure_index();
210 let Some(edge) = self.edge_index.as_ref().and_then(|index| index.get(&id)) else {
211 return self.floor;
212 };
213 let edge = *edge;
214 if edge.degenerate {
215 return self.floor;
216 }
217 let cap = self.edge_cap(edge);
218 // A measurement that failed says nothing, so it must not widen
219 // anything: the floor stands (I2).
220 let worst = self.raw_edge(id).unwrap_or(0.0);
221 clamp_band(self.floor, worst, cap)
222 }
223
224 /// The unclamped edge measurement behind [`Self::measure_edge`]; see
225 /// [`Self::measured_edge_deviation`] for the contract.
226 fn raw_edge(&mut self, id: u64) -> Option<f64> {
227 self.ensure_index();
228 let edge = *self.edge_index.as_ref()?.get(&id)?;
229 if edge.degenerate {
230 return None;
231 }
232 // The band handed to the sampler DRIVES ITS REFINEMENT (it subdivides
233 // while the deviation is non-linear relative to this number), it is not
234 // a verdict the measurement is judged against. So it must be the TIGHT
235 // policy floor: passing the cap here would stop subdivision at depth 0
236 // on a smooth curve and alias a real 4e-4 residual down to nothing —
237 // which is the same aliasing `offset/measure.rs` documents, arriving
238 // through the band instead of through the sample grid.
239 let probe_band = self.floor;
240 let uses = self.coedges.as_ref()?.get(&id)?;
241 let mut worst: Option<f64> = None;
242 for use_record in uses {
243 match measure_edge_against_pcurve_image(
244 use_record.surface,
245 use_record.pcurve,
246 edge,
247 use_record.forward,
248 probe_band,
249 ) {
250 Err(_) => continue,
251 Ok(measured) => {
252 let deviation = measured.deviation();
253 if deviation.is_finite() {
254 worst = Some(worst.map_or(deviation, |w| w.max(deviation)));
255 }
256 }
257 }
258 }
259 worst
260 }
261
262 /// O(edges) per query and deliberately index-free: the vertex band needs
263 /// only curve endpoints, not the coedge index the edge band builds, and a
264 /// caller that asks for one vertex should not pay for the other structure.
265 fn measure_vertex(&mut self, id: u64) -> f64 {
266 match self.raw_vertex(id) {
267 None => self.floor,
268 Some((gap, shortest)) => clamp_band(self.floor, gap, VERTEX_CAP_FRACTION * shortest),
269 }
270 }
271
272 /// `(g_meas(V), shortest incident edge length)`, unclamped; `None` when
273 /// the vertex is unknown or no non-degenerate edge ends at it.
274 fn raw_vertex(&mut self, id: u64) -> Option<(f64, f64)> {
275 let point = self
276 .solid
277 .vertices
278 .iter()
279 .find(|vertex| vertex.id == id)
280 .map(|vertex| vertex.point)?;
281 let mut gap = 0.0f64;
282 let mut shortest = f64::INFINITY;
283 for edge in &self.solid.edges {
284 if edge.degenerate {
285 continue;
286 }
287 for (vertex_id, parameter) in [
288 (edge.start_vertex_id, edge.t0),
289 (edge.end_vertex_id, edge.t1),
290 ] {
291 if vertex_id != id {
292 continue;
293 }
294 shortest = shortest.min(edge_length(edge));
295 if let Ok(end) = edge.curve.evaluate(parameter) {
296 let distance = point.sub(end).length();
297 if distance.is_finite() {
298 gap = gap.max(distance);
299 }
300 }
301 }
302 }
303 if !shortest.is_finite() {
304 return None;
305 }
306 Some((gap, shortest))
307 }
308
309 /// `cap_E = min(EDGE_CAP_FRACTION * len(E), PCURVE_ACCEPTANCE_REL * D)`.
310 fn edge_cap(&self, edge: &EdgeRecord) -> f64 {
311 (EDGE_CAP_FRACTION * edge_length(edge)).min(self.size_cap)
312 }
313
314 fn ensure_index(&mut self) {
315 if self.coedges.is_some() {
316 return;
317 }
318 let mut coedges: HashMap<u64, Vec<CoedgeUse<'s>>> = HashMap::default();
319 for face in self.solid.shells.iter().flat_map(|shell| &shell.faces) {
320 let face: &'s FaceRecord = face;
321 for loop_record in &face.loops {
322 for coedge in &loop_record.coedges {
323 coedges
324 .entry(coedge.edge_id)
325 .or_default()
326 .push(CoedgeUse {
327 surface: &face.surface,
328 pcurve: &coedge.pcurve,
329 forward: coedge.forward,
330 });
331 }
332 }
333 }
334 self.coedges = Some(coedges);
335 self.edge_index = Some(
336 self.solid
337 .edges
338 .iter()
339 .map(|edge| (edge.id, edge))
340 .collect(),
341 );
342 }
343}
344
345/// `min(cap, max(floor, measured))`, guarded so no non-finite input escapes and
346/// so the result is never below the floor: the fail-safe direction is NARROW.
347fn clamp_band(floor: f64, measured: f64, cap: f64) -> f64 {
348 let measured = if measured.is_finite() && measured > 0.0 {
349 measured
350 } else {
351 0.0
352 };
353 let cap = if cap.is_finite() && cap > 0.0 {
354 cap
355 } else {
356 floor
357 };
358 floor.max(measured.min(cap.max(floor)))
359}
360
361/// Chord length of `LENGTH_SAMPLES` uniform samples across the edge's live
362/// span. Underestimates a curved edge, which narrows its cap — the safe way to
363/// be wrong.
364fn edge_length(edge: &EdgeRecord) -> f64 {
365 let mut total = 0.0f64;
366 let mut previous: Option<Vec3> = None;
367 for step in 0..=LENGTH_SAMPLES {
368 let fraction = step as f64 / LENGTH_SAMPLES as f64;
369 let Ok(point) = edge.curve.evaluate(edge.t0 + (edge.t1 - edge.t0) * fraction) else {
370 return 0.0;
371 };
372 if let Some(last) = previous {
373 total += point.sub(last).length();
374 }
375 previous = Some(point);
376 }
377 if total.is_finite() {
378 total
379 } else {
380 0.0
381 }
382}
383