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}