1use 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
35const POINT_TOLERANCE: f64 = 1e-9;
37
38#[derive(Debug, Clone, PartialEq)]
40#[non_exhaustive]
41pub enum RegionConformanceFailure {
42 Base(ConformanceFailure),
44 ReturnedError {
46 case: &'static str,
48 error: String,
50 },
51 WrongChains {
53 case: &'static str,
55 },
56 WrongRegions {
58 case: &'static str,
60 },
61 WrongEvidence {
63 case: &'static str,
65 },
66 WrongRefusal {
68 case: &'static str,
70 expected: &'static str,
72 actual: String,
74 },
75 WrongCutRegions {
77 case: &'static str,
79 },
80 NonDeterministic {
82 case: &'static str,
84 },
85}
86
87#[derive(Debug, Clone, PartialEq)]
89#[non_exhaustive]
90pub struct RegionConformanceReport {
91 pub failures: Vec<RegionConformanceFailure>,
93 pub claimed: bool,
96 pub sheets_claimed: bool,
100 pub cut_regions_claimed: bool,
104}
105
106impl RegionConformanceReport {
107 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
135pub struct RegionConformanceSuite;
137
138impl RegionConformanceSuite {
139 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 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
257fn 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
306fn 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
338fn 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
362fn 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(®ion.outer).chain(®ion.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(®ion.outer)
396 .chain(®ion.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
405fn 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
460fn open_ends_are_region_vertices(section: &PlaneSection) -> bool {
463 let on_region = |point: &Point2| {
464 section.regions.iter().any(|region| {
465 std::iter::once(®ion.outer)
466 .chain(®ion.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}