Skip to main content

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}