Skip to main content

axiolid_mesh_hidden_lines_contract/conformance/
faces.rs

1//! Shared conformance checks for portable visible-face providers.
2//!
3//! Like the hidden-line suite, it calls the provider directly, so a provider
4//! must refuse bad input itself. Each exact case states the regions it must
5//! return, bit for bit; the area cases state the area per source where the
6//! coordinates are not binary64; the snap cases are scenes that binary64
7//! rounding used to refuse and that must now be answered, with the snapping
8//! in the evidence. Every outcome is also checked for shape
9//! (ring orientation and start, sort order, triangle lists, caps, evidence).
10//! The scenes and the arithmetic behind them are in the (private) fixtures,
11//! checked by their own unit tests.
12
13use core::cmp::Ordering;
14use core::fmt;
15
16use axiolid_contracts::{DisplacementBound, ExecutionOptions};
17use axiolid_core::{Point2, Tolerance};
18
19use crate::face_fixtures::{
20    area_cases, cases, face_refusals, snap_cases, EvidenceExpect, FaceFixture, AREA_TOLERANCE,
21};
22use crate::fixtures::reversed;
23use crate::{
24    FaceSource, HiddenLineError, HiddenLineMesh, MeshId, MeshRole, MeshVisibleFaces, View,
25    VisibleFaceLimits, VisibleFaceOutcome, VisibleFaceRequest, VisibleRegion,
26};
27
28#[derive(Debug, Clone, PartialEq)]
29pub enum FaceConformanceFailure {
30    /// The provider refused a request it must answer, or refused a request
31    /// that it must refuse with a different error.
32    ReturnedError {
33        case: &'static str,
34        error: HiddenLineError,
35    },
36    /// The provider answered a request it must refuse.
37    MissingRefusal { case: &'static str },
38    /// The refusal names the wrong mesh, or none.
39    WrongRefusalMesh {
40        case: &'static str,
41        expected: Option<MeshId>,
42        actual: Option<MeshId>,
43    },
44    /// The regions differ from the expected ones.
45    WrongRegions {
46        case: &'static str,
47        expected: Vec<VisibleRegion>,
48        actual: Vec<VisibleRegion>,
49    },
50    /// The total area of the regions of one source is wrong.
51    WrongArea {
52        case: &'static str,
53        mesh: MeshId,
54        source: FaceSource,
55        expected: f64,
56        actual: f64,
57    },
58    /// The outcome has regions of sources other than the expected ones.
59    WrongRegionCount {
60        case: &'static str,
61        expected: usize,
62        actual: usize,
63    },
64    /// Evidence disagrees with the request, the outcome or the scene.
65    IncorrectEvidence { case: &'static str },
66    /// Two identical requests returned different outcomes.
67    NonDeterministic { case: &'static str },
68    /// Reversing every triangle changed the outcome.
69    OrientationDependent { case: &'static str },
70    /// A region breaks the shape the contract fixes.
71    InvalidShape { case: &'static str, detail: String },
72}
73
74#[derive(Debug, Clone, Default, PartialEq)]
75pub struct FaceConformanceReport {
76    pub failures: Vec<FaceConformanceFailure>,
77}
78
79impl FaceConformanceReport {
80    pub fn is_success(&self) -> bool {
81        self.failures.is_empty()
82    }
83}
84
85impl fmt::Display for FaceConformanceReport {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        if self.is_success() {
88            write!(f, "conformant")
89        } else {
90            write!(f, "{:?}", self.failures)
91        }
92    }
93}
94
95pub struct FaceConformanceSuite;
96
97/// What the generic checks need to know about the request.
98struct Scene<'a> {
99    name: &'static str,
100    meshes: &'a [FaceFixture],
101    view: View,
102}
103
104impl FaceConformanceSuite {
105    pub fn run(provider: &dyn MeshVisibleFaces) -> FaceConformanceReport {
106        let mut report = FaceConformanceReport::default();
107        let options = ExecutionOptions::new(Tolerance::new(1e-9, 1e-9).expect("valid tolerance"));
108
109        for case in cases() {
110            let scene = Scene {
111                name: case.name,
112                meshes: &case.meshes,
113                view: case.view,
114            };
115            let first = answer(
116                provider,
117                &case.meshes,
118                case.view,
119                case.region,
120                case.limits,
121                &options,
122            );
123            let second = answer(
124                provider,
125                &case.meshes,
126                case.view,
127                case.region,
128                case.limits,
129                &options,
130            );
131            match (first, second) {
132                (Ok(a), Ok(b)) => {
133                    check_shape(&mut report, &scene, &a);
134                    if a.regions != case.regions {
135                        report.failures.push(FaceConformanceFailure::WrongRegions {
136                            case: case.name,
137                            expected: case.regions.clone(),
138                            actual: a.regions.clone(),
139                        });
140                    }
141                    check_expected_evidence(&mut report, case.name, &case.evidence, &a);
142                    if a != b {
143                        report
144                            .failures
145                            .push(FaceConformanceFailure::NonDeterministic { case: case.name });
146                    }
147                    check_orientation(
148                        provider,
149                        &mut report,
150                        &scene,
151                        case.region,
152                        case.limits,
153                        &a,
154                        &options,
155                    );
156                }
157                (Err(error), _) | (_, Err(error)) => {
158                    report.failures.push(FaceConformanceFailure::ReturnedError {
159                        case: case.name,
160                        error,
161                    })
162                }
163            }
164        }
165
166        for case in area_cases() {
167            let scene = Scene {
168                name: case.name,
169                meshes: &case.meshes,
170                view: case.view,
171            };
172            let first = answer(
173                provider,
174                &case.meshes,
175                case.view,
176                None,
177                case.limits,
178                &options,
179            );
180            let second = answer(
181                provider,
182                &case.meshes,
183                case.view,
184                None,
185                case.limits,
186                &options,
187            );
188            match (first, second) {
189                (Ok(a), Ok(b)) => {
190                    check_shape(&mut report, &scene, &a);
191                    check_areas(&mut report, &case.areas, case.name, &a);
192                    if a != b {
193                        report
194                            .failures
195                            .push(FaceConformanceFailure::NonDeterministic { case: case.name });
196                    }
197                    check_orientation(
198                        provider,
199                        &mut report,
200                        &scene,
201                        None,
202                        case.limits,
203                        &a,
204                        &options,
205                    );
206                }
207                (Err(error), _) | (_, Err(error)) => {
208                    report.failures.push(FaceConformanceFailure::ReturnedError {
209                        case: case.name,
210                        error,
211                    })
212                }
213            }
214        }
215
216        for case in snap_cases() {
217            let scene = Scene {
218                name: case.name,
219                meshes: &case.meshes,
220                view: case.view,
221            };
222            let first = answer(
223                provider,
224                &case.meshes,
225                case.view,
226                None,
227                case.limits,
228                &options,
229            );
230            let second = answer(
231                provider,
232                &case.meshes,
233                case.view,
234                None,
235                case.limits,
236                &options,
237            );
238            match (first, second) {
239                (Ok(a), Ok(b)) => {
240                    check_shape(&mut report, &scene, &a);
241                    // The scene is one binary64 rounding used to refuse: the
242                    // answer must say what the snapping did.
243                    let evidence = &a.evidence;
244                    if evidence.rerouted_edges
245                        + evidence.collapsed_regions
246                        + evidence.collapsed_holes
247                        == 0
248                        || evidence.snap_displacement == DisplacementBound::ZERO
249                    {
250                        report
251                            .failures
252                            .push(FaceConformanceFailure::IncorrectEvidence { case: case.name });
253                    }
254                    if a != b {
255                        report
256                            .failures
257                            .push(FaceConformanceFailure::NonDeterministic { case: case.name });
258                    }
259                    check_orientation(
260                        provider,
261                        &mut report,
262                        &scene,
263                        None,
264                        case.limits,
265                        &a,
266                        &options,
267                    );
268                }
269                (Err(error), _) | (_, Err(error)) => {
270                    report.failures.push(FaceConformanceFailure::ReturnedError {
271                        case: case.name,
272                        error,
273                    })
274                }
275            }
276        }
277
278        for refusal in face_refusals() {
279            match answer(
280                provider,
281                &refusal.meshes,
282                refusal.view,
283                None,
284                refusal.limits,
285                &options,
286            ) {
287                Ok(_) => report
288                    .failures
289                    .push(FaceConformanceFailure::MissingRefusal { case: refusal.name }),
290                Err(error) if !(refusal.accepts)(&error.error) => {
291                    report.failures.push(FaceConformanceFailure::ReturnedError {
292                        case: refusal.name,
293                        error,
294                    })
295                }
296                Err(error) if error.mesh != refusal.mesh => {
297                    report
298                        .failures
299                        .push(FaceConformanceFailure::WrongRefusalMesh {
300                            case: refusal.name,
301                            expected: refusal.mesh,
302                            actual: error.mesh,
303                        })
304                }
305                Err(_) => {}
306            }
307        }
308
309        report
310    }
311}
312
313fn answer(
314    provider: &dyn MeshVisibleFaces,
315    fixtures: &[FaceFixture],
316    view: View,
317    region: Option<axiolid_core::Aabb2>,
318    limits: VisibleFaceLimits,
319    options: &ExecutionOptions,
320) -> Result<VisibleFaceOutcome, HiddenLineError> {
321    let meshes: Vec<HiddenLineMesh<'_>> = fixtures
322        .iter()
323        .map(|f| {
324            let mesh = HiddenLineMesh::new(f.id, f.role, &f.mesh);
325            if f.planar {
326                mesh.with_planar_groups()
327            } else if f.groups.is_empty() {
328                mesh
329            } else {
330                mesh.with_groups(&f.groups)
331            }
332        })
333        .collect();
334    let mut request = VisibleFaceRequest::new(&meshes, view, limits);
335    if let Some(region) = region {
336        request = request.with_region(region);
337    }
338    provider.visible_faces(&request, options)
339}
340
341/// Equal up to `exact_escalations`, which may differ between two otherwise
342/// identical requests.
343fn same_result(a: &VisibleFaceOutcome, b: &VisibleFaceOutcome) -> bool {
344    let mut evidence = b.evidence;
345    evidence.exact_escalations = a.evidence.exact_escalations;
346    a.view == b.view && a.regions == b.regions && a.evidence == evidence
347}
348
349fn check_orientation(
350    provider: &dyn MeshVisibleFaces,
351    report: &mut FaceConformanceReport,
352    scene: &Scene<'_>,
353    region: Option<axiolid_core::Aabb2>,
354    limits: VisibleFaceLimits,
355    expected: &VisibleFaceOutcome,
356    options: &ExecutionOptions,
357) {
358    let flipped: Vec<FaceFixture> = scene
359        .meshes
360        .iter()
361        .map(|f| FaceFixture {
362            id: f.id,
363            role: f.role,
364            mesh: reversed(&f.mesh),
365            groups: f.groups.clone(),
366            planar: f.planar,
367        })
368        .collect();
369    match answer(provider, &flipped, scene.view, region, limits, options) {
370        Ok(outcome) if same_result(&outcome, expected) => {}
371        Ok(_) => report
372            .failures
373            .push(FaceConformanceFailure::OrientationDependent { case: scene.name }),
374        Err(error) => report.failures.push(FaceConformanceFailure::ReturnedError {
375            case: scene.name,
376            error,
377        }),
378    }
379}
380
381fn signed_area(ring: &[Point2]) -> f64 {
382    let n = ring.len();
383    (0..n)
384        .map(|i| {
385            let (a, b) = (ring[i], ring[(i + 1) % n]);
386            a.x * b.y - b.x * a.y
387        })
388        .sum::<f64>()
389        / 2.0
390}
391
392/// Add `b` to the nonoverlapping expansion `expansion`, exactly (Shewchuk's
393/// grow-expansion over error-free two-sums).
394fn grow(expansion: &mut Vec<f64>, b: f64) {
395    let mut carry = b;
396    for component in expansion.iter_mut() {
397        let sum = carry + *component;
398        let virtual_b = sum - carry;
399        let virtual_a = sum - virtual_b;
400        *component = (carry - virtual_a) + (*component - virtual_b);
401        carry = sum;
402    }
403    expansion.push(carry);
404}
405
406/// The exact sign of the signed area of a ring. Snapped regions can be
407/// thinner than a rounding error of the shoelace sum, so the sum is exact.
408fn area_sign(ring: &[Point2]) -> Ordering {
409    let mut sum: Vec<f64> = Vec::new();
410    for (i, a) in ring.iter().enumerate() {
411        let b = ring[(i + 1) % ring.len()];
412        for (x, y, sign) in [(a.x, b.y, 1.0), (b.x, a.y, -1.0)] {
413            let product = x * y;
414            grow(&mut sum, sign * product);
415            grow(&mut sum, sign * x.mul_add(y, -product));
416        }
417    }
418    sum.iter()
419        .rev()
420        .find(|&&component| component != 0.0)
421        .map_or(Ordering::Equal, |component| component.total_cmp(&0.0))
422}
423
424fn point_order(a: &Point2, b: &Point2) -> Ordering {
425    a.x.total_cmp(&b.x).then(a.y.total_cmp(&b.y))
426}
427
428fn sequence_order(a: &[Point2], b: &[Point2]) -> Ordering {
429    a.iter()
430        .zip(b)
431        .map(|(p, q)| point_order(p, q))
432        .find(|o| o.is_ne())
433        .unwrap_or_else(|| a.len().cmp(&b.len()))
434}
435
436/// Whether the near plane cuts a solid: some vertex is beyond it (`W >
437/// near`) and some is not.
438fn cut_by_near(fixture: &FaceFixture, view: &View) -> bool {
439    let Some(near) = view.near else {
440        return false;
441    };
442    let depth = |p: &axiolid_core::Point3| (*p - view.frame.origin).dot(view.frame.z);
443    let beyond = fixture
444        .mesh
445        .positions
446        .iter()
447        .filter(|p| depth(p) > near)
448        .count();
449    fixture.role == MeshRole::Solid && beyond > 0 && beyond < fixture.mesh.positions.len()
450}
451
452fn check_ring(detail: &mut Option<String>, what: &str, ring: &[Point2], outer: bool) {
453    let mut fail = |text: String| {
454        detail.get_or_insert(text);
455    };
456    if ring.len() < 3 {
457        return fail(format!("{what} has fewer than three points"));
458    }
459    if ring.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
460        return fail(format!("{what} has a non-finite point"));
461    }
462    if ring.iter().any(|p| point_order(p, &ring[0]).is_lt()) {
463        fail(format!("{what} does not start at its smallest point"));
464    }
465    if ring.first() == ring.last() {
466        fail(format!("{what} repeats its first point"));
467    }
468    let area = area_sign(ring);
469    if outer && area != Ordering::Greater {
470        fail(format!("{what} is not counter-clockwise"));
471    }
472    if !outer && area != Ordering::Less {
473        fail(format!("{what} is not clockwise"));
474    }
475}
476
477/// The checks every outcome must pass, whatever the scene.
478fn check_shape(
479    report: &mut FaceConformanceReport,
480    scene: &Scene<'_>,
481    outcome: &VisibleFaceOutcome,
482) {
483    let mut invalid = |detail: String| {
484        report.failures.push(FaceConformanceFailure::InvalidShape {
485            case: scene.name,
486            detail,
487        });
488    };
489    let position = |id: MeshId| scene.meshes.iter().position(|m| m.id == id);
490    let mut previous: Option<(usize, FaceSource, &[Point2])> = None;
491    let mut caps = 0;
492    for region in &outcome.regions {
493        let Some(index) = position(region.mesh) else {
494            invalid(format!(
495                "region of mesh {} that is not in the request",
496                region.mesh.0
497            ));
498            continue;
499        };
500        let fixture = &scene.meshes[index];
501        let mut detail = None;
502        check_ring(&mut detail, "outer ring", &region.outer, true);
503        for hole in &region.holes {
504            check_ring(&mut detail, "hole", hole, false);
505        }
506        if region
507            .holes
508            .windows(2)
509            .any(|w| sequence_order(&w[0], &w[1]).is_ge())
510        {
511            detail.get_or_insert("holes are not strictly sorted".into());
512        }
513        if region.triangles.windows(2).any(|w| w[0] >= w[1]) {
514            detail.get_or_insert("triangles are not strictly ascending".into());
515        }
516        let count = fixture.mesh.triangle_count();
517        if region.triangles.iter().any(|&t| t as usize >= count) {
518            detail.get_or_insert("a triangle index is out of range".into());
519        }
520        match region.source {
521            FaceSource::Group(group) => {
522                if region
523                    .triangles
524                    .iter()
525                    .any(|&t| (t as usize) < count && fixture.group(t as usize) != group)
526                {
527                    detail.get_or_insert("a triangle is not in the region's group".into());
528                }
529            }
530            FaceSource::Cap => {
531                caps += 1;
532                if !cut_by_near(fixture, &scene.view) {
533                    detail.get_or_insert("cap of a mesh the near plane does not cut".into());
534                }
535                if !region.triangles.is_empty() {
536                    detail.get_or_insert("cap lists triangles".into());
537                }
538            }
539        }
540        if let Some((prev_mesh, prev_source, prev_outer)) = previous {
541            let order = prev_mesh
542                .cmp(&index)
543                .then(prev_source.cmp(&region.source))
544                .then_with(|| sequence_order(prev_outer, &region.outer));
545            if order.is_ge() {
546                detail.get_or_insert("regions are not strictly sorted".into());
547            }
548        }
549        previous = Some((index, region.source, &region.outer));
550        if let Some(detail) = detail {
551            invalid(format!("mesh {}: {detail}", region.mesh.0));
552        }
553    }
554
555    let evidence = &outcome.evidence;
556    let triangles: usize = scene.meshes.iter().map(|m| m.mesh.triangle_count()).sum();
557    if !evidence.is_derived_from_input_mesh()
558        || evidence.source_meshes != scene.meshes.len()
559        || evidence.source_triangles != triangles
560        || evidence.regions != outcome.regions.len()
561        || evidence.cap_regions != caps
562    {
563        report
564            .failures
565            .push(FaceConformanceFailure::IncorrectEvidence { case: scene.name });
566    }
567}
568
569fn check_expected_evidence(
570    report: &mut FaceConformanceReport,
571    case: &'static str,
572    expect: &EvidenceExpect,
573    outcome: &VisibleFaceOutcome,
574) {
575    let evidence = &outcome.evidence;
576    // The exact cases have binary64 expectations: nothing is snapped.
577    let wrong = evidence.snap_displacement != DisplacementBound::ZERO
578        || evidence.rerouted_edges != 0
579        || evidence.collapsed_regions != 0
580        || evidence.collapsed_holes != 0
581        || (expect.intersections && evidence.intersection_segments == 0)
582        || (expect.ties && evidence.tied_sides == 0)
583        || expect
584            .edge_on
585            .is_some_and(|n| evidence.edge_on_triangles != n);
586    if wrong {
587        report
588            .failures
589            .push(FaceConformanceFailure::IncorrectEvidence { case });
590    }
591}
592
593fn check_areas(
594    report: &mut FaceConformanceReport,
595    expected: &[(MeshId, FaceSource, f64)],
596    case: &'static str,
597    outcome: &VisibleFaceOutcome,
598) {
599    let known = |r: &VisibleRegion| {
600        expected
601            .iter()
602            .any(|&(m, s, _)| m == r.mesh && s == r.source)
603    };
604    let unexpected = outcome.regions.iter().filter(|r| !known(r)).count();
605    if unexpected > 0 {
606        report
607            .failures
608            .push(FaceConformanceFailure::WrongRegionCount {
609                case,
610                expected: expected.len(),
611                actual: unexpected + expected.len(),
612            });
613    }
614    for &(mesh, source, area) in expected {
615        let actual: f64 = outcome
616            .regions
617            .iter()
618            .filter(|r| r.mesh == mesh && r.source == source)
619            .map(|r| signed_area(&r.outer) + r.holes.iter().map(|h| signed_area(h)).sum::<f64>())
620            .sum();
621        // Written so that a NaN area fails.
622        let close = (actual - area).abs() <= AREA_TOLERANCE * area;
623        if !close {
624            report.failures.push(FaceConformanceFailure::WrongArea {
625                case,
626                mesh,
627                source,
628                expected: area,
629                actual,
630            });
631        }
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use axiolid_contracts::{Backend, BackendDescriptor, BackendId, ExecutionTarget, GeomError};
638
639    use super::*;
640    use crate::VisibleFaceEvidence;
641
642    const REFUSAL_CASES: [&str; 8] = [
643        "three triangles on one edge",
644        "inconsistent winding",
645        "sheet declared solid",
646        "group boundaries with a short group slice",
647        "group count mismatch",
648        "region limit",
649        "region vertex limit",
650        "candidate limit",
651    ];
652
653    /// Refuses every request: the offending mesh (the second of two) for a
654    /// per-mesh refusal, or the whole request when `blame_offender` is off,
655    /// and the budget the limits name.
656    #[derive(Debug)]
657    struct Refuser {
658        blame_offender: bool,
659    }
660
661    impl Backend for Refuser {
662        fn descriptor(&self) -> BackendDescriptor {
663            BackendDescriptor::new(
664                BackendId::new("face-conformance-refuser"),
665                ExecutionTarget::PortableCpu,
666            )
667        }
668    }
669
670    impl MeshVisibleFaces for Refuser {
671        fn visible_faces(
672            &self,
673            request: &VisibleFaceRequest<'_>,
674            _options: &ExecutionOptions,
675        ) -> Result<VisibleFaceOutcome, HiddenLineError> {
676            if request.meshes.len() == 2 && request.meshes[1].id == MeshId(2) {
677                let mut error = GeomError::NotManifold("refused".into());
678                let groups = request.meshes[1].groups;
679                if matches!(groups, crate::FaceGroups::PerTriangle(g)
680                    if g.len() != request.meshes[1].mesh.triangle_count())
681                {
682                    error = GeomError::InvalidInput("refused".into());
683                }
684                if request.meshes[1].mesh.triangle_count() == 12
685                    && matches!(groups, crate::FaceGroups::Single)
686                {
687                    // The two-box scenes carry budgets; refuse by limits below.
688                    return self.budget(request);
689                }
690                return Err(if self.blame_offender {
691                    HiddenLineError::mesh(request.meshes[1].id, error)
692                } else {
693                    HiddenLineError::request(error)
694                });
695            }
696            Err(GeomError::Cancelled.into())
697        }
698    }
699
700    impl Refuser {
701        fn budget(
702            &self,
703            request: &VisibleFaceRequest<'_>,
704        ) -> Result<VisibleFaceOutcome, HiddenLineError> {
705            let resource = if request.limits.max_regions == 1 {
706                "visible-face regions"
707            } else if request.limits.max_region_vertices == 5 {
708                "visible-face region vertices"
709            } else if request.limits.max_candidate_pairs == 1 {
710                "visible-face candidate pairs"
711            } else {
712                return Err(GeomError::Cancelled.into());
713            };
714            Err(GeomError::BudgetExceeded { resource }.into())
715        }
716    }
717
718    fn refusal_failures(report: &FaceConformanceReport) -> Vec<&FaceConformanceFailure> {
719        report
720            .failures
721            .iter()
722            .filter(|failure| match failure {
723                FaceConformanceFailure::ReturnedError { case, .. }
724                | FaceConformanceFailure::MissingRefusal { case }
725                | FaceConformanceFailure::WrongRefusalMesh { case, .. } => {
726                    REFUSAL_CASES.contains(case)
727                }
728                _ => false,
729            })
730            .collect()
731    }
732
733    #[test]
734    fn a_provider_that_names_the_offending_mesh_passes_the_refusal_cases() {
735        let report = FaceConformanceSuite::run(&Refuser {
736            blame_offender: true,
737        });
738        assert!(refusal_failures(&report).is_empty(), "{report}");
739        // It answers nothing, so every region case fails.
740        assert!(!report.is_success());
741    }
742
743    #[test]
744    fn a_refusal_that_loses_the_mesh_id_fails_exactly_the_mesh_cases() {
745        let report = FaceConformanceSuite::run(&Refuser {
746            blame_offender: false,
747        });
748        let wrong = refusal_failures(&report);
749        assert_eq!(wrong.len(), 5, "{wrong:?}");
750        for failure in wrong {
751            assert!(matches!(
752                failure,
753                FaceConformanceFailure::WrongRefusalMesh { actual: None, .. }
754            ));
755        }
756    }
757
758    /// Answers every request with no regions and honest evidence.
759    #[derive(Debug)]
760    struct Empty;
761
762    impl Backend for Empty {
763        fn descriptor(&self) -> BackendDescriptor {
764            BackendDescriptor::new(
765                BackendId::new("face-conformance-empty"),
766                ExecutionTarget::PortableCpu,
767            )
768        }
769    }
770
771    impl MeshVisibleFaces for Empty {
772        fn visible_faces(
773            &self,
774            request: &VisibleFaceRequest<'_>,
775            _options: &ExecutionOptions,
776        ) -> Result<VisibleFaceOutcome, HiddenLineError> {
777            let triangles = request.meshes.iter().map(|m| m.mesh.triangle_count()).sum();
778            Ok(VisibleFaceOutcome::new(
779                request.view,
780                Vec::new(),
781                VisibleFaceEvidence::input_mesh(request.meshes.len(), triangles),
782            ))
783        }
784    }
785
786    #[test]
787    fn an_empty_answer_and_missing_refusals_are_reported() {
788        let report = FaceConformanceSuite::run(&Empty);
789        let cases = cases().len();
790        let missing = report
791            .failures
792            .iter()
793            .filter(|f| matches!(f, FaceConformanceFailure::MissingRefusal { .. }))
794            .count();
795        let regions = report
796            .failures
797            .iter()
798            .filter(|f| matches!(f, FaceConformanceFailure::WrongRegions { .. }))
799            .count();
800        let areas = report
801            .failures
802            .iter()
803            .filter(|f| matches!(f, FaceConformanceFailure::WrongArea { .. }))
804            .count();
805        assert_eq!(missing, REFUSAL_CASES.len(), "{report}");
806        assert_eq!(regions, cases, "{report}");
807        assert_eq!(areas, 6, "{report}");
808    }
809
810    /// Returns the expected regions of one exact case, whatever it is asked,
811    /// with evidence that is deliberately wrong.
812    #[derive(Debug)]
813    struct Fixed(Vec<VisibleRegion>);
814
815    impl Backend for Fixed {
816        fn descriptor(&self) -> BackendDescriptor {
817            BackendDescriptor::new(
818                BackendId::new("face-conformance-fixed"),
819                ExecutionTarget::PortableCpu,
820            )
821        }
822    }
823
824    impl MeshVisibleFaces for Fixed {
825        fn visible_faces(
826            &self,
827            request: &VisibleFaceRequest<'_>,
828            _options: &ExecutionOptions,
829        ) -> Result<VisibleFaceOutcome, HiddenLineError> {
830            Ok(VisibleFaceOutcome::new(
831                request.view,
832                self.0.clone(),
833                VisibleFaceEvidence::input_mesh(0, 0),
834            ))
835        }
836    }
837
838    #[test]
839    fn evidence_that_disagrees_with_the_request_is_reported() {
840        let first = cases().remove(0);
841        let report = FaceConformanceSuite::run(&Fixed(first.regions.clone()));
842        let hit = report.failures.iter().any(|f| {
843            matches!(f, FaceConformanceFailure::IncorrectEvidence { case } if *case == first.name)
844        });
845        assert!(hit, "{report}");
846        // The regions themselves are right for that case.
847        assert!(!report.failures.iter().any(|f| {
848            matches!(f, FaceConformanceFailure::WrongRegions { case, .. } if *case == first.name)
849        }));
850    }
851
852    #[test]
853    fn the_expected_regions_pass_the_shape_checks() {
854        for case in cases() {
855            let triangles: usize = case.meshes.iter().map(|m| m.mesh.triangle_count()).sum();
856            let caps = case
857                .regions
858                .iter()
859                .filter(|r| r.source == FaceSource::Cap)
860                .count();
861            let mut evidence = crate::VisibleFaceEvidence::input_mesh(case.meshes.len(), triangles);
862            evidence.regions = case.regions.len();
863            evidence.cap_regions = caps;
864            let outcome = VisibleFaceOutcome::new(case.view, case.regions.clone(), evidence);
865            let scene = Scene {
866                name: case.name,
867                meshes: &case.meshes,
868                view: case.view,
869            };
870            let mut report = FaceConformanceReport::default();
871            check_shape(&mut report, &scene, &outcome);
872            assert!(report.is_success(), "{}: {report}", case.name);
873        }
874    }
875
876    #[test]
877    fn the_area_sign_is_exact_for_slivers() {
878        let at = |x: f64, y: f64| Point2::new(x, y);
879        // A one-ulp apex above, on and below the diagonal of a square.
880        let apex = |y: f64| [at(1.0, 1.0), at(2.0, 2.0), at(1.5, y)];
881        assert_eq!(area_sign(&apex(1.5f64.next_up())), Ordering::Greater);
882        assert_eq!(area_sign(&apex(1.5)), Ordering::Equal);
883        assert_eq!(area_sign(&apex(1.5f64.next_down())), Ordering::Less);
884        let mut reversed = apex(1.5f64.next_down());
885        reversed.reverse();
886        assert_eq!(area_sign(&reversed), Ordering::Greater);
887    }
888
889    #[test]
890    fn shape_checks_catch_each_kind_of_bad_region() {
891        let case = cases().remove(0);
892        let scene = Scene {
893            name: case.name,
894            meshes: &case.meshes,
895            view: case.view,
896        };
897        let triangles = 24;
898        let mut evidence = crate::VisibleFaceEvidence::input_mesh(2, triangles);
899        evidence.regions = 2;
900        let run = |regions: Vec<VisibleRegion>| {
901            let mut e = evidence;
902            e.regions = regions.len();
903            let mut report = FaceConformanceReport::default();
904            check_shape(
905                &mut report,
906                &scene,
907                &VisibleFaceOutcome::new(case.view, regions, e),
908            );
909            report
910        };
911        let good = case.regions.clone();
912        assert!(run(good.clone()).is_success());
913
914        let mutate = |f: &dyn Fn(&mut Vec<VisibleRegion>)| {
915            let mut regions = good.clone();
916            f(&mut regions);
917            run(regions)
918        };
919        let shaped = |r: &FaceConformanceReport| {
920            r.failures
921                .iter()
922                .any(|f| matches!(f, FaceConformanceFailure::InvalidShape { .. }))
923        };
924        // Clockwise outer ring.
925        assert!(shaped(&mutate(&|r| r[0].outer.reverse())));
926        // Ring that does not start at its smallest point.
927        assert!(shaped(&mutate(&|r| r[0].outer.rotate_left(1))));
928        // Regions out of order.
929        assert!(shaped(&mutate(&|r| r.reverse())));
930        // Unsorted or out-of-range triangles.
931        assert!(shaped(&mutate(&|r| r[0].triangles = vec![3, 2])));
932        assert!(shaped(&mutate(&|r| r[0].triangles = vec![2, 99])));
933        // A triangle of another group; a cap of an uncut solid.
934        assert!(shaped(&mutate(&|r| r[0].source = FaceSource::Cap)));
935        // A hole that is counter-clockwise.
936        assert!(shaped(&mutate(&|r| {
937            r[0].holes = vec![vec![
938                Point2::new(0.5, 0.5),
939                Point2::new(1.0, 0.5),
940                Point2::new(1.0, 1.0),
941            ]]
942        })));
943        // A mesh that is not in the request.
944        assert!(shaped(&mutate(&|r| r[0].mesh = MeshId(99))));
945    }
946}