Skip to main content

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, 1e-7)
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/// Fraction of the model bounding-box diagonal used as the size-coupled edge/
223/// pcurve coincidence ceiling in [`KernelTolerances::pcurve_acceptance`].
224pub const PCURVE_ACCEPTANCE_REL: f64 = 0.025;
225
226/// Derive a parametric tolerance from one spatial tolerance and the local
227/// derivative magnitude: `e = x / |c'(t)|`.
228///
229/// The same spatial error corresponds to different parametric errors on
230/// every curve and surface, so parameter-space tolerances must be derived
231/// locally from a single spatial precision, never written as fixed
232/// parameter-space literals (Golovanov, "Geometric Modeling" §4.13).  The
233/// derivative floor guards degenerate directions (poles, collapsed edges)
234/// from producing an unbounded band; callers that know their domain span
235/// should additionally cap the result to a fraction of it.
236pub fn parametric_tolerance(spatial: f64, derivative_magnitude: f64) -> f64 {
237    spatial.abs().max(1e-15) / derivative_magnitude.abs().max(1e-9)
238}
239
240/// Scalar UV band for surface queries at a point with derivative magnitudes
241/// `|r_u|`, `|r_v|`: the conservative bound that contains the anisotropic
242/// `(x/|r_u|, x/|r_v|)` box is `x` over the smaller derivative.
243pub fn surface_uv_tolerance(spatial: f64, du_magnitude: f64, dv_magnitude: f64) -> f64 {
244    parametric_tolerance(spatial, du_magnitude.abs().min(dv_magnitude.abs()))
245}
246
247// ---------------------------------------------------------------------------
248// Weld / distinct-vertex radii — one named source per drifting expression.
249//
250// The kernel welds "the same vertex reached by two independent fits" at several
251// sites, and the survey found the SAME concept written three different ways with
252// three different effective values (drift).  These accessors name each variant
253// and RETURN its exact prior value, so the drift is visible and single-sourced
254// in ONE place WITHOUT changing behaviour (bit-identical by construction).  A
255// future decision to truly unify them becomes a one-line edit here.
256// ---------------------------------------------------------------------------
257
258/// Absolute floor for the assembler/imprint weld radius (see [`assembler_weld`]).
259pub const WELD_FLOOR: f64 = 1e-5;
260
261/// Absolute floor for the endpoint-commit weld radius (see [`commit_weld`]).
262/// Deliberately looser than [`WELD_FLOOR`] (1e-4 vs 1e-5): the commit pass runs
263/// on already-healed solids where the surviving endpoint gaps are larger than
264/// assembly-time vertex noise.  This difference is intentional and preserved.
265pub const COMMIT_WELD_FLOOR: f64 = 1e-4;
266
267/// Assembler / imprint weld radius: the model identity tolerance `model` floored
268/// at [`WELD_FLOOR`].  Answers "which independently-fitted endpoints are the
269/// SAME vertex?" — a search radius, floored generously so a noise-free model
270/// (model ~ 1e-7) still welds coincident endpoints into a topological loop.
271/// Used by the boolean assembler (`vertex`/`edge`/triple-junction polish) and
272/// imprint's edge weld/limit sites.
273pub fn assembler_weld(model: f64) -> f64 {
274    model.max(WELD_FLOOR)
275}
276
277/// Endpoint-commit weld radius (`commit_nearby_edge_endpoints`, used by fillet /
278/// heal edge-gap closing): the caller's SEARCH tolerance floored at
279/// [`COMMIT_WELD_FLOOR`].  Same distinct-vertex concept as [`assembler_weld`]
280/// but a looser floor by design — see [`COMMIT_WELD_FLOOR`].
281pub fn commit_weld(search: f64) -> f64 {
282    search.max(COMMIT_WELD_FLOOR)
283}
284
285/// Size factor applied to imprint vertex-merge bands: `1 + extent`, where
286/// `extent` is the operands' bbox extent ([`solid_scale`]).  This replaces the
287/// former `1 + ‖point‖` (distance-from-ORIGIN) coupling, an anti-pattern in
288/// which a part far from the origin got a wrongly-inflated merge band — a
289/// translation-VARIANCE defect (the same fillet at the origin vs translated
290/// far away produced different volumes, and eventually a broken result).
291/// Deriving the factor from the operands' bbox extent instead makes the band
292/// depend on the part's SIZE, not its position, matching the intent of
293/// [`solid_scale`] / [`KernelTolerances::heal_band`], and makes the imprint
294/// (hence booleans/fillets) translation-INVARIANT.
295pub fn merge_scale(extent: f64) -> f64 {
296    1.0 + extent
297}
298
299/// Absolute floor for the imprint "do these two surfaces COINCIDE?" distance
300/// band, applied under `(tolerance * k).max(COINCIDENCE_DISTANCE_FLOOR)` at each
301/// coincidence decision (`coplanar_pair` perpendicular gap, `cosurface_pair`
302/// projection gap).  This is the single named source for that floor.
303///
304/// The survey once found this coincidence question answered with two different
305/// values (a `1e-5` floor at some imprint sites vs `1e-6` elsewhere); that value
306/// drift was already eliminated upstream — the coincidence distance band is now
307/// uniformly floored at `1e-6` — so single-sourcing here is bit-identical and
308/// only removes the remaining duplicated literal.  The companion normal-parallel
309/// test (`|n_a·n_b|` vs `1 - 1e-6/1e-9`) is a separate ANGULAR criterion and is
310/// deliberately NOT folded in here.
311pub const COINCIDENCE_DISTANCE_FLOOR: f64 = 1e-6;
312
313pub fn solid_scale(solid: &BrepSolid) -> f64 {
314    if solid.vertices.is_empty() {
315        return 1.0;
316    }
317    let mut low = crate::Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
318    let mut high = crate::Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
319    for vertex in &solid.vertices {
320        low.x = low.x.min(vertex.point.x);
321        low.y = low.y.min(vertex.point.y);
322        low.z = low.z.min(vertex.point.z);
323        high.x = high.x.max(vertex.point.x);
324        high.y = high.y.max(vertex.point.y);
325        high.z = high.z.max(vertex.point.z);
326    }
327    high.sub(low).length().max(1.0)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::{make_box_brep, Vec3};
334
335    #[test]
336    fn policy_is_scale_aware_and_ordered() {
337        let small = KernelTolerances::for_scale(1.0, 1e-7);
338        let large = KernelTolerances::for_scale(10_000.0, 1e-7);
339        small.check().unwrap();
340        large.check().unwrap();
341        assert!(large.sew_search > small.sew_search);
342        assert_eq!(large.model, small.model);
343    }
344
345    #[test]
346    fn spatial_returns_the_base_model_tolerance() {
347        let policy = KernelTolerances::for_scale(1.0, 1e-7);
348        assert_eq!(policy.spatial(), policy.model);
349        assert_eq!(policy.spatial(), 1e-7);
350    }
351
352    #[test]
353    fn heal_band_floors_model_by_a_size_relative_width() {
354        let policy = KernelTolerances::for_scale(1.0, 1e-7);
355        // Below the size-relative width the base spatial tolerance dominates.
356        assert_eq!(policy.heal_band(1.0, 1e-9), policy.model);
357        // Above it the diagonal-scaled width takes over.
358        assert!((policy.heal_band(1000.0, 1e-3) - 1.0).abs() < 1e-15);
359    }
360
361    #[test]
362    fn pcurve_acceptance_size_couples_above_the_absolute_floor() {
363        let policy = KernelTolerances::for_scale(1.0, 1e-7);
364        // Tiny parts keep the tight ABSOLUTE pcurve-consistency floor: the
365        // size-relative term (diag * 2.5%) is below it, so nothing loosens.
366        assert_eq!(policy.pcurve_acceptance(0.0), policy.pcurve_consistency);
367        assert_eq!(policy.pcurve_acceptance(0.1), policy.pcurve_consistency);
368        // On the sub-unit model that motivated this (ABC 00000041, diagonal
369        // ~0.897, vendor edge/surface gap ~0.018), the size-coupled ceiling
370        // clears the gap while the raw absolute floor (4e-3) would reject it.
371        let gap = 0.018;
372        assert!(policy.pcurve_consistency < gap);
373        assert!(policy.pcurve_acceptance(0.897) > gap);
374        // The band is exactly the diagonal-scaled width once it dominates.
375        assert!((policy.pcurve_acceptance(2.0) - 2.0 * PCURVE_ACCEPTANCE_REL).abs() < 1e-15);
376        // Never returns less than the absolute contract.
377        assert!(policy.pcurve_acceptance(1e6) >= policy.pcurve_consistency);
378    }
379
380    #[test]
381    fn solid_scale_uses_extent_not_distance_from_origin() {
382        let solid = make_box_brep(Vec3::new(1e6, 1e6, 1e6), 3.0, 4.0, 12.0).unwrap();
383        assert!((solid_scale(&solid) - 13.0).abs() < 1e-9);
384    }
385
386    #[test]
387    fn parametric_tolerance_scales_inversely_with_derivative() {
388        assert!((parametric_tolerance(1e-6, 10.0) - 1e-7).abs() < 1e-20);
389        assert!((parametric_tolerance(1e-6, 0.1) - 1e-5).abs() < 1e-18);
390        // Degenerate derivative floors instead of blowing up.
391        assert!(parametric_tolerance(1e-6, 0.0).is_finite());
392    }
393
394    #[test]
395    fn surface_uv_tolerance_uses_the_smaller_derivative() {
396        let band = surface_uv_tolerance(1e-6, 100.0, 2.0);
397        assert!((band - 5e-7).abs() < 1e-18);
398    }
399
400    #[test]
401    fn inverted_policy_is_rejected() {
402        let policy = KernelTolerances {
403            convergence: 1e-2,
404            model: 1e-4,
405            ..KernelTolerances::default()
406        };
407        assert!(policy.check().is_err());
408    }
409}