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}