1use 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#[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 ReturnedError {
37 case: &'static str,
38 error: HiddenLineError,
39 },
40 MissingRefusal { case: &'static str },
42 WrongRefusalMesh {
44 case: &'static str,
45 expected: Option<MeshId>,
46 actual: Option<MeshId>,
47 },
48 WrongLength {
50 case: &'static str,
51 kind: EdgeKind,
52 visibility: Visibility,
53 expected: f64,
54 actual: f64,
55 },
56 WrongSegmentCount {
58 case: &'static str,
59 kind: EdgeKind,
60 visibility: Visibility,
61 expected: usize,
62 actual: usize,
63 },
64 WrongEdge {
66 case: &'static str,
67 edge: SourceEdge,
68 expected: Vec<EdgePiece>,
69 actual: Vec<EdgePiece>,
70 },
71 IncorrectEvidence { case: &'static str },
73 NonDeterministic { case: &'static str },
75 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
160fn 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
216fn 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 #[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 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 #[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}