Skip to main content

axiolid_fixtures/
lib.rs

1#![forbid(unsafe_code)]
2//! Shared adversarial and degenerate geometry fixtures.
3//!
4//! # Why a crate rather than a directory of files
5//!
6//! A degenerate case is usually a NUMBER, not a file: a 2e-9 plane tilt, a
7//! sliver a fraction of a millimetre wide, two vertices that coincide. Storing
8//! those as mesh files invites silent corruption -- an exporter rounds a
9//! coordinate and the fixture stops being degenerate while still passing.
10//!
11//! Constructing them in code keeps the exact bit pattern under version
12//! control and makes the reproduction steps the fixture itself.
13//!
14//! # Provenance
15//!
16//! Every fixture carries a [`Provenance`] naming where it came from and under
17//! what licence. Fixtures here are ORIGINAL: constructed from published bug
18//! descriptions and geometric first principles, not copied from any corpus.
19//! That keeps the licence question trivial and the repository redistributable.
20//!
21//! # Adding a fixture
22//!
23//! Add a constructor returning [`Fixture`], fill in every [`Provenance`]
24//! field, and state in `expectation` what an implementation must do -- not what
25//! it currently does. A fixture that records present behaviour cannot detect a
26//! regression, because the regression becomes the new expectation.
27
28use axiolid_core::Point3;
29use axiolid_mesh::TriMesh;
30
31/// Where a fixture came from and under what terms it may be redistributed.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub struct Provenance {
34    /// Where the case came from: an issue, a specification, or first
35    /// principles. Specific enough that a reader can go and check it.
36    pub source: &'static str,
37    /// Licence covering redistribution of this fixture's data.
38    ///
39    /// Every fixture so far is original work under the repository licence. A
40    /// fixture taken from an external corpus must name that corpus's licence
41    /// here.
42    pub licence: &'static str,
43    /// What an implementation must do with it, stated as a requirement.
44    pub expectation: &'static str,
45}
46
47/// A named mesh fixture with its provenance.
48#[derive(Debug, Clone, PartialEq)]
49pub struct Fixture {
50    /// Stable identifier, usable in a test name or a failure message.
51    pub name: &'static str,
52    /// The geometry.
53    pub mesh: TriMesh,
54    /// Where it came from and what it demands.
55    pub provenance: Provenance,
56}
57
58/// A well-formed unit cube. The control: every operation must handle it.
59#[must_use]
60pub fn unit_cube() -> Fixture {
61    Fixture {
62        name: "unit_cube",
63        mesh: box_mesh(1.0, 1.0, 1.0),
64        provenance: Provenance {
65            source: "First principles: the simplest closed two-manifold solid.",
66            licence: "Original work, same licence as the repository.",
67            expectation: "Volume 1, closed, every operation succeeds.",
68        },
69    }
70}
71
72/// A sliver triangle: three nearly collinear vertices.
73///
74/// Area is ~5e-11, far below any sane linear tolerance squared, so a normal
75/// computed by cross product is dominated by rounding.
76#[must_use]
77pub fn sliver_triangle() -> Fixture {
78    Fixture {
79        name: "sliver_triangle",
80        mesh: TriMesh::new(
81            vec![
82                Point3::new(0.0, 0.0, 0.0),
83                Point3::new(1.0, 0.0, 0.0),
84                Point3::new(0.5, 1.0e-10, 0.0),
85            ],
86            vec![0, 1, 2],
87        ),
88        provenance: Provenance {
89            source: "First principles: the classic degenerate-normal case.",
90            licence: "Original work, same licence as the repository.",
91            expectation: "Refuse as degenerate, or handle without producing NaN.",
92        },
93    }
94}
95
96/// A triangle with two coincident vertices: zero area, not merely small.
97#[must_use]
98pub fn duplicate_vertex_triangle() -> Fixture {
99    Fixture {
100        name: "duplicate_vertex_triangle",
101        mesh: TriMesh::new(
102            vec![
103                Point3::new(0.0, 0.0, 0.0),
104                Point3::new(1.0, 0.0, 0.0),
105                Point3::new(1.0, 0.0, 0.0),
106            ],
107            vec![0, 1, 2],
108        ),
109        provenance: Provenance {
110            source: "First principles: exact degeneracy, no tolerance can rescue it.",
111            licence: "Original work, same licence as the repository.",
112            expectation: "Refuse as degenerate. It has no normal and no area.",
113        },
114    }
115}
116
117/// An open box: the top face is missing, so the shell is not closed.
118///
119/// Volume is undefined for an open shell. A provider that reports a plausible
120/// number here is guessing, which is exactly the failure this fixture catches.
121#[must_use]
122pub fn open_shell() -> Fixture {
123    let mut mesh = box_mesh(1.0, 1.0, 1.0);
124    // Drop the last two triangles: one face of the cube.
125    mesh.indices.truncate(mesh.indices.len() - 6);
126    Fixture {
127        name: "open_shell",
128        mesh,
129        provenance: Provenance {
130            source: "First principles: volume needs a closed boundary.",
131            licence: "Original work, same licence as the repository.",
132            expectation: "Report not-closed; refuse volume rather than guess.",
133        },
134    }
135}
136
137/// Two cubes whose sizes differ by nine orders of magnitude.
138///
139/// Catches absolute epsilons: a tolerance tuned for the metre-scale box
140/// swallows the nanometre box whole, and a bounds routine that adds the two
141/// loses the small one to rounding entirely.
142#[must_use]
143pub fn scale_disparity() -> Fixture {
144    let mut mesh = box_mesh(1.0e3, 1.0e3, 1.0e3);
145    let small = box_mesh(1.0e-6, 1.0e-6, 1.0e-6);
146    let base = u32::try_from(mesh.positions.len()).expect("fixture is small");
147    mesh.positions.extend(small.positions.iter().copied());
148    mesh.indices
149        .extend(small.indices.iter().map(|index| index + base));
150    Fixture {
151        name: "scale_disparity",
152        mesh,
153        provenance: Provenance {
154            source: "First principles: absolute epsilons fail across scales.",
155            licence: "Original work, same licence as the repository.",
156            expectation: "Bounds must contain both boxes; no relative feature is lost.",
157        },
158    }
159}
160
161/// The ADR 0014 near-degenerate half-space column, in millimetres.
162///
163/// A 250 x 250 x 11940 mm column clipped by a plane tilted 2e-9 off axis. The
164/// historical failure was a flyaway: output escaping the input bounds by
165/// kilometres. See `docs/adr/0014-adopt-boolmesh-mesh-boolean.md`.
166#[must_use]
167pub fn millimetre_column() -> Fixture {
168    Fixture {
169        name: "millimetre_column",
170        mesh: column_mesh(),
171        provenance: Provenance {
172            source: "ADR 0014, upstream issue 1155: a half-space clip flyaway.",
173            licence: "Original reconstruction from the published bug description.",
174            expectation: "Clipped output stays within the column bounds, or refuses.",
175        },
176    }
177}
178
179/// Two cubes sharing exactly one face plane: coplanar boolean contact.
180///
181/// Coplanar faces are the hardest boolean case: the classifier must decide
182/// whether the shared plane is inside, outside, or on the boundary, and a
183/// tolerance-based answer flips with the ordering of the operands.
184#[must_use]
185pub fn coplanar_contact() -> (Fixture, Fixture) {
186    let mut second = box_mesh(1.0, 1.0, 1.0);
187    for position in &mut second.positions {
188        position.x += 1.0;
189    }
190    (
191        Fixture {
192            name: "coplanar_contact_left",
193            mesh: box_mesh(1.0, 1.0, 1.0),
194            provenance: COPLANAR,
195        },
196        Fixture {
197            name: "coplanar_contact_right",
198            mesh: second,
199            provenance: COPLANAR,
200        },
201    )
202}
203
204const COPLANAR: Provenance = Provenance {
205    source: "First principles: the canonical coplanar-boolean ambiguity.",
206    licence: "Original work, same licence as the repository.",
207    expectation: "Union volume is 2 exactly; intersection has zero volume.",
208};
209
210/// Every single-mesh fixture in the corpus.
211///
212/// Iterating this is how a differential test picks up new fixtures without
213/// being edited: add a constructor here and every consumer covers it.
214#[must_use]
215pub fn corpus() -> Vec<Fixture> {
216    vec![
217        unit_cube(),
218        sliver_triangle(),
219        duplicate_vertex_triangle(),
220        open_shell(),
221        scale_disparity(),
222        millimetre_column(),
223    ]
224}
225
226/// An axis-aligned box with a corner at the origin, wound outward.
227#[must_use]
228pub fn box_mesh(sx: f64, sy: f64, sz: f64) -> TriMesh {
229    TriMesh::new(
230        vec![
231            Point3::new(0.0, 0.0, 0.0),
232            Point3::new(sx, 0.0, 0.0),
233            Point3::new(sx, sy, 0.0),
234            Point3::new(0.0, sy, 0.0),
235            Point3::new(0.0, 0.0, sz),
236            Point3::new(sx, 0.0, sz),
237            Point3::new(sx, sy, sz),
238            Point3::new(0.0, sy, sz),
239        ],
240        vec![
241            0, 2, 1, 0, 3, 2, // bottom, wound outward (downward)
242            4, 5, 6, 4, 6, 7, // top
243            0, 1, 5, 0, 5, 4, // front
244            1, 2, 6, 1, 6, 5, // right
245            2, 3, 7, 2, 7, 6, // back
246            3, 0, 4, 3, 4, 7, // left
247        ],
248    )
249}
250
251/// The ADR 0014 column: 250 x 250 mm in plan, from z = 11940 to z = 23880.
252fn column_mesh() -> TriMesh {
253    let mut mesh = box_mesh(250.0, 250.0, 11_940.0);
254    for position in &mut mesh.positions {
255        position.x -= 125.0;
256        position.y -= 125.0;
257        position.z += 11_940.0;
258    }
259    mesh
260}