Skip to main content

axiolid_construct/
profile_validate.rs

1//! Exact validation of profiles on the contour the exact path builds (#352).
2//!
3//! [`ExactProfileValidator`] implements [`ValidateProfile`] for
4//! [`ContourProfile`] and [`Profile`]. It decides closure, self-contact and
5//! hole nesting, and refuses with a typed [`ProfileFinding`] instead of
6//! guessing.
7//!
8//! # Why the lowered ring, not the authored curves
9//!
10//! A `ProfileSegment` stores a `Circle2` (an `f64` frame and radius) and an
11//! `f64` angle domain, so an arc end is `centre + r (cos t, sin t)`. By the
12//! Lindemann-Weierstrass theorem `cos t` and `sin t` are transcendental for
13//! every nonzero dyadic `t`: an arc-to-arc joint almost never closes exactly,
14//! and an arc end almost never lies exactly on a line. An exact predicate on
15//! the literal data would answer "open contour" and "off the axis", both
16//! correctly and both uselessly. Intent can only be recovered through a
17//! declared interpretation, and the exact path already has one:
18//! [`contour_to_arc_ring`](crate::contour_lower::contour_to_arc_ring).
19//!
20//! - A joint is ONE `f64` point, the start of the segment leaving it.
21//! - A joint closes when the entering end lies within
22//!   `max(tolerance.linear(), 8 eps * operand magnitudes)` of it.
23//! - Each arc becomes a bulge, split into equal sub-arcs of less than half a
24//!   turn.
25//!
26//! The extruder, the revolver and the half-space path build from that ring.
27//! So the validator judges the lowered ring and "valid" means valid as the
28//! exact path will build it. Each ring edge maps back to the segment it came
29//! from, and findings name segments, not ring edges.
30//!
31//! # Exact, then tolerance-free
32//!
33//! Self-contact, shared boundary and hole nesting are decided on the lowered
34//! ring by `axiolid_overlay::check_arc_polygon`: exact signs over the ring's
35//! `f64` data, no tolerance in any contact or nesting decision (ADR 0070).
36//! The tolerance enters only where it does in the exact path: joint closure,
37//! and the per-ring degeneracies of `validate_arc_ring` (repeated vertex,
38//! zero-radius arc, zero area).
39//!
40//! What the lowering moves is reported, not hidden:
41//! [`ProfileReport::max_joint_gap`] is the largest joint gap it closed. The
42//! lowered boundary lies within `2 * max_joint_gap` plus rounding of the
43//! authored one:
44//!
45//! - Each lowered arc is a similarity image of the authored arc, its two
46//!   ends moved by at most the gap at their joints.
47//! - For a sub-arc of less than half a turn, a point of the image moves by
48//!   at most `|d0| + |d1 - d0|`, where `d0` and `d1` are the movements of
49//!   its ends; both are at most the gap, which gives the bound.
50//!
51//! Two parts of the authored boundary that come closer than that, without
52//! touching, are decided for the lowered ring.
53//!
54//! # Near-contacts (opt-in, #388)
55//!
56//! Parts that miss each other by less than a distance are valid exactly.
57//! A consumer that treats them as touching asks
58//! [`ExactProfileValidator::validate_with`] for a clearance (usually
59//! `tolerance.linear()`) and gets a [`ProfileFinding::Clearance`] naming the
60//! two segments, with a certified lower bound on their distance. A pair is
61//! reported when its distance has a local minimum below the clearance away
62//! from where they meet, or when it forms an exact cusp (they meet and leave
63//! in the same direction); the neighbourhood of a meeting point at a nonzero
64//! angle, a narrow wedge, is not. The check is
65//! `axiolid_overlay::check_arc_polygon_with`'s: exact, and a pair it cannot
66//! decide is reported with `undecided` set, never cleared. It is off in
67//! [`ValidateProfile::validate`], so the extruder and every existing caller
68//! see the same report as before. The lowered ring is judged, as everywhere
69//! here: the authored parts may be `2 * max_joint_gap` closer or farther.
70//!
71//! # Refused and reported
72//!
73//! Refused with a finding, checking nothing more of that contour: too few
74//! ring vertices (a polyline segment lowers exactly, to one vertex per
75//! polyline vertex it passes), a segment family the lowering refuses
76//! (ellipse, B-spline, chains, ...), a degenerate frame, an empty parameter
77//! range, a polyline domain outside its points, fewer than two finite
78//! polyline points, more than a whole turn, and an open joint. A contour
79//! that does not lower makes the profile report only those findings,
80//! because nesting against a missing ring would be a wrong answer.
81//!
82//! Reported, with the segments involved: a self-contact (crossing, pinch,
83//! tangency or fold-back), a shared stretch of boundary between contours, a
84//! hole outside the outer contour, and holes whose interiors meet. An
85//! isolated point where two contours meet is a [`ProfileContact`], not a
86//! finding: [`ProfileReport::is_valid`] accepts it and
87//! [`ProfileReport::is_strict`] refuses it. Contacts that together cut the
88//! interior apart (a hole touching the outer contour at several points, a
89//! closed chain of holes) are a `DisconnectedInterior` finding.
90
91use std::collections::BTreeMap;
92
93use axiolid_contracts::GeomError;
94use axiolid_core::{Point2, Scalar, Tolerance};
95use axiolid_overlay::{
96    check_arc_polygon_with, ArcCheckOptions, ArcPolygon, ArcPolygonDefect, ArcRingRef, OverlayError,
97};
98use axiolid_profile::{Contour, ContourProfile, Profile, ValidateProfile};
99
100use crate::contour_lower::{lower_contour, ContourFault, LoweredContour};
101use crate::extrude_exact::profile_to_contour;
102
103/// Which contour of a [`ContourProfile`].
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
105pub enum ContourRef {
106    Outer,
107    Hole(usize),
108}
109
110impl From<ArcRingRef> for ContourRef {
111    fn from(ring: ArcRingRef) -> Self {
112        match ring {
113            ArcRingRef::Outer => Self::Outer,
114            ArcRingRef::Hole(k) => Self::Hole(k),
115        }
116    }
117}
118
119/// Why a profile is not valid as the exact path would build it.
120#[non_exhaustive]
121#[derive(Debug, Clone, PartialEq)]
122pub enum ProfileFinding {
123    /// The profile does not lower to a contour (family or dimensions);
124    /// nothing else was checked.
125    NotLowered(GeomError),
126    /// The segments lower to fewer than two ring vertices (a polyline or a
127    /// full circle is a whole ring by itself); `count` is the segments.
128    TooFewSegments { contour: ContourRef, count: usize },
129    /// A segment family the exact lowering refuses (ellipse, B-spline, ...),
130    /// or an arc of more than a whole turn: `kind` names it.
131    UnsupportedSegment {
132        contour: ContourRef,
133        segment: usize,
134        kind: &'static str,
135    },
136    /// Degenerate frame or empty parameter range.
137    DegenerateSegment {
138        contour: ContourRef,
139        segment: usize,
140        reason: &'static str,
141    },
142    /// The end of `segment` and the start of the next (`(segment + 1) % n`,
143    /// so `n - 1` is the closing joint) are `gap` apart, beyond the slack.
144    Gap {
145        contour: ContourRef,
146        segment: usize,
147        gap: Scalar,
148    },
149    /// A `validate_arc_ring` refusal of the lowered ring.
150    DegenerateRing {
151        contour: ContourRef,
152        error: OverlayError,
153    },
154    /// Two segments of one contour share a point other than the joint
155    /// between consecutive ones. `segments` are segment indices, ascending;
156    /// both name one segment when a single arc touches itself. `at` is the
157    /// first such point, rounded once.
158    SelfContact {
159        contour: ContourRef,
160        segments: [usize; 2],
161        at: Point2,
162    },
163    /// Two contours share a stretch of boundary. `contours` ascending.
164    BoundaryOverlap { contours: [ContourRef; 2] },
165    /// Part of a hole lies outside the outer contour.
166    HoleOutsideOuter { hole: usize },
167    /// Contacts between contours cut the interior into separate pieces.
168    /// `contours` ascending, every contour of the cut component.
169    DisconnectedInterior { contours: Vec<ContourRef> },
170    /// A defect of the arc polygon check this version does not map to a
171    /// finding of its own, such as `NestingUndecided`: nesting could not be
172    /// decided, so the profile is not valid.
173    Polygon(ArcPolygonDefect),
174    /// Two holes' interiors meet. `holes` ascending.
175    HolesOverlap { holes: [usize; 2] },
176    /// Two segments are closer than the requested clearance
177    /// ([`ExactProfileValidator::validate_with`]): their distance has a
178    /// local minimum strictly between zero and it away from their meeting
179    /// points, or they form an exact cusp (they meet and leave in the same
180    /// direction). The neighbourhood of a meeting point at a nonzero angle (a
181    /// narrow wedge) is not reported. `segments` are
182    /// `(contour, segment)` pairs, ascending. `lower_bound` is a certified
183    /// lower bound on their distance; `undecided` is false when the exact
184    /// test proved the distance is below the clearance and true when it
185    /// could not decide and the pair is reported rather than cleared.
186    Clearance {
187        segments: [(ContourRef, usize); 2],
188        lower_bound: Scalar,
189        undecided: bool,
190    },
191}
192
193/// A point where two different contours meet.
194#[non_exhaustive]
195#[derive(Debug, Clone, Copy, PartialEq)]
196pub struct ProfileContact {
197    /// The two contours, ascending.
198    pub contours: [ContourRef; 2],
199    /// The meeting point, rounded once.
200    pub at: Point2,
201}
202
203/// The result of [`ExactProfileValidator`].
204#[non_exhaustive]
205#[derive(Debug, Clone, PartialEq, Default)]
206pub struct ProfileReport {
207    pub findings: Vec<ProfileFinding>,
208    pub contacts: Vec<ProfileContact>,
209    /// Largest joint gap closed by lowering; the validated boundary lies
210    /// within `2 * max_joint_gap` plus rounding of the authored one.
211    pub max_joint_gap: Scalar,
212}
213
214impl ProfileReport {
215    /// No finding. Isolated contacts between contours are allowed.
216    pub fn is_valid(&self) -> bool {
217        self.findings.is_empty()
218    }
219
220    /// No finding and no contact between contours.
221    pub fn is_strict(&self) -> bool {
222        self.findings.is_empty() && self.contacts.is_empty()
223    }
224}
225
226/// Validates a profile on the arc ring the exact path lowers it to.
227///
228/// See the module documentation for what is decided and what is refused.
229#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
230pub struct ExactProfileValidator;
231
232impl ExactProfileValidator {
233    /// [`ValidateProfile::validate`] with options: the default options are
234    /// exactly `validate`; `ArcCheckOptions::with_clearance` adds
235    /// [`ProfileFinding::Clearance`] for segments closer than the distance.
236    pub fn validate_with(
237        &self,
238        profile: &Profile,
239        tolerance: Tolerance,
240        options: ArcCheckOptions,
241    ) -> ProfileReport {
242        match profile_to_contour(profile, tolerance) {
243            Ok(contours) => validate_contours_with(&contours, tolerance, options),
244            Err(error) => ProfileReport {
245                findings: vec![ProfileFinding::NotLowered(error)],
246                ..ProfileReport::default()
247            },
248        }
249    }
250
251    /// [`ExactProfileValidator::validate_with`] for a [`ContourProfile`].
252    pub fn validate_contours_with(
253        &self,
254        profile: &ContourProfile,
255        tolerance: Tolerance,
256        options: ArcCheckOptions,
257    ) -> ProfileReport {
258        validate_contours_with(profile, tolerance, options)
259    }
260}
261
262impl ValidateProfile<ContourProfile> for ExactProfileValidator {
263    type Report = ProfileReport;
264
265    fn validate(&self, profile: &ContourProfile, tolerance: Tolerance) -> ProfileReport {
266        validate_contours(profile, tolerance)
267    }
268}
269
270impl ValidateProfile<Profile> for ExactProfileValidator {
271    type Report = ProfileReport;
272
273    fn validate(&self, profile: &Profile, tolerance: Tolerance) -> ProfileReport {
274        self.validate_with(profile, tolerance, ArcCheckOptions::new())
275    }
276}
277
278pub(crate) fn validate_contours(profile: &ContourProfile, tolerance: Tolerance) -> ProfileReport {
279    validate_contours_with(profile, tolerance, ArcCheckOptions::new())
280}
281
282fn validate_contours_with(
283    profile: &ContourProfile,
284    tolerance: Tolerance,
285    options: ArcCheckOptions,
286) -> ProfileReport {
287    let mut report = ProfileReport::default();
288    let mut lowered: Vec<LoweredContour> = Vec::with_capacity(1 + profile.holes.len());
289    let mut broken = false;
290    let contours: Vec<(ContourRef, &Contour)> =
291        std::iter::once((ContourRef::Outer, &profile.outer))
292            .chain(
293                profile
294                    .holes
295                    .iter()
296                    .enumerate()
297                    .map(|(k, hole)| (ContourRef::Hole(k), hole)),
298            )
299            .collect();
300    for (name, contour) in contours {
301        match lower_contour(contour, tolerance) {
302            Ok(done) => {
303                report.max_joint_gap = report.max_joint_gap.max(done.max_gap);
304                lowered.push(done);
305            }
306            Err(fault) => {
307                report.findings.push(finding_of(name, fault));
308                broken = true;
309            }
310        }
311    }
312    if broken {
313        return report;
314    }
315
316    let mut rings = lowered.iter().map(|l| l.ring.clone());
317    let polygon = ArcPolygon {
318        outer: rings.next().expect("the outer contour lowered"),
319        holes: rings.collect(),
320    };
321    let check = check_arc_polygon_with(&polygon, tolerance, options);
322    let segment = |ring: ArcRingRef, edge: usize| match ring {
323        ArcRingRef::Outer => lowered[0].segment_of_edge[edge],
324        ArcRingRef::Hole(k) => lowered[k + 1].segment_of_edge[edge],
325    };
326    // Clearance defects name edges; several edges of one arc make one
327    // finding, so they are merged per segment pair and sorted.
328    let mut near: BTreeMap<[(ContourRef, usize); 2], (Scalar, bool)> = BTreeMap::new();
329    for defect in check.defects {
330        report.findings.push(match defect {
331            ArcPolygonDefect::Clearance {
332                edges,
333                lower_bound,
334                undecided,
335            } => {
336                let mut pair = edges.map(|(ring, edge)| (ring.into(), segment(ring, edge)));
337                pair.sort_unstable();
338                let entry = near.entry(pair).or_insert((lower_bound, undecided));
339                entry.0 = entry.0.min(lower_bound);
340                entry.1 &= undecided;
341                continue;
342            }
343            ArcPolygonDefect::Ring { ring, error } => ProfileFinding::DegenerateRing {
344                contour: ring.into(),
345                error,
346            },
347            ArcPolygonDefect::SelfContact { ring, edges, at } => {
348                let mut segments = edges.map(|edge| segment(ring, edge));
349                segments.sort_unstable();
350                ProfileFinding::SelfContact {
351                    contour: ring.into(),
352                    segments,
353                    at,
354                }
355            }
356            ArcPolygonDefect::BoundaryOverlap { rings } => ProfileFinding::BoundaryOverlap {
357                contours: rings.map(ContourRef::from),
358            },
359            ArcPolygonDefect::HoleOutsideOuter { hole } => {
360                ProfileFinding::HoleOutsideOuter { hole }
361            }
362            ArcPolygonDefect::HolesOverlap { holes } => ProfileFinding::HolesOverlap { holes },
363            ArcPolygonDefect::DisconnectedInterior { rings } => {
364                ProfileFinding::DisconnectedInterior {
365                    contours: rings.into_iter().map(ContourRef::from).collect(),
366                }
367            }
368            // `ArcPolygonDefect` is non-exhaustive: a defect this version
369            // does not know is carried whole, never dropped.
370            other => ProfileFinding::Polygon(other),
371        });
372    }
373    report.findings.extend(
374        near.into_iter().map(
375            |(segments, (lower_bound, undecided))| ProfileFinding::Clearance {
376                segments,
377                lower_bound,
378                undecided,
379            },
380        ),
381    );
382    report.contacts = check
383        .contacts
384        .into_iter()
385        .map(|contact| ProfileContact {
386            contours: contact.rings.map(ContourRef::from),
387            at: contact.at,
388        })
389        .collect();
390    report
391}
392
393fn finding_of(contour: ContourRef, fault: ContourFault) -> ProfileFinding {
394    match fault {
395        ContourFault::TooFewSegments { count, .. } => {
396            ProfileFinding::TooFewSegments { contour, count }
397        }
398        ContourFault::UnsupportedSegment { segment, kind } => ProfileFinding::UnsupportedSegment {
399            contour,
400            segment,
401            kind,
402        },
403        ContourFault::DegenerateSegment { segment, reason } => ProfileFinding::DegenerateSegment {
404            contour,
405            segment,
406            reason,
407        },
408        ContourFault::Gap { segment, gap, .. } => ProfileFinding::Gap {
409            contour,
410            segment,
411            gap,
412        },
413    }
414}