pantometry_shape/voxels.rs
1//! A mesh rasterised into cells, and an account of what the cells could not hold.
2
3use crate::mesh::Mesh;
4use glam::DVec3;
5use pantometry_units::{Length, LengthVec, Volume};
6
7/// What a rasterisation lost, reported rather than left to be discovered.
8///
9/// A missing feature has no symptom. It does not make a solver fail, produce a `NaN` or trip the
10/// conservation audit — it produces a smooth, plausible answer about a different object, which is the
11/// failure this workspace is organised around not having. So every [`Voxels`] carries one of these and
12/// [`Loss::is_clean`] says in one call whether anything here needs reading.
13#[derive(Clone, Copy, Debug, PartialEq)]
14pub struct Loss {
15 /// `voxel volume / mesh volume − 1`.
16 ///
17 /// The aggregate of what the grid kept and did not, and it is **signed**, because the cells the
18 /// surface bulges out of and the ones it cuts into partly cancel.
19 ///
20 /// That cancellation is why this is *not* a discretisation error with an order, and it is worth
21 /// saying plainly because the expectation is so natural. Rasterising a sphere of radius 10 mm — a
22 /// 32×64 tessellation, and the numbers move a few tenths of a point with that — at 2.5, 2.0 and
23 /// 1.5 mm gives `+4.9%, +5.8%, −2.3%`: the first refinement makes it **worse** and the second changes
24 /// its sign. Sliding the mesh relative to the grid changes none of it. What is left after the
25 /// cancellation is a lattice-point count, and its error is erratic by nature.
26 ///
27 /// So use it as a *result* and not as a bound on the next resolution.
28 pub volume_error: f64,
29 /// The share of the voxel volume in cells that have a face on the outside.
30 ///
31 /// **The rasterisation's uncertainty rather than its error**: the volume that sat close enough to the
32 /// surface for the answer to have gone either way.
33 ///
34 /// Unlike the volume error this is clean, because it is a surface area rather than a cancellation:
35 /// the layer is one cell thick over an area `A`, so the fraction is `A·dx/V` — **first order, with a
36 /// coefficient**. For a sphere that is `c·3dx/R`, and the tests measure `c ≈ 0.82`.
37 ///
38 /// It is the number to put in front of someone choosing a cell size. *Forty-three percent of your
39 /// object's volume is in cells that could have gone either way* says what 2 mm on a 20 mm ball means
40 /// in a way that a step count does not.
41 ///
42 /// # It does **not** bound the volume error, and an earlier version of this sentence said it did
43 ///
44 /// The argument is seductive and wrong: "only cells the surface passes through can be
45 /// misclassified, and those are these". They are not. This counts exposed cells that came out
46 /// **filled**; a cell the surface passes through whose centre landed outside is misclassified too and
47 /// appears in neither the numerator nor the denominator. The two are also fractions of different
48 /// volumes — this of the voxel volume, the error of the mesh's.
49 ///
50 /// The 0.4 mm plate in `a_designed_shape.rs` is the counterexample and it was in the suite the whole
51 /// time: at a 2 mm cell it reports a volume error of `+4.0` against a boundary fraction of `1.0`.
52 pub boundary_fraction: f64,
53 /// Solid runs one or two cells thick, counted along all three axes.
54 ///
55 /// A feature two cells across is not resolved by any scheme here — a seven-point stencil has no
56 /// interior in it, a trilinear element has one element — and a feature *one* cell across exists only
57 /// by luck of where the surface fell relative to the cell centres. Move the mesh half a cell and it
58 /// may not be there at all.
59 pub thin_runs: usize,
60 /// Triangles smaller than one face of a cell. See [`Mesh::triangles_below`].
61 pub small_triangles: usize,
62 /// Scanlines whose first ray was degenerate and which a perturbed one decided.
63 ///
64 /// Not a loss — these rows came out right — but the count says the mesh has geometry lined up with
65 /// the grid, which is the common case rather than the exotic one: a cube on cell boundaries sends
66 /// **every** row through a face diagonal, and this reads in the hundreds.
67 ///
68 /// It is here because the alternative is a mechanism nothing can see working. [`Loss::ambiguous_rows`]
69 /// counts only the rows where *all* the perturbations failed, and no mesh in the test suite has ever
70 /// produced one — so without this field the whole retry path would be exercised only by inference
71 /// from a cell count, and a broken retry would look exactly like a mesh that never needed it.
72 pub retried_rows: usize,
73 /// Scanlines no ray could decide, after every perturbation was tried.
74 ///
75 /// A ray through a closed surface must cross it an even number of times, and must not pass through
76 /// an edge. A row that fails both tests on all four rays cannot be filled by parity. **These rows are
77 /// left empty**, which is visible as a slot missing from the shape rather than as a subtly wrong
78 /// fill — and the count is here so it is not only visible in a picture.
79 ///
80 /// **No mesh in the test suite has produced one**, which is stated rather than left to be assumed:
81 /// the branch is written and reasoned about and is not covered by a measurement, so a caller who sees
82 /// a nonzero value here is in territory this crate has not walked. [`Loss::retried_rows`] is the
83 /// nearby thing that *is* exercised.
84 pub ambiguous_rows: usize,
85}
86
87impl Loss {
88 /// Whether anything was lost that a caller should look at.
89 ///
90 /// The thresholds are deliberately loose and deliberately stated: **2% of volume**, and *any* thin run
91 /// or ambiguous row at all. Volume error is a smooth thing that a caller trades against cost, so it
92 /// gets a number; a feature one cell thick and a row that could not be filled are not trade-offs, they
93 /// are things that either happened or did not.
94 ///
95 /// Three fields are deliberately **not** here, for two different reasons.
96 ///
97 /// [`Loss::boundary_fraction`] and [`Loss::small_triangles`] are left out because no threshold on
98 /// either is right for more than one physics: a diffusion problem run to steady state barely notices
99 /// a boundary layer that would move a stress concentration by a factor of two, and a large flat face
100 /// tessellated into a thousand slivers loses nothing at all. Putting a number on those would be this
101 /// library guessing, which is the one thing it does not do — so they are reported and the judgement is
102 /// the caller's.
103 ///
104 /// [`Loss::retried_rows`] is left out because it is not a loss. Those rows came out right.
105 pub fn is_clean(&self) -> bool {
106 self.volume_error.abs() < 0.02 && self.thin_runs == 0 && self.ambiguous_rows == 0
107 }
108}
109
110/// A mesh rasterised onto a grid of cubes.
111///
112/// The grid is the mesh's own bounding box, grown to whole cells, with the shape sitting inside it. Cells
113/// are **inside or outside** — there is no partial cell, because the domains this feeds have no partial
114/// cell either.
115#[derive(Clone, Debug)]
116pub struct Voxels {
117 counts: (usize, usize, usize),
118 dx: f64,
119 origin: DVec3,
120 inside: Vec<bool>,
121 loss: Loss,
122}
123
124impl Voxels {
125 /// Rasterise `mesh` onto cubes of side `cell`.
126 ///
127 /// # By scanline, and why the crossings are counted a row at a time
128 ///
129 /// A point-in-mesh test per cell would cast one ray per cell and cost `cells × triangles`. Casting one
130 /// ray along `x` for each `(j, k)` row and filling between sorted pairs of crossings costs
131 /// `rows × triangles` — the same answer for a factor of `nx` less work, and it is not only faster: a
132 /// whole row decided from one sorted list of crossings **cannot** disagree with itself about parity,
133 /// where per-cell tests can and do near a surface.
134 ///
135 /// # Degenerate rays are detected, perturbed deterministically, and then admitted
136 ///
137 /// Detected, and that word is doing the work. **Parity is not enough to find them.** A ray through
138 /// the edge two triangles share hits both, so the count stays even and the two crossings coincide;
139 /// the row pairs them against each other, fills nothing, and reports success. A cube loses a whole
140 /// diagonal plane of rows to this at every resolution — 64 cells of 512 on an eight-cell cube — with
141 /// no error anywhere. So a hit on an edge is a case of its own, distinct from both a miss and a
142 /// clean crossing, and a row that produces one is retried whatever its parity says.
143 ///
144 /// The retry moves the ray, and the offsets are **fixed** — a short list of small multiples of the
145 /// cell — so the same mesh and cell size give bit-for-bit the same voxels on every platform and at
146 /// every optimisation level, which is a promise the whole workspace makes.
147 ///
148 /// A row still degenerate or still odd after all of them is left **empty** and counted in
149 /// [`Loss::ambiguous_rows`]. Empty is the honest failure: a hole is visible, and a row filled by
150 /// guessing which crossing to drop is a wrong shape that looks right.
151 ///
152 /// Refuses an open mesh, because parity has no meaning through a surface with a hole in it.
153 pub fn of(mesh: &Mesh, cell: Length) -> Result<Voxels, String> {
154 let dx = cell.to_si();
155 if !(dx.is_finite() && dx > 0.0) {
156 return Err(format!("a cell size must be finite and positive, is {dx}"));
157 }
158 if !mesh.is_closed() {
159 return Err(
160 "the mesh is not closed: some edge is not shared by exactly two triangles, so a ray \
161 can pass through the surface and parity cannot say what is inside. STL stores no \
162 topology, so this is matched on the vertices as written — see `Mesh::is_closed`"
163 .to_string(),
164 );
165 }
166 let (low, high) = mesh
167 .bounds()
168 .ok_or_else(|| "an empty mesh has no bounds to rasterise".to_string())?;
169 let (low, high) = (low.to_si(), high.to_si());
170
171 // Whole cells, with the shape centred in them. The half cell of margin means the surface never
172 // lands exactly on the outer boundary, where a crossing is hardest to count.
173 let span = high - low;
174 let counts = (
175 ((span.x / dx).ceil() as usize + 2).max(1),
176 ((span.y / dx).ceil() as usize + 2).max(1),
177 ((span.z / dx).ceil() as usize + 2).max(1),
178 );
179 let grid = DVec3::new(
180 counts.0 as f64 * dx,
181 counts.1 as f64 * dx,
182 counts.2 as f64 * dx,
183 );
184 let origin = low - (grid - span) * 0.5;
185 Ok(Voxels::rasterise(mesh, origin, counts, dx))
186 }
187
188 /// Rasterise `mesh` onto a grid somebody else chose: a stated origin, cell count and cell.
189 ///
190 /// **This is what assembly needs.** [`Voxels::of`] gives every mesh its own box and its own
191 /// origin, so two parts come back on two grids that share no cell and cannot be adjacent to
192 /// each other — `ARCHITECTURE.md` names that as the gap that arrives first in practice.
193 /// Rasterised onto one grid, two parts occupy neighbouring cells of the same array, and a
194 /// domain that fills from both gets a conducting interface between them for free, because
195 /// its stencil already crosses a face between cells of different materials.
196 ///
197 /// The grid is the caller's and the mesh's coordinates are read as they are written: an STL
198 /// carries absolute positions, so where two parts sit relative to each other is what their
199 /// files already say. No pose is applied here — `Pose` places a *domain*, and both parts are
200 /// inside one domain now.
201 ///
202 /// **A mesh that does not fit is refused, with both boxes named.** Cropping it silently is
203 /// the failure this workspace keeps finding: a part with its corner cut off runs, audits,
204 /// renders and answers a question about a different shape.
205 pub fn onto(
206 mesh: &Mesh,
207 origin: LengthVec,
208 counts: (usize, usize, usize),
209 cell: Length,
210 ) -> Result<Voxels, String> {
211 let dx = cell.to_si();
212 if !(dx.is_finite() && dx > 0.0) {
213 return Err(format!("a cell size must be finite and positive, is {dx}"));
214 }
215 if counts.0 == 0 || counts.1 == 0 || counts.2 == 0 {
216 return Err(format!("a grid of {counts:?} cells holds nothing"));
217 }
218 if !mesh.is_closed() {
219 return Err(
220 "the mesh is not closed: some edge is not shared by exactly two triangles, so a \
221 ray can pass through the surface and parity cannot say what is inside"
222 .to_string(),
223 );
224 }
225 let (low, high) = mesh
226 .bounds()
227 .ok_or_else(|| "an empty mesh has no bounds to rasterise".to_string())?;
228 let (low, high) = (low.to_si(), high.to_si());
229 let o = origin.to_si();
230 let far = o + DVec3::new(
231 counts.0 as f64 * dx,
232 counts.1 as f64 * dx,
233 counts.2 as f64 * dx,
234 );
235 let fits = low.x >= o.x
236 && low.y >= o.y
237 && low.z >= o.z
238 && high.x <= far.x
239 && high.y <= far.y
240 && high.z <= far.z;
241 if !fits {
242 return Err(format!(
243 "the mesh spans ({:.4}, {:.4}, {:.4}) to ({:.4}, {:.4}, {:.4}) m and the grid \
244 covers ({:.4}, {:.4}, {:.4}) to ({:.4}, {:.4}, {:.4}) m, so part of it would be \
245 cut off — a part with its corner missing runs and audits and answers about a \
246 different shape, so it is refused rather than cropped",
247 low.x, low.y, low.z, high.x, high.y, high.z, o.x, o.y, o.z, far.x, far.y, far.z
248 ));
249 }
250 Ok(Voxels::rasterise(mesh, o, counts, dx))
251 }
252
253 /// The scanline itself, on whatever grid it is handed. Shared by [`Voxels::of`] and
254 /// [`Voxels::onto`] so there is one rasteriser and not two that agree until they do not.
255 fn rasterise(mesh: &Mesh, origin: DVec3, counts: (usize, usize, usize), dx: f64) -> Voxels {
256 let mut inside = vec![false; counts.0 * counts.1 * counts.2];
257 let mut ambiguous_rows = 0;
258 let mut retried_rows = 0;
259 // Fixed offsets, tried in order. Irregular multiples so a second attempt does not land on the
260 // same symmetry the first one did, and constant so the result is reproducible.
261 const NUDGE: [(f64, f64); 4] = [(0.0, 0.0), (0.19, 0.07), (-0.11, 0.23), (0.31, -0.29)];
262
263 let mut crossings: Vec<f64> = Vec::new();
264 for k in 0..counts.2 {
265 for j in 0..counts.1 {
266 let mut filled = false;
267 for (attempt, (dy, dz)) in NUDGE.into_iter().enumerate() {
268 let y = origin.y + (j as f64 + 0.5 + dy) * dx;
269 let z = origin.z + (k as f64 + 0.5 + dz) * dx;
270 crossings.clear();
271 let mut degenerate = false;
272 for t in mesh.triangles() {
273 match hit_x(t.a, t.b, t.c, y, z) {
274 Hit::Miss => {}
275 Hit::At(x) => crossings.push(x),
276 // One is enough to spoil the row, and the rest of the triangles cannot
277 // un-spoil it.
278 Hit::Degenerate => {
279 degenerate = true;
280 break;
281 }
282 }
283 }
284 // Clippy on current stable suggests `usize::is_multiple_of`, stabilised in 1.87 —
285 // later than the 1.78 this workspace declares and its CI verifies. A declared MSRV
286 // is a promise to a consumer and a lint suggestion is not.
287 #[allow(clippy::manual_is_multiple_of)]
288 let odd = crossings.len() % 2 != 0;
289 if degenerate || odd {
290 continue;
291 }
292 crossings.sort_by(|a, b| a.partial_cmp(b).expect("finite"));
293 for pair in crossings.chunks_exact(2) {
294 for i in 0..counts.0 {
295 let x = origin.x + (i as f64 + 0.5) * dx;
296 if x > pair[0] && x < pair[1] {
297 inside[i + counts.0 * (j + counts.1 * k)] = true;
298 }
299 }
300 }
301 if attempt > 0 {
302 retried_rows += 1;
303 }
304 filled = true;
305 break;
306 }
307 if !filled {
308 ambiguous_rows += 1;
309 }
310 }
311 }
312
313 let mut voxels = Voxels {
314 counts,
315 dx,
316 origin,
317 inside,
318 loss: Loss {
319 volume_error: 0.0,
320 boundary_fraction: 0.0,
321 thin_runs: 0,
322 small_triangles: mesh.triangles_below(Length::from_si(dx)),
323 retried_rows,
324 ambiguous_rows,
325 },
326 };
327 let meshed = mesh.volume().to_si();
328 voxels.loss.volume_error = if meshed != 0.0 {
329 voxels.volume().to_si() / meshed - 1.0
330 } else {
331 f64::NAN
332 };
333 voxels.loss.boundary_fraction = voxels.boundary_share();
334 voxels.loss.thin_runs = voxels.count_thin_runs();
335 voxels
336 }
337
338 /// Cells along each axis.
339 pub fn counts(&self) -> (usize, usize, usize) {
340 self.counts
341 }
342
343 /// The cell side.
344 pub fn cell(&self) -> Length {
345 Length::from_si(self.dx)
346 }
347
348 /// The low corner of cell `(0, 0, 0)`.
349 pub fn origin(&self) -> LengthVec {
350 LengthVec::from_si(self.origin)
351 }
352
353 /// Whether a cell is inside the surface. Out-of-range indices are outside.
354 ///
355 /// This is the predicate a domain's `fill` takes, and the whole coupling between this crate and the
356 /// physics:
357 ///
358 /// ```no_run
359 /// # use pantometry_shape::Voxels;
360 /// # fn go(voxels: &Voxels, block: &mut impl FnMut(&dyn Fn(usize, usize, usize) -> bool)) {
361 /// block(&|i, j, k| voxels.contains(i, j, k));
362 /// # }
363 /// ```
364 pub fn contains(&self, i: usize, j: usize, k: usize) -> bool {
365 if i >= self.counts.0 || j >= self.counts.1 || k >= self.counts.2 {
366 return false;
367 }
368 self.inside[i + self.counts.0 * (j + self.counts.1 * k)]
369 }
370
371 /// How many cells are inside.
372 pub fn filled(&self) -> usize {
373 self.inside.iter().filter(|b| **b).count()
374 }
375
376 /// The volume those cells occupy.
377 pub fn volume(&self) -> Volume {
378 Volume::from_si(self.filled() as f64 * self.dx.powi(3))
379 }
380
381 /// What the rasterisation lost. See [`Loss`].
382 pub fn loss(&self) -> Loss {
383 self.loss
384 }
385
386 /// The share of filled cells with a face on the outside.
387 ///
388 /// Six neighbours, not twenty-six: a cell touching the outside only at an edge or a corner is not one
389 /// the seven-point stencils here exchange anything through, and counting it would make the layer
390 /// thicker than the physics sees it.
391 fn boundary_share(&self) -> f64 {
392 let filled = self.filled();
393 if filled == 0 {
394 return 0.0;
395 }
396 let (nx, ny, nz) = self.counts;
397 let mut on_surface = 0;
398 for k in 0..nz {
399 for j in 0..ny {
400 for i in 0..nx {
401 if !self.contains(i, j, k) {
402 continue;
403 }
404 let exposed = i == 0
405 || j == 0
406 || k == 0
407 || !self.contains(i - 1, j, k)
408 || !self.contains(i + 1, j, k)
409 || !self.contains(i, j - 1, k)
410 || !self.contains(i, j + 1, k)
411 || !self.contains(i, j, k - 1)
412 || !self.contains(i, j, k + 1);
413 if exposed {
414 on_surface += 1;
415 }
416 }
417 }
418 }
419 on_surface as f64 / filled as f64
420 }
421
422 /// Solid runs one or two cells long, along all three axes.
423 fn count_thin_runs(&self) -> usize {
424 let (nx, ny, nz) = self.counts;
425 let mut thin = 0;
426 let mut tally = |run: usize| {
427 if run == 1 || run == 2 {
428 thin += 1;
429 }
430 };
431 for k in 0..nz {
432 for j in 0..ny {
433 let mut run = 0;
434 for i in 0..nx {
435 if self.contains(i, j, k) {
436 run += 1;
437 } else {
438 tally(run);
439 run = 0;
440 }
441 }
442 tally(run);
443 }
444 }
445 for k in 0..nz {
446 for i in 0..nx {
447 let mut run = 0;
448 for j in 0..ny {
449 if self.contains(i, j, k) {
450 run += 1;
451 } else {
452 tally(run);
453 run = 0;
454 }
455 }
456 tally(run);
457 }
458 }
459 for j in 0..ny {
460 for i in 0..nx {
461 let mut run = 0;
462 for k in 0..nz {
463 if self.contains(i, j, k) {
464 run += 1;
465 } else {
466 tally(run);
467 run = 0;
468 }
469 }
470 tally(run);
471 }
472 }
473 thin
474 }
475}
476
477/// What a ray along `+x` did to one triangle.
478enum Hit {
479 /// It missed, or ran parallel to the triangle's plane. Either way the triangle is not on this row.
480 Miss,
481 /// It crossed cleanly, at this `x`.
482 At(f64),
483 /// It went through an edge, or grazed the plane, so this row's parity cannot be trusted.
484 ///
485 /// **Parity does not detect this, which is why it has its own variant.** A ray through the edge two
486 /// triangles share hits *both*, and the count stays even — so a box whose face diagonal passes
487 /// through a cell centre yields crossings `[0, 0, 24, 24]`, pairs them as `(0, 0)` and `(24, 24)`,
488 /// fills nothing, and reports no error. A cube at any resolution loses a whole diagonal plane of
489 /// rows that way, silently. The row has to be retried on a moved ray instead.
490 Degenerate,
491}
492
493/// Where a ray along `+x` through `(y, z)` meets a triangle.
494///
495/// Möller–Trumbore reduced to a fixed direction, with the degenerate cases separated out rather than
496/// rounded into an answer. Both thresholds are on **scale-free** quantities so a mesh in metres and the
497/// same mesh in millimetres are judged alike:
498///
499/// - `u`, `v` and `1 − u − v` are barycentric, and near zero means the hit is on an edge;
500/// - `det / |e1 × e2|` is exactly `−n·x̂`, the triangle normal's component along the ray, so near zero
501/// means edge-on. A triangle *at* zero contributes nothing and is a miss; one merely close gives a
502/// crossing position divided by that small number, which is a position not worth having.
503fn hit_x(a: DVec3, b: DVec3, c: DVec3, y: f64, z: f64) -> Hit {
504 // Both are six orders above the rounding and four below anything a real mesh does on purpose, and
505 // that gap is the whole justification — neither is a measurement.
506 //
507 // `u` and `v` come out of two divisions and a handful of products, so a well-conditioned triangle
508 // carries a few ulps and a sliver a few thousand; `1e-9` leaves six orders over the worst of that. In
509 // the other direction, a *deliberate* feature 1e-9 of a facet across is below any manufacturing
510 // tolerance and below `f32`, which is what the file it came from stores. So the band between "this is
511 // rounding" and "this is geometry" is wide, and where in it the threshold sits does not matter.
512 //
513 // What is *not* claimed: the tests do not distinguish `1e-9` from any value between about `1e-16` and
514 // `1e-4`, because the case they exercise — a cube's face diagonal — is degenerate exactly rather than
515 // nearly. Finding a mesh that lands in between is possible and has not been done.
516 const ON_EDGE: f64 = 1e-9;
517 const GRAZING: f64 = 1e-9;
518
519 let origin = DVec3::new(0.0, y, z);
520 let direction = DVec3::X;
521 let (e1, e2) = (b - a, c - a);
522 let twice_area = e1.cross(e2).length();
523 if twice_area == 0.0 {
524 // A degenerate triangle: three collinear vertices, which exporters do produce. It has no
525 // inside, so it is on no row.
526 return Hit::Miss;
527 }
528 let h = direction.cross(e2);
529 let det = e1.dot(h);
530 let along = det / twice_area;
531 if along == 0.0 {
532 return Hit::Miss;
533 }
534 if along.abs() < GRAZING {
535 return Hit::Degenerate;
536 }
537 let inv = 1.0 / det;
538 let s = origin - a;
539 let u = inv * s.dot(h);
540 let q = s.cross(e1);
541 let v = inv * direction.dot(q);
542 let w = 1.0 - u - v;
543 if u < -ON_EDGE || v < -ON_EDGE || w < -ON_EDGE {
544 return Hit::Miss;
545 }
546 if u < ON_EDGE || v < ON_EDGE || w < ON_EDGE {
547 return Hit::Degenerate;
548 }
549 Hit::At(inv * e2.dot(q))
550}