Skip to main content

lindhard/
geometry.rs

1//! Target geometry: the engine-facing [`Geometry`] trait, the 1D layered
2//! [`Stack`], the 3D [`VoxelGrid`], the triangle-mesh [`MeshGeometry`] and the
3//! constructive-solid-geometry [`CsgGeometry`].
4//!
5//! # Coordinates
6//!
7//! `x` is depth, measured from the front surface (`x = 0`) into the target;
8//! `y` and `z` are lateral. Lengths are metres (SI, see [`crate::units`]).
9//! A [`Stack`] is an ordered list of layers, each homogeneous and laterally
10//! infinite (the planar slab model of amorphous-target binary-collision codes,
11//! e.g. J. P. Biersack and L. G. Haggmark, Nucl. Instrum. Methods 174 (1980)
12//! 257). The last layer may be semi-infinite (a substrate); otherwise the
13//! target has a back face at its total thickness.
14//!
15//! # The `Geometry` contract
16//!
17//! A geometry is a set of numbered *regions* (layers of a stack, voxels of a
18//! grid), each filled by one of a numbered list of *materials*. A transport
19//! engine keeps a particle's current region, asks [`Geometry::exit`] how far
20//! a straight flight can go before something changes, truncates the free path
21//! there, and carries the unused optical depth into the next region (the
22//! same contract as the layer interface of the M0 engine). "Something
23//! changes" means the material changes, the particle leaves the target, or
24//! it wraps through a periodic boundary; crossing into a region of the same
25//! material is *not* an event, so a voxel boundary inside a homogeneous block
26//! costs nothing. Per-material data (scattering, stopping) is cached by
27//! material index, never per region.
28//!
29//! # Voxel grids
30//!
31//! [`VoxelGrid`] is a regular box `[0, Lx] x [0, Ly] x [0, Lz]`, with
32//! `L = n h` for `n` voxels of spacing `h` on each axis, and the origin at the
33//! corner of the front face (`x = 0` is the front surface, so a grid can
34//! encode a [`Stack`] with the layers along `x`). Voxel `(i, j, k)` has the
35//! flat region index `i + nx (j + ny k)` (x fastest). It owns the half-open
36//! box `[i h, (i + 1) h)` on each axis, so a point exactly on an interior
37//! face belongs to the voxel with the higher index, as a depth exactly on a
38//! layer interface belongs to the deeper layer of a stack.
39//!
40//! Each axis is either [`Boundary::Vacuum`] (a particle reaching either end
41//! of the axis leaves the target) or [`Boundary::Periodic`] (it re-enters at
42//! the opposite face with the same direction; the travelled length and so the
43//! energy loss are those of the unwrapped path). Faces on the `x` axis are
44//! reported as [`Face::Front`] (`x = 0`) and [`Face::Back`] (`x = Lx`); faces
45//! on `y` and `z` are [`Face::Side`].
46//!
47//! The exact ray traversal is the 3D digital differential analyser of
48//! J. Amanatides and A. Woo, "A fast voxel traversal algorithm for ray
49//! tracing", Eurographics '87 (1987) 3-10: the distance to the next face of
50//! each axis is `(face - position) / direction`, the axis with the smallest
51//! distance is crossed, and the face distances are recomputed from the face
52//! coordinates (not accumulated), so rounding does not drift along a long
53//! ray. Axes with a zero direction component are never crossed. When several
54//! axes reach a face at exactly the same distance (an edge or corner), they
55//! are crossed together, so the ray passes directly to the diagonal voxel
56//! without visiting the voxels it only touches. If a tied crossing leaves the
57//! target through several vacuum faces, the face of the lowest axis (`x`,
58//! then `y`, then `z`) is reported.
59
60//! # Triangle meshes
61//!
62//! [`MeshGeometry`] (module [`mesh`]) is a set of closed STL/OBJ triangle
63//! solids, one region each, with a BVH for ray queries. Its overlap, surface
64//! and tolerance rules, the `Face` mapping of its escapes, the loaders and the
65//! watertightness rules are in the [`mesh`] module docs.
66
67//! # CSG
68//!
69//! [`CsgGeometry`] (module [`csg`]) is a set of [`Csg`] solids built from box,
70//! cylinder and half-space [`Primitive`]s by union, intersection and
71//! difference, one region per top-level solid, classified along each flight by
72//! ray casting. It follows the mesh ownership and tolerance rules, so a box
73//! gives the same events as a CSG solid, a mesh or a voxel grid; the rules,
74//! the handling of unbounded half-spaces and the algorithms are in the
75//! [`csg`] module docs.
76
77mod bvh;
78pub mod csg;
79pub mod mesh;
80mod stack;
81#[cfg(test)]
82mod test_util;
83mod voxel;
84
85pub use csg::{Csg, CsgGeometry, Primitive};
86pub use mesh::{MeshGeometry, TriMesh};
87pub use stack::{Layer, Stack};
88pub use voxel::{Boundary, VoxelGrid};
89
90use crate::material::Material;
91
92/// Errors from building a [`Stack`], a [`VoxelGrid`], a [`MeshGeometry`] or a
93/// [`CsgGeometry`].
94#[derive(Debug, Clone, PartialEq, thiserror::Error)]
95pub enum GeometryError {
96    /// No layers and no substrate.
97    #[error("a stack needs at least one layer or a substrate")]
98    Empty,
99    /// A layer thickness was zero, negative or not finite.
100    #[error("layer {index} has invalid thickness {thickness_m} m: must be finite and positive")]
101    InvalidThickness {
102        /// Layer index (0 = front).
103        index: usize,
104        /// Offending value.
105        thickness_m: f64,
106    },
107    /// A voxel grid needs at least one material.
108    #[error("a voxel grid needs at least one material")]
109    NoMaterials,
110    /// A voxel grid dimension was zero.
111    #[error("voxel grid dimension on axis {axis} is zero")]
112    ZeroDimension {
113        /// Axis (0 = x, 1 = y, 2 = z).
114        axis: usize,
115    },
116    /// A voxel spacing was zero, negative, not finite, or so large that the
117    /// grid extent overflows.
118    #[error("voxel spacing {spacing_m} m on axis {axis} is invalid: must be finite and positive, with a finite extent")]
119    InvalidSpacing {
120        /// Axis (0 = x, 1 = y, 2 = z).
121        axis: usize,
122        /// Offending value.
123        spacing_m: f64,
124    },
125    /// The number of voxels does not fit in `usize`.
126    #[error("voxel grid dimensions {dims:?} overflow the voxel count")]
127    TooManyVoxels {
128        /// Requested dimensions.
129        dims: [usize; 3],
130    },
131    /// The material-index array does not have one entry per voxel.
132    #[error("voxel grid needs {expected} material indices, got {actual}")]
133    CellCountMismatch {
134        /// `nx ny nz`.
135        expected: usize,
136        /// Length supplied.
137        actual: usize,
138    },
139    /// A voxel names a material that does not exist.
140    #[error("voxel {cell} has material index {index}, but there are only {n_materials} materials")]
141    MaterialIndexOutOfRange {
142        /// Flat voxel index.
143        cell: usize,
144        /// Offending material index.
145        index: u32,
146        /// Number of materials supplied.
147        n_materials: usize,
148    },
149    /// A mesh or CSG geometry needs at least one solid.
150    #[error("a mesh or CSG geometry needs at least one solid")]
151    NoSolids,
152    /// A solid names a material that does not exist.
153    #[error(
154        "solid {solid} has material index {index}, but there are only {n_materials} materials"
155    )]
156    SolidMaterialOutOfRange {
157        /// Solid index (listing order).
158        solid: usize,
159        /// Offending material index.
160        index: u32,
161        /// Number of materials supplied.
162        n_materials: usize,
163    },
164    /// A mesh file could not be parsed. `line` is 1-based (0 for a binary file).
165    #[error("{format} parse error (line {line}): {message}")]
166    MeshParse {
167        /// `"STL"` or `"OBJ"`.
168        format: &'static str,
169        /// Line number, or 0 when not applicable.
170        line: usize,
171        /// What was wrong.
172        message: String,
173    },
174    /// The file-unit scale of a mesh loader was not finite and positive.
175    #[error("mesh unit scale {unit_m} m is invalid: must be finite and positive")]
176    MeshInvalidScale {
177        /// Offending value.
178        unit_m: f64,
179    },
180    /// A mesh has no triangles.
181    #[error("the mesh has no triangles")]
182    MeshEmpty,
183    /// A mesh vertex coordinate is NaN or infinite.
184    #[error("triangle {triangle} has a non-finite coordinate")]
185    MeshNonFinite {
186        /// Triangle index (file order).
187        triangle: usize,
188    },
189    /// A triangle has zero (or negligible) area.
190    #[error("triangle {triangle} is degenerate (zero area): vertices {vertices:?}")]
191    MeshDegenerateTriangle {
192        /// Triangle index (file order).
193        triangle: usize,
194        /// Its three vertex positions, m.
195        vertices: [[f64; 3]; 3],
196    },
197    /// An edge belongs to only one triangle: the mesh is not watertight.
198    #[error("mesh is not watertight: edge {edge:?} has no neighbouring triangle traversing it in the opposite direction")]
199    MeshEdgeNotShared {
200        /// The two vertex positions, m.
201        edge: [[f64; 3]; 2],
202    },
203    /// Two triangles traverse an edge in the same direction (inconsistent
204    /// orientation, or more than two triangles on the edge).
205    #[error(
206        "mesh orientation is inconsistent: edge {edge:?} is traversed twice in the same direction"
207    )]
208    MeshInconsistentOrientation {
209        /// The two vertex positions, m.
210        edge: [[f64; 3]; 2],
211    },
212    /// The signed volume is not positive: the normals point inward.
213    #[error("mesh has signed volume {volume_m3} m^3: triangles must be counter-clockwise seen from outside")]
214    MeshNotOutward {
215        /// The signed volume, m^3.
216        volume_m3: f64,
217    },
218    /// A CSG solid has an invalid primitive (non-finite or non-positive
219    /// size, zero axis or normal), an empty union or intersection, or an
220    /// empty bounding box.
221    #[error("CSG solid {solid} is invalid: {reason}")]
222    CsgInvalid {
223        /// Solid index (listing order).
224        solid: usize,
225        /// What was wrong.
226        reason: &'static str,
227    },
228    /// A top-level CSG solid is unbounded (a half-space not bounded by the
229    /// combination it is in; see the [`csg`] module docs).
230    #[error("CSG solid {solid} is unbounded: half-spaces must be bounded by an intersection or difference")]
231    CsgUnbounded {
232        /// Solid index (listing order).
233        solid: usize,
234    },
235}
236
237/// Which face of the target a particle left through.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239pub enum Face {
240    /// The front surface, `x = 0` (backscattering, sputtering).
241    Front,
242    /// The back face, `x = Lx` of a finite stack or voxel grid (transmission).
243    Back,
244    /// A lateral face of a voxel grid, `y = 0`, `y = Ly`, `z = 0` or
245    /// `z = Lz` (only reachable with a [`Boundary::Vacuum`] lateral axis).
246    /// For electrons it releases the particle whatever the `EscapeRule`,
247    /// which only governs the back face.
248    Side,
249}
250
251/// What happens at the end of a flight segment returned by
252/// [`Geometry::exit`].
253#[derive(Debug, Clone, Copy, PartialEq)]
254pub enum ExitOutcome {
255    /// The particle continues in `region` at `pos` (the crossing point,
256    /// wrapped into the domain if a periodic boundary was crossed).
257    Enter {
258        /// The region entered.
259        region: usize,
260        /// Position after the crossing, m.
261        pos: [f64; 3],
262    },
263    /// The particle reaches the target surface.
264    Escape {
265        /// Which face.
266        face: Face,
267        /// Outward unit normal of the face.
268        normal: [f64; 3],
269    },
270}
271
272/// The first event along a straight flight.
273#[derive(Debug, Clone, Copy, PartialEq)]
274pub struct Exit {
275    /// Path length from the start of the flight to the event, m (the flight is
276    /// truncated here).
277    pub distance: f64,
278    /// The point where the event happens, on the crossed face (before any
279    /// periodic wrap), m.
280    pub at: [f64; 3],
281    /// What the event is.
282    pub outcome: ExitOutcome,
283}
284
285/// The outcome of a flight query, [`Geometry::flight`].
286#[derive(Debug, Clone, Copy, PartialEq)]
287pub enum Flight {
288    /// An event happens within the flight limit (see [`Geometry::exit`]).
289    Event(Exit),
290    /// No event within the limit: the whole flight stays in one material,
291    /// and ends in `region`. This may differ from the starting region when
292    /// the flight crossed faces between regions of the same material (voxels
293    /// of one block). A face reached exactly at the limit counts as crossed,
294    /// so `region` is the region the particle is entering there, the same
295    /// directional ownership as an [`ExitOutcome::Enter`].
296    Clear {
297        /// The region containing the end of the flight.
298        region: usize,
299    },
300}
301
302/// The engine-facing description of a target. See the module docs for the
303/// contract. Implemented by [`Stack`], [`VoxelGrid`], [`MeshGeometry`] and
304/// [`CsgGeometry`].
305pub trait Geometry: Sync {
306    /// Number of distinct materials.
307    fn n_materials(&self) -> usize;
308
309    /// Material `index` (`0..n_materials()`).
310    fn material(&self, index: usize) -> &Material;
311
312    /// Number of regions.
313    fn n_regions(&self) -> usize;
314
315    /// Index of the material filling `region`.
316    fn material_index(&self, region: usize) -> usize;
317
318    /// The region containing `pos`, or `None` outside the target (or for a
319    /// non-finite position). Ownership of shared faces: see the module docs.
320    fn locate(&self, pos: [f64; 3]) -> Option<usize>;
321
322    /// Where a beam enters by default: the origin for a [`Stack`], the centre
323    /// of the front face for a [`VoxelGrid`].
324    fn entry_point(&self) -> [f64; 3];
325
326    /// The first event along the straight flight from `pos` (inside `region`,
327    /// or on one of its faces) in the unit direction `dir`, if it happens at
328    /// a path length of at most `limit`; otherwise `None`. The event is a
329    /// change of material, a periodic wrap, or leaving the target.
330    ///
331    /// `dir` must be a finite unit vector; a zero or non-finite direction
332    /// returns `None`.
333    fn exit(&self, region: usize, pos: [f64; 3], dir: [f64; 3], limit: f64) -> Option<Exit>;
334
335    /// [`Geometry::exit`], also reporting the region the flight ends in when
336    /// there is no event, so a transport engine can keep its particle's
337    /// region consistent with its position after crossing same-material
338    /// faces. The default suits a geometry whose regions are each a whole
339    /// material run along every flight (a [`Stack`], where leaving a layer is
340    /// always an event): without an event the region is unchanged.
341    fn flight(&self, region: usize, pos: [f64; 3], dir: [f64; 3], limit: f64) -> Flight {
342        match self.exit(region, pos, dir, limit) {
343            Some(e) => Flight::Event(e),
344            None => Flight::Clear { region },
345        }
346    }
347
348    /// Whether `pos` lies outside the target in vacuum, used to skip weak-
349    /// collision partners that would lie beyond a surface. For a [`Stack`]
350    /// this is in front of the front face only (as in the M0 engine).
351    fn in_vacuum(&self, pos: [f64; 3]) -> bool;
352
353    /// This geometry as a layered [`Stack`], if it is one. Tallies defined
354    /// per layer (dose, depth-dose, interface transmission) need it.
355    fn as_stack(&self) -> Option<&Stack> {
356        None
357    }
358}