1use core::fmt;
36
37use axiolid_core::{BooleanOperator, Tolerance};
38
39use crate::{BooleanOutcome, MeshBoolean};
40use axiolid_contracts::{Determinism, ExecutionOptions, GeomError};
41use axiolid_mesh::TriMesh;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum Outcome {
46 Passed,
48 Skipped {
50 reason: String,
52 },
53 Failed {
55 detail: String,
57 },
58}
59
60#[derive(Debug, Clone)]
62pub struct Check {
63 pub name: &'static str,
65 pub outcome: Outcome,
67}
68
69#[derive(Debug, Clone, Default)]
71pub struct ConformanceReport {
72 pub checks: Vec<Check>,
74}
75
76impl ConformanceReport {
77 #[must_use]
82 pub fn is_conformant(&self) -> bool {
83 !self
84 .checks
85 .iter()
86 .any(|check| matches!(check.outcome, Outcome::Failed { .. }))
87 }
88
89 #[must_use]
91 pub fn exercised(&self) -> usize {
92 self.checks
93 .iter()
94 .filter(|check| check.outcome == Outcome::Passed)
95 .count()
96 }
97
98 #[must_use]
100 pub fn skipped(&self) -> usize {
101 self.checks
102 .iter()
103 .filter(|check| matches!(check.outcome, Outcome::Skipped { .. }))
104 .count()
105 }
106
107 fn record(&mut self, name: &'static str, outcome: Outcome) {
108 self.checks.push(Check { name, outcome });
109 }
110}
111
112impl fmt::Display for ConformanceReport {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 writeln!(
115 f,
116 "conformance: {} passed, {} skipped, {} failed",
117 self.exercised(),
118 self.skipped(),
119 self.checks.len() - self.exercised() - self.skipped()
120 )?;
121 for check in &self.checks {
122 match &check.outcome {
123 Outcome::Passed => writeln!(f, " pass {}", check.name)?,
124 Outcome::Skipped { reason } => {
125 writeln!(f, " skip {} ({reason})", check.name)?;
126 }
127 Outcome::Failed { detail } => {
128 writeln!(f, " FAIL {} -- {detail}", check.name)?;
129 }
130 }
131 }
132 Ok(())
133 }
134}
135
136#[must_use]
138pub fn box_at(min: [f64; 3], max: [f64; 3]) -> TriMesh {
139 let [x0, y0, z0] = min;
140 let [x1, y1, z1] = max;
141 let positions = vec![
142 [x0, y0, z0].into(),
143 [x1, y0, z0].into(),
144 [x1, y1, z0].into(),
145 [x0, y1, z0].into(),
146 [x0, y0, z1].into(),
147 [x1, y0, z1].into(),
148 [x1, y1, z1].into(),
149 [x0, y1, z1].into(),
150 ];
151 let indices = vec![
152 0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6,
153 3, 0, 4, 3, 4, 7,
154 ];
155 TriMesh::new(positions, indices)
156}
157
158#[must_use]
160pub fn volume(mesh: &TriMesh) -> f64 {
161 mesh.indices
162 .chunks_exact(3)
163 .map(|t| {
164 let a = mesh.positions[t[0] as usize];
165 let b = mesh.positions[t[1] as usize];
166 let c = mesh.positions[t[2] as usize];
167 a.dot(b.cross(c))
168 })
169 .sum::<f64>()
170 / 6.0
171}
172
173fn options() -> ExecutionOptions {
174 ExecutionOptions::new(Tolerance::METRE)
175}
176
177fn attempt(
179 provider: &impl MeshBoolean,
180 subject: &TriMesh,
181 tool: &TriMesh,
182 operation: BooleanOperator,
183) -> Result<BooleanOutcome, Outcome> {
184 match provider.boolean(subject, tool, operation, &options()) {
185 Ok(outcome) => Ok(outcome),
186 Err(GeomError::Unsupported { .. }) => Err(Outcome::Skipped {
187 reason: format!("provider does not support {operation:?}"),
188 }),
189 Err(error) => Err(Outcome::Failed {
190 detail: format!("{operation:?} on admissible operands failed: {error}"),
191 }),
192 }
193}
194
195#[must_use]
199pub fn run(provider: &impl MeshBoolean) -> ConformanceReport {
200 let mut report = ConformanceReport::default();
201
202 check_disjoint_algebra(provider, &mut report);
203 check_identical_operands(provider, &mut report);
204 check_difference_is_ordered(provider, &mut report);
205 check_empty_result_is_a_value(provider, &mut report);
206 check_inadmissible_operands_rejected(provider, &mut report);
207 check_evidence_is_populated(provider, &mut report);
208 check_determinism(provider, &mut report);
209 check_determinism_is_not_overstated(provider, &mut report);
210 check_declared_granularity_is_honoured(provider, &mut report);
211
212 report
213}
214
215fn check_disjoint_algebra(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
217 let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
218 let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
219
220 let outcome = match attempt(provider, &a, &b, BooleanOperator::Union) {
221 Ok(outcome) => outcome,
222 Err(skip_or_fail) => return report.record("disjoint_union_sums_volume", skip_or_fail),
223 };
224 let measured = volume(&outcome.mesh);
225 report.record(
226 "disjoint_union_sums_volume",
227 if (measured - 2.0).abs() < 1e-9 {
228 Outcome::Passed
229 } else {
230 Outcome::Failed {
231 detail: format!("expected volume 2.0 for two disjoint unit cubes, got {measured}"),
232 }
233 },
234 );
235
236 match attempt(provider, &a, &b, BooleanOperator::Difference) {
237 Ok(outcome) => {
238 let measured = volume(&outcome.mesh);
239 report.record(
240 "disjoint_difference_preserves_subject",
241 if (measured - 1.0).abs() < 1e-9 {
242 Outcome::Passed
243 } else {
244 Outcome::Failed {
245 detail: format!("A \\ B must equal A when disjoint, got volume {measured}"),
246 }
247 },
248 );
249 }
250 Err(skip_or_fail) => {
251 report.record("disjoint_difference_preserves_subject", skip_or_fail);
252 }
253 }
254}
255
256fn check_identical_operands(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
258 let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
259
260 match attempt(provider, &a, &a, BooleanOperator::Union) {
261 Ok(outcome) => {
262 let measured = volume(&outcome.mesh);
263 report.record(
264 "self_union_is_idempotent",
265 if (measured - 1.0).abs() < 1e-9 {
266 Outcome::Passed
267 } else {
268 Outcome::Failed {
269 detail: format!("A union A must equal A, got volume {measured}"),
270 }
271 },
272 );
273 }
274 Err(skip_or_fail) => report.record("self_union_is_idempotent", skip_or_fail),
275 }
276
277 match attempt(provider, &a, &a, BooleanOperator::Difference) {
278 Ok(outcome) => {
279 let measured = volume(&outcome.mesh).abs();
280 report.record(
281 "self_difference_annihilates",
282 if measured < 1e-9 {
283 Outcome::Passed
284 } else {
285 Outcome::Failed {
286 detail: format!("A minus A must be empty, got volume {measured}"),
287 }
288 },
289 );
290 }
291 Err(skip_or_fail) => report.record("self_difference_annihilates", skip_or_fail),
292 }
293}
294
295fn check_difference_is_ordered(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
297 let outer = box_at([0.0, 0.0, 0.0], [4.0, 4.0, 4.0]);
298 let inner = box_at([1.0, 1.0, 1.0], [2.0, 2.0, 2.0]);
299
300 let forward = attempt(provider, &outer, &inner, BooleanOperator::Difference);
301 let reverse = attempt(provider, &inner, &outer, BooleanOperator::Difference);
302 match (forward, reverse) {
303 (Ok(forward), Ok(reverse)) => {
304 let (big, small) = (volume(&forward.mesh), volume(&reverse.mesh).abs());
305 report.record(
306 "difference_respects_operand_order",
307 if (big - 63.0).abs() < 1e-9 && small < 1e-9 {
308 Outcome::Passed
309 } else {
310 Outcome::Failed {
311 detail: format!(
312 "outer minus inner should be 63.0 and inner minus outer empty, \
313 got {big} and {small}"
314 ),
315 }
316 },
317 );
318 }
319 (Err(skip_or_fail), _) | (_, Err(skip_or_fail)) => {
320 report.record("difference_respects_operand_order", skip_or_fail);
321 }
322 }
323}
324
325fn check_empty_result_is_a_value(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
327 let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
328 let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
329
330 match attempt(provider, &a, &b, BooleanOperator::Intersection) {
331 Ok(outcome) => report.record(
332 "empty_intersection_is_a_value_not_an_error",
333 if outcome.mesh.triangle_count() == 0 {
334 Outcome::Passed
335 } else {
336 Outcome::Failed {
337 detail: format!(
338 "disjoint intersection must be empty, got {} triangles",
339 outcome.mesh.triangle_count()
340 ),
341 }
342 },
343 ),
344 Err(skip_or_fail) => {
345 report.record("empty_intersection_is_a_value_not_an_error", skip_or_fail);
346 }
347 }
348}
349
350fn check_inadmissible_operands_rejected(
352 provider: &impl MeshBoolean,
353 report: &mut ConformanceReport,
354) {
355 let good = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
356 let empty = TriMesh::new(Vec::new(), Vec::new());
357
358 let result = provider.boolean(&good, &empty, BooleanOperator::Union, &options());
359 report.record(
360 "inadmissible_operand_is_refused",
361 match result {
362 Err(GeomError::InvalidInput(_) | GeomError::Degenerate(_)) => Outcome::Passed,
363 Err(GeomError::Unsupported { .. }) => Outcome::Skipped {
364 reason: "provider declined before validating".into(),
365 },
366 Err(other) => Outcome::Failed {
367 detail: format!("expected InvalidInput or Degenerate, got {other:?}"),
368 },
369 Ok(_) => Outcome::Failed {
370 detail: "an empty mesh is not a solid and must be refused".into(),
371 },
372 },
373 );
374}
375
376fn check_evidence_is_populated(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
378 let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
379 let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
380
381 match attempt(provider, &a, &b, BooleanOperator::Union) {
382 Ok(outcome) => {
383 let evidence = &outcome.evidence;
384 let mut problems = Vec::new();
385 if evidence.subject_triangles != a.triangle_count() {
386 problems.push(format!(
387 "subject_triangles {} != {}",
388 evidence.subject_triangles,
389 a.triangle_count()
390 ));
391 }
392 if evidence.output_triangles != outcome.mesh.triangle_count() {
393 problems.push(format!(
394 "output_triangles {} != actual {}",
395 evidence.output_triangles,
396 outcome.mesh.triangle_count()
397 ));
398 }
399 if evidence.sub_operations == 0 {
400 problems.push("sub_operations must be at least 1".into());
401 }
402 report.record(
403 "evidence_describes_the_work",
404 if problems.is_empty() {
405 Outcome::Passed
406 } else {
407 Outcome::Failed {
408 detail: problems.join("; "),
409 }
410 },
411 );
412 }
413 Err(skip_or_fail) => report.record("evidence_describes_the_work", skip_or_fail),
414 }
415}
416
417fn check_determinism(provider: &impl MeshBoolean, report: &mut ConformanceReport) {
419 let a = box_at([0.0, 0.0, 0.0], [4.0, 4.0, 4.0]);
420 let b = box_at([1.0, 1.0, 1.0], [2.0, 2.0, 2.0]);
421
422 let first = attempt(provider, &a, &b, BooleanOperator::Difference);
423 let second = attempt(provider, &a, &b, BooleanOperator::Difference);
424 match (first, second) {
425 (Ok(first), Ok(second)) => report.record(
426 "repeated_calls_are_deterministic",
427 if first.mesh.positions == second.mesh.positions
428 && first.mesh.indices == second.mesh.indices
429 {
430 Outcome::Passed
431 } else {
432 Outcome::Failed {
433 detail: "identical inputs produced different geometry".into(),
434 }
435 },
436 ),
437 (Err(skip_or_fail), _) | (_, Err(skip_or_fail)) => {
438 report.record("repeated_calls_are_deterministic", skip_or_fail);
439 }
440 }
441}
442
443fn check_determinism_is_not_overstated(
463 provider: &impl MeshBoolean,
464 report: &mut ConformanceReport,
465) {
466 let declared = provider.determinism();
467 report.record(
468 "determinism_declaration_is_verifiable",
469 if declared == Determinism::Bitwise {
470 Outcome::Failed {
471 detail: "provider declares Determinism::Bitwise, which this suite cannot \
472 verify: cross-process byte equality is not observable from a \
473 single-process check. Declare the strongest level the provider \
474 can prove by construction instead"
475 .into(),
476 }
477 } else {
478 Outcome::Passed
479 },
480 );
481}
482
483fn check_declared_granularity_is_honoured(
485 provider: &impl MeshBoolean,
486 report: &mut ConformanceReport,
487) {
488 use axiolid_contracts::{CancellationGranularity, CancellationToken};
489
490 let granularity = provider.cancellation_granularity();
491 if granularity == CancellationGranularity::None {
492 report.record(
493 "declared_cancellation_is_honoured",
494 Outcome::Skipped {
495 reason: "provider declares it does not poll".into(),
496 },
497 );
498 return;
499 }
500
501 let a = box_at([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
502 let b = box_at([9.0, 9.0, 9.0], [10.0, 10.0, 10.0]);
503 let token = CancellationToken::new();
504 token.cancel();
505 let cancelled = options().with_cancellation(token);
506
507 let result = provider.boolean(&a, &b, BooleanOperator::Union, &cancelled);
508 report.record(
509 "declared_cancellation_is_honoured",
510 match result {
511 Err(GeomError::Cancelled) => Outcome::Passed,
512 Err(GeomError::Unsupported { .. }) => Outcome::Skipped {
513 reason: "provider declined the operation".into(),
514 },
515 Ok(_) => Outcome::Failed {
516 detail: format!(
517 "provider declares {granularity:?} polling but ran to completion \
518 with a pre-cancelled token"
519 ),
520 },
521 Err(other) => Outcome::Failed {
522 detail: format!("expected Cancelled, got {other:?}"),
523 },
524 },
525 );
526}