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}