Skip to main content

axiolid_mesh_section_contract/
conformance.rs

1//! Shared conformance checks for portable mesh-section providers.
2
3use core::fmt;
4
5use axiolid_contracts::ExecutionOptions;
6use axiolid_core::{Frame3, Point3, Tolerance, Vec3};
7use axiolid_mesh::TriMesh;
8
9pub use crate::region_conformance::{
10    RegionConformanceFailure, RegionConformanceReport, RegionConformanceSuite,
11};
12use crate::{MeshPlaneSection, SectionLimits};
13
14#[derive(Debug, Clone, PartialEq)]
15pub enum ConformanceFailure {
16    ReturnedError(String),
17    EmptyCentralSection,
18    IncorrectEvidence,
19    NonDeterministic,
20    /// A section returned the wrong number of closed contours.
21    WrongContourCount {
22        case: &'static str,
23        expected: usize,
24        actual: usize,
25    },
26    /// A section contour enclosed the wrong area.
27    WrongSectionArea {
28        case: &'static str,
29        expected: f64,
30        actual: f64,
31    },
32    /// A plane tangent to the solid produced interior area instead of
33    /// touching it.
34    TangentPlaneHasArea {
35        case: &'static str,
36        area: f64,
37    },
38}
39
40#[derive(Debug, Clone, Default, PartialEq)]
41pub struct ConformanceReport {
42    pub failures: Vec<ConformanceFailure>,
43}
44
45impl ConformanceReport {
46    pub fn is_success(&self) -> bool {
47        self.failures.is_empty()
48    }
49}
50
51impl fmt::Display for ConformanceReport {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        if self.is_success() {
54            write!(f, "conformant")
55        } else {
56            write!(f, "{:?}", self.failures)
57        }
58    }
59}
60
61pub struct ConformanceSuite;
62
63impl ConformanceSuite {
64    pub fn run(provider: &dyn MeshPlaneSection) -> ConformanceReport {
65        let mut report = ConformanceReport::default();
66        let mesh = unit_cube();
67        let frame = Frame3 {
68            origin: Point3::new(0.0, 0.0, 0.5),
69            x: Vec3::X,
70            y: Vec3::Y,
71            z: Vec3::Z,
72        };
73        let limits = SectionLimits::new(8, 12, 32, 4);
74        let options = ExecutionOptions::new(Tolerance::new(1e-9, 1e-9).expect("valid tolerance"));
75        let first = provider.section(&mesh, frame, limits, &options);
76        let second = provider.section(&mesh, frame, limits, &options);
77        match (first, second) {
78            (Ok(a), Ok(b)) => {
79                if a.contours.is_empty() {
80                    report
81                        .failures
82                        .push(ConformanceFailure::EmptyCentralSection);
83                }
84                if a.evidence.source_triangles != mesh.triangles().len()
85                    || !a.evidence.is_derived_from_input_mesh()
86                {
87                    report.failures.push(ConformanceFailure::IncorrectEvidence);
88                }
89                if a != b {
90                    report.failures.push(ConformanceFailure::NonDeterministic);
91                }
92            }
93            (Err(error), _) | (_, Err(error)) => report
94                .failures
95                .push(ConformanceFailure::ReturnedError(error.to_string())),
96        }
97
98        // Geometry, not just non-emptiness. A cube section that merely
99        // exists proves nothing about the numbers the provider returned;
100        // these cases check the actual enclosed area and contour count
101        // against an analytic oracle, including the degenerate planes
102        // that a naive implementation gets wrong.
103        //
104        // Subdivision 3 is deliberate: fine enough that the inscribed
105        // polygon is within ~0.5% of the true circle, coarse enough that
106        // the fixture stays cheap for every provider that runs it.
107        let sphere = icosphere(1.0, 3);
108        for spec in &[
109            // Central: the largest circle, area pi.
110            SphereCase {
111                case: "sphere central",
112                height: 0.0,
113                contours: 1,
114                tolerance: 0.01,
115            },
116            // Off-centre: a smaller circle and a different oracle value, so a
117            // provider returning a constant cannot pass both.
118            SphereCase {
119                case: "sphere h=0.5",
120                height: 0.5,
121                contours: 1,
122                tolerance: 0.02,
123            },
124            // Above the pole: empty, no contour at all.
125            SphereCase {
126                case: "sphere above pole",
127                height: 1.5,
128                contours: 0,
129                tolerance: 0.0,
130            },
131            // Tangent at the pole: touches one point, so zero enclosed area.
132            // The icosphere has a VERTEX at the pole, so this is also the
133            // vertex-hit case.
134            SphereCase {
135                case: "sphere tangent pole",
136                height: 1.0,
137                contours: 0,
138                tolerance: 0.0,
139            },
140        ] {
141            check_sphere_section(provider, &mut report, &sphere, 1.0, spec);
142        }
143
144        report
145    }
146}
147
148pub(crate) fn unit_cube() -> TriMesh {
149    TriMesh::new(
150        vec![
151            Point3::new(0.0, 0.0, 0.0),
152            Point3::new(1.0, 0.0, 0.0),
153            Point3::new(1.0, 1.0, 0.0),
154            Point3::new(0.0, 1.0, 0.0),
155            Point3::new(0.0, 0.0, 1.0),
156            Point3::new(1.0, 0.0, 1.0),
157            Point3::new(1.0, 1.0, 1.0),
158            Point3::new(0.0, 1.0, 1.0),
159        ],
160        vec![
161            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,
162            6, 3, 0, 4, 3, 4, 7,
163        ],
164    )
165}
166
167/// Signed area of a closed contour, by the shoelace formula.
168fn contour_area(contour: &crate::SectionContour) -> f64 {
169    let p = &contour.points;
170    if p.len() < 3 {
171        return 0.0;
172    }
173    let mut twice = 0.0;
174    for i in 0..p.len() {
175        let a = p[i];
176        let b = p[(i + 1) % p.len()];
177        twice += a.x * b.y - b.x * a.y;
178    }
179    (twice / 2.0).abs()
180}
181
182/// Total enclosed area across every contour.
183fn total_area(contours: &[crate::SectionContour]) -> f64 {
184    contours.iter().map(contour_area).sum()
185}
186
187/// An icosphere of `radius` about the origin, subdivided `n` times.
188///
189/// The section oracle is derived from the TESSELLATED solid, not from the
190/// ideal sphere: an icosphere inscribes its sphere, so a plane at height h
191/// cuts a polygon slightly smaller than the true circle of radius
192/// sqrt(r^2 - h^2). Comparing against the ideal radius would charge the
193/// provider for the caller's tessellation choice.
194fn icosphere(radius: f64, subdivisions: u32) -> TriMesh {
195    let t = (1.0 + 5.0_f64.sqrt()) / 2.0;
196    let mut v: Vec<[f64; 3]> = vec![
197        [-1.0, t, 0.0],
198        [1.0, t, 0.0],
199        [-1.0, -t, 0.0],
200        [1.0, -t, 0.0],
201        [0.0, -1.0, t],
202        [0.0, 1.0, t],
203        [0.0, -1.0, -t],
204        [0.0, 1.0, -t],
205        [t, 0.0, -1.0],
206        [t, 0.0, 1.0],
207        [-t, 0.0, -1.0],
208        [-t, 0.0, 1.0],
209    ];
210    let mut f: Vec<[u32; 3]> = vec![
211        [0, 11, 5],
212        [0, 5, 1],
213        [0, 1, 7],
214        [0, 7, 10],
215        [0, 10, 11],
216        [1, 5, 9],
217        [5, 11, 4],
218        [11, 10, 2],
219        [10, 7, 6],
220        [7, 1, 8],
221        [3, 9, 4],
222        [3, 4, 2],
223        [3, 2, 6],
224        [3, 6, 8],
225        [3, 8, 9],
226        [4, 9, 5],
227        [2, 4, 11],
228        [6, 2, 10],
229        [8, 6, 7],
230        [9, 8, 1],
231    ];
232    for _ in 0..subdivisions {
233        let mut mid: std::collections::HashMap<(u32, u32), u32> = std::collections::HashMap::new();
234        let mut next: Vec<[u32; 3]> = Vec::with_capacity(f.len() * 4);
235        for tri in &f {
236            let mut m = [0u32; 3];
237            for e in 0..3 {
238                let (a, b) = (tri[e], tri[(e + 1) % 3]);
239                let key = (a.min(b), a.max(b));
240                m[e] = *mid.entry(key).or_insert_with(|| {
241                    let (pa, pb) = (v[a as usize], v[b as usize]);
242                    v.push([
243                        (pa[0] + pb[0]) * 0.5,
244                        (pa[1] + pb[1]) * 0.5,
245                        (pa[2] + pb[2]) * 0.5,
246                    ]);
247                    (v.len() - 1) as u32
248                });
249            }
250            next.push([tri[0], m[0], m[2]]);
251            next.push([tri[1], m[1], m[0]]);
252            next.push([tri[2], m[2], m[1]]);
253            next.push([m[0], m[1], m[2]]);
254        }
255        f = next;
256    }
257    let positions: Vec<Point3> = v
258        .iter()
259        .map(|p| {
260            let l = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
261            let k = radius / l;
262            Point3::new(p[0] * k, p[1] * k, p[2] * k)
263        })
264        .collect();
265    let indices: Vec<u32> = f.iter().flat_map(|t| [t[0], t[1], t[2]]).collect();
266    TriMesh::new(positions, indices)
267}
268
269/// One sphere-section case: the plane height and what it should produce.
270struct SphereCase {
271    /// Name used in failure reports.
272    case: &'static str,
273    /// Height of the sectioning plane above the sphere centre.
274    height: f64,
275    /// Closed contours the plane should produce.
276    contours: usize,
277    /// Relative area tolerance, sized to the tessellation.
278    tolerance: f64,
279}
280
281/// Section a sphere and compare against the oracle derived from the
282/// tessellated solid.
283///
284/// A plane at height h through a sphere of radius r cuts a circle of
285/// radius sqrt(r^2 - h^2) and area pi*(r^2 - h^2). The icosphere inscribes
286/// that sphere, so the measured area is slightly under; the tolerance is
287/// sized to the tessellation, not to the provider.
288fn check_sphere_section(
289    provider: &dyn MeshPlaneSection,
290    report: &mut ConformanceReport,
291    sphere: &TriMesh,
292    radius: f64,
293    spec: &SphereCase,
294) {
295    let SphereCase {
296        case,
297        height,
298        contours: expect_contours,
299        tolerance: rel_tolerance,
300    } = *spec;
301    let frame = Frame3 {
302        origin: Point3::new(0.0, 0.0, height),
303        x: Vec3::X,
304        y: Vec3::Y,
305        z: Vec3::Z,
306    };
307    let limits = SectionLimits::new(4096, 8192, 16384, 64);
308    let options = ExecutionOptions::new(Tolerance::new(1e-9, 1e-9).expect("valid tolerance"));
309    match provider.section(sphere, frame, limits, &options) {
310        Err(error) => report
311            .failures
312            .push(ConformanceFailure::ReturnedError(error.to_string())),
313        Ok(outcome) => {
314            let closed = outcome.contours.len();
315            if closed != expect_contours {
316                report.failures.push(ConformanceFailure::WrongContourCount {
317                    case,
318                    expected: expect_contours,
319                    actual: closed,
320                });
321            }
322            let area = total_area(&outcome.contours);
323            if expect_contours == 0 {
324                // A tangent plane touches at one point: any enclosed area is
325                // a real defect, not a rounding artefact.
326                if area > 1e-9 {
327                    report
328                        .failures
329                        .push(ConformanceFailure::TangentPlaneHasArea { case, area });
330                }
331                return;
332            }
333            let expected = core::f64::consts::PI * (radius * radius - height * height);
334            if expected > 0.0 && (area - expected).abs() / expected > rel_tolerance {
335                report.failures.push(ConformanceFailure::WrongSectionArea {
336                    case,
337                    expected,
338                    actual: area,
339                });
340            }
341        }
342    }
343}