Skip to main content

axiolid_mesh_section_contract/
region_conformance.rs

1//! Conformance checks for providers of
2//! [`MeshPlaneSection::section_request`](crate::MeshPlaneSection::section_request).
3//!
4//! The suite runs analytic fixtures with hand-derived expectations
5//! (`fixtures.rs`): faces in the plane, a post rising out of a parapet whose
6//! top lies in the plane, edge and point contacts, overlapping in-plane
7//! faces, and sheets (terrain, a sheet in the plane, an annulus, a saddle). A provider whose first case returns `Unsupported` does not claim
8//! the method: the report passes with `claimed == false`. Every case runs
9//! twice and the results must be identical.
10//!
11//! Every case runs a second time with cut regions requested (ADR 0092). A
12//! provider whose first such request returns `Unsupported`, or an answer
13//! without cut regions (it predates them), does not claim them
14//! (`cut_regions_claimed == false`) and the rest is skipped. Otherwise
15//! the answer must equal the plain one, plus the fixture's cut regions and
16//! the cut evidence; a plain refusal must be repeated; a pinched cut may
17//! instead be refused by name (#373); a sheet is invalid input; the cut rings
18//! must cover the chains bit for bit; and every `Both` region must be a cut
19//! region.
20
21use core::fmt;
22
23use axiolid_contracts::{ExecutionOptions, GeomError, Operation};
24use axiolid_core::{Point2, Tolerance};
25
26use crate::conformance::{ConformanceFailure, ConformanceSuite};
27use crate::cut::{CutRegion, CutRegionLimits};
28use crate::fixtures::{cases, Case, Cut, Expected, Output};
29use crate::request::{
30    ChainEnd, InPlaneRegion, InPlaneSide, PlaneSection, PlaneSectionEvidence, RegionLimits,
31    SectionChain, SectionRequest, SectionRole,
32};
33use crate::{MeshPlaneSection, SectionLimits};
34
35/// Absolute tolerance for comparing output points with the fixtures.
36const POINT_TOLERANCE: f64 = 1e-9;
37
38/// One conformance failure of the region suite.
39#[derive(Debug, Clone, PartialEq)]
40#[non_exhaustive]
41pub enum RegionConformanceFailure {
42    /// The provider's legacy `section` fails the base suite.
43    Base(ConformanceFailure),
44    /// A case returned an error that is not its expected refusal.
45    ReturnedError {
46        /// Fixture name.
47        case: &'static str,
48        /// The error.
49        error: String,
50    },
51    /// The chains differ from the fixture's.
52    WrongChains {
53        /// Fixture name.
54        case: &'static str,
55    },
56    /// The regions differ from the fixture's.
57    WrongRegions {
58        /// Fixture name.
59        case: &'static str,
60    },
61    /// The evidence does not match the fixture and the output.
62    WrongEvidence {
63        /// Fixture name.
64        case: &'static str,
65    },
66    /// A case must be refused and was not, or was refused otherwise.
67    WrongRefusal {
68        /// Fixture name.
69        case: &'static str,
70        /// The expected input family.
71        expected: &'static str,
72        /// What happened instead.
73        actual: String,
74    },
75    /// The cut regions, or the answer around them, differ from the fixture's.
76    WrongCutRegions {
77        /// Fixture name.
78        case: &'static str,
79    },
80    /// Two runs of one case differ.
81    NonDeterministic {
82        /// Fixture name.
83        case: &'static str,
84    },
85}
86
87/// Result of [`RegionConformanceSuite::run`].
88#[derive(Debug, Clone, PartialEq)]
89#[non_exhaustive]
90pub struct RegionConformanceReport {
91    /// Every failure found.
92    pub failures: Vec<RegionConformanceFailure>,
93    /// Whether the provider implements `section_request`. When `false` the
94    /// suite ran nothing else and passes.
95    pub claimed: bool,
96    /// Whether the provider also sections `SectionRole::Sheet`. When
97    /// `false`, the first sheet case returned `Unsupported` and the sheet
98    /// cases were skipped; the report can still pass.
99    pub sheets_claimed: bool,
100    /// Whether the provider also traces cut regions. When `false`, the first
101    /// cut-region request returned `Unsupported` and the cut checks were
102    /// skipped; the report can still pass.
103    pub cut_regions_claimed: bool,
104}
105
106impl RegionConformanceReport {
107    /// Whether no case failed.
108    pub fn is_success(&self) -> bool {
109        self.failures.is_empty()
110    }
111}
112
113impl fmt::Display for RegionConformanceReport {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        if !self.claimed {
116            write!(f, "not claimed")
117        } else if self.is_success() {
118            let sheets = if self.sheets_claimed {
119                ""
120            } else {
121                " (sheets not claimed)"
122            };
123            let cuts = if self.cut_regions_claimed {
124                ""
125            } else {
126                " (cut regions not claimed)"
127            };
128            write!(f, "conformant{sheets}{cuts}")
129        } else {
130            write!(f, "{:?}", self.failures)
131        }
132    }
133}
134
135/// The conformance suite for `section_request`.
136pub struct RegionConformanceSuite;
137
138impl RegionConformanceSuite {
139    /// Run every fixture against `provider`.
140    pub fn run(provider: &dyn MeshPlaneSection) -> RegionConformanceReport {
141        let mut report = RegionConformanceReport {
142            failures: Vec::new(),
143            claimed: true,
144            sheets_claimed: true,
145            cut_regions_claimed: true,
146        };
147        let mut seen_sheet = false;
148        let limits = SectionLimits::new(4096, 8192, 16384, 64);
149        let region_limits = RegionLimits::new(64, 4096, 1 << 20);
150        let cut_limits = CutRegionLimits::new(64, 4096, 1 << 20);
151        let mut seen_solid = false;
152        let options = ExecutionOptions::new(Tolerance::new(1e-9, 1e-9).expect("valid tolerance"));
153        for (index, case) in cases().iter().enumerate() {
154            if case.role == SectionRole::Sheet && !report.sheets_claimed {
155                continue;
156            }
157            let request =
158                SectionRequest::new(case.frame, limits, region_limits).with_role(case.role);
159            let first = provider.section_request(&case.mesh, &request, &options);
160            if case.role == SectionRole::Sheet
161                && !seen_sheet
162                && matches!(first, Err(GeomError::Unsupported { .. }))
163            {
164                report.sheets_claimed = false;
165                continue;
166            }
167            seen_sheet |= case.role == SectionRole::Sheet;
168            if index == 0 && matches!(first, Err(GeomError::Unsupported { .. })) {
169                report.claimed = false;
170                return report;
171            }
172            let second = provider.section_request(&case.mesh, &request, &options);
173            let plain = first.clone();
174            check_case(case, first, second, &mut report);
175            if !report.cut_regions_claimed {
176                continue;
177            }
178            let cut_request = request.with_cut_regions(cut_limits);
179            let first = provider.section_request(&case.mesh, &cut_request, &options);
180            if case.role == SectionRole::Solid
181                && !seen_solid
182                && match &first {
183                    Err(error) => matches!(error, GeomError::Unsupported { .. }),
184                    // A provider that predates cut regions ignores the request.
185                    Ok(section) => section.cut_regions.is_none(),
186                }
187            {
188                report.cut_regions_claimed = false;
189                continue;
190            }
191            seen_solid |= case.role == SectionRole::Solid;
192            let second = provider.section_request(&case.mesh, &cut_request, &options);
193            check_cut_case(case, &plain, first, second, &mut report);
194        }
195        for failure in ConformanceSuite::run(provider).failures {
196            report
197                .failures
198                .push(RegionConformanceFailure::Base(failure));
199        }
200        report
201    }
202}
203
204fn check_case(
205    case: &Case,
206    first: Result<PlaneSection, GeomError>,
207    second: Result<PlaneSection, GeomError>,
208    report: &mut RegionConformanceReport,
209) {
210    let name = case.name;
211    let stable = match (&first, &second) {
212        (Ok(a), Ok(b)) => a == b,
213        (Err(a), Err(b)) => a.to_string() == b.to_string(),
214        _ => false,
215    };
216    if !stable {
217        report
218            .failures
219            .push(RegionConformanceFailure::NonDeterministic { case: name });
220    }
221    match (&case.expected, first) {
222        (Expected::Output(expected), Ok(got))
223        | (Expected::OutputOrRefused(_, expected), Ok(got)) => {
224            check_output(case, expected, &got, report);
225        }
226        (Expected::OutputOrRefused(family, _) | Expected::Refused(family), Err(error))
227            if is_refusal(&error, family) => {}
228        (Expected::Refused(family), Ok(_)) => {
229            report
230                .failures
231                .push(RegionConformanceFailure::WrongRefusal {
232                    case: name,
233                    expected: family,
234                    actual: "a section was returned".into(),
235                });
236        }
237        (Expected::Output(_), Err(error)) => {
238            report
239                .failures
240                .push(RegionConformanceFailure::ReturnedError {
241                    case: name,
242                    error: error.to_string(),
243                });
244        }
245        (Expected::OutputOrRefused(family, _) | Expected::Refused(family), Err(error)) => {
246            report
247                .failures
248                .push(RegionConformanceFailure::WrongRefusal {
249                    case: name,
250                    expected: family,
251                    actual: error.to_string(),
252                });
253        }
254    }
255}
256
257/// Check the answer to a cut-region request against the plain answer.
258fn check_cut_case(
259    case: &Case,
260    plain: &Result<PlaneSection, GeomError>,
261    first: Result<PlaneSection, GeomError>,
262    second: Result<PlaneSection, GeomError>,
263    report: &mut RegionConformanceReport,
264) {
265    let name = case.name;
266    let wrong = RegionConformanceFailure::WrongCutRegions { case: name };
267    let stable = match (&first, &second) {
268        (Ok(a), Ok(b)) => a == b,
269        (Err(a), Err(b)) => a.to_string() == b.to_string(),
270        _ => false,
271    };
272    if !stable {
273        report
274            .failures
275            .push(RegionConformanceFailure::NonDeterministic { case: name });
276    }
277    match (&case.cut, first) {
278        (Cut::Invalid, Err(GeomError::InvalidInput(_))) => {}
279        (Cut::Regions(_) | Cut::RegionsOrRefused(..), Err(error))
280            if plain.as_ref().is_err_and(|p| same_error(p, &error)) => {}
281        (Cut::RegionsOrRefused(family, _), Err(error)) if is_refusal(&error, family) => {}
282        (_, Err(error)) => {
283            report
284                .failures
285                .push(RegionConformanceFailure::ReturnedError {
286                    case: name,
287                    error: error.to_string(),
288                });
289        }
290        (Cut::Regions(expected) | Cut::RegionsOrRefused(_, expected), Ok(got)) => {
291            if !plain
292                .as_ref()
293                .is_ok_and(|plain| cut_answer_matches(case, plain, expected, &got))
294            {
295                report.failures.push(wrong);
296            }
297        }
298        (Cut::Invalid, Ok(_)) => report.failures.push(wrong),
299    }
300}
301
302fn same_error(left: &GeomError, right: &GeomError) -> bool {
303    left.to_string() == right.to_string()
304}
305
306/// Whether a cut answer is the plain one plus the expected cut regions, with
307/// matching evidence, and its rings cover the chains.
308fn cut_answer_matches(
309    case: &Case,
310    plain: &PlaneSection,
311    expected: &[CutRegion],
312    got: &PlaneSection,
313) -> bool {
314    let Some(cut) = got.cut_regions.as_deref() else {
315        return false;
316    };
317    let evidence = got.evidence;
318    let derived = PlaneSectionEvidence::input_mesh(case.mesh.triangle_count())
319        .with_source_counts(
320            plain.evidence.in_plane_triangles,
321            plain.evidence.touching_edges,
322            plain.evidence.in_plane_pairs,
323        )
324        .with_in_plane_crossings(plain.evidence.in_plane_crossings)
325        .with_role(case.role, case.boundary_edges)
326        .with_output(&got.chains, &got.regions)
327        .with_cut_pairs(evidence.cut_pairs)
328        .with_cut_output(cut);
329    got.frame == plain.frame
330        && got.chains == plain.chains
331        && got.regions == plain.regions
332        && cut_regions_match(expected, cut)
333        && evidence == derived
334        && cut_rings_cover_the_chains(got, cut)
335        && both_regions_are_cut_regions(got, cut)
336}
337
338/// Every `Both` region is a cut region: equal outer ring and holes.
339fn both_regions_are_cut_regions(section: &PlaneSection, cut: &[CutRegion]) -> bool {
340    section
341        .regions
342        .iter()
343        .filter(|region| region.side == InPlaneSide::Both)
344        .all(|region| {
345            cut.iter()
346                .any(|cut| cut.outer == region.outer && cut.holes == region.holes)
347        })
348}
349
350fn cut_regions_match(expected: &[CutRegion], got: &[CutRegion]) -> bool {
351    expected.len() == got.len()
352        && expected.iter().zip(got).all(|(e, g)| {
353            points_match(&e.outer, &g.outer)
354                && e.holes.len() == g.holes.len()
355                && e.holes
356                    .iter()
357                    .zip(&g.holes)
358                    .all(|(eh, gh)| points_match(eh, gh))
359        })
360}
361
362/// The bit-for-bit relations between chains, regions and cut rings: every
363/// closed chain is a ring; every open chain with both ends on a region is a
364/// contiguous run of exactly one ring, forward or reversed; every ring
365/// vertex is a chain point or a region vertex.
366fn cut_rings_cover_the_chains(section: &PlaneSection, cut: &[CutRegion]) -> bool {
367    let rings: Vec<&Vec<Point2>> = cut
368        .iter()
369        .flat_map(|region| std::iter::once(&region.outer).chain(&region.holes))
370        .collect();
371    let closed_chains_are_rings = section
372        .chains
373        .iter()
374        .filter(|chain| chain.is_closed())
375        .all(|chain| rings.iter().any(|ring| **ring == chain.points));
376    let open_chains_are_runs = section
377        .chains
378        .iter()
379        .filter(|chain| chain.ends == Some([ChainEnd::InPlaneRegion; 2]))
380        .all(|chain| {
381            let mut backwards = chain.points.clone();
382            backwards.reverse();
383            rings
384                .iter()
385                .filter(|ring| is_run(ring, &chain.points) || is_run(ring, &backwards))
386                .count()
387                == 1
388        });
389    let known = |point: &Point2| {
390        section
391            .chains
392            .iter()
393            .any(|chain| chain.points.contains(point))
394            || section.regions.iter().any(|region| {
395                std::iter::once(&region.outer)
396                    .chain(&region.holes)
397                    .any(|ring| ring.contains(point))
398            })
399    };
400    closed_chains_are_rings
401        && open_chains_are_runs
402        && rings.iter().all(|ring| ring.iter().all(known))
403}
404
405/// Whether `run` is a contiguous cyclic run of `ring`, in ring order.
406fn is_run(ring: &[Point2], run: &[Point2]) -> bool {
407    run.len() <= ring.len()
408        && (0..ring.len()).any(|start| {
409            run.iter()
410                .enumerate()
411                .all(|(offset, point)| ring[(start + offset) % ring.len()] == *point)
412        })
413}
414
415fn is_refusal(error: &GeomError, family: &str) -> bool {
416    matches!(
417        error,
418        GeomError::UnsupportedInput { operation: Operation::MeshPlaneSection, input, .. }
419            if *input == family
420    )
421}
422
423fn check_output(
424    case: &Case,
425    expected: &Output,
426    got: &PlaneSection,
427    report: &mut RegionConformanceReport,
428) {
429    let name = case.name;
430    if got.frame != case.frame
431        || !chains_match(&expected.chains, &got.chains)
432        || !open_ends_are_region_vertices(got)
433    {
434        report
435            .failures
436            .push(RegionConformanceFailure::WrongChains { case: name });
437    }
438    if !regions_match(&expected.regions, &got.regions) {
439        report
440            .failures
441            .push(RegionConformanceFailure::WrongRegions { case: name });
442    }
443    let evidence = got.evidence;
444    let derived = PlaneSectionEvidence::input_mesh(case.mesh.triangle_count())
445        .with_source_counts(
446            expected.in_plane_triangles,
447            expected.touching_edges,
448            evidence.in_plane_pairs,
449        )
450        .with_in_plane_crossings(expected.in_plane_crossings)
451        .with_role(case.role, case.boundary_edges)
452        .with_output(&got.chains, &got.regions);
453    if evidence != derived || !evidence.is_derived_from_input_mesh() {
454        report
455            .failures
456            .push(RegionConformanceFailure::WrongEvidence { case: name });
457    }
458}
459
460/// Every end of an open chain declared to end on a region equals a region
461/// vertex bit for bit: chains and regions are projected by one function.
462fn open_ends_are_region_vertices(section: &PlaneSection) -> bool {
463    let on_region = |point: &Point2| {
464        section.regions.iter().any(|region| {
465            std::iter::once(&region.outer)
466                .chain(&region.holes)
467                .any(|ring| ring.iter().any(|vertex| vertex == point))
468        })
469    };
470    section
471        .chains
472        .iter()
473        .filter_map(|chain| Some((chain, chain.ends?)))
474        .all(|(chain, ends)| {
475            let on_region_if_declared = |end: ChainEnd, point: Option<&Point2>| {
476                end != ChainEnd::InPlaneRegion || point.is_some_and(on_region)
477            };
478            on_region_if_declared(ends[0], chain.points.first())
479                && on_region_if_declared(ends[1], chain.points.last())
480        })
481}
482
483fn chains_match(expected: &[SectionChain], got: &[SectionChain]) -> bool {
484    expected.len() == got.len()
485        && expected
486            .iter()
487            .zip(got)
488            .all(|(e, g)| e.ends == g.ends && points_match(&e.points, &g.points))
489}
490
491fn regions_match(expected: &[InPlaneRegion], got: &[InPlaneRegion]) -> bool {
492    expected.len() == got.len()
493        && expected.iter().zip(got).all(|(e, g)| {
494            e.side == g.side
495                && points_match(&e.outer, &g.outer)
496                && e.holes.len() == g.holes.len()
497                && e.holes
498                    .iter()
499                    .zip(&g.holes)
500                    .all(|(eh, gh)| points_match(eh, gh))
501        })
502}
503
504fn points_match(expected: &[Point2], got: &[Point2]) -> bool {
505    expected.len() == got.len()
506        && expected.iter().zip(got).all(|(e, g)| {
507            (e.x - g.x).abs() <= POINT_TOLERANCE && (e.y - g.y).abs() <= POINT_TOLERANCE
508        })
509}