Skip to main content

axiolid_brep_boolean/
report.rs

1//! Which within-tolerance decisions a boolean took (#236).
2//!
3//! Every decision the crate docs list under "decisions within tolerance"
4//! goes through one of the helpers here, which either settles it without
5//! the tolerance or records it in the report of the boolean that is
6//! running:
7//!
8//! - **Support decisions** ([`support`]) are about the operands' own
9//!   surfaces: two supports are one, a plane is parallel or perpendicular to
10//!   a cylinder's axis or touches it. An exact predicate on the operands'
11//!   numbers (`axiolid-exact`, see `crate::predicate`) settles the exactly
12//!   coincident, parallel and perpendicular cases; only a reading the exact
13//!   predicate does not confirm is recorded.
14//! - **Point decisions** ([`near`], [`near_angle`]) are about constructed
15//!   points and curves: a cut on an edge, cuts or vertices read as one, a
16//!   section running along an edge. Those values are rounded `f64`
17//!   evaluations of exact curves, so a residue up to the rounding floor
18//!   ([`rounding`]: `2^-40` of the operands' extent, about `4000` units in
19//!   the last place) is the rounding of one exact point, not a decision; a
20//!   residue above it and within the caller's tolerance is recorded.
21//!
22//! The report lives in a per-thread session opened by the public entry
23//! points ([`crate::boolean_with_report`], [`crate::boolean`],
24//! [`crate::section_edges`], [`crate::split_face`]) with the caller's
25//! tolerance; an entry point called inside another's session joins it.
26//! Nothing is shared across threads and nothing outlives the call.
27//!
28//! **Nothing is read at a zero tolerance (#251).** A support reading the
29//! exact predicate rejects moves or turns the operands by a positive
30//! amount, however small its `f64` measure: one whose measure rounds to
31//! exactly `0` is not exact. So a tolerance with a zero part takes no such
32//! reading ([`support`]): at [`Tolerance::ZERO`] the exact answer stands
33//! and the general closed form decides. The session enforces the contract
34//! too: a decision recorded at [`Tolerance::ZERO`], or beyond the caller's
35//! tolerance, is a bug -- a debug assertion -- and in a release build the
36//! entry point refuses it ([`BooleanError::ToleranceExceeded`]) rather than
37//! return a result its report misdescribes.
38
39use std::cell::RefCell;
40
41use axiolid_brep::ExactBRep;
42use axiolid_core::{Scalar, Tolerance};
43use axiolid_surface::Surface;
44
45use crate::BooleanError;
46
47/// What a within-tolerance decision read (#236).
48#[non_exhaustive]
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
50pub enum ToleranceDecisionKind {
51    /// Two faces' supports read as one surface: normals or axes within the
52    /// angular tolerance, offsets within the linear one.
53    CoincidentSupports,
54    /// Two surfaces read as touching, not crossing, where their normals
55    /// agree within the angular tolerance.
56    Contact,
57    /// A point read as lying on an edge, a curve, a pole or a surface.
58    IncidentPoint,
59    /// A section read as running along a boundary edge.
60    SectionAlongEdge,
61    /// A section touching the surface next to an edge, cut where it meets
62    /// the edge itself.
63    TangentCrossing,
64    /// Cuts on a curve, a boundary split and the piece's end, or two
65    /// evaluations of a vertex, read as one point.
66    MergedPoints,
67    /// A section read as an iso-parameter curve of a face (a meridian, a
68    /// latitude, a ruling or a circle about an axis).
69    IsoCurve,
70    /// A plane read as parallel to a cylinder's axis.
71    PlaneParallelToAxis,
72    /// A plane read as perpendicular to a cylinder's axis.
73    PlanePerpendicularToAxis,
74    /// A plane read as touching a cylinder along a ruling.
75    PlaneTouchesCylinder,
76}
77
78/// The worst reading of one kind a boolean made within tolerance.
79#[non_exhaustive]
80#[derive(Debug, Clone, Copy, PartialEq)]
81pub struct ToleranceDecision {
82    /// What was read.
83    pub kind: ToleranceDecisionKind,
84    /// The largest distance a feature was moved by a reading of this kind,
85    /// in the operands' length unit (zero for a purely angular reading).
86    pub linear: Scalar,
87    /// The largest angle a direction was turned by a reading of this kind,
88    /// in radians (zero for a purely linear reading).
89    pub angular: Scalar,
90}
91
92/// The within-tolerance decisions one boolean took (#236).
93///
94/// Empty means no such decision fired: the result is the exact boolean of
95/// the operands as given, its new vertices and curves evaluated in `f64`
96/// (see the crate docs). Otherwise it is the exact boolean of operands
97/// moved by at most [`Self::linear`] and turned by at most
98/// [`Self::angular`], both within the caller's tolerance.
99///
100/// Either way, constructed points closer than [`ROUNDING_FACTOR`] times
101/// [`Self::extent`] were read as one point without a decision, and are not
102/// reported (#244); [`Self::rounding_floor`] covers that residue and the
103/// move of an unrecorded angular one.
104///
105/// A boolean the caller's tolerance left refused is cut again at
106/// [`FINER_FACTOR`] times it (#332); its readings then lie within that
107/// finer tolerance, and [`Self::finer`] says so.
108#[derive(Debug, Clone, Default, PartialEq)]
109pub struct BooleanReport {
110    decisions: Vec<ToleranceDecision>,
111    /// The operands' extent the rounding floor was scaled from (#244).
112    extent: Scalar,
113    /// Whether the boolean was cut at the finer tolerance (#332).
114    finer: bool,
115}
116
117impl BooleanReport {
118    /// One entry per kind of reading that fired, in [`ToleranceDecisionKind`]
119    /// order, each with its worst magnitude.
120    #[must_use]
121    pub fn decisions(&self) -> &[ToleranceDecision] {
122        &self.decisions
123    }
124
125    /// Whether no within-tolerance decision fired: the result is the exact
126    /// boolean of the operands as given.
127    #[must_use]
128    pub fn is_exact(&self) -> bool {
129        self.decisions.is_empty()
130    }
131
132    /// The largest distance any reading moved a feature (zero when exact).
133    #[must_use]
134    pub fn linear(&self) -> Scalar {
135        self.decisions
136            .iter()
137            .map(|d| d.linear)
138            .fold(0.0, Scalar::max)
139    }
140
141    /// The largest angle any reading turned a direction (zero when exact).
142    #[must_use]
143    pub fn angular(&self) -> Scalar {
144        self.decisions
145            .iter()
146            .map(|d| d.angular)
147            .fold(0.0, Scalar::max)
148    }
149
150    /// Whether a reading of `kind` fired.
151    #[must_use]
152    pub fn contains(&self, kind: ToleranceDecisionKind) -> bool {
153        self.decisions.iter().any(|d| d.kind == kind)
154    }
155
156    /// The absolute rounding floor that applied to this result (#244), in
157    /// the operands' length unit: [`ROUNDING_FACTOR`] times
158    /// [`Self::extent`], plus what an angular residue of that factor
159    /// (radians, read without a decision) moves a point of the operands by,
160    /// at most `2 sqrt(3)` times [`Self::extent`] (a turn about any point
161    /// of the operands' box).
162    ///
163    /// A residue between constructed points (cuts, vertices, a point on an
164    /// edge) up to [`ROUNDING_FACTOR`] times [`Self::extent`] was read as one
165    /// exact point evaluated twice, without the tolerance, and is in no
166    /// [`ToleranceDecision`]; only a larger one is a decision within
167    /// tolerance. This floor is the widening that covers those unrecorded
168    /// linear and angular residues, not the threshold between them and a
169    /// decision: a residue below it may still be a reported decision. So a
170    /// consumer widening
171    /// distances on the result widens them by this floor too, even when
172    /// [`Self::is_exact`] holds. Zero when no general boolean produced the
173    /// report (the default report).
174    #[must_use]
175    pub fn rounding_floor(&self) -> Scalar {
176        ROUNDING_FACTOR * self.extent * (1.0 + 2.0 * 3.0_f64.sqrt())
177    }
178
179    /// The extent [`Self::rounding_floor`] was scaled from (#244): the
180    /// largest coordinate magnitude of the operands as given to the
181    /// boolean, the larger of the two operands' readings, each the largest
182    /// of
183    ///
184    /// - `max(|x|, |y|, |z|)` over every topology vertex, and
185    /// - per plane, cylinder, elliptical cylinder, cone, sphere or torus
186    ///   surface, `max(|x|, |y|, |z|)` of its frame origin plus its size:
187    ///   zero for a plane, `|radius|` for a cylinder, a cone (its radius at
188    ///   the frame origin) or a sphere, the larger semi-axis for an
189    ///   elliptical cylinder, `|major + minor|` for a torus; other surface
190    ///   families add nothing beyond their vertices.
191    ///
192    /// Coordinates are read about the operands' own origin, so the extent
193    /// grows with their distance from it. An operand whose reading is not
194    /// finite counts as zero. After [`Self::merged`], the largest extent of the merged
195    /// reports.
196    #[must_use]
197    pub fn extent(&self) -> Scalar {
198        self.extent
199    }
200
201    /// This report and `other` together: what a chain of booleans read
202    /// (each kind once, with the worst magnitude of either).
203    ///
204    /// The merged rounding floor is the larger of the two
205    /// ([`Self::extent`] is the larger extent), sound for every boolean of
206    /// the chain: each one's floor is at most it. A rigid placement of a
207    /// result leaves distances between its points unchanged, so a report
208    /// carried through one keeps its floor as is (#244).
209    ///
210    /// The merged report is [`Self::finer`] when either is.
211    #[must_use]
212    pub fn merged(mut self, other: &BooleanReport) -> BooleanReport {
213        for d in &other.decisions {
214            self.add(d.kind, d.linear, d.angular);
215        }
216        self.extent = self.extent.max(other.extent);
217        self.finer |= other.finer;
218        self
219    }
220
221    /// Whether the boolean was cut at [`FINER_FACTOR`] times the caller's
222    /// tolerance, its readings at the caller's own having left it refused
223    /// (#332): features of the operands closer than the caller's tolerance
224    /// but further apart than the finer one were then decided by the exact
225    /// predicates, and every reading in [`Self::decisions`] lies within
226    /// the finer tolerance. After [`Self::merged`], whether any boolean of
227    /// the chain was.
228    #[must_use]
229    pub fn finer(&self) -> bool {
230        self.finer
231    }
232
233    fn add(&mut self, kind: ToleranceDecisionKind, linear: Scalar, angular: Scalar) {
234        match self.decisions.binary_search_by(|d| d.kind.cmp(&kind)) {
235            Ok(i) => {
236                let d = &mut self.decisions[i];
237                d.linear = d.linear.max(linear);
238                d.angular = d.angular.max(angular);
239            }
240            Err(i) => self.decisions.insert(
241                i,
242                ToleranceDecision {
243                    kind,
244                    linear,
245                    angular,
246                },
247            ),
248        }
249    }
250}
251
252/// The rounding floor relative to the operands' extent: `2^-40` (#236,
253/// #244).
254///
255/// The absolute linear floor of a boolean is this factor times the
256/// operands' extent; [`BooleanReport::rounding_floor`] returns it, with
257/// what the angular floor adds, and [`BooleanReport::extent`] defines the
258/// extent. Angular residues have a floor of this factor itself, in
259/// radians, unscaled.
260pub const ROUNDING_FACTOR: Scalar = 1.0 / 1_099_511_627_776.0;
261
262/// The ratio of the tolerance a refused boolean is cut again at to the
263/// caller's (#332): `2^-10`, about a micrometre under a millimetre.
264///
265/// Readings within the caller's tolerance make features closer than it one
266/// (#228); two such features further apart than rounding (the sides of two
267/// abutting openings a few micrometres to just under a millimetre apart,
268/// a box tangent to a curved face) can leave no reading that every face
269/// pair agrees with, and the boolean is refused. At this fraction of the
270/// tolerance such features are apart, decided by the exact predicates,
271/// while the readings still cover what they exist for: the rounding of
272/// separately placed operands, orders of magnitude below it.
273/// [`BooleanReport::finer`] says when a result was cut so.
274pub const FINER_FACTOR: Scalar = 1.0 / 1024.0;
275
276/// The relative rounding floor ([`ROUNDING_FACTOR`]).
277pub(crate) const ROUNDING: Scalar = ROUNDING_FACTOR;
278
279struct Session {
280    /// The tolerance the caller asked for, which no retry changes: the
281    /// bound of the exit check ([`caller`], #445).
282    caller: Tolerance,
283    /// The caller's tolerance: every recorded decision lies within it, and
284    /// at [`Tolerance::ZERO`] none may be recorded (#251).
285    tolerance: Tolerance,
286    /// Whether a decision the tolerance does not admit was asked for: the
287    /// entry point refuses ([`BooleanError::ToleranceExceeded`]).
288    exceeded: bool,
289    /// The operands' extent ([`BooleanReport::extent`]).
290    extent: Scalar,
291    /// The linear rounding floor: [`ROUNDING`] times the operands' extent.
292    rounding: Scalar,
293    report: BooleanReport,
294    /// Whether a cut near an imprinted edge's end merges into it within the
295    /// caller's tolerance (`true`), or only within the rounding floor: the
296    /// retry of a boolean the wider merge left refused (#291).
297    ends_within_tolerance: bool,
298    /// Whether such a merge above the rounding floor was made.
299    merged_an_end: bool,
300    /// Whether the session was started over at the finer tolerance (#332).
301    finer: bool,
302    /// Whether an imprinted line edge short of the other face's boundary
303    /// by less than the tolerance is extended to it (#409): the last retry.
304    extends_ends: bool,
305}
306
307thread_local! {
308    static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
309}
310
311/// The open session of one entry point; dropping it closes the session.
312pub(crate) struct Guard {
313    owner: bool,
314}
315
316impl Guard {
317    /// The report of the session, closing it. A guard that joined an outer
318    /// session returns an empty report: the outer one holds the decisions.
319    ///
320    /// # Errors
321    ///
322    /// [`BooleanError::ToleranceExceeded`] when a decision the caller's
323    /// tolerance does not admit was asked for (see [`record`]).
324    pub(crate) fn finish(self) -> Result<BooleanReport, BooleanError> {
325        if !self.owner {
326            return Ok(BooleanReport::default());
327        }
328        SESSION
329            .with(|s| {
330                s.borrow_mut().as_mut().map(|s| {
331                    if s.exceeded {
332                        return Err(BooleanError::ToleranceExceeded);
333                    }
334                    Ok(BooleanReport {
335                        extent: s.extent,
336                        finer: s.finer,
337                        ..std::mem::take(&mut s.report)
338                    })
339                })
340            })
341            .unwrap_or_else(|| Ok(BooleanReport::default()))
342    }
343}
344
345impl Drop for Guard {
346    fn drop(&mut self) {
347        if self.owner {
348            SESSION.with(|s| *s.borrow_mut() = None);
349        }
350    }
351}
352
353impl Guard {
354    /// Whether this guard's own session merged a cut into an imprinted
355    /// edge's end above the rounding floor, so that a refusal may be the
356    /// merge's ([`imprint_end_tolerance`]); `false` for a joined session.
357    pub(crate) fn merged_an_end(&self) -> bool {
358        self.owner && SESSION.with(|s| s.borrow().as_ref().is_some_and(|s| s.merged_an_end))
359    }
360
361    /// Whether this guard owns its session, so that it can be restarted.
362    pub(crate) fn owns(&self) -> bool {
363        self.owner
364    }
365
366    /// Whether a decision the caller's tolerance does not admit was asked
367    /// for in this guard's own session ([`Guard::finish`] would refuse).
368    pub(crate) fn exceeded(&self) -> bool {
369        self.owner && SESSION.with(|s| s.borrow().as_ref().is_some_and(|s| s.exceeded))
370    }
371
372    /// Start this guard's own session over as it was opened, at the
373    /// caller's `tolerance`: no decision, no retry flag (#418, the cut of
374    /// operands as given after a recentred cut was refused). `false` for a
375    /// joined session.
376    pub(crate) fn restart(&self, tolerance: Tolerance) -> bool {
377        if !self.owner {
378            return false;
379        }
380        SESSION.with(|s| {
381            if let Some(s) = s.borrow_mut().as_mut() {
382                s.tolerance = tolerance;
383                s.report = BooleanReport::default();
384                s.exceeded = false;
385                s.ends_within_tolerance = true;
386                s.merged_an_end = false;
387                s.finer = false;
388                s.extends_ends = false;
389            }
390        });
391        true
392    }
393
394    /// Start this guard's own session over at `tolerance`, its decisions
395    /// dropped and imprinted edges' ends merged within it again: the retry
396    /// of a boolean at a finer tolerance (#332). `false` for a joined
397    /// session, which cannot be retried.
398    pub(crate) fn retry_at(&self, tolerance: Tolerance) -> bool {
399        if !self.owner {
400            return false;
401        }
402        SESSION.with(|s| {
403            if let Some(s) = s.borrow_mut().as_mut() {
404                s.tolerance = tolerance;
405                s.report = BooleanReport::default();
406                s.exceeded = false;
407                s.ends_within_tolerance = true;
408                s.merged_an_end = false;
409                s.finer = true;
410            }
411        });
412        true
413    }
414
415    /// Start this guard's own session over at the caller's `tolerance`, its
416    /// decisions dropped, with an imprinted line edge short of the other
417    /// face's boundary extended to it (#409). `false` for a joined session.
418    pub(crate) fn retry_extending(&self, tolerance: Tolerance) -> bool {
419        if !self.owner {
420            return false;
421        }
422        SESSION.with(|s| {
423            if let Some(s) = s.borrow_mut().as_mut() {
424                s.tolerance = tolerance;
425                s.report = BooleanReport::default();
426                s.exceeded = false;
427                s.ends_within_tolerance = true;
428                s.merged_an_end = false;
429                s.finer = false;
430                s.extends_ends = true;
431            }
432        });
433        true
434    }
435
436    /// Start this guard's own session over, its decisions dropped, with
437    /// imprinted edges' ends merged only within the rounding floor (#291).
438    pub(crate) fn retry_with_rounding_ends(&self) {
439        if !self.owner {
440            return;
441        }
442        SESSION.with(|s| {
443            if let Some(s) = s.borrow_mut().as_mut() {
444                s.report = BooleanReport::default();
445                s.exceeded = false;
446                s.ends_within_tolerance = false;
447                s.merged_an_end = false;
448            }
449        });
450    }
451}
452
453/// The tolerance within which a cut on an imprinted edge merges into the
454/// edge's own end: the caller's, or [`Tolerance::ZERO`] (the rounding floor
455/// only) on the retry of a boolean the wider merge left refused (#291).
456pub(crate) fn imprint_end_tolerance(tolerance: Tolerance) -> Tolerance {
457    let within = SESSION.with(|s| s.borrow().as_ref().is_none_or(|s| s.ends_within_tolerance));
458    if within {
459        tolerance
460    } else {
461        Tolerance::ZERO
462    }
463}
464
465/// Whether an imprinted line edge short of the other face's boundary is
466/// extended to it (#409).
467pub(crate) fn extends_ends() -> bool {
468    SESSION.with(|s| s.borrow().as_ref().is_some_and(|s| s.extends_ends))
469}
470
471/// Note a merge into an imprinted edge's end above the rounding floor.
472pub(crate) fn merged_an_end() {
473    SESSION.with(|s| {
474        if let Some(s) = s.borrow_mut().as_mut() {
475            s.merged_an_end = true;
476        }
477    });
478}
479
480/// Open a session for the given operands at the caller's tolerance, or
481/// join the one already open.
482pub(crate) fn open(operands: &[&ExactBRep], tolerance: Tolerance) -> Guard {
483    let owner = SESSION.with(|s| s.borrow().is_none());
484    if owner {
485        let extent = operands.iter().map(|b| extent(b)).fold(0.0, Scalar::max);
486        SESSION.with(|s| {
487            *s.borrow_mut() = Some(Session {
488                caller: tolerance,
489                tolerance,
490                exceeded: false,
491                extent,
492                rounding: ROUNDING * extent,
493                report: BooleanReport::default(),
494                ends_within_tolerance: true,
495                merged_an_end: false,
496                finer: false,
497                extends_ends: false,
498            });
499        });
500    }
501    Guard { owner }
502}
503
504/// The largest coordinate magnitude of a B-rep: its vertices, and each
505/// surface's origin widened by its size.
506fn extent(brep: &ExactBRep) -> Scalar {
507    let mut out: Scalar = 0.0;
508    for v in brep.topology().vertices() {
509        out = out.max(v.position.abs().max_element());
510    }
511    for s in brep.surfaces() {
512        let (origin, size) = match s {
513            Surface::Plane(p) => (p.frame.origin, 0.0),
514            Surface::Cylinder(c) => (c.frame.origin, c.radius),
515            Surface::EllipticalCylinder(c) => (c.frame.origin, c.semi_axis_x.max(c.semi_axis_y)),
516            Surface::Cone(c) => (c.frame.origin, c.radius.abs()),
517            Surface::Sphere(c) => (c.frame.origin, c.radius),
518            Surface::Torus(t) => (t.frame.origin, t.major_radius + t.minor_radius),
519            _ => continue,
520        };
521        out = out.max(origin.abs().max_element() + size.abs());
522    }
523    if out.is_finite() {
524        out
525    } else {
526        0.0
527    }
528}
529
530/// The linear rounding floor of the open session (zero without one).
531pub(crate) fn rounding() -> Scalar {
532    SESSION.with(|s| s.borrow().as_ref().map_or(0.0, |s| s.rounding))
533}
534
535/// The tolerance the caller opened the session with, whatever a retry has
536/// since cut at (`None` without a session).
537pub(crate) fn caller() -> Option<Tolerance> {
538    SESSION.with(|s| s.borrow().as_ref().map(|s| s.caller))
539}
540
541/// Whether no decision has been recorded in the open session so far (`true`
542/// without one): the report so far is exact.
543pub(crate) fn exact() -> bool {
544    SESSION.with(|s| {
545        s.borrow()
546            .as_ref()
547            .is_none_or(|s| s.report.decisions.is_empty())
548    })
549}
550
551/// The tolerance with its parts raised to the rounding floor: for naming a
552/// rounded point on the curve or surface it was evaluated from, which is
553/// bookkeeping, not a decision.
554pub(crate) fn floored(tolerance: Tolerance) -> Tolerance {
555    Tolerance::new(
556        tolerance.linear().max(rounding()),
557        tolerance.angular().max(ROUNDING),
558    )
559    .unwrap_or(tolerance)
560}
561
562/// Record a within-tolerance reading in the open session.
563///
564/// A reading must lie within the session's tolerance, and at
565/// [`Tolerance::ZERO`] there is none (#236, #251): the helpers below never
566/// ask otherwise. A call that does is a bug -- a debug assertion -- and in
567/// a release build marks the session, whose entry point then refuses
568/// ([`Guard::finish`]) instead of returning a result the report
569/// misdescribes.
570pub(crate) fn record(kind: ToleranceDecisionKind, linear: Scalar, angular: Scalar) {
571    SESSION.with(|s| {
572        if let Some(s) = s.borrow_mut().as_mut() {
573            let admitted = s.tolerance != Tolerance::ZERO
574                && linear <= s.tolerance.linear()
575                && angular <= s.tolerance.angular();
576            // The crate's own unit tests take the release build's refusal.
577            #[cfg(not(test))]
578            debug_assert!(
579                admitted,
580                "{kind:?} ({linear}, {angular}) recorded at tolerance {:?} (#251)",
581                s.tolerance
582            );
583            if admitted {
584                s.report.add(kind, linear, angular);
585            } else {
586                s.exceeded = true;
587            }
588        }
589    });
590}
591
592/// A point decision on a linear residue: within the rounding floor it is
593/// one point, unrecorded; within the caller's tolerance it is recorded.
594pub(crate) fn near(kind: ToleranceDecisionKind, residue: Scalar, tolerance: Tolerance) -> bool {
595    let within = residue <= tolerance.linear().max(rounding());
596    if !within {
597        return false;
598    }
599    if residue > rounding() {
600        record(kind, residue, 0.0);
601    }
602    true
603}
604
605/// A point decision on an angular residue, as [`near`].
606pub(crate) fn near_angle(
607    kind: ToleranceDecisionKind,
608    residue: Scalar,
609    tolerance: Tolerance,
610) -> bool {
611    let within = residue <= tolerance.angular().max(ROUNDING);
612    if !within {
613        return false;
614    }
615    if residue > ROUNDING {
616        record(kind, 0.0, residue);
617    }
618    true
619}
620
621/// How a support decision came out.
622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
623pub(crate) enum Reading {
624    /// The exact predicate holds: no tolerance was used.
625    Exact,
626    /// Not exactly, but within the caller's tolerance: recorded.
627    Within,
628    /// Neither.
629    Apart,
630}
631
632impl Reading {
633    /// Exactly, or within tolerance.
634    pub(crate) fn holds(self) -> bool {
635        self != Reading::Apart
636    }
637}
638
639/// A support decision: `linear` and `angular` are the `f64` measures of how
640/// far the reading moves and turns the operands. Within the caller's
641/// tolerance (or within rounding of zero), the exact predicate is asked:
642/// confirmed, the reading is exact and unrecorded; otherwise it is taken
643/// within tolerance and recorded, or refused beyond it.
644///
645/// A reading the exact predicate rejects moves or turns the operands by a
646/// positive amount even when its `f64` measure is exactly `0` (a rotation's
647/// rounding cancels in `f64`, not in the numbers given). A tolerance part of
648/// zero admits no positive amount, so such a reading is refused there,
649/// whatever its measure: at [`Tolerance::ZERO`] the exact answer stands
650/// (#251).
651pub(crate) fn support(
652    kind: ToleranceDecisionKind,
653    linear: Scalar,
654    angular: Scalar,
655    tolerance: Tolerance,
656    exact: impl FnOnce() -> bool,
657) -> Reading {
658    let (eps, alpha) = (tolerance.linear(), tolerance.angular());
659    if !(linear <= eps.max(rounding()) && angular <= alpha.max(ROUNDING)) {
660        return Reading::Apart;
661    }
662    if exact() {
663        return Reading::Exact;
664    }
665    if eps > 0.0 && alpha > 0.0 && linear <= eps && angular <= alpha {
666        record(kind, linear, angular);
667        return Reading::Within;
668    }
669    Reading::Apart
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use ToleranceDecisionKind::*;
676
677    #[test]
678    fn a_report_keeps_each_kind_once_with_its_worst_magnitude() {
679        let mut r = BooleanReport::default();
680        assert!(r.is_exact());
681        r.add(MergedPoints, 1e-9, 0.0);
682        r.add(CoincidentSupports, 2e-9, 1e-12);
683        r.add(MergedPoints, 3e-9, 0.0);
684        assert_eq!(r.decisions().len(), 2);
685        assert_eq!(r.decisions()[0].kind, CoincidentSupports);
686        assert_eq!(r.linear(), 3e-9);
687        assert_eq!(r.angular(), 1e-12);
688        let merged = BooleanReport::default().merged(&r);
689        assert_eq!(merged, r);
690    }
691
692    #[test]
693    fn merged_reports_keep_the_larger_rounding_floor() {
694        let small = BooleanReport {
695            extent: 3.0,
696            ..BooleanReport::default()
697        };
698        let large = BooleanReport {
699            extent: 10.0,
700            ..BooleanReport::default()
701        };
702        for merged in [small.clone().merged(&large), large.clone().merged(&small)] {
703            assert_eq!(merged.extent(), 10.0);
704            assert_eq!(
705                merged.rounding_floor(),
706                ROUNDING_FACTOR * 10.0 * (1.0 + 2.0 * 3.0_f64.sqrt())
707            );
708            assert!(merged.is_exact());
709        }
710        assert_eq!(BooleanReport::default().rounding_floor(), 0.0);
711    }
712
713    #[test]
714    fn a_session_reports_the_floor_it_applied() {
715        let guard = open(&[], Tolerance::ZERO);
716        assert_eq!(rounding(), 0.0);
717        let report = guard.finish().expect("nothing recorded");
718        assert_eq!((report.extent(), report.rounding_floor()), (0.0, 0.0));
719        assert_eq!(ROUNDING_FACTOR, (2.0 as Scalar).powi(-40));
720    }
721
722    #[test]
723    fn the_caller_tolerance_survives_every_retry() {
724        let tol = Tolerance::METRE;
725        let guard = open(&[], tol);
726        assert_eq!(caller(), Some(tol));
727        let finer = Tolerance::new(tol.linear() / 1024.0, tol.angular()).expect("a tolerance");
728        assert!(guard.retry_at(finer));
729        assert_eq!(caller(), Some(tol));
730        assert!(guard.retry_extending(tol));
731        assert_eq!(caller(), Some(tol));
732        assert!(guard.restart(tol));
733        assert_eq!(caller(), Some(tol));
734        drop(guard);
735        assert_eq!(caller(), None);
736    }
737
738    #[test]
739    fn a_session_is_exact_until_a_decision_is_recorded() {
740        assert!(exact());
741        let guard = open(&[], Tolerance::METRE);
742        assert!(exact());
743        assert!(near(MergedPoints, 5e-7, Tolerance::METRE));
744        assert!(!exact());
745        assert!(guard.restart(Tolerance::METRE));
746        assert!(exact());
747    }
748
749    #[test]
750    fn a_restarted_session_is_as_it_was_opened() {
751        let tol = Tolerance::METRE;
752        let guard = open(&[], tol);
753        let finer = Tolerance::new(tol.linear() / 1024.0, tol.angular()).expect("a tolerance");
754        assert!(guard.retry_at(finer));
755        guard.retry_with_rounding_ends();
756        assert!(guard.retry_extending(tol));
757        assert!(extends_ends());
758        assert_eq!(imprint_end_tolerance(tol), tol);
759        guard.retry_with_rounding_ends();
760        assert_eq!(imprint_end_tolerance(tol), Tolerance::ZERO);
761        assert!(guard.restart(tol));
762        assert!(!extends_ends());
763        assert_eq!(imprint_end_tolerance(tol), tol);
764        assert!(!guard.exceeded());
765        let report = guard.finish().expect("nothing recorded");
766        assert!(!report.finer());
767        assert!(report.decisions().is_empty());
768    }
769
770    #[test]
771    fn a_joined_session_cannot_be_restarted() {
772        let outer = open(&[], Tolerance::METRE);
773        let inner = open(&[], Tolerance::METRE);
774        assert!(!inner.owns() && outer.owns());
775        assert!(!inner.restart(Tolerance::METRE));
776    }
777
778    #[test]
779    fn readings_within_rounding_are_unrecorded_and_beyond_tolerance_refused() {
780        let tol = Tolerance::METRE;
781        let guard = open(&[], tol);
782        // No operands: the floor is zero, so any residue is a decision.
783        assert!(near(MergedPoints, 0.0, tol));
784        assert!(near(MergedPoints, 5e-7, tol));
785        assert!(!near(MergedPoints, 2e-6, tol));
786        assert!(!near(MergedPoints, Scalar::NAN, tol));
787        // Exact support readings are not recorded; others are.
788        let exact = support(CoincidentSupports, 1e-7, 0.0, tol, || true);
789        assert_eq!(exact, Reading::Exact);
790        let within = support(PlaneParallelToAxis, 1e-7, 0.0, tol, || false);
791        assert_eq!(within, Reading::Within);
792        let apart = support(PlaneParallelToAxis, 1e-5, 0.0, tol, || true);
793        assert_eq!(apart, Reading::Apart);
794        let report = guard.finish().expect("within tolerance");
795        assert_eq!(report.decisions().len(), 2);
796        assert!(report.contains(MergedPoints));
797        assert!(report.contains(PlaneParallelToAxis));
798        assert!(!report.contains(CoincidentSupports));
799    }
800
801    #[test]
802    fn a_reading_the_exact_predicate_rejects_is_never_taken_at_a_zero_part() {
803        // #251: an `f64` measure of exactly zero is not exact. At
804        // `Tolerance::ZERO`, or with a zero part, only the exact predicate
805        // can confirm a reading; nothing is recorded.
806        let partial = [
807            Tolerance::ZERO,
808            Tolerance::new(1e-6, 0.0).expect("valid"),
809            Tolerance::new(0.0, 1e-9).expect("valid"),
810        ];
811        for tol in partial {
812            let guard = open(&[], tol);
813            for kind in [
814                CoincidentSupports,
815                PlaneParallelToAxis,
816                PlanePerpendicularToAxis,
817                PlaneTouchesCylinder,
818            ] {
819                assert_eq!(support(kind, 0.0, 0.0, tol, || false), Reading::Apart);
820                assert_eq!(support(kind, 0.0, 0.0, tol, || true), Reading::Exact);
821            }
822            let report = guard.finish().expect("nothing recorded");
823            assert!(report.is_exact(), "{tol:?}: {report:?}");
824        }
825        // Both parts positive: a zero measure the predicate rejects is
826        // still taken, and reported (unchanged).
827        let guard = open(&[], Tolerance::METRE);
828        let within = support(PlanePerpendicularToAxis, 0.0, 0.0, Tolerance::METRE, || {
829            false
830        });
831        assert_eq!(within, Reading::Within);
832        let report = guard.finish().expect("within tolerance");
833        assert!(report.contains(PlanePerpendicularToAxis));
834    }
835
836    #[test]
837    fn points_within_rounding_are_never_recorded_at_zero_tolerance() {
838        let guard = open(&[], Tolerance::ZERO);
839        assert!(near(MergedPoints, 0.0, Tolerance::ZERO));
840        assert!(!near(MergedPoints, 1e-300, Tolerance::ZERO));
841        assert!(near_angle(Contact, 0.0, Tolerance::ZERO));
842        assert!(!near_angle(Contact, 2.0 * ROUNDING, Tolerance::ZERO));
843        assert!(guard.finish().expect("nothing recorded").is_exact());
844    }
845
846    #[test]
847    fn a_decision_recorded_at_zero_tolerance_is_refused() {
848        // The helpers never record at `Tolerance::ZERO`; a direct record is
849        // a bug: a debug assertion outside these tests, and a named refusal.
850        let guard = open(&[], Tolerance::ZERO);
851        record(PlanePerpendicularToAxis, 0.0, 0.0);
852        assert_eq!(guard.finish(), Err(BooleanError::ToleranceExceeded));
853    }
854
855    #[test]
856    fn a_decision_beyond_the_tolerance_is_refused() {
857        let guard = open(&[], Tolerance::METRE);
858        record(MergedPoints, 2e-6, 0.0);
859        assert_eq!(guard.finish(), Err(BooleanError::ToleranceExceeded));
860    }
861
862    #[test]
863    fn a_joined_session_keeps_the_outer_tolerance() {
864        let outer = open(&[], Tolerance::METRE);
865        let inner = open(&[], Tolerance::ZERO);
866        // The outer session's tolerance applies to a joined entry point.
867        assert!(near(MergedPoints, 5e-7, Tolerance::METRE));
868        assert!(inner.finish().expect("joined").is_exact());
869        assert!(outer.finish().expect("within").contains(MergedPoints));
870    }
871}