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/// Absolute floor (mm) of the edge-endpoint-vs-vertex identity band used by
227/// [`BrepSolid::validate`]: below this a curve end and its topological vertex
228/// ARE the same point (vendor export precision), above it the model is asked to
229/// state the meeting exactly.
230///
231/// Single-sourced because the STEP importer must heal precisely the misses this
232/// band refuses: healing less leaves an import that cannot validate, healing
233/// more rewrites vendor geometry the kernel already accepts as-is (see
234/// `heal_imported_edge_endpoints`).
235pub const VERTEX_MATCH_FLOOR: f64 = 5e-3;
236
237/// Derive a parametric tolerance from one spatial tolerance and the local
238/// derivative magnitude: `e = x / |c'(t)|`.
239///
240/// The same spatial error corresponds to different parametric errors on
241/// every curve and surface, so parameter-space tolerances must be derived
242/// locally from a single spatial precision, never written as fixed
243/// parameter-space literals (Golovanov, "Geometric Modeling" §4.13).  The
244/// derivative floor guards degenerate directions (poles, collapsed edges)
245/// from producing an unbounded band; callers that know their domain span
246/// should additionally cap the result to a fraction of it.
247pub fn parametric_tolerance(spatial: f64, derivative_magnitude: f64) -> f64 {
248    spatial.abs().max(1e-15) / derivative_magnitude.abs().max(1e-9)
249}
250
251/// Scalar UV band for surface queries at a point with derivative magnitudes
252/// `|r_u|`, `|r_v|`: the conservative bound that contains the anisotropic
253/// `(x/|r_u|, x/|r_v|)` box is `x` over the smaller derivative.
254pub fn surface_uv_tolerance(spatial: f64, du_magnitude: f64, dv_magnitude: f64) -> f64 {
255    parametric_tolerance(spatial, du_magnitude.abs().min(dv_magnitude.abs()))
256}
257
258// ---------------------------------------------------------------------------
259// Weld / distinct-vertex radii — one named source per drifting expression.
260//
261// The kernel welds "the same vertex reached by two independent fits" at several
262// sites, and the survey found the SAME concept written three different ways with
263// three different effective values (drift).  These accessors name each variant
264// and RETURN its exact prior value, so the drift is visible and single-sourced
265// in ONE place WITHOUT changing behaviour (bit-identical by construction).  A
266// future decision to truly unify them becomes a one-line edit here.
267// ---------------------------------------------------------------------------
268
269/// Absolute floor for the assembler/imprint weld radius (see [`assembler_weld`]).
270pub const WELD_FLOOR: f64 = 1e-5;
271
272/// Absolute floor for the endpoint-commit weld radius (see [`commit_weld`]).
273/// Deliberately looser than [`WELD_FLOOR`] (1e-4 vs 1e-5): the commit pass runs
274/// on already-healed solids where the surviving endpoint gaps are larger than
275/// assembly-time vertex noise.  This difference is intentional and preserved.
276pub const COMMIT_WELD_FLOOR: f64 = 1e-4;
277
278/// Assembler / imprint weld radius: the model identity tolerance `model` floored
279/// at [`WELD_FLOOR`].  Answers "which independently-fitted endpoints are the
280/// SAME vertex?" — a search radius, floored generously so a noise-free model
281/// (model ~ 1e-7) still welds coincident endpoints into a topological loop.
282/// Used by the boolean assembler (`vertex`/`edge`/triple-junction polish) and
283/// imprint's edge weld/limit sites.
284pub fn assembler_weld(model: f64) -> f64 {
285    model.max(WELD_FLOOR)
286}
287
288/// Endpoint-commit weld radius (`commit_nearby_edge_endpoints`, used by fillet /
289/// heal edge-gap closing): the caller's SEARCH tolerance floored at
290/// [`COMMIT_WELD_FLOOR`].  Same distinct-vertex concept as [`assembler_weld`]
291/// but a looser floor by design — see [`COMMIT_WELD_FLOOR`].
292pub fn commit_weld(search: f64) -> f64 {
293    search.max(COMMIT_WELD_FLOOR)
294}
295
296/// Size factor applied to imprint vertex-merge bands: `1 + extent`, where
297/// `extent` is the operands' bbox extent ([`solid_scale`]).  This replaces the
298/// former `1 + ‖point‖` (distance-from-ORIGIN) coupling, an anti-pattern in
299/// which a part far from the origin got a wrongly-inflated merge band — a
300/// translation-VARIANCE defect (the same fillet at the origin vs translated
301/// far away produced different volumes, and eventually a broken result).
302/// Deriving the factor from the operands' bbox extent instead makes the band
303/// depend on the part's SIZE, not its position, matching the intent of
304/// [`solid_scale`] / [`KernelTolerances::heal_band`], and makes the imprint
305/// (hence booleans/fillets) translation-INVARIANT.
306pub fn merge_scale(extent: f64) -> f64 {
307    1.0 + extent
308}
309
310/// Absolute floor for the imprint "do these two surfaces COINCIDE?" distance
311/// band, applied under `(tolerance * k).max(COINCIDENCE_DISTANCE_FLOOR)` at each
312/// coincidence decision (`coplanar_pair` perpendicular gap, `cosurface_pair`
313/// projection gap).  This is the single named source for that floor.
314///
315/// The survey once found this coincidence question answered with two different
316/// values (a `1e-5` floor at some imprint sites vs `1e-6` elsewhere); that value
317/// drift was already eliminated upstream — the coincidence distance band is now
318/// uniformly floored at `1e-6` — so single-sourcing here is bit-identical and
319/// only removes the remaining duplicated literal.  The companion normal-parallel
320/// test (`|n_a·n_b|` vs `1 - 1e-6/1e-9`) is a separate ANGULAR criterion and is
321/// deliberately NOT folded in here.
322pub const COINCIDENCE_DISTANCE_FLOOR: f64 = 1e-6;
323
324// ---------------------------------------------------------------------------
325// Model scale — the ONE characteristic length every size-relative tolerance in
326// this kernel is measured against.
327// ---------------------------------------------------------------------------
328
329/// The kernel's single definition of a model's characteristic length: the
330/// **bounding-box diagonal** of a point set.
331///
332/// Two properties define it, and both are load-bearing:
333///
334/// * **Size-based.** It answers "how big is *this* geometry?", which is the only
335///   question a size-relative tolerance may ask.  Note what "this" means: the
336///   answer is the extent of the point set handed in, so the caller chooses the
337///   scope by choosing the points.  A local solve should pass its own local
338///   geometry (see [`curve_model_scale`]) rather than the whole solid's
339///   vertices, or a 2 mm feature inherits a 2 m frame's band.
340/// * **Translation-invariant.** Moving the geometry rigidly does not change it,
341///   so the identical feature solves to the identical tolerance wherever the
342///   modeller happened to place it.
343///
344/// # Why the origin-distance form is wrong
345///
346/// The tempting one-liner `points.map(|p| p.length()).fold(1.0, f64::max)` — the
347/// maximum distance from the **world origin** — satisfies neither property:
348///
349/// * It **grows with placement.** A 10 mm part at the origin gets scale ≈ 10; the
350///   same part translated to (5000, 0, 0) gets scale ≈ 5000.  Every tolerance
351///   derived from it loosens by 500× for a part that did not change shape, so
352///   coincidence, weld and convergence bands silently absorb real geometric
353///   error the moment a part is placed away from the origin.
354/// * It **ignores size.** Two parts whose bounding boxes differ by three orders
355///   of magnitude get the same scale if they sit the same distance out.
356/// * It is **translation-VARIANT**, so results are not reproducible under a rigid
357///   move: the same fillet at the origin and translated far away produced
358///   different volumes, and eventually a broken result.  [`merge_scale`] records
359///   that exact failure for the imprint vertex-merge band, which was fixed the
360///   same way.
361///
362/// # Degenerate input
363///
364/// An empty set, a single point, coincident points, or non-finite coordinates
365/// all return `1.0`: there is no meaningful extent to scale by, and a zero (or
366/// NaN, or infinite) scale would collapse or explode every derived band.
367///
368/// # Floors are the caller's business
369///
370/// `model_scale` returns the RAW diagonal for any well-formed input — it is not
371/// floored at 1.0.  Sub-unit parts are real (see
372/// [`KernelTolerances::pcurve_acceptance`]'s ABC 00000041 case) and flooring
373/// would hand a 0.3 mm part a band larger than itself.  Sites that want the
374/// historical `>= 1.0` floor take [`solid_scale`], which applies it explicitly.
375pub fn model_scale(points: impl IntoIterator<Item = crate::Vec3>) -> f64 {
376    let mut low = crate::Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
377    let mut high = crate::Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
378    let mut any = false;
379    for point in points {
380        any = true;
381        low.x = low.x.min(point.x);
382        low.y = low.y.min(point.y);
383        low.z = low.z.min(point.z);
384        high.x = high.x.max(point.x);
385        high.y = high.y.max(point.y);
386        high.z = high.z.max(point.z);
387    }
388    if !any {
389        return 1.0;
390    }
391    let diagonal = high.sub(low).length();
392    if diagonal.is_finite() && diagonal > 0.0 {
393        diagonal
394    } else {
395        1.0
396    }
397}
398
399/// [`model_scale`] of a solid's vertices — the RAW bounding-box diagonal, with
400/// no `>= 1.0` floor.  This is what direct-edit wants: a sub-unit part must keep
401/// sub-unit bands.  (`BrepSolid::validate` wants the same thing and still
402/// hand-rolls it, for the reason its own comment gives — it answers `0.0`, not
403/// `1.0`, for a vertexless solid.  Reconciling that contract is a follow-up, not
404/// a free ride-along.)  Use [`solid_scale`] for the floored variant the
405/// tolerance policy is built on.
406pub fn solid_model_scale(solid: &BrepSolid) -> f64 {
407    model_scale(solid.vertices.iter().map(|vertex| vertex.point))
408}
409
410/// [`model_scale`] of a curve, sampled uniformly over `[t0, t1]`.
411///
412/// The characteristic length of a *local* solve — one edge and its immediate
413/// neighbourhood — is the extent of the curve being marched, not the extent of
414/// the whole solid it belongs to and emphatically not its distance from the
415/// origin.  Sampling is uniform and at a fixed count so the result is
416/// deterministic and cheap next to the solves it scales.  Probes are `evaluate`,
417/// which CLAMPS to the knot domain, so an overshot range still measures the
418/// curve's own extent rather than an extrapolation's.
419pub fn curve_model_scale(curve: &crate::NurbsCurve, t0: f64, t1: f64) -> Result<f64, String> {
420    let mut samples = Vec::with_capacity(CURVE_SCALE_SAMPLES + 1);
421    for index in 0..=CURVE_SCALE_SAMPLES {
422        let t = t0 + (t1 - t0) * index as f64 / CURVE_SCALE_SAMPLES as f64;
423        samples.push(curve.evaluate(t)?);
424    }
425    Ok(model_scale(samples))
426}
427
428/// Uniform sample count used by [`curve_model_scale`].  Enough to bound a
429/// closed conic or a wavy spline's extent within a few percent; far too cheap to
430/// matter beside the Newton solve whose tolerance it scales.
431pub const CURVE_SCALE_SAMPLES: usize = 16;
432
433/// Migration diagnostic for the one-[`model_scale`] change
434/// (`docs/developer/kernel-plans/offset-unification-audit.md`, slice 0).
435///
436/// Switching a pipeline's `scale` definition moves EVERY tolerance derived from
437/// it at once, so the corpus must be measurable before and after.  Call this at
438/// a site that is changing over, with the old origin-distance value and the new
439/// size-based one, and run the batteries with `BREP_SCALE_DIAG=1` to see which
440/// fixtures actually move and by how much.  `ratio > 1` means the old
441/// definition was LOOSER than the new one there (the placement-inflation this
442/// slice removes); `ratio < 1` means it was tighter.
443///
444/// Silent and free unless the variable is set: the lookup happens once and the
445/// superseded `origin_form` closure is never called otherwise, so the retired
446/// definition costs nothing in the shipped path while staying re-measurable.
447pub fn report_scale_migration(site: &str, model_form: f64, origin_form: impl FnOnce() -> f64) {
448    static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
449    if !*ENABLED.get_or_init(|| std::env::var("BREP_SCALE_DIAG").is_ok()) {
450        return;
451    }
452    let origin_form = origin_form();
453    let ratio = if model_form != 0.0 {
454        origin_form / model_form
455    } else {
456        f64::NAN
457    };
458    eprintln!("scale-diag site={site} origin={origin_form:.9} model={model_form:.9} ratio={ratio:.6}");
459}
460
461/// [`solid_model_scale`] floored at 1.0 — the characteristic length
462/// [`KernelTolerances::for_solid`] and the boolean/imprint/heal sites are tuned
463/// against.  The floor keeps a unit-and-below part on the historical bands those
464/// call sites were fitted to; new size-relative sites should prefer the unfloored
465/// [`model_scale`] family and floor explicitly where they mean to.
466pub fn solid_scale(solid: &BrepSolid) -> f64 {
467    solid_model_scale(solid).max(1.0)
468}
469
470// ---------------------------------------------------------------------------
471// MEASURED tolerance — the deviation actually OBSERVED at construction
472// ---------------------------------------------------------------------------
473
474/// A deviation **measured** between a constructed entity and the geometry that
475/// entity was built to reproduce, recorded next to the **derived** band it was
476/// judged against.
477///
478/// # Why this type exists
479///
480/// Everything above this line in this module is *derived*: a band predicted
481/// from a size (`model_scale`), a policy field, or a constant, computed
482/// **before** the geometry exists.  Nothing above this line ever looks at what
483/// the construction actually produced.  A collocation fit, a polyline
484/// interpolation or a marched section can miss its intended locus by far more
485/// (or, far more often, far less) than the derived band assumed, and today that
486/// number is either thrown away or never taken.
487///
488/// This is the other half, and it is the half OCCT has and we did not:
489/// `BRepOffset_SimpleOffset::FillEdgeData` sets an edge's tolerance to the
490/// *measured* maximum distance between its new 3D curve and its pcurve on the
491/// offset surface, over every adjacent face
492/// (`BRepOffset_SimpleOffset.cxx:296-310`), and `UpdateTolerance`
493/// (`BRepOffset_MakeOffset.cxx:4196-4302`) re-measures and raises after the
494/// whole pipeline has run.  See
495/// `docs/developer/kernel-plans/occt-offset-algorithms.md` §6.
496///
497/// # The direction rule — this may never loosen anything
498///
499/// A `MeasuredTolerance` is a **record of what happened, not a budget to
500/// spend.**  The measured number may flow into
501///
502/// * a diagnostic,
503/// * a refusal message,
504/// * a comparison against a derived band that *tightens* an existing gate,
505///
506/// and nowhere else.  It must never be `max`-ed into an acceptance band, a weld
507/// radius or a search radius, because a construction would then certify its own
508/// error: the sloppier the fit, the wider the band that judges it.  That is the
509/// self-certification failure `per-entity-tolerances.md`'s invariant I1 excludes
510/// structurally, and it is why this type deliberately exposes no
511/// `f64`-producing "band to use" accessor — only [`Self::deviation`] (what
512/// happened), [`Self::band`] (what was demanded), and the verdict between them.
513///
514/// # Where it lives
515///
516/// Alongside the constructed entity, in the transient result struct the
517/// construction already returns — never as a field on [`crate::BrepSolid`]'s
518/// records.  `BrepSolid` is serialized by `io/snapshot.rs`, which is a
519/// documented durable format; a record field would be a format change with a
520/// reader obligation.  Persisting per-entity tolerances is a real and planned
521/// piece of work with its own design
522/// (`docs/developer/kernel-plans/per-entity-tolerances.md`, slice S3) — this
523/// type is the measurement layer beneath it, and lands with no format churn at
524/// all.
525#[derive(Clone, Copy, Debug, PartialEq)]
526pub struct MeasuredTolerance {
527    deviation: f64,
528    band: f64,
529}
530
531impl MeasuredTolerance {
532    /// Record `deviation` against the derived `band` that was in force.
533    ///
534    /// Both arguments are normalised in the FAIL-SAFE direction, so a
535    /// measurement that went wrong reads as "worse than the band", never as
536    /// "fine": a non-finite or negative deviation becomes `+inf`, and a
537    /// non-finite or negative band becomes `0.0`.  Either way
538    /// [`Self::exceeds_band`] answers `true`.
539    pub fn new(deviation: f64, band: f64) -> Self {
540        let deviation = if deviation.is_finite() && deviation >= 0.0 {
541            deviation
542        } else {
543            f64::INFINITY
544        };
545        let band = if band.is_finite() && band >= 0.0 {
546            band
547        } else {
548            0.0
549        };
550        Self { deviation, band }
551    }
552
553    /// A construction with no fit to measure — a closed-form analytic result,
554    /// or an affine offset that shifts a control net rigidly.  Records `0.0`
555    /// deviation as a *claim*, which is why it is a named constructor: reading
556    /// `MeasuredTolerance::exact(band)` at a call site says "this lane has no
557    /// approximation error by construction", where `new(0.0, band)` would read
558    /// as an unmeasured default.
559    pub fn exact(band: f64) -> Self {
560        Self::new(0.0, band)
561    }
562
563    /// What the construction actually did.
564    pub fn deviation(&self) -> f64 {
565        self.deviation
566    }
567
568    /// What the size-derived policy demanded of it.
569    pub fn band(&self) -> f64 {
570        self.band
571    }
572
573    /// The construction met the band it was judged against.
574    pub fn within_band(&self) -> bool {
575        self.deviation <= self.band
576    }
577
578    /// The construction was WORSE than the derived band assumed — the case
579    /// worth acting on.  A site that gates on this refuses; a site that only
580    /// reports names both numbers.
581    pub fn exceeds_band(&self) -> bool {
582        !self.within_band()
583    }
584
585    /// `deviation / band` — how much of the derived band the construction
586    /// spent.  `> 1` is [`Self::exceeds_band`]; a value orders of magnitude
587    /// below `1` says the band is loose here, which is worth knowing and is
588    /// exactly what the corpus distribution reports.  A zero band answers `0.0`
589    /// for a zero deviation and `+inf` otherwise.
590    pub fn utilisation(&self) -> f64 {
591        if self.band > 0.0 {
592            self.deviation / self.band
593        } else if self.deviation == 0.0 {
594            0.0
595        } else {
596            f64::INFINITY
597        }
598    }
599
600    /// The worse of two records: the larger deviation against the tighter band.
601    ///
602    /// Both halves move in the fail-safe direction, so folding a set of
603    /// per-coedge or per-edge measurements can only ever make the summary
604    /// harder to pass, never easier.
605    pub fn worse_of(self, other: Self) -> Self {
606        Self {
607            deviation: self.deviation.max(other.deviation),
608            band: self.band.min(other.band),
609        }
610    }
611
612    /// Fold a set of measurements into their worst, or `None` when empty.
613    pub fn worst(records: impl IntoIterator<Item = Self>) -> Option<Self> {
614        records.into_iter().reduce(Self::worse_of)
615    }
616
617    /// `measured 1.234e-7 against band 4.000e-3` — the shared wording for
618    /// refusal messages and diagnostics, so every site that reports a measured
619    /// deviation reports both numbers in the same form.
620    pub fn describe(&self) -> String {
621        format!(
622            "measured {:.3e} against band {:.3e}",
623            self.deviation, self.band
624        )
625    }
626}
627
628/// Fraction of the local characteristic length an APPROXIMATE offset
629/// construction may deviate from the exact geometry it approximates — see
630/// [`offset_construction_band`].
631pub const OFFSET_CONSTRUCTION_REL: f64 = 5e-4;
632
633/// Absolute floor of [`offset_construction_band`], so a vanishingly small part
634/// is not held to a band below the arithmetic that builds it.
635pub const OFFSET_CONSTRUCTION_FLOOR: f64 = 5e-6;
636
637/// The band an approximate offset construction is judged against: 0.05% of the
638/// caller's local characteristic length, floored at
639/// [`OFFSET_CONSTRUCTION_FLOOR`].
640///
641/// This is a **fit-accuracy** band in this module's taxonomy — "how closely
642/// must committed geometry agree?" — and it is the offset family's own
643/// established answer, not a new number.  Three sites hand-roll exactly this
644/// expression today: the ruled push's rim march
645/// (`edit/direct_edit/face_offset.rs:303`, whose comment names it "the in-tree
646/// precedent for how far an approximate offset result may be off"), the same
647/// file's hole rebuild (`:1343`), and the free-form push's dense residual gate
648/// (`edit/direct_edit/face_offset_freeform.rs:22`).  Naming it here gives the
649/// MEASURED half of the tolerance model ([`MeasuredTolerance`]) the same bar the
650/// gated half already uses, so a measurement and a gate on the same construction
651/// cannot silently disagree.  The three copies are deliberately NOT converted
652/// yet: two of those files are being rewritten by the general image-curve work
653/// (`occt-offset-algorithms.md` §7 item 1), and a drive-by edit there would
654/// collide for no behavioural gain — the expressions are identical, so
655/// converting them later is a rename, not a change.
656///
657/// It is deliberately ~50x TIGHTER than
658/// [`KernelTolerances::pcurve_acceptance`], which is where the same edge is
659/// judged later by [`crate::BrepSolid::validate`].  The two answer different
660/// questions: `pcurve_acceptance` asks "is this edge and this surface the same
661/// entity?" and must absorb a vendor import's independent approximations, while
662/// this asks "did OUR fit reproduce what we asked it to?" and has no vendor to
663/// forgive.  A construction that passes here passes validate with two orders of
664/// magnitude to spare; one that fails here is a bad fit even though validate
665/// would still accept it, which is precisely the case a measured tolerance
666/// exists to surface.
667pub fn offset_construction_band(scale: f64) -> f64 {
668    (scale.abs() * OFFSET_CONSTRUCTION_REL).max(OFFSET_CONSTRUCTION_FLOOR)
669}
670
671/// Propagate measured tolerances up one level of the entity hierarchy: from a
672/// vertex's incident edges (and the gaps between their ends and the vertex
673/// point) to the vertex itself.
674///
675/// The vertex's measured tolerance is the largest deviation any representation
676/// meeting there exhibits: the worst endpoint gap `|p_V − c_E(t_end)|` over the
677/// incident edge ends, and the worst measured deviation of those edges
678/// themselves — because a point that sits exactly on a curve which is itself
679/// `d` off its intended locus is `d` off that locus too.
680///
681/// # On OCCT's 1.001 factor — assessed, and deliberately NOT copied
682///
683/// `BRepOffset_SimpleOffset::FillVertexData` sets the vertex tolerance to
684/// `1.001 × max(adjacent edge tolerances, endpoint spread)`
685/// (`BRepOffset_SimpleOffset.cxx:398-424`).  That factor is not geometry.  OCCT
686/// requires the ordering `Tol(V) ≥ Tol(E) ≥ Tol(F)` to hold as a *validity
687/// invariant* on the persisted shape, re-checked by `BRepCheck_Vertex` /
688/// `BRepCheck_Edge` and re-imposed by `BRepLib::UpdateTolerances` after
689/// operations that recompute either side.  A vertex tolerance set exactly equal
690/// to its edge's can be inverted by nothing more than a recomputation's last
691/// bit, so they pad by a tenth of a percent.  It is an invariant fudge, and it
692/// is theirs because their tolerances are persisted, grow-only, and re-derived
693/// by checkers that compare them with `>`.
694///
695/// We have no such consumer.  Nothing in this kernel stores a per-entity
696/// tolerance (see [`MeasuredTolerance`]'s "where it lives") and therefore
697/// nothing re-derives one and compares it strictly against another.  Copying
698/// the factor here would make the record *false* — it would report 0.1% more
699/// deviation than was observed — for a benefit that does not exist.  So there is
700/// no factor, and this function returns exactly the worst thing it saw.
701///
702/// If a later slice does persist these values and does enforce an ordering
703/// invariant across a recompute (`per-entity-tolerances.md` S3), that slice
704/// should reintroduce a named padding constant *at the invariant it protects*,
705/// with the comparison it protects named — not here, and not silently.
706pub fn vertex_tolerance_from_edges(
707    endpoint_gaps: impl IntoIterator<Item = f64>,
708    incident_edge_deviations: impl IntoIterator<Item = f64>,
709) -> f64 {
710    let sanitise = |value: f64| {
711        if value.is_finite() && value >= 0.0 {
712            value
713        } else {
714            f64::INFINITY
715        }
716    };
717    endpoint_gaps
718        .into_iter()
719        .chain(incident_edge_deviations)
720        .map(sanitise)
721        .fold(0.0f64, f64::max)
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use crate::{make_box_brep, Vec3};
728
729    #[test]
730    fn measured_tolerance_normalises_bad_input_to_the_failing_side() {
731        // A measurement that went wrong must read as "worse than the band",
732        // never as "fine": that is the whole fail-safe contract.
733        for bad in [f64::NAN, f64::INFINITY, -1.0] {
734            let record = MeasuredTolerance::new(bad, 1.0);
735            assert!(record.exceeds_band(), "deviation {bad} must fail its band");
736            assert_eq!(record.deviation(), f64::INFINITY);
737        }
738        for bad in [f64::NAN, -1.0] {
739            let record = MeasuredTolerance::new(1e-9, bad);
740            assert!(record.exceeds_band(), "band {bad} must fail everything");
741            assert_eq!(record.band(), 0.0);
742        }
743        // A zero band with a zero deviation is the only "passing" degenerate.
744        assert!(MeasuredTolerance::new(0.0, 0.0).within_band());
745        assert_eq!(MeasuredTolerance::new(0.0, 0.0).utilisation(), 0.0);
746        assert_eq!(
747            MeasuredTolerance::new(1e-12, 0.0).utilisation(),
748            f64::INFINITY
749        );
750    }
751
752    #[test]
753    fn measured_tolerance_folds_toward_the_harder_verdict() {
754        let loose = MeasuredTolerance::new(1e-6, 1e-2);
755        let tight = MeasuredTolerance::new(1e-8, 1e-5);
756        let folded = loose.worse_of(tight);
757        // Worst deviation against the TIGHTEST band: folding a set can only
758        // make the summary harder to pass.
759        assert_eq!(folded.deviation(), 1e-6);
760        assert_eq!(folded.band(), 1e-5);
761        assert_eq!(
762            MeasuredTolerance::worst([loose, tight]).unwrap(),
763            folded,
764            "worst() is the same fold"
765        );
766        assert!(MeasuredTolerance::worst(std::iter::empty()).is_none());
767        // `exact` claims no approximation error but keeps its band.
768        assert_eq!(MeasuredTolerance::exact(4.0).deviation(), 0.0);
769        assert_eq!(MeasuredTolerance::exact(4.0).band(), 4.0);
770    }
771
772    #[test]
773    fn vertex_tolerance_is_the_worst_thing_seen_and_is_not_inflated() {
774        let gaps = [1e-7, 3e-6];
775        let incident = [5e-6, 2e-6];
776        let vertex = vertex_tolerance_from_edges(gaps, incident);
777        // Exactly the maximum — see the function's doc for why OCCT's 1.001
778        // factor is theirs and not ours. A padded value would be a false
779        // record, and no consumer in this kernel needs the padding.
780        assert_eq!(vertex, 5e-6);
781        assert_eq!(vertex_tolerance_from_edges([], []), 0.0);
782        // A non-finite input fails safe: the vertex reads as unbounded, never
783        // as clean.
784        assert_eq!(
785            vertex_tolerance_from_edges([f64::NAN], [1e-9]),
786            f64::INFINITY
787        );
788    }
789
790    #[test]
791    fn offset_construction_band_is_size_relative_above_its_absolute_floor() {
792        // Size-coupled where the part is big enough to earn it ...
793        assert!((offset_construction_band(20.0) - 20.0 * OFFSET_CONSTRUCTION_REL).abs() < 1e-15);
794        // ... and floored so a vanishingly small part is not held below the
795        // arithmetic that builds it.
796        assert_eq!(offset_construction_band(1e-6), OFFSET_CONSTRUCTION_FLOOR);
797        // Translation cannot reach it: the argument is an extent, not a
798        // position, and a negative extent is nonsense rather than a widening.
799        assert_eq!(offset_construction_band(-20.0), offset_construction_band(20.0));
800        // Strictly tighter than the validator's vendor-forgiving acceptance on
801        // any part where that band is size-coupled at all.
802        let policy = KernelTolerances::for_scale(1.0, 1e-7);
803        for diagonal in [1.0, 20.0, 1000.0] {
804            assert!(
805                offset_construction_band(diagonal) < policy.pcurve_acceptance(diagonal),
806                "construction band must stay below validate's acceptance at {diagonal}"
807            );
808        }
809    }
810
811    #[test]
812    fn policy_is_scale_aware_and_ordered() {
813        let small = KernelTolerances::for_scale(1.0, 1e-7);
814        let large = KernelTolerances::for_scale(10_000.0, 1e-7);
815        small.check().unwrap();
816        large.check().unwrap();
817        assert!(large.sew_search > small.sew_search);
818        assert_eq!(large.model, small.model);
819    }
820
821    #[test]
822    fn spatial_returns_the_base_model_tolerance() {
823        let policy = KernelTolerances::for_scale(1.0, 1e-7);
824        assert_eq!(policy.spatial(), policy.model);
825        assert_eq!(policy.spatial(), 1e-7);
826    }
827
828    #[test]
829    fn heal_band_floors_model_by_a_size_relative_width() {
830        let policy = KernelTolerances::for_scale(1.0, 1e-7);
831        // Below the size-relative width the base spatial tolerance dominates.
832        assert_eq!(policy.heal_band(1.0, 1e-9), policy.model);
833        // Above it the diagonal-scaled width takes over.
834        assert!((policy.heal_band(1000.0, 1e-3) - 1.0).abs() < 1e-15);
835    }
836
837    #[test]
838    fn pcurve_acceptance_size_couples_above_the_absolute_floor() {
839        let policy = KernelTolerances::for_scale(1.0, 1e-7);
840        // Tiny parts keep the tight ABSOLUTE pcurve-consistency floor: the
841        // size-relative term (diag * 2.5%) is below it, so nothing loosens.
842        assert_eq!(policy.pcurve_acceptance(0.0), policy.pcurve_consistency);
843        assert_eq!(policy.pcurve_acceptance(0.1), policy.pcurve_consistency);
844        // On the sub-unit model that motivated this (ABC 00000041, diagonal
845        // ~0.897, vendor edge/surface gap ~0.018), the size-coupled ceiling
846        // clears the gap while the raw absolute floor (4e-3) would reject it.
847        let gap = 0.018;
848        assert!(policy.pcurve_consistency < gap);
849        assert!(policy.pcurve_acceptance(0.897) > gap);
850        // The band is exactly the diagonal-scaled width once it dominates.
851        assert!((policy.pcurve_acceptance(2.0) - 2.0 * PCURVE_ACCEPTANCE_REL).abs() < 1e-15);
852        // Never returns less than the absolute contract.
853        assert!(policy.pcurve_acceptance(1e6) >= policy.pcurve_consistency);
854    }
855
856    #[test]
857    fn solid_scale_uses_extent_not_distance_from_origin() {
858        let solid = make_box_brep(Vec3::new(1e6, 1e6, 1e6), 3.0, 4.0, 12.0).unwrap();
859        assert!((solid_scale(&solid) - 13.0).abs() < 1e-9);
860    }
861
862    #[test]
863    fn model_scale_is_size_based_and_translation_invariant() {
864        let box_points = |origin: Vec3| {
865            [
866                origin,
867                origin.add(Vec3::new(3.0, 0.0, 0.0)),
868                origin.add(Vec3::new(3.0, 4.0, 0.0)),
869                origin.add(Vec3::new(3.0, 4.0, 12.0)),
870            ]
871        };
872        let here = model_scale(box_points(Vec3::new(0.0, 0.0, 0.0)));
873        let away = model_scale(box_points(Vec3::new(5000.0, -2000.0, 800.0)));
874        // Size-based: the 3-4-12 box's diagonal, not its distance out.
875        assert!((here - 13.0).abs() < 1e-9, "{here}");
876        // Translation-invariant: a rigid move changes nothing at all.
877        assert_eq!(here.to_bits(), away.to_bits());
878        // The origin-distance form this replaces would have answered ~5.4e3.
879        let origin_form = box_points(Vec3::new(5000.0, -2000.0, 800.0))
880            .iter()
881            .map(|point| point.length())
882            .fold(1.0, f64::max);
883        assert!(origin_form > 400.0 * away, "{origin_form} vs {away}");
884    }
885
886    #[test]
887    fn model_scale_is_unfloored_while_solid_scale_floors_at_one() {
888        // A genuinely sub-unit part keeps its own extent through `model_scale`
889        // (the validator and direct-edit rely on this), while `solid_scale`
890        // keeps the historical >= 1.0 floor its call sites were tuned against.
891        let tiny = make_box_brep(Vec3::new(0.0, 0.0, 0.0), 0.1, 0.2, 0.2).unwrap();
892        let raw = solid_model_scale(&tiny);
893        assert!(raw < 0.4 && raw > 0.0, "{raw}");
894        assert_eq!(solid_scale(&tiny), 1.0);
895        // Degenerate input has no extent to scale by, so it answers 1.0.
896        assert_eq!(model_scale(std::iter::empty()), 1.0);
897        assert_eq!(model_scale([Vec3::new(7.0, 7.0, 7.0)]), 1.0);
898        assert_eq!(model_scale([Vec3::new(f64::NAN, 0.0, 0.0)]), 1.0);
899    }
900
901    #[test]
902    fn curve_model_scale_measures_extent_not_origin_distance() {
903        // A unit-radius circle: its BOUNDING-BOX diagonal (2·√2 for a 2×2×0
904        // box), the same box semantics `solid_scale` uses — and identical
905        // wherever the circle is centred.
906        let here =
907            crate::make_circle(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0).unwrap();
908        let away =
909            crate::make_circle(Vec3::new(900.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0).unwrap();
910        let [t0, t1] = here.domain().unwrap();
911        let near_scale = curve_model_scale(&here, t0, t1).unwrap();
912        let [a0, a1] = away.domain().unwrap();
913        let away_scale = curve_model_scale(&away, a0, a1).unwrap();
914        assert!(
915            (near_scale - 2.0 * std::f64::consts::SQRT_2).abs() < 1e-6,
916            "{near_scale}"
917        );
918        assert!((away_scale - near_scale).abs() < 1e-9, "{away_scale}");
919    }
920
921    #[test]
922    fn parametric_tolerance_scales_inversely_with_derivative() {
923        assert!((parametric_tolerance(1e-6, 10.0) - 1e-7).abs() < 1e-20);
924        assert!((parametric_tolerance(1e-6, 0.1) - 1e-5).abs() < 1e-18);
925        // Degenerate derivative floors instead of blowing up.
926        assert!(parametric_tolerance(1e-6, 0.0).is_finite());
927    }
928
929    #[test]
930    fn surface_uv_tolerance_uses_the_smaller_derivative() {
931        let band = surface_uv_tolerance(1e-6, 100.0, 2.0);
932        assert!((band - 5e-7).abs() < 1e-18);
933    }
934
935    #[test]
936    fn inverted_policy_is_rejected() {
937        let policy = KernelTolerances {
938            convergence: 1e-2,
939            model: 1e-4,
940            ..KernelTolerances::default()
941        };
942        assert!(policy.check().is_err());
943    }
944}