Skip to main content

rustial_engine/visualization/
geo_grid.rs

1//! Georeferenced regular grid descriptor.
2
3use rustial_math::GeoCoord;
4
5/// A georeferenced regular grid anchored to a geographic origin.
6///
7/// Cell `(0, 0)` is the **north-west** corner. Row index increases
8/// southward, column index increases eastward. All dimensions are in
9/// meters.
10#[derive(Debug, Clone)]
11pub struct GeoGrid {
12    /// Geographic origin (north-west corner of cell `(0, 0)`).
13    pub origin: GeoCoord,
14    /// Number of rows (south-ward).
15    pub rows: usize,
16    /// Number of columns (east-ward).
17    pub cols: usize,
18    /// Cell width in meters (east-west extent per cell).
19    pub cell_width: f64,
20    /// Cell height in meters (north-south extent per cell).
21    pub cell_height: f64,
22    /// Grid rotation in radians (clockwise from north). Default `0.0`.
23    pub rotation: f64,
24    /// Altitude mode for the grid surface.
25    pub altitude_mode: crate::models::AltitudeMode,
26}
27
28/// Approximate meters per degree of latitude (WGS-84 mean).
29const METERS_PER_DEG_LAT: f64 = 111_320.0;
30
31impl GeoGrid {
32    /// Create a new grid with the given dimensions and cell size.
33    ///
34    /// `origin` is the north-west corner. `cell_width` and `cell_height`
35    /// are in meters.
36    pub fn new(
37        origin: GeoCoord,
38        rows: usize,
39        cols: usize,
40        cell_width: f64,
41        cell_height: f64,
42    ) -> Self {
43        Self {
44            origin,
45            rows,
46            cols,
47            cell_width,
48            cell_height,
49            rotation: 0.0,
50            altitude_mode: crate::models::AltitudeMode::ClampToGround,
51        }
52    }
53
54    /// Total number of cells.
55    #[inline]
56    pub fn cell_count(&self) -> usize {
57        self.rows * self.cols
58    }
59
60    /// Geographic coordinate of the centre of cell `(row, col)`.
61    ///
62    /// Returns `None` if `row >= rows` or `col >= cols`.
63    pub fn cell_center(&self, row: usize, col: usize) -> Option<GeoCoord> {
64        if row >= self.rows || col >= self.cols {
65            return None;
66        }
67        // Offset from origin in meters (unrotated).
68        let dx = (col as f64 + 0.5) * self.cell_width;
69        let dy = (row as f64 + 0.5) * self.cell_height;
70
71        // Apply rotation.
72        let (sin_r, cos_r) = self.rotation.sin_cos();
73        let rx = dx * cos_r - dy * sin_r;
74        let ry = dx * sin_r + dy * cos_r;
75
76        Some(offset_geo(&self.origin, rx, ry))
77    }
78
79    /// Geographic bounding box of the grid `(north-west, south-east)`.
80    pub fn geo_bounds(&self) -> (GeoCoord, GeoCoord) {
81        let total_dx = self.cols as f64 * self.cell_width;
82        let total_dy = self.rows as f64 * self.cell_height;
83
84        if self.rotation.abs() < 1e-12 {
85            let se = offset_geo(&self.origin, total_dx, total_dy);
86            return (self.origin, se);
87        }
88
89        // Sample corners and compute bounding box.
90        let corners = [
91            (0.0, 0.0),
92            (total_dx, 0.0),
93            (0.0, total_dy),
94            (total_dx, total_dy),
95        ];
96        let (sin_r, cos_r) = self.rotation.sin_cos();
97        let mut min_lat = f64::MAX;
98        let mut max_lat = f64::MIN;
99        let mut min_lon = f64::MAX;
100        let mut max_lon = f64::MIN;
101        for &(dx, dy) in &corners {
102            let rx = dx * cos_r - dy * sin_r;
103            let ry = dx * sin_r + dy * cos_r;
104            let c = offset_geo(&self.origin, rx, ry);
105            min_lat = min_lat.min(c.lat);
106            max_lat = max_lat.max(c.lat);
107            min_lon = min_lon.min(c.lon);
108            max_lon = max_lon.max(c.lon);
109        }
110        (
111            GeoCoord::from_lat_lon(max_lat, min_lon),
112            GeoCoord::from_lat_lon(min_lat, max_lon),
113        )
114    }
115
116    /// Find the grid cell `(row, col)` containing a geographic coordinate.
117    ///
118    /// Returns `None` if the coordinate is outside the grid.
119    pub fn cell_at_geo(&self, coord: &GeoCoord) -> Option<(usize, usize)> {
120        // Offset from origin in meters.
121        let (dx, dy) = geo_offset(&self.origin, coord);
122
123        // Undo rotation.
124        let (sin_r, cos_r) = self.rotation.sin_cos();
125        let ux = dx * cos_r + dy * sin_r;
126        let uy = -dx * sin_r + dy * cos_r;
127
128        if ux < 0.0 || uy < 0.0 {
129            return None;
130        }
131
132        let col = (ux / self.cell_width) as usize;
133        let row = (uy / self.cell_height) as usize;
134
135        if row < self.rows && col < self.cols {
136            Some((row, col))
137        } else {
138            None
139        }
140    }
141}
142
143/// Offset a `GeoCoord` by `dx` meters east and `dy` meters south.
144fn offset_geo(origin: &GeoCoord, dx_meters: f64, dy_meters: f64) -> GeoCoord {
145    let lat = origin.lat - dy_meters / METERS_PER_DEG_LAT;
146    let cos_lat = origin.lat.to_radians().cos().max(1e-10);
147    let lon = origin.lon + dx_meters / (METERS_PER_DEG_LAT * cos_lat);
148    GeoCoord::from_lat_lon(lat, lon)
149}
150
151/// Compute the offset in meters from `origin` to `coord` (`dx` east, `dy` south).
152fn geo_offset(origin: &GeoCoord, coord: &GeoCoord) -> (f64, f64) {
153    let cos_lat = origin.lat.to_radians().cos().max(1e-10);
154    let dx = (coord.lon - origin.lon) * METERS_PER_DEG_LAT * cos_lat;
155    let dy = (origin.lat - coord.lat) * METERS_PER_DEG_LAT;
156    (dx, dy)
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn cell_center_round_trips_with_cell_at_geo() {
165        let grid = GeoGrid::new(GeoCoord::from_lat_lon(51.1, 17.0), 10, 10, 100.0, 100.0);
166        for row in 0..grid.rows {
167            for col in 0..grid.cols {
168                let center = grid.cell_center(row, col).unwrap();
169                let (r, c) = grid.cell_at_geo(&center).unwrap();
170                assert_eq!((r, c), (row, col), "round-trip failed for ({row}, {col})");
171            }
172        }
173    }
174
175    #[test]
176    fn cell_center_out_of_bounds() {
177        let grid = GeoGrid::new(GeoCoord::from_lat_lon(0.0, 0.0), 5, 5, 50.0, 50.0);
178        assert!(grid.cell_center(5, 0).is_none());
179        assert!(grid.cell_center(0, 5).is_none());
180    }
181
182    #[test]
183    fn cell_at_geo_outside_grid() {
184        let grid = GeoGrid::new(GeoCoord::from_lat_lon(51.1, 17.0), 5, 5, 100.0, 100.0);
185        // Far south of the grid
186        assert!(grid.cell_at_geo(&GeoCoord::from_lat_lon(40.0, 17.0)).is_none());
187        // West of the grid
188        assert!(grid.cell_at_geo(&GeoCoord::from_lat_lon(51.1, 16.0)).is_none());
189    }
190
191    #[test]
192    fn geo_bounds_no_rotation() {
193        let grid = GeoGrid::new(GeoCoord::from_lat_lon(51.1, 17.0), 10, 10, 100.0, 100.0);
194        let (nw, se) = grid.geo_bounds();
195        assert!((nw.lat - 51.1).abs() < 1e-6);
196        assert!((nw.lon - 17.0).abs() < 1e-6);
197        assert!(se.lat < nw.lat);
198        assert!(se.lon > nw.lon);
199    }
200
201    #[test]
202    fn cell_count() {
203        let grid = GeoGrid::new(GeoCoord::from_lat_lon(0.0, 0.0), 3, 7, 10.0, 10.0);
204        assert_eq!(grid.cell_count(), 21);
205    }
206
207    #[test]
208    fn geo_bounds_at_high_latitude() {
209        let grid = GeoGrid::new(GeoCoord::from_lat_lon(70.0, 25.0), 5, 5, 200.0, 200.0);
210        let (nw, se) = grid.geo_bounds();
211        assert!(se.lat < nw.lat);
212        assert!(se.lon > nw.lon);
213    }
214}