Skip to main content

sim_lib_interference_core/
sampling.rs

1//! Checked finite planes for two-dimensional physical sampling.
2
3use crate::{InterferenceError, Point3M, PositiveMetres, UnitVector3};
4
5/// Largest accepted absolute dot product between sampling axes.
6///
7/// Axes are normalized before they enter [`SamplingPlane`]. This tolerance
8/// permits ordinary floating-point construction of rotated frames while still
9/// rejecting skewed grids.
10pub const SAMPLING_AXIS_ORTHOGONALITY_TOLERANCE: f64 = 1.0e-12;
11
12/// A finite rectangular sampling plane embedded in three-dimensional space.
13///
14/// `origin` is the corner before the first cell. Columns advance along `u` and
15/// rows advance along `v`. A cell at `(row, column)` is sampled at its centre:
16///
17/// `origin + (column + 1/2) * extent_u / columns * u`
18/// `       + (row + 1/2) * extent_v / rows * v`.
19///
20/// Construction validates the orthonormal frame, dimensions, derived cell
21/// sizes, and `rows * columns` before any caller needs to allocate grid
22/// storage.
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub struct SamplingPlane {
25    origin: Point3M,
26    u_axis: UnitVector3,
27    v_axis: UnitVector3,
28    normal: UnitVector3,
29    extent_u: PositiveMetres,
30    extent_v: PositiveMetres,
31    rows: usize,
32    columns: usize,
33    cell_count: usize,
34    cell_size_u_m: f64,
35    cell_size_v_m: f64,
36}
37
38impl SamplingPlane {
39    /// Constructs a checked finite sampling plane.
40    #[allow(clippy::too_many_arguments)]
41    pub fn new(
42        origin: Point3M,
43        u_axis: UnitVector3,
44        v_axis: UnitVector3,
45        extent_u: PositiveMetres,
46        extent_v: PositiveMetres,
47        rows: usize,
48        columns: usize,
49    ) -> Result<Self, InterferenceError> {
50        if rows == 0 {
51            return Err(InterferenceError::ZeroSamplingDimension { name: "rows" });
52        }
53        if columns == 0 {
54            return Err(InterferenceError::ZeroSamplingDimension { name: "columns" });
55        }
56        let cell_count = rows
57            .checked_mul(columns)
58            .ok_or(InterferenceError::SamplingCellCountOverflow { rows, columns })?;
59
60        let [ux, uy, uz] = u_axis.components();
61        let [vx, vy, vz] = v_axis.components();
62        let dot_product = ux * vx + uy * vy + uz * vz;
63        if dot_product.abs() > SAMPLING_AXIS_ORTHOGONALITY_TOLERANCE {
64            return Err(InterferenceError::NonOrthogonalSamplingAxes {
65                dot_product,
66                max_abs_dot_product: SAMPLING_AXIS_ORTHOGONALITY_TOLERANCE,
67            });
68        }
69
70        let normal = UnitVector3::new(uy * vz - uz * vy, uz * vx - ux * vz, ux * vy - uy * vx)?;
71        let cell_size_u_m = extent_u.get() / columns as f64;
72        let cell_size_v_m = extent_v.get() / rows as f64;
73        require_cell_size("u", cell_size_u_m)?;
74        require_cell_size("v", cell_size_v_m)?;
75
76        Ok(Self {
77            origin,
78            u_axis,
79            v_axis,
80            normal,
81            extent_u,
82            extent_v,
83            rows,
84            columns,
85            cell_count,
86            cell_size_u_m,
87            cell_size_v_m,
88        })
89    }
90
91    /// Returns the corner before the first cell.
92    pub fn origin(self) -> Point3M {
93        self.origin
94    }
95
96    /// Returns the direction in which columns advance.
97    pub fn u_axis(self) -> UnitVector3 {
98        self.u_axis
99    }
100
101    /// Returns the direction in which rows advance.
102    pub fn v_axis(self) -> UnitVector3 {
103        self.v_axis
104    }
105
106    /// Returns the right-handed unit normal `u cross v`.
107    pub fn normal(self) -> UnitVector3 {
108        self.normal
109    }
110
111    /// Returns the physical `u` extent.
112    pub fn extent_u(self) -> PositiveMetres {
113        self.extent_u
114    }
115
116    /// Returns the physical `v` extent.
117    pub fn extent_v(self) -> PositiveMetres {
118        self.extent_v
119    }
120
121    /// Returns the number of rows along `v`.
122    pub fn rows(self) -> usize {
123        self.rows
124    }
125
126    /// Returns the number of columns along `u`.
127    pub fn columns(self) -> usize {
128        self.columns
129    }
130
131    /// Returns the checked product `rows * columns`.
132    pub fn cell_count(self) -> usize {
133        self.cell_count
134    }
135
136    /// Returns the centre-to-centre spacing along `u` in metres.
137    pub fn cell_size_u_m(self) -> f64 {
138        self.cell_size_u_m
139    }
140
141    /// Returns the centre-to-centre spacing along `v` in metres.
142    pub fn cell_size_v_m(self) -> f64 {
143        self.cell_size_v_m
144    }
145
146    /// Returns the exact centre defined for one sampling cell.
147    pub fn point_at(self, row: usize, column: usize) -> Result<Point3M, InterferenceError> {
148        if row >= self.rows || column >= self.columns {
149            return Err(InterferenceError::SamplingCellOutOfBounds {
150                row,
151                column,
152                rows: self.rows,
153                columns: self.columns,
154            });
155        }
156
157        let offset_u = (column as f64 + 0.5) * self.cell_size_u_m;
158        let offset_v = (row as f64 + 0.5) * self.cell_size_v_m;
159        let [origin_x, origin_y, origin_z] = self.origin.coordinates_metres();
160        let [ux, uy, uz] = self.u_axis.components();
161        let [vx, vy, vz] = self.v_axis.components();
162
163        Point3M::from_metres(
164            origin_x + offset_u * ux + offset_v * vx,
165            origin_y + offset_u * uy + offset_v * vy,
166            origin_z + offset_u * uz + offset_v * vz,
167        )
168    }
169}
170
171fn require_cell_size(axis: &'static str, value: f64) -> Result<(), InterferenceError> {
172    if value.is_finite() && value > 0.0 {
173        Ok(())
174    } else {
175        Err(InterferenceError::InvalidSamplingCellSize { axis, value })
176    }
177}