brep_kernel/geometry/tolerance.rs
1//! # Tolerance taxonomy — which bands may scale with part size, and which must not
2//!
3//! Every numeric tolerance in this kernel belongs to exactly one of the KINDS
4//! below. The distinction that matters for correctness is whether a band may be
5//! **size-coupled** (widened in proportion to the local part extent) or must stay
6//! a **tight absolute** floor. Getting this wrong is not academic: coupling the
7//! base spatial `model` tolerance to part size was tried and REJECTED because
8//! `model` also feeds the fit-accuracy fields, and scaling it degraded SSI curve
9//! fitting enough to produce invalid boolean topology (see the B1 finding in
10//! `revolve_pole_union_fixture`). The rules:
11//!
12//! * **Identity / coincidence** — "are these two entities the same point / are
13//! these surfaces the same surface?". MAY size-couple: a big part legitimately
14//! needs a proportionally wider identity band. Couple it *at the site* via
15//! [`KernelTolerances::heal_band`] (`max(model, diagonal * k)`), taking the
16//! local `diagonal`/extent explicitly — never by inflating the global `model`.
17//!
18//! * **Fit-accuracy** — `intersection_fit`, the SSI/CSI marcher, curve/surface
19//! fitting. MUST stay TIGHT. Do NOT size-couple: this is "how closely must
20//! committed geometry agree?", and loosening it silently degrades every export.
21//! This is the field that broke `revolve_pole_union` when coupled (B1).
22//!
23//! * **Weld / sew** — the distinct-vertex / weld radius used when knitting
24//! endpoints and edges together (assembler weld, endpoint commit, vertex
25//! merge). A *search* radius answering "which entities might match?", floored
26//! to a small absolute so noise-free models still weld; see the named weld
27//! accessors on [`KernelTolerances`].
28//!
29//! * **Knot-parameter** — knot-vector identity and numerical knot dedup. Two
30//! distinct purposes at two distinct values: knot IDENTITY
31//! (`KNOT_IDENTITY_TOL`, 1e-9) and numerical knot DEDUP (`KNOT_DEDUP_EPS`,
32//! 1e-12), both single-sourced in `curve.rs`. Parameter-space, not spatial;
33//! derived from a spatial band only via [`parametric_tolerance`].
34//!
35//! * **Angular** — direction/normal agreement in radians (`angular`). A fixed
36//! absolute; does not scale with part size.
37//!
38//! * **Strict-interior** — small skip epsilons that keep a parameter off the
39//! exact domain end (so knot insertion / split does not refuse it). Fixed,
40//! derived from the knot-identity band, not size-coupled.
41//!
42//! * **Floating-point floor** — the `1e-12`/`1e-15` guards that keep a division
43//! or a degenerate direction from blowing up. Never a modelling tolerance.
44//!
45//! Bottom line: identity/weld bands couple to size *locally* through
46//! [`KernelTolerances::heal_band`]/[`solid_scale`]; fit-accuracy, angular, and
47//! the floating-point floors stay tight.
48
49use crate::topology::BrepSolid;
50use serde::{Deserialize, Serialize};
51
52/// Ordered accuracy targets plus deliberately separate geometric search radii.
53///
54/// Search tolerances answer "which entities might match?". Accuracy
55/// tolerances answer "how closely must committed geometry agree?". Keeping
56/// those questions separate prevents a generous sewing search radius from
57/// becoming the accuracy of the exported BREP.
58#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
59#[serde(default)]
60pub struct KernelTolerances {
61 /// Iterative projection/refinement convergence target.
62 pub convergence: f64,
63 /// Vertex identity and exact topological coincidence tolerance.
64 pub model: f64,
65 /// Maximum geometric error accepted while fitting SSI curves.
66 pub intersection_fit: f64,
67 /// Maximum edge/pcurve/carrier disagreement in a valid in-memory BREP.
68 pub pcurve_consistency: f64,
69 /// Output edge-to-carrier contract used before STEP serialization.
70 pub export_knit: f64,
71 /// Candidate radius used while looking for endpoints or edges to weld.
72 pub sew_search: f64,
73 /// Features shorter than this are candidates for explicit sliver repair.
74 pub sliver: f64,
75 /// Angular tolerance in radians.
76 pub angular: f64,
77}
78
79impl Default for KernelTolerances {
80 fn default() -> Self {
81 Self::for_scale(1.0, DEFAULT_MODEL)
82 }
83}
84
85impl KernelTolerances {
86 /// Construct a scale-aware policy while preserving the historical model
87 /// identity tolerance used by the public API.
88 ///
89 /// The base spatial identity tolerance `model` is deliberately NOT coupled
90 /// to part size here: coupling it was tried and rejected because `model`
91 /// also feeds fit-accuracy fields (`intersection_fit`, `sew_search`), so
92 /// scaling it degrades SSI curve fitting on ordinary parts and produces
93 /// invalid boolean topology (see `revolve_pole_union_fixture`). Identity
94 /// bands that legitimately scale with size are size-coupled per site via
95 /// [`KernelTolerances::heal_band`] instead, leaving fit accuracy tight.
96 pub fn for_scale(scale: f64, model: f64) -> Self {
97 let scale = scale.abs().max(1.0);
98 let model = model.abs().max(1e-12);
99 Self {
100 convergence: (model * 1e-3).clamp(1e-12, model),
101 model,
102 intersection_fit: (model * 20.0).max(scale * 1e-8),
103 // Existing fitted NURBS intersections can deviate by a few
104 // microns. This remains an explicit contract rather than a
105 // validator-local magic number.
106 pcurve_consistency: (model * 200.0).max(4e-3),
107 export_knit: (model * 100.0).max(4e-3),
108 sew_search: (model * 20.0).max(scale * 1e-8),
109 sliver: (model * 4.0).max(scale * 1e-10),
110 angular: 1e-4,
111 }
112 }
113
114 pub fn for_solid(solid: &BrepSolid, model: f64) -> Self {
115 Self::for_scale(solid_scale(solid), model)
116 }
117
118 pub fn for_pair(first: &BrepSolid, second: &BrepSolid, model: f64) -> Self {
119 Self::for_scale(solid_scale(first).max(solid_scale(second)), model)
120 }
121
122 /// Reject policies whose accuracy ladder is inverted or whose search
123 /// radii cannot even find model-identical entities.
124 pub fn check(&self) -> Result<(), String> {
125 let positive = [
126 ("convergence", self.convergence),
127 ("model", self.model),
128 ("intersection_fit", self.intersection_fit),
129 ("pcurve_consistency", self.pcurve_consistency),
130 ("export_knit", self.export_knit),
131 ("sew_search", self.sew_search),
132 ("sliver", self.sliver),
133 ("angular", self.angular),
134 ];
135 for (name, value) in positive {
136 if !value.is_finite() || value <= 0.0 {
137 return Err(format!("invalid {name} tolerance {value}"));
138 }
139 }
140 for ((first_name, first), (second_name, second)) in [
141 (("convergence", self.convergence), ("model", self.model)),
142 (
143 ("model", self.model),
144 ("intersection_fit", self.intersection_fit),
145 ),
146 ] {
147 if first > second {
148 return Err(format!(
149 "tolerance ladder violated: {first_name} ({first:.3e}) > \
150 {second_name} ({second:.3e})"
151 ));
152 }
153 }
154 if self.sew_search < self.model {
155 return Err(format!(
156 "sew_search ({:.3e}) is below model tolerance ({:.3e})",
157 self.sew_search, self.model
158 ));
159 }
160 Ok(())
161 }
162
163 /// Canonical accessor for the single spatial tolerance `x` (Golovanov
164 /// §4.13). `model` is that `x`: the base identity/coincidence band from
165 /// which parametric, search, and healing tolerances are derived. Call
166 /// sites should prefer this over reaching for `.model` directly so intent
167 /// (the ONE spatial tolerance) reads clearly and later levers have a single
168 /// seam to evolve.
169 pub fn spatial(&self) -> f64 {
170 self.model
171 }
172
173 /// Size-coupled healing/identity band: the base spatial tolerance floored
174 /// by a fraction `k` of the caller's local `diagonal`. This is the ONE
175 /// charter helper for the `max(model, diagonal * k)` pattern that healing
176 /// and identity sites otherwise hand-roll (e.g. classification's
177 /// near-coincidence probe `(model * 10).max(diagonal * 1e-7)`), so a
178 /// size-relative band is derived from `x` in a single place instead of
179 /// re-hardcoded per call site (Golovanov §4.13). Coupling the band HERE —
180 /// at the identity/weld/heal site, taking `diagonal` explicitly — gives big
181 /// parts a proportionally wider identity band WITHOUT inflating the global
182 /// `model` or the fit-accuracy path (`intersection_fit`, the marcher).
183 /// `k` is a dimensionless part-per-diagonal factor; `diagonal` is the local
184 /// extent the band should scale with.
185 pub fn heal_band(&self, diagonal: f64, k: f64) -> f64 {
186 self.model.max(diagonal.abs() * k.abs())
187 }
188
189 /// Size-coupled acceptance ceiling for the edge-vs-pcurve COINCIDENCE check
190 /// in [`BrepSolid::validate`]: "does this coedge's curve-on-surface, pushed
191 /// back to 3D, still trace the same locus as the edge's 3D curve?"
192 ///
193 /// This is an IDENTITY/coincidence band (are the two representations the
194 /// SAME edge?), not a fit-accuracy target, so per this module's taxonomy it
195 /// MAY — and, for imported geometry, MUST — size-couple with the part: a
196 /// vendor STEP file routinely commits an edge's 3D curve and its face
197 /// surface as INDEPENDENT approximations that disagree by a small fraction
198 /// of the model, and (like Parasolid/OCC, which absorb the gap in a widened
199 /// per-edge tolerance) a coincidence band floored to a tight ABSOLUTE
200 /// `pcurve_consistency` wrongly rejects such a shared edge on a sub-unit
201 /// part. The gap is intrinsic to the vendor data (it is the closest-point
202 /// residual of the edge against the surface, so no pcurve fit can beat it),
203 /// and the edge is SHARED — snapping it onto one face's surface only pushes
204 /// it off the neighbour's — so accepting the size-relative gap is the
205 /// faithful, non-destructive resolution.
206 ///
207 /// Coupling lives HERE (taking the model `diagonal` explicitly, floored by
208 /// the tight `pcurve_consistency`) exactly like [`KernelTolerances::heal_band`],
209 /// so the fit target and the STEP-import edge-reconcile screen — which read
210 /// the raw `pcurve_consistency` FIELD — stay tight and are NOT relaxed by
211 /// this validator-only ceiling. `PCURVE_ACCEPTANCE_REL` (2.5% of the model
212 /// diagonal) sits above the vendor near-miss this admits (~2% of the
213 /// diagonal on ABC 00000041) yet far below the many-percent excursion a
214 /// genuinely wrong carrier or branch-jumped pcurve produces, so real breakage
215 /// is still refused.
216 pub fn pcurve_acceptance(&self, diagonal: f64) -> f64 {
217 self.pcurve_consistency
218 .max(diagonal.abs() * PCURVE_ACCEPTANCE_REL)
219 }
220}
221
222/// The default spatial identity tolerance, `KernelTolerances::default().model`
223/// (consolidation L9). The modules that hard-coded it as their own
224/// `LINEAR_TOLERANCE` (pcurve, projection, SSI) name it here; they still do not
225/// read a caller's non-default `model` (threading the policy is a separate,
226/// behaviour-changing step).
227pub(crate) const DEFAULT_MODEL: f64 = 1e-7;
228
229/// Floating-point floor (consolidation L9): guards a division, a
230/// normalisation or a Newton step against a value indistinguishable from zero.
231/// Never a modelling tolerance.
232pub(crate) const FP_FLOOR: f64 = 1e-12;
233
234/// Fraction of the model bounding-box diagonal used as the size-coupled edge/
235/// pcurve coincidence ceiling in [`KernelTolerances::pcurve_acceptance`].
236pub const PCURVE_ACCEPTANCE_REL: f64 = 0.025;
237
238/// Absolute floor (mm) of the edge-endpoint-vs-vertex identity band used by
239/// [`BrepSolid::validate`]: below this a curve end and its topological vertex
240/// ARE the same point (vendor export precision), above it the model is asked to
241/// state the meeting exactly.
242///
243/// Single-sourced because the STEP importer must heal precisely the misses this
244/// band refuses: healing less leaves an import that cannot validate, healing
245/// more rewrites vendor geometry the kernel already accepts as-is (see
246/// `heal_imported_edge_endpoints`).
247pub const VERTEX_MATCH_FLOOR: f64 = 5e-3;
248
249/// Derive a parametric tolerance from one spatial tolerance and the local
250/// derivative magnitude: `e = x / |c'(t)|`.
251///
252/// The same spatial error corresponds to different parametric errors on
253/// every curve and surface, so parameter-space tolerances must be derived
254/// locally from a single spatial precision, never written as fixed
255/// parameter-space literals (Golovanov, "Geometric Modeling" §4.13). The
256/// derivative floor guards degenerate directions (poles, collapsed edges)
257/// from producing an unbounded band; callers that know their domain span
258/// should additionally cap the result to a fraction of it.
259pub fn parametric_tolerance(spatial: f64, derivative_magnitude: f64) -> f64 {
260 spatial.abs().max(1e-15) / derivative_magnitude.abs().max(1e-9)
261}
262
263/// Scalar UV band for surface queries at a point with derivative magnitudes
264/// `|r_u|`, `|r_v|`: the conservative bound that contains the anisotropic
265/// `(x/|r_u|, x/|r_v|)` box is `x` over the smaller derivative.
266pub fn surface_uv_tolerance(spatial: f64, du_magnitude: f64, dv_magnitude: f64) -> f64 {
267 parametric_tolerance(spatial, du_magnitude.abs().min(dv_magnitude.abs()))
268}
269
270// ---------------------------------------------------------------------------
271// Weld / distinct-vertex radii — one named source per drifting expression.
272//
273// The kernel welds "the same vertex reached by two independent fits" at several
274// sites, and the survey found the SAME concept written three different ways with
275// three different effective values (drift). These accessors name each variant
276// and RETURN its exact prior value, so the drift is visible and single-sourced
277// in ONE place WITHOUT changing behaviour (bit-identical by construction). A
278// future decision to truly unify them becomes a one-line edit here.
279// ---------------------------------------------------------------------------
280
281/// Absolute floor for the assembler/imprint weld radius (see [`assembler_weld`]).
282pub const WELD_FLOOR: f64 = 1e-5;
283
284/// Absolute floor for the endpoint-commit weld radius (see [`commit_weld`]).
285/// Deliberately looser than [`WELD_FLOOR`] (1e-4 vs 1e-5): the commit pass runs
286/// on already-healed solids where the surviving endpoint gaps are larger than
287/// assembly-time vertex noise. This difference is intentional and preserved.
288pub const COMMIT_WELD_FLOOR: f64 = 1e-4;
289
290/// Assembler / imprint weld radius: the model identity tolerance `model` floored
291/// at [`WELD_FLOOR`]. Answers "which independently-fitted endpoints are the
292/// SAME vertex?" — a search radius, floored generously so a noise-free model
293/// (model ~ 1e-7) still welds coincident endpoints into a topological loop.
294/// Used by the boolean assembler (`vertex`/`edge`/triple-junction polish) and
295/// imprint's edge weld/limit sites.
296pub fn assembler_weld(model: f64) -> f64 {
297 model.max(WELD_FLOOR)
298}
299
300/// Endpoint-commit weld radius (`commit_nearby_edge_endpoints`, used by fillet /
301/// heal edge-gap closing): the caller's SEARCH tolerance floored at
302/// [`COMMIT_WELD_FLOOR`]. Same distinct-vertex concept as [`assembler_weld`]
303/// but a looser floor by design — see [`COMMIT_WELD_FLOOR`].
304pub fn commit_weld(search: f64) -> f64 {
305 search.max(COMMIT_WELD_FLOOR)
306}
307
308/// Size factor applied to imprint vertex-merge bands: `1 + extent`, where
309/// `extent` is the operands' bbox extent ([`solid_scale`]). This replaces the
310/// former `1 + ‖point‖` (distance-from-ORIGIN) coupling, an anti-pattern in
311/// which a part far from the origin got a wrongly-inflated merge band — a
312/// translation-VARIANCE defect (the same fillet at the origin vs translated
313/// far away produced different volumes, and eventually a broken result).
314/// Deriving the factor from the operands' bbox extent instead makes the band
315/// depend on the part's SIZE, not its position, matching the intent of
316/// [`solid_scale`] / [`KernelTolerances::heal_band`], and makes the imprint
317/// (hence booleans/fillets) translation-INVARIANT.
318pub fn merge_scale(extent: f64) -> f64 {
319 1.0 + extent
320}
321
322/// Absolute floor for the imprint "do these two surfaces COINCIDE?" distance
323/// band, applied under `(tolerance * k).max(COINCIDENCE_DISTANCE_FLOOR)` at each
324/// coincidence decision (`coplanar_pair` perpendicular gap, `cosurface_pair`
325/// projection gap). This is the single named source for that floor.
326///
327/// The survey once found this coincidence question answered with two different
328/// values (a `1e-5` floor at some imprint sites vs `1e-6` elsewhere); that value
329/// drift was already eliminated upstream — the coincidence distance band is now
330/// uniformly floored at `1e-6` — so single-sourcing here is bit-identical and
331/// only removes the remaining duplicated literal. The companion normal-parallel
332/// test (`|n_a·n_b|` vs `1 - 1e-6/1e-9`) is a separate ANGULAR criterion and is
333/// deliberately NOT folded in here.
334pub const COINCIDENCE_DISTANCE_FLOOR: f64 = 1e-6;
335
336// ---------------------------------------------------------------------------
337// Model scale — the ONE characteristic length every size-relative tolerance in
338// this kernel is measured against.
339// ---------------------------------------------------------------------------
340
341/// The kernel's single definition of a model's characteristic length: the
342/// **bounding-box diagonal** of a point set.
343///
344/// Two properties define it, and both are load-bearing:
345///
346/// * **Size-based.** It answers "how big is *this* geometry?", which is the only
347/// question a size-relative tolerance may ask. Note what "this" means: the
348/// answer is the extent of the point set handed in, so the caller chooses the
349/// scope by choosing the points. A local solve should pass its own local
350/// geometry (see [`curve_model_scale`]) rather than the whole solid's
351/// vertices, or a 2 mm feature inherits a 2 m frame's band.
352/// * **Translation-invariant.** Moving the geometry rigidly does not change it,
353/// so the identical feature solves to the identical tolerance wherever the
354/// modeller happened to place it.
355///
356/// # Why the origin-distance form is wrong
357///
358/// The tempting one-liner `points.map(|p| p.length()).fold(1.0, f64::max)` — the
359/// maximum distance from the **world origin** — satisfies neither property:
360///
361/// * It **grows with placement.** A 10 mm part at the origin gets scale ≈ 10; the
362/// same part translated to (5000, 0, 0) gets scale ≈ 5000. Every tolerance
363/// derived from it loosens by 500× for a part that did not change shape, so
364/// coincidence, weld and convergence bands silently absorb real geometric
365/// error the moment a part is placed away from the origin.
366/// * It **ignores size.** Two parts whose bounding boxes differ by three orders
367/// of magnitude get the same scale if they sit the same distance out.
368/// * It is **translation-VARIANT**, so results are not reproducible under a rigid
369/// move: the same fillet at the origin and translated far away produced
370/// different volumes, and eventually a broken result. `merge_scale` records
371/// that exact failure for the imprint vertex-merge band, which was fixed the
372/// same way.
373///
374/// # Degenerate input
375///
376/// An empty set, a single point, coincident points, or non-finite coordinates
377/// all return `1.0`: there is no meaningful extent to scale by, and a zero (or
378/// NaN, or infinite) scale would collapse or explode every derived band.
379///
380/// # Floors are the caller's business
381///
382/// `model_scale` returns the RAW diagonal for any well-formed input — it is not
383/// floored at 1.0. Sub-unit parts are real (see
384/// [`KernelTolerances::pcurve_acceptance`]'s ABC 00000041 case) and flooring
385/// would hand a 0.3 mm part a band larger than itself. Sites that want the
386/// historical `>= 1.0` floor take [`solid_scale`], which applies it explicitly.
387pub fn model_scale(points: impl IntoIterator<Item = crate::Vec3>) -> f64 {
388 let mut low = crate::Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
389 let mut high = crate::Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
390 let mut any = false;
391 for point in points {
392 any = true;
393 low.x = low.x.min(point.x);
394 low.y = low.y.min(point.y);
395 low.z = low.z.min(point.z);
396 high.x = high.x.max(point.x);
397 high.y = high.y.max(point.y);
398 high.z = high.z.max(point.z);
399 }
400 if !any {
401 return 1.0;
402 }
403 let diagonal = high.sub(low).length();
404 if diagonal.is_finite() && diagonal > 0.0 {
405 diagonal
406 } else {
407 1.0
408 }
409}
410
411/// [`model_scale`] of a solid's vertices — the RAW bounding-box diagonal, with
412/// no `>= 1.0` floor. This is what direct-edit wants: a sub-unit part must keep
413/// sub-unit bands. (`BrepSolid::validate` wants the same thing and still
414/// hand-rolls it, for the reason its own comment gives — it answers `0.0`, not
415/// `1.0`, for a vertexless solid. Reconciling that contract is a follow-up, not
416/// a free ride-along.) Use [`solid_scale`] for the floored variant the
417/// tolerance policy is built on.
418pub fn solid_model_scale(solid: &BrepSolid) -> f64 {
419 model_scale(solid.vertices.iter().map(|vertex| vertex.point))
420}
421
422/// [`model_scale`] of a curve, sampled uniformly over `[t0, t1]`.
423///
424/// The characteristic length of a *local* solve — one edge and its immediate
425/// neighbourhood — is the extent of the curve being marched, not the extent of
426/// the whole solid it belongs to and emphatically not its distance from the
427/// origin. Sampling is uniform and at a fixed count so the result is
428/// deterministic and cheap next to the solves it scales. Probes are `evaluate`,
429/// which CLAMPS to the knot domain, so an overshot range still measures the
430/// curve's own extent rather than an extrapolation's.
431pub fn curve_model_scale(curve: &crate::NurbsCurve, t0: f64, t1: f64) -> Result<f64, String> {
432 let mut samples = Vec::with_capacity(CURVE_SCALE_SAMPLES + 1);
433 for index in 0..=CURVE_SCALE_SAMPLES {
434 let t = t0 + (t1 - t0) * index as f64 / CURVE_SCALE_SAMPLES as f64;
435 samples.push(curve.evaluate(t)?);
436 }
437 Ok(model_scale(samples))
438}
439
440/// Uniform sample count used by [`curve_model_scale`]. Enough to bound a
441/// closed conic or a wavy spline's extent within a few percent; far too cheap to
442/// matter beside the Newton solve whose tolerance it scales.
443pub const CURVE_SCALE_SAMPLES: usize = 16;
444
445/// Migration diagnostic for the one-[`model_scale`] change (slice 0).
446///
447/// Switching a pipeline's `scale` definition moves EVERY tolerance derived from
448/// it at once, so the corpus must be measurable before and after. Call this at
449/// a site that is changing over, with the old origin-distance value and the new
450/// size-based one, and run the batteries with `BREP_SCALE_DIAG=1` to see which
451/// fixtures actually move and by how much. `ratio > 1` means the old
452/// definition was LOOSER than the new one there (the placement-inflation this
453/// slice removes); `ratio < 1` means it was tighter.
454///
455/// Silent and free unless the variable is set: the lookup happens once and the
456/// superseded `origin_form` closure is never called otherwise, so the retired
457/// definition costs nothing in the shipped path while staying re-measurable.
458pub fn report_scale_migration(site: &str, model_form: f64, origin_form: impl FnOnce() -> f64) {
459 static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
460 if !*ENABLED.get_or_init(|| std::env::var("BREP_SCALE_DIAG").is_ok()) {
461 return;
462 }
463 let origin_form = origin_form();
464 let ratio = if model_form != 0.0 {
465 origin_form / model_form
466 } else {
467 f64::NAN
468 };
469 eprintln!("scale-diag site={site} origin={origin_form:.9} model={model_form:.9} ratio={ratio:.6}");
470}
471
472/// [`solid_model_scale`] floored at 1.0 — the characteristic length
473/// [`KernelTolerances::for_solid`] and the boolean/imprint/heal sites are tuned
474/// against. The floor keeps a unit-and-below part on the historical bands those
475/// call sites were fitted to; new size-relative sites should prefer the unfloored
476/// [`model_scale`] family and floor explicitly where they mean to.
477pub fn solid_scale(solid: &BrepSolid) -> f64 {
478 solid_model_scale(solid).max(1.0)
479}
480
481// ---------------------------------------------------------------------------
482// MEASURED tolerance — the deviation actually OBSERVED at construction
483// ---------------------------------------------------------------------------
484
485/// A deviation **measured** between a constructed entity and the geometry that
486/// entity was built to reproduce, recorded next to the **derived** band it was
487/// judged against.
488///
489/// # Why this type exists
490///
491/// Everything above this line in this module is *derived*: a band predicted
492/// from a size (`model_scale`), a policy field, or a constant, computed
493/// **before** the geometry exists. Nothing above this line ever looks at what
494/// the construction actually produced. A collocation fit, a polyline
495/// interpolation or a marched section can miss its intended locus by far more
496/// (or, far more often, far less) than the derived band assumed, and today that
497/// number is either thrown away or never taken.
498///
499/// This is the other half, and it is the half OCCT has and we did not:
500/// `BRepOffset_SimpleOffset::FillEdgeData` sets an edge's tolerance to the
501/// *measured* maximum distance between its new 3D curve and its pcurve on the
502/// offset surface, over every adjacent face
503/// (`BRepOffset_SimpleOffset.cxx:296-310`), and `UpdateTolerance`
504/// (`BRepOffset_MakeOffset.cxx:4196-4302`) re-measures and raises after the
505/// whole pipeline has run. Their model throughout is *measure, do not
506/// assume*: no tolerance in that pipeline is predicted from a size heuristic,
507/// each is measured off the geometry that was actually built and then
508/// propagated up the entity hierarchy.
509///
510/// # The direction rule — this may never loosen anything
511///
512/// A `MeasuredTolerance` is a **record of what happened, not a budget to
513/// spend.** The measured number may flow into
514///
515/// * a diagnostic,
516/// * a refusal message,
517/// * a comparison against a derived band that *tightens* an existing gate,
518///
519/// and nowhere else. It must never be `max`-ed into an acceptance band, a weld
520/// radius or a search radius, because a construction would then certify its own
521/// error: the sloppier the fit, the wider the band that judges it.
522///
523/// # Where it lives
524///
525/// Alongside the constructed entity, in the transient result struct the
526/// construction already returns — never as a field on [`crate::BrepSolid`]'s
527/// records. `BrepSolid` is serialized by `io/snapshot.rs`, which is a
528/// documented durable format; a record field would be a format change with a
529/// reader obligation. Persisting per-entity tolerances is a real and planned
530/// piece of work with its own design (slice S3) — this type is the measurement
531/// layer beneath it, and lands with no format churn at all.
532#[derive(Clone, Copy, Debug, PartialEq)]
533pub struct MeasuredTolerance {
534 deviation: f64,
535 band: f64,
536}
537
538impl MeasuredTolerance {
539 /// Record `deviation` against the derived `band` that was in force.
540 ///
541 /// Both arguments are normalised in the FAIL-SAFE direction, so a
542 /// measurement that went wrong reads as "worse than the band", never as
543 /// "fine": a non-finite or negative deviation becomes `+inf`, and a
544 /// non-finite or negative band becomes `0.0`. Either way
545 /// [`Self::exceeds_band`] answers `true`.
546 pub fn new(deviation: f64, band: f64) -> Self {
547 let deviation = if deviation.is_finite() && deviation >= 0.0 {
548 deviation
549 } else {
550 f64::INFINITY
551 };
552 let band = if band.is_finite() && band >= 0.0 {
553 band
554 } else {
555 0.0
556 };
557 Self { deviation, band }
558 }
559
560 /// A construction with no fit to measure — a closed-form analytic result,
561 /// or an affine offset that shifts a control net rigidly. Records `0.0`
562 /// deviation as a *claim*, which is why it is a named constructor: reading
563 /// `MeasuredTolerance::exact(band)` at a call site says "this lane has no
564 /// approximation error by construction", where `new(0.0, band)` would read
565 /// as an unmeasured default.
566 pub fn exact(band: f64) -> Self {
567 Self::new(0.0, band)
568 }
569
570 /// What the construction actually did.
571 pub fn deviation(&self) -> f64 {
572 self.deviation
573 }
574
575 /// What the size-derived policy demanded of it.
576 pub fn band(&self) -> f64 {
577 self.band
578 }
579
580 /// The construction met the band it was judged against.
581 pub fn within_band(&self) -> bool {
582 self.deviation <= self.band
583 }
584
585 /// The construction was WORSE than the derived band assumed — the case
586 /// worth acting on. A site that gates on this refuses; a site that only
587 /// reports names both numbers.
588 pub fn exceeds_band(&self) -> bool {
589 !self.within_band()
590 }
591
592 /// `deviation / band` — how much of the derived band the construction
593 /// spent. `> 1` is [`Self::exceeds_band`]; a value orders of magnitude
594 /// below `1` says the band is loose here, which is worth knowing and is
595 /// exactly what the corpus distribution reports. A zero band answers `0.0`
596 /// for a zero deviation and `+inf` otherwise.
597 pub fn utilisation(&self) -> f64 {
598 if self.band > 0.0 {
599 self.deviation / self.band
600 } else if self.deviation == 0.0 {
601 0.0
602 } else {
603 f64::INFINITY
604 }
605 }
606
607 /// The worse of two records: the larger deviation against the tighter band.
608 ///
609 /// Both halves move in the fail-safe direction, so folding a set of
610 /// per-coedge or per-edge measurements can only ever make the summary
611 /// harder to pass, never easier.
612 pub fn worse_of(self, other: Self) -> Self {
613 Self {
614 deviation: self.deviation.max(other.deviation),
615 band: self.band.min(other.band),
616 }
617 }
618
619 /// Fold a set of measurements into their worst, or `None` when empty.
620 pub fn worst(records: impl IntoIterator<Item = Self>) -> Option<Self> {
621 records.into_iter().reduce(Self::worse_of)
622 }
623
624 /// `measured 1.234e-7 against band 4.000e-3` — the shared wording for
625 /// refusal messages and diagnostics, so every site that reports a measured
626 /// deviation reports both numbers in the same form.
627 pub fn describe(&self) -> String {
628 format!(
629 "measured {:.3e} against band {:.3e}",
630 self.deviation, self.band
631 )
632 }
633}
634
635/// Relative volume drift between two measurements of the same body that is
636/// still the same answer: the case gate's band between a replay and its
637/// baseline (`examples/case_replay.rs`), and the perturbation rescue's bar for
638/// a rung against the exact lane's inclusion–exclusion identity
639/// (`csg::boolean::perturbation_retry`). One number, so the rescue can never
640/// accept a body the gate would call a different one. It only absorbs
641/// last-bit summation noise.
642pub const VOLUME_DRIFT_REL: f64 = 1e-9;
643
644/// Absolute floor of the volume drift band, see [`VOLUME_DRIFT_REL`].
645pub const VOLUME_DRIFT_ABS: f64 = 1e-9;
646
647/// Fraction of the local characteristic length an APPROXIMATE offset
648/// construction may deviate from the exact geometry it approximates — see
649/// [`offset_construction_band`].
650pub const OFFSET_CONSTRUCTION_REL: f64 = 5e-4;
651
652/// Absolute floor of [`offset_construction_band`], so a vanishingly small part
653/// is not held to a band below the arithmetic that builds it.
654pub const OFFSET_CONSTRUCTION_FLOOR: f64 = 5e-6;
655
656/// The band an approximate offset construction is judged against: 0.05% of the
657/// caller's local characteristic length, floored at
658/// [`OFFSET_CONSTRUCTION_FLOOR`].
659///
660/// This is a **fit-accuracy** band in this module's taxonomy — "how closely
661/// must committed geometry agree?" — and it is the offset family's own
662/// established answer, not a new number. Three sites hand-roll exactly this
663/// expression today: the ruled push's rim march
664/// (`edit/direct_edit/face_offset.rs:303`, whose comment names it "the in-tree
665/// precedent for how far an approximate offset result may be off"), the same
666/// file's hole rebuild (`:1343`), and the free-form push's dense residual gate
667/// (`edit/direct_edit/face_offset_freeform.rs:22`). Naming it here gives the
668/// MEASURED half of the tolerance model ([`MeasuredTolerance`]) the same bar the
669/// gated half already uses, so a measurement and a gate on the same construction
670/// cannot silently disagree. The three copies are deliberately NOT converted
671/// yet: two of those files are being rewritten by the general image-curve work
672/// — the planned move to a general `image_curve(surface, pcurve)` transfer for
673/// every pcurve, which retires the four iso-curve refusals at once — and a
674/// drive-by edit there would collide for no behavioural gain; the expressions
675/// are identical, so converting them later is a rename, not a change.
676///
677/// It is deliberately ~50x TIGHTER than
678/// [`KernelTolerances::pcurve_acceptance`], which is where the same edge is
679/// judged later by [`crate::BrepSolid::validate`]. The two answer different
680/// questions: `pcurve_acceptance` asks "is this edge and this surface the same
681/// entity?" and must absorb a vendor import's independent approximations, while
682/// this asks "did OUR fit reproduce what we asked it to?" and has no vendor to
683/// forgive. A construction that passes here passes validate with two orders of
684/// magnitude to spare; one that fails here is a bad fit even though validate
685/// would still accept it, which is precisely the case a measured tolerance
686/// exists to surface.
687pub fn offset_construction_band(scale: f64) -> f64 {
688 (scale.abs() * OFFSET_CONSTRUCTION_REL).max(OFFSET_CONSTRUCTION_FLOOR)
689}
690
691/// Propagate measured tolerances up one level of the entity hierarchy: from a
692/// vertex's incident edges (and the gaps between their ends and the vertex
693/// point) to the vertex itself.
694///
695/// The vertex's measured tolerance is the largest deviation any representation
696/// meeting there exhibits: the worst endpoint gap `|p_V − c_E(t_end)|` over the
697/// incident edge ends, and the worst measured deviation of those edges
698/// themselves — because a point that sits exactly on a curve which is itself
699/// `d` off its intended locus is `d` off that locus too.
700///
701/// # On OCCT's 1.001 factor — assessed, and deliberately NOT copied
702///
703/// `BRepOffset_SimpleOffset::FillVertexData` sets the vertex tolerance to
704/// `1.001 × max(adjacent edge tolerances, endpoint spread)`
705/// (`BRepOffset_SimpleOffset.cxx:398-424`). That factor is not geometry. OCCT
706/// requires the ordering `Tol(V) ≥ Tol(E) ≥ Tol(F)` to hold as a *validity
707/// invariant* on the persisted shape, re-checked by `BRepCheck_Vertex` /
708/// `BRepCheck_Edge` and re-imposed by `BRepLib::UpdateTolerances` after
709/// operations that recompute either side. A vertex tolerance set exactly equal
710/// to its edge's can be inverted by nothing more than a recomputation's last
711/// bit, so they pad by a tenth of a percent. It is an invariant fudge, and it
712/// is theirs because their tolerances are persisted, grow-only, and re-derived
713/// by checkers that compare them with `>`.
714///
715/// We have no such consumer. Nothing in this kernel stores a per-entity
716/// tolerance (see [`MeasuredTolerance`]'s "where it lives") and therefore
717/// nothing re-derives one and compares it strictly against another. Copying
718/// the factor here would make the record *false* — it would report 0.1% more
719/// deviation than was observed — for a benefit that does not exist. So there is
720/// no factor, and this function returns exactly the worst thing it saw.
721///
722/// If a later slice does persist these values and does enforce an ordering
723/// invariant across a recompute, that slice should reintroduce a named padding
724/// constant *at the invariant it protects*, with the comparison it protects
725/// named — not here, and not silently.
726pub fn vertex_tolerance_from_edges(
727 endpoint_gaps: impl IntoIterator<Item = f64>,
728 incident_edge_deviations: impl IntoIterator<Item = f64>,
729) -> f64 {
730 let sanitise = |value: f64| {
731 if value.is_finite() && value >= 0.0 {
732 value
733 } else {
734 f64::INFINITY
735 }
736 };
737 endpoint_gaps
738 .into_iter()
739 .chain(incident_edge_deviations)
740 .map(sanitise)
741 .fold(0.0f64, f64::max)
742}
743
744
745/// Shared rigidity band for component and STEP placements.
746pub const RIGID_TOL: f64 = 1e-8;