Skip to main content

axiolid_mesh_hidden_lines_contract/
conformance.rs

1//! Shared conformance checks for portable hidden-line providers.
2//!
3//! The suite calls the provider directly, so a provider must refuse bad
4//! input itself and not rely on a registry to do it. Each drawing case
5//! states its expected counts and lengths per edge kind and visibility, and
6//! the exact pieces of chosen edges; the scenes and the arithmetic behind
7//! them are in the (private) fixtures, checked by their own unit tests.
8
9use core::fmt;
10
11pub mod faces;
12pub mod perspective;
13
14use axiolid_contracts::ExecutionOptions;
15use axiolid_core::{Point2, Tolerance};
16
17use crate::fixtures::{cases, refusals, reversed, Case, Fixture, Piece, LENGTH_TOLERANCE};
18use crate::{
19    EdgeKind, HiddenLineError, HiddenLineMesh, HiddenLineOutcome, HiddenLineRequest,
20    MeshHiddenLines, MeshId, SourceEdge, Visibility,
21};
22
23/// One piece of an edge, as expected or as returned.
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct EdgePiece {
26    pub kind: EdgeKind,
27    pub visibility: Visibility,
28    pub start: Point2,
29    pub end: Point2,
30}
31
32#[derive(Debug, Clone, PartialEq)]
33pub enum ConformanceFailure {
34    /// The provider refused a request it must draw, or refused a request
35    /// that it must refuse with a different error.
36    ReturnedError {
37        case: &'static str,
38        error: HiddenLineError,
39    },
40    /// The provider drew a request it must refuse.
41    MissingRefusal { case: &'static str },
42    /// The refusal names the wrong mesh, or none.
43    WrongRefusalMesh {
44        case: &'static str,
45        expected: Option<MeshId>,
46        actual: Option<MeshId>,
47    },
48    /// The total length of the segments of one kind and visibility is wrong.
49    WrongLength {
50        case: &'static str,
51        kind: EdgeKind,
52        visibility: Visibility,
53        expected: f64,
54        actual: f64,
55    },
56    /// The number of segments of one kind and visibility is wrong.
57    WrongSegmentCount {
58        case: &'static str,
59        kind: EdgeKind,
60        visibility: Visibility,
61        expected: usize,
62        actual: usize,
63    },
64    /// The pieces of one source edge differ from the expected ones.
65    WrongEdge {
66        case: &'static str,
67        edge: SourceEdge,
68        expected: Vec<EdgePiece>,
69        actual: Vec<EdgePiece>,
70    },
71    /// Evidence disagrees with the request, the outcome or the scene.
72    IncorrectEvidence { case: &'static str },
73    /// Two identical requests returned different outcomes.
74    NonDeterministic { case: &'static str },
75    /// Reversing every triangle changed the outcome.
76    OrientationDependent { case: &'static str },
77}
78
79#[derive(Debug, Clone, Default, PartialEq)]
80pub struct ConformanceReport {
81    pub failures: Vec<ConformanceFailure>,
82}
83
84impl ConformanceReport {
85    pub fn is_success(&self) -> bool {
86        self.failures.is_empty()
87    }
88}
89
90impl fmt::Display for ConformanceReport {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        if self.is_success() {
93            write!(f, "conformant")
94        } else {
95            write!(f, "{:?}", self.failures)
96        }
97    }
98}
99
100pub struct ConformanceSuite;
101
102impl ConformanceSuite {
103    pub fn run(provider: &dyn MeshHiddenLines) -> ConformanceReport {
104        let mut report = ConformanceReport::default();
105        let options = ExecutionOptions::new(Tolerance::new(1e-9, 1e-9).expect("valid tolerance"));
106
107        for case in cases() {
108            let first = draw(provider, &case, &case.meshes, &options);
109            let second = draw(provider, &case, &case.meshes, &options);
110            match (first, second) {
111                (Ok(a), Ok(b)) => {
112                    check_outcome(&mut report, &case, &a);
113                    if a != b {
114                        report
115                            .failures
116                            .push(ConformanceFailure::NonDeterministic { case: case.name });
117                    }
118                    if case.orientation_independent {
119                        check_orientation(provider, &mut report, &case, &a, &options);
120                    }
121                }
122                (Err(error), _) | (_, Err(error)) => {
123                    report.failures.push(ConformanceFailure::ReturnedError {
124                        case: case.name,
125                        error,
126                    })
127                }
128            }
129        }
130
131        for refusal in refusals() {
132            let meshes: Vec<HiddenLineMesh<'_>> = refusal.meshes.iter().map(mesh_of).collect();
133            let request =
134                HiddenLineRequest::new(&meshes, refusal.view, refusal.edges, refusal.limits);
135            match provider.hidden_lines(&request, &options) {
136                Ok(_) => report
137                    .failures
138                    .push(ConformanceFailure::MissingRefusal { case: refusal.name }),
139                Err(error) if !(refusal.accepts)(&error.error) => {
140                    report.failures.push(ConformanceFailure::ReturnedError {
141                        case: refusal.name,
142                        error,
143                    })
144                }
145                Err(error) if error.mesh != refusal.mesh => {
146                    report.failures.push(ConformanceFailure::WrongRefusalMesh {
147                        case: refusal.name,
148                        expected: refusal.mesh,
149                        actual: error.mesh,
150                    })
151                }
152                Err(_) => {}
153            }
154        }
155
156        report
157    }
158}
159
160/// The request mesh of a fixture, with its groups.
161fn mesh_of(f: &Fixture) -> HiddenLineMesh<'_> {
162    let mesh = HiddenLineMesh::new(f.id, f.role, &f.mesh);
163    if f.planar {
164        mesh.with_planar_groups()
165    } else if f.groups.is_empty() {
166        mesh
167    } else {
168        mesh.with_groups(&f.groups)
169    }
170}
171
172fn draw(
173    provider: &dyn MeshHiddenLines,
174    case: &Case,
175    fixtures: &[Fixture],
176    options: &ExecutionOptions,
177) -> Result<HiddenLineOutcome, HiddenLineError> {
178    let meshes: Vec<HiddenLineMesh<'_>> = fixtures.iter().map(mesh_of).collect();
179    let mut request = HiddenLineRequest::new(&meshes, case.view, case.edges, case.limits);
180    if let Some(region) = case.region {
181        request = request.with_region(region);
182    }
183    provider.hidden_lines(&request, options)
184}
185
186fn check_orientation(
187    provider: &dyn MeshHiddenLines,
188    report: &mut ConformanceReport,
189    case: &Case,
190    expected: &HiddenLineOutcome,
191    options: &ExecutionOptions,
192) {
193    let flipped: Vec<Fixture> = case
194        .meshes
195        .iter()
196        .map(|f| Fixture {
197            id: f.id,
198            role: f.role,
199            mesh: reversed(&f.mesh),
200            groups: f.groups.clone(),
201            planar: f.planar,
202        })
203        .collect();
204    match draw(provider, case, &flipped, options) {
205        Ok(outcome) if same_drawing(&outcome, expected) => {}
206        Ok(_) => report
207            .failures
208            .push(ConformanceFailure::OrientationDependent { case: case.name }),
209        Err(error) => report.failures.push(ConformanceFailure::ReturnedError {
210            case: case.name,
211            error,
212        }),
213    }
214}
215
216/// Equal segments and evidence, except exact_escalations, which depends on
217/// the order in which the vertices are met.
218fn same_drawing(a: &HiddenLineOutcome, b: &HiddenLineOutcome) -> bool {
219    let mut evidence = a.evidence;
220    evidence.exact_escalations = b.evidence.exact_escalations;
221    a.view == b.view && a.segments == b.segments && evidence == b.evidence
222}
223
224fn length(start: Point2, end: Point2) -> f64 {
225    (end - start).length()
226}
227
228fn check_outcome(report: &mut ConformanceReport, case: &Case, outcome: &HiddenLineOutcome) {
229    let name = case.name;
230    let count = |visibility: Visibility| {
231        outcome
232            .segments
233            .iter()
234            .filter(|s| s.visibility == visibility)
235            .count()
236    };
237    let evidence = &outcome.evidence;
238    let expect = &case.expect;
239    let triangles: usize = case.meshes.iter().map(|f| f.mesh.triangle_count()).sum();
240    let evidence_ok = outcome.view == case.view
241        && evidence.is_derived_from_input_mesh()
242        && evidence.source_meshes == case.meshes.len()
243        && evidence.source_triangles == triangles
244        && evidence.visible_segments == count(Visibility::Visible)
245        && evidence.hidden_segments == count(Visibility::Hidden)
246        && (case.edges.hidden || evidence.hidden_segments == 0)
247        && expect
248            .selected_edges
249            .is_none_or(|n| evidence.selected_edges == n)
250        && expect.point_edges.is_none_or(|n| evidence.point_edges == n)
251        && expect
252            .clipped_edges
253            .is_none_or(|n| evidence.clipped_edges == n);
254    if !evidence_ok {
255        report
256            .failures
257            .push(ConformanceFailure::IncorrectEvidence { case: name });
258    }
259
260    if let Some(rows) = &expect.rows {
261        for kind in [
262            EdgeKind::Boundary,
263            EdgeKind::Silhouette,
264            EdgeKind::Crease,
265            EdgeKind::GroupBoundary,
266        ] {
267            for visibility in [Visibility::Visible, Visibility::Hidden] {
268                let (want_count, want_length) = rows
269                    .iter()
270                    .find(|r| r.kind == kind && r.visibility == visibility)
271                    .map_or((0, 0.0), |r| (r.count, r.length));
272                let picked = outcome
273                    .segments
274                    .iter()
275                    .filter(|s| s.kind == kind && s.visibility == visibility);
276                let (got_count, got_length) = picked.fold((0, 0.0), |(n, sum), s| {
277                    (n + 1, sum + length(s.start, s.end))
278                });
279                if got_count != want_count {
280                    report.failures.push(ConformanceFailure::WrongSegmentCount {
281                        case: name,
282                        kind,
283                        visibility,
284                        expected: want_count,
285                        actual: got_count,
286                    });
287                }
288                if (got_length - want_length).abs() > LENGTH_TOLERANCE {
289                    report.failures.push(ConformanceFailure::WrongLength {
290                        case: name,
291                        kind,
292                        visibility,
293                        expected: want_length,
294                        actual: got_length,
295                    });
296                }
297            }
298        }
299    }
300
301    for wanted in &expect.edges {
302        let expected: Vec<EdgePiece> = wanted
303            .pieces
304            .iter()
305            .map(|p| to_piece(wanted.kind, p))
306            .collect();
307        let actual: Vec<EdgePiece> = outcome
308            .segments
309            .iter()
310            .filter(|s| s.source == wanted.edge)
311            .map(|s| EdgePiece {
312                kind: s.kind,
313                visibility: s.visibility,
314                start: s.start,
315                end: s.end,
316            })
317            .collect();
318        let same = expected.len() == actual.len()
319            && expected.iter().zip(&actual).all(|(e, a)| {
320                e.kind == a.kind
321                    && e.visibility == a.visibility
322                    && length(e.start, a.start) <= LENGTH_TOLERANCE
323                    && length(e.end, a.end) <= LENGTH_TOLERANCE
324            });
325        if !same {
326            report.failures.push(ConformanceFailure::WrongEdge {
327                case: name,
328                edge: wanted.edge,
329                expected,
330                actual,
331            });
332        }
333    }
334}
335
336fn to_piece(kind: EdgeKind, piece: &Piece) -> EdgePiece {
337    EdgePiece {
338        kind,
339        visibility: piece.visibility,
340        start: Point2::new(piece.start[0], piece.start[1]),
341        end: Point2::new(piece.end[0], piece.end[1]),
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use axiolid_contracts::{Backend, BackendDescriptor, BackendId, ExecutionTarget, GeomError};
348
349    use super::*;
350
351    /// Refuses everything: the offending mesh (the second of two) for the
352    /// per-mesh refusals, or the whole request when `blame_offender` is off.
353    #[derive(Debug)]
354    struct Refuser {
355        blame_offender: bool,
356    }
357
358    impl Backend for Refuser {
359        fn descriptor(&self) -> BackendDescriptor {
360            BackendDescriptor::new(
361                BackendId::new("conformance-refuser"),
362                ExecutionTarget::PortableCpu,
363            )
364        }
365    }
366
367    impl MeshHiddenLines for Refuser {
368        fn hidden_lines(
369            &self,
370            request: &HiddenLineRequest<'_>,
371            _options: &ExecutionOptions,
372        ) -> Result<HiddenLineOutcome, HiddenLineError> {
373            if request.meshes.len() == 2 {
374                let error = if request.edges.group_boundary {
375                    GeomError::InvalidInput("refused".into())
376                } else {
377                    GeomError::NotManifold("refused".into())
378                };
379                return Err(if self.blame_offender {
380                    HiddenLineError::mesh(request.meshes[1].id, error)
381                } else {
382                    HiddenLineError::request(error)
383                });
384            }
385            if request.limits.max_output_segments == 1 {
386                return Err(GeomError::BudgetExceeded {
387                    resource: "hidden-line output segments",
388                }
389                .into());
390            }
391            if request.limits.max_candidate_pairs == 0 {
392                return Err(GeomError::BudgetExceeded {
393                    resource: "hidden-line candidate pairs",
394                }
395                .into());
396            }
397            Err(GeomError::Cancelled.into())
398        }
399    }
400
401    const REFUSAL_CASES: [&str; 6] = [
402        "three triangles on one edge",
403        "inconsistent winding",
404        "sheet declared solid",
405        "group boundaries with a short group slice",
406        "output limit",
407        "candidate limit",
408    ];
409
410    fn refusal_failures(report: &ConformanceReport) -> Vec<&ConformanceFailure> {
411        report
412            .failures
413            .iter()
414            .filter(|failure| match failure {
415                ConformanceFailure::ReturnedError { case, .. }
416                | ConformanceFailure::MissingRefusal { case }
417                | ConformanceFailure::WrongRefusalMesh { case, .. } => REFUSAL_CASES.contains(case),
418                _ => false,
419            })
420            .collect()
421    }
422
423    #[test]
424    fn a_provider_that_names_the_offending_mesh_passes_the_refusal_cases() {
425        let report = ConformanceSuite::run(&Refuser {
426            blame_offender: true,
427        });
428        assert!(refusal_failures(&report).is_empty(), "{report}");
429        // It draws nothing, so every drawing case fails.
430        assert!(!report.is_success());
431    }
432
433    #[test]
434    fn a_refusal_that_loses_the_mesh_id_fails_exactly_the_mesh_cases() {
435        let report = ConformanceSuite::run(&Refuser {
436            blame_offender: false,
437        });
438        let wrong = refusal_failures(&report);
439        assert_eq!(wrong.len(), 4, "{wrong:?}");
440        for failure in wrong {
441            assert!(matches!(
442                failure,
443                ConformanceFailure::WrongRefusalMesh {
444                    expected: Some(MeshId(2)),
445                    actual: None,
446                    ..
447                }
448            ));
449        }
450    }
451
452    /// Draws nothing and refuses nothing.
453    #[derive(Debug)]
454    struct Silent;
455
456    impl Backend for Silent {
457        fn descriptor(&self) -> BackendDescriptor {
458            BackendDescriptor::new(
459                BackendId::new("conformance-silent"),
460                ExecutionTarget::PortableCpu,
461            )
462        }
463    }
464
465    impl MeshHiddenLines for Silent {
466        fn hidden_lines(
467            &self,
468            request: &HiddenLineRequest<'_>,
469            _options: &ExecutionOptions,
470        ) -> Result<HiddenLineOutcome, HiddenLineError> {
471            let triangles = request.meshes.iter().map(|m| m.mesh.triangle_count()).sum();
472            Ok(HiddenLineOutcome::new(
473                request.view,
474                Vec::new(),
475                crate::HiddenLineEvidence::input_mesh(request.meshes.len(), triangles),
476            ))
477        }
478    }
479
480    #[test]
481    fn an_empty_drawing_and_a_missing_refusal_are_reported() {
482        let report = ConformanceSuite::run(&Silent);
483        let missing = report
484            .failures
485            .iter()
486            .filter(|f| matches!(f, ConformanceFailure::MissingRefusal { .. }))
487            .count();
488        assert_eq!(missing, REFUSAL_CASES.len());
489        assert!(report
490            .failures
491            .iter()
492            .any(|f| matches!(f, ConformanceFailure::WrongSegmentCount { .. })));
493        assert!(report
494            .failures
495            .iter()
496            .any(|f| matches!(f, ConformanceFailure::WrongEdge { .. })));
497    }
498}