Skip to main content

axiolid_mesh_hidden_lines_contract/conformance/
perspective.rs

1//! Conformance checks for providers that draw perspective views (ADR 0107).
2//!
3//! The base suites cannot grow a field ([`ConformanceReport`]
4//! is exhaustive), so perspective has a suite of its own, as the region
5//! suite does for section requests (ADR 0091). The claim is inferred from
6//! `supports_projection`, never from probing: a provider that answers `false`
7//! for [`ViewProjection::Perspective`] does not claim it, and must then refuse a
8//! valid perspective request with `Unsupported` or `UnsupportedInput`, so
9//! that a registry can fall back past it. A provider that answers `true`
10//! claims it, and the suite then draws the perspective fixtures (a box, a box
11//! cut by the near plane, an eye inside a box, an exact boundary, an edge
12//! along the eye ray, an eye inside an edge-on triangle) and checks the drawing and the refusal of a bad near
13//! depth. Hidden-line fixtures exist; the visible-face claim has none yet and
14//! fails closed with [`PerspectiveConformanceFailure::ClaimUnverifiable`]
15//! until they land.
16
17use core::fmt;
18
19use axiolid_contracts::{ExecutionOptions, GeomError};
20use axiolid_core::Tolerance;
21
22use super::faces::FaceConformanceFailure;
23use super::{check_outcome, draw, mesh_of, same_drawing, ConformanceFailure, ConformanceReport};
24use crate::fixtures::{
25    box_mesh, limits, perspective_cases, perspective_refusals, plan_frame, reversed, Fixture,
26};
27use crate::{
28    EdgeSelection, HiddenLineError, HiddenLineMesh, HiddenLineRequest, MeshHiddenLines, MeshId,
29    MeshRole, MeshVisibleFaces, View, ViewProjection, VisibleFaceRequest,
30};
31
32/// One failure of the perspective suite.
33#[derive(Debug, Clone, PartialEq)]
34#[non_exhaustive]
35pub enum PerspectiveConformanceFailure {
36    /// The provider fails the hidden-line suite. Reported by a registry that
37    /// runs both suites, never by [`PerspectiveConformanceSuite`] itself.
38    HiddenLinesBase(ConformanceFailure),
39    /// The provider fails the visible-face suite; see
40    /// [`HiddenLinesBase`](Self::HiddenLinesBase).
41    VisibleFacesBase(FaceConformanceFailure),
42    /// The provider does not claim perspective but drew a valid perspective
43    /// request, which it would have to ignore the projection to do.
44    UnclaimedButDrawn {
45        /// The operation, `"hidden lines"` or `"visible faces"`.
46        operation: &'static str,
47    },
48    /// The provider does not claim perspective and refused a valid
49    /// perspective request with something other than `Unsupported` or
50    /// `UnsupportedInput`, which a registry would not fall back past.
51    UnclaimedWrongRefusal {
52        /// The operation, `"hidden lines"` or `"visible faces"`.
53        operation: &'static str,
54        /// The refusal.
55        error: HiddenLineError,
56    },
57    /// A hidden-line provider that claims perspective failed a perspective
58    /// fixture; the failure names the fixture.
59    HiddenLines(ConformanceFailure),
60    /// The provider claims perspective, which this suite cannot verify.
61    ClaimUnverifiable {
62        /// The operation, `"hidden lines"` or `"visible faces"`.
63        operation: &'static str,
64    },
65}
66
67/// Result of [`PerspectiveConformanceSuite`].
68#[derive(Debug, Clone, PartialEq)]
69#[non_exhaustive]
70pub struct PerspectiveConformanceReport {
71    /// Every failure found.
72    pub failures: Vec<PerspectiveConformanceFailure>,
73    /// Whether the provider claims perspective views, as
74    /// `supports_projection(ViewProjection::Perspective)` says.
75    pub claimed: bool,
76}
77
78impl PerspectiveConformanceReport {
79    /// Whether no check failed.
80    pub fn is_success(&self) -> bool {
81        self.failures.is_empty()
82    }
83}
84
85impl fmt::Display for PerspectiveConformanceReport {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        if self.is_success() {
88            write!(
89                f,
90                "{}",
91                if self.claimed {
92                    "conformant"
93                } else {
94                    "not claimed"
95                }
96            )
97        } else {
98            write!(f, "{:?}", self.failures)
99        }
100    }
101}
102
103/// The perspective conformance suite for `MeshHiddenLines` and
104/// `MeshVisibleFaces`.
105pub struct PerspectiveConformanceSuite;
106
107impl PerspectiveConformanceSuite {
108    /// Check a hidden-line provider's perspective claim.
109    pub fn run(provider: &dyn MeshHiddenLines) -> PerspectiveConformanceReport {
110        let claimed = provider.supports_projection(ViewProjection::Perspective);
111        let mut report = PerspectiveConformanceReport {
112            failures: Vec::new(),
113            claimed,
114        };
115        const OPERATION: &str = "hidden lines";
116        if claimed {
117            report.failures = claimed_failures(provider)
118                .into_iter()
119                .map(PerspectiveConformanceFailure::HiddenLines)
120                .collect();
121            return report;
122        }
123        let mesh = box_mesh([-1.0, -1.0, -4.0], [1.0, 1.0, -2.0]);
124        let meshes = [HiddenLineMesh::new(MeshId(1), MeshRole::Solid, &mesh)];
125        let request = HiddenLineRequest::new(
126            &meshes,
127            perspective_view(),
128            EdgeSelection::new(Some(0.5)),
129            limits(),
130        );
131        let result = provider.hidden_lines(&request, &options());
132        record_unclaimed(&mut report, OPERATION, result.map(|_| ()));
133        report
134    }
135
136    /// Check a visible-face provider's perspective claim.
137    pub fn run_faces(provider: &dyn MeshVisibleFaces) -> PerspectiveConformanceReport {
138        let claimed = provider.supports_projection(ViewProjection::Perspective);
139        let mut report = PerspectiveConformanceReport {
140            failures: Vec::new(),
141            claimed,
142        };
143        const OPERATION: &str = "visible faces";
144        if claimed {
145            report
146                .failures
147                .push(PerspectiveConformanceFailure::ClaimUnverifiable {
148                    operation: OPERATION,
149                });
150            return report;
151        }
152        let mesh = box_mesh([-1.0, -1.0, -4.0], [1.0, 1.0, -2.0]);
153        let meshes = [HiddenLineMesh::new(MeshId(1), MeshRole::Solid, &mesh)];
154        let request =
155            VisibleFaceRequest::new(&meshes, perspective_view(), crate::face_fixtures::limits());
156        let result = provider.visible_faces(&request, &options());
157        record_unclaimed(&mut report, OPERATION, result.map(|_| ()));
158        report
159    }
160}
161
162/// The failures of a provider that claims perspective against the
163/// perspective fixtures. The provider is called directly, as in the base
164/// suite, so it must refuse a bad near depth itself.
165fn claimed_failures(provider: &dyn MeshHiddenLines) -> Vec<ConformanceFailure> {
166    let options = options();
167    let mut report = ConformanceReport::default();
168    for case in perspective_cases() {
169        let first = draw(provider, &case, &case.meshes, &options);
170        let second = draw(provider, &case, &case.meshes, &options);
171        match (first, second) {
172            (Ok(a), Ok(b)) => {
173                check_outcome(&mut report, &case, &a);
174                if a != b {
175                    report
176                        .failures
177                        .push(ConformanceFailure::NonDeterministic { case: case.name });
178                }
179                if case.orientation_independent {
180                    let flipped: Vec<Fixture> = case
181                        .meshes
182                        .iter()
183                        .map(|f| Fixture {
184                            id: f.id,
185                            role: f.role,
186                            mesh: reversed(&f.mesh),
187                            groups: f.groups.clone(),
188                            planar: f.planar,
189                        })
190                        .collect();
191                    match draw(provider, &case, &flipped, &options) {
192                        Ok(outcome) if same_drawing(&outcome, &a) => {}
193                        Ok(_) => report
194                            .failures
195                            .push(ConformanceFailure::OrientationDependent { case: case.name }),
196                        Err(error) => report.failures.push(ConformanceFailure::ReturnedError {
197                            case: case.name,
198                            error,
199                        }),
200                    }
201                }
202            }
203            (Err(error), _) | (_, Err(error)) => {
204                report.failures.push(ConformanceFailure::ReturnedError {
205                    case: case.name,
206                    error,
207                })
208            }
209        }
210    }
211    for refusal in perspective_refusals() {
212        let meshes: Vec<HiddenLineMesh<'_>> = refusal.meshes.iter().map(mesh_of).collect();
213        let request = HiddenLineRequest::new(&meshes, refusal.view, refusal.edges, refusal.limits);
214        match provider.hidden_lines(&request, &options) {
215            Ok(_) => report
216                .failures
217                .push(ConformanceFailure::MissingRefusal { case: refusal.name }),
218            Err(error) if !(refusal.accepts)(&error.error) => {
219                report.failures.push(ConformanceFailure::ReturnedError {
220                    case: refusal.name,
221                    error,
222                })
223            }
224            Err(_) => {}
225        }
226    }
227    report.failures
228}
229
230fn perspective_view() -> View {
231    View::perspective(plan_frame(), -1.0).expect("a negative finite near")
232}
233
234fn options() -> ExecutionOptions {
235    ExecutionOptions::new(Tolerance::new(1e-9, 1e-9).expect("valid tolerance"))
236}
237
238fn record_unclaimed(
239    report: &mut PerspectiveConformanceReport,
240    operation: &'static str,
241    result: Result<(), HiddenLineError>,
242) {
243    match result {
244        Ok(()) => report
245            .failures
246            .push(PerspectiveConformanceFailure::UnclaimedButDrawn { operation }),
247        Err(HiddenLineError {
248            error: GeomError::Unsupported { .. } | GeomError::UnsupportedInput { .. },
249            ..
250        }) => {}
251        Err(error) => report
252            .failures
253            .push(PerspectiveConformanceFailure::UnclaimedWrongRefusal { operation, error }),
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use axiolid_contracts::{Backend, BackendDescriptor, BackendId, ExecutionTarget, Operation};
260
261    use super::*;
262    use crate::{HiddenLineOutcome, VisibleFaceOutcome};
263
264    /// A provider whose behaviour on a perspective request is chosen.
265    #[derive(Debug)]
266    struct Probe {
267        claims: bool,
268        answer: Answer,
269    }
270
271    #[derive(Debug, Clone, Copy)]
272    enum Answer {
273        Unsupported,
274        UnsupportedInput,
275        Invalid,
276        Draw,
277    }
278
279    impl Backend for Probe {
280        fn descriptor(&self) -> BackendDescriptor {
281            BackendDescriptor::new(
282                BackendId::new("perspective-probe"),
283                ExecutionTarget::PortableCpu,
284            )
285        }
286    }
287
288    impl Probe {
289        fn refusal(&self, operation: Operation) -> Option<HiddenLineError> {
290            let backend = BackendId::new("perspective-probe");
291            match self.answer {
292                Answer::Unsupported => Some(GeomError::Unsupported { backend, operation }.into()),
293                Answer::UnsupportedInput => Some(
294                    GeomError::UnsupportedInput {
295                        backend,
296                        operation,
297                        input: "perspective view",
298                    }
299                    .into(),
300                ),
301                Answer::Invalid => Some(GeomError::InvalidInput("no".into()).into()),
302                Answer::Draw => None,
303            }
304        }
305    }
306
307    impl MeshHiddenLines for Probe {
308        fn supports_projection(&self, projection: ViewProjection) -> bool {
309            projection == ViewProjection::Orthographic || self.claims
310        }
311
312        fn hidden_lines(
313            &self,
314            request: &HiddenLineRequest<'_>,
315            _options: &ExecutionOptions,
316        ) -> Result<HiddenLineOutcome, HiddenLineError> {
317            match self.refusal(Operation::MeshHiddenLines) {
318                Some(error) => Err(error),
319                None => Ok(HiddenLineOutcome::new(
320                    request.view,
321                    Vec::new(),
322                    crate::HiddenLineEvidence::input_mesh(0, 0),
323                )),
324            }
325        }
326    }
327
328    impl MeshVisibleFaces for Probe {
329        fn supports_projection(&self, projection: ViewProjection) -> bool {
330            projection == ViewProjection::Orthographic || self.claims
331        }
332
333        fn visible_faces(
334            &self,
335            request: &VisibleFaceRequest<'_>,
336            _options: &ExecutionOptions,
337        ) -> Result<VisibleFaceOutcome, HiddenLineError> {
338            match self.refusal(Operation::MeshVisibleFaces) {
339                Some(error) => Err(error),
340                None => Ok(VisibleFaceOutcome::new(
341                    request.view,
342                    Vec::new(),
343                    crate::VisibleFaceEvidence::input_mesh(0, 0),
344                )),
345            }
346        }
347    }
348
349    fn both(probe: &Probe) -> [PerspectiveConformanceReport; 2] {
350        [
351            PerspectiveConformanceSuite::run(probe),
352            PerspectiveConformanceSuite::run_faces(probe),
353        ]
354    }
355
356    #[test]
357    fn a_provider_that_refuses_by_name_passes_unclaimed() {
358        for answer in [Answer::Unsupported, Answer::UnsupportedInput] {
359            for report in both(&Probe {
360                claims: false,
361                answer,
362            }) {
363                assert!(!report.claimed);
364                assert!(report.is_success(), "{report}");
365                assert_eq!(report.to_string(), "not claimed");
366            }
367        }
368    }
369
370    #[test]
371    fn an_unclaimed_provider_that_draws_or_refuses_otherwise_fails() {
372        let [lines, faces] = both(&Probe {
373            claims: false,
374            answer: Answer::Draw,
375        });
376        assert_eq!(
377            lines.failures,
378            [PerspectiveConformanceFailure::UnclaimedButDrawn {
379                operation: "hidden lines"
380            }]
381        );
382        assert_eq!(
383            faces.failures,
384            [PerspectiveConformanceFailure::UnclaimedButDrawn {
385                operation: "visible faces"
386            }]
387        );
388        for report in both(&Probe {
389            claims: false,
390            answer: Answer::Invalid,
391        }) {
392            assert!(matches!(
393                report.failures[..],
394                [PerspectiveConformanceFailure::UnclaimedWrongRefusal { .. }]
395            ));
396        }
397    }
398
399    #[test]
400    fn a_claim_is_inferred_from_supports_projection_and_verified_by_the_fixtures() {
401        let probe = Probe {
402            claims: true,
403            answer: Answer::Draw,
404        };
405        let lines = PerspectiveConformanceSuite::run(&probe);
406        assert!(lines.claimed);
407        // The probe draws nothing, so every fixture with segments fails and
408        // every bad near depth is drawn instead of refused.
409        assert!(!lines.is_success());
410        assert!(lines
411            .failures
412            .iter()
413            .all(|f| matches!(f, PerspectiveConformanceFailure::HiddenLines(_))));
414        for case in [
415            "perspective box",
416            "perspective box cut by the near plane",
417            "perspective eye inside a box",
418        ] {
419            assert!(
420                lines.failures.iter().any(|f| matches!(
421                    f,
422                    PerspectiveConformanceFailure::HiddenLines(
423                        ConformanceFailure::WrongSegmentCount { case: c, .. }
424                    ) if *c == case
425                )),
426                "{case}: {lines}"
427            );
428        }
429        assert!(lines.failures.iter().any(|f| matches!(
430            f,
431            PerspectiveConformanceFailure::HiddenLines(ConformanceFailure::MissingRefusal {
432                case: "perspective view without a near depth"
433            })
434        )));
435    }
436
437    #[test]
438    fn a_visible_face_claim_still_fails_closed() {
439        let faces = PerspectiveConformanceSuite::run_faces(&Probe {
440            claims: true,
441            answer: Answer::Draw,
442        });
443        assert!(faces.claimed);
444        assert!(matches!(
445            faces.failures[..],
446            [PerspectiveConformanceFailure::ClaimUnverifiable {
447                operation: "visible faces"
448            }]
449        ));
450    }
451}