rustial_engine/shapes.rs
1// ---------------------------------------------------------------------------
2//! # Shape generators -- create geographic geometries from parameters
3//!
4//! Generate common geometric shapes as [`GeoCoord`] rings suitable for
5//! use with the tessellator, vector layers, or direct rendering:
6//!
7//! - [`circle`] -- regular polygon approximating a circle
8//! - [`arc`] -- partial circle (sector boundary)
9//! - [`ellipse`] -- elliptical ring
10//! - [`rectangle`] -- axis-aligned geographic rectangle
11//! - [`regular_polygon`] -- equilateral N-gon
12//! - [`sector`] -- pie-slice shape (arc closed to centre)
13//! - [`line_along`] -- sample points along a great-circle path
14//!
15//! All generators produce `Vec<GeoCoord>` suitable for constructing
16//! [`Polygon`](crate::geometry::Polygon) exteriors or
17//! [`LineString`](crate::geometry::LineString) coordinates.
18//!
19//! Distance calculations use the geodesic destination formula on the
20//! WGS-84 sphere (radius 6,378,137 m).
21// ---------------------------------------------------------------------------
22
23use rustial_math::GeoCoord;
24
25/// Mean radius of the Earth in meters (WGS-84 semi-major axis).
26const EARTH_RADIUS: f64 = 6_378_137.0;
27
28/// Compute a destination point given a start, bearing (degrees), and
29/// distance (meters) on the WGS-84 sphere.
30fn destination(from: &GeoCoord, bearing_deg: f64, distance_m: f64) -> GeoCoord {
31 let lat1 = from.lat.to_radians();
32 let lon1 = from.lon.to_radians();
33 let brng = bearing_deg.to_radians();
34 let d = distance_m / EARTH_RADIUS;
35
36 let lat2 = (lat1.sin() * d.cos() + lat1.cos() * d.sin() * brng.cos()).asin();
37 let lon2 = lon1 + (brng.sin() * d.sin() * lat1.cos()).atan2(d.cos() - lat1.sin() * lat2.sin());
38
39 GeoCoord::from_lat_lon(lat2.to_degrees(), lon2.to_degrees())
40}
41
42// ---------------------------------------------------------------------------
43// Circle
44// ---------------------------------------------------------------------------
45
46/// Generate a circle as a closed polygon ring.
47///
48/// # Arguments
49///
50/// - `center` -- centre of the circle.
51/// - `radius_m` -- radius in meters.
52/// - `segments` -- number of line segments (more = smoother).
53/// Clamped to a minimum of 4.
54///
55/// The returned ring is closed (first == last) and counter-clockwise.
56pub fn circle(center: &GeoCoord, radius_m: f64, segments: usize) -> Vec<GeoCoord> {
57 let n = segments.max(4);
58 let step = 360.0 / n as f64;
59 let mut ring = Vec::with_capacity(n + 1);
60 for i in 0..n {
61 ring.push(destination(center, i as f64 * step, radius_m));
62 }
63 ring.push(ring[0]); // close
64 ring
65}
66
67// ---------------------------------------------------------------------------
68// Arc
69// ---------------------------------------------------------------------------
70
71/// Generate an arc (partial circle) as a polyline.
72///
73/// # Arguments
74///
75/// - `center` -- centre point.
76/// - `radius_m` -- radius in meters.
77/// - `start_bearing` -- start angle in degrees (0 = north, 90 = east).
78/// - `end_bearing` -- end angle in degrees (swept clockwise from start).
79/// - `segments` -- number of segments in the arc (clamped to min 2).
80///
81/// Returns a list of points along the arc from `start_bearing` to
82/// `end_bearing`. Does **not** close the ring.
83pub fn arc(
84 center: &GeoCoord,
85 radius_m: f64,
86 start_bearing: f64,
87 end_bearing: f64,
88 segments: usize,
89) -> Vec<GeoCoord> {
90 let n = segments.max(2);
91 let mut sweep = end_bearing - start_bearing;
92 if sweep <= 0.0 {
93 sweep += 360.0;
94 }
95 let step = sweep / n as f64;
96 let mut points = Vec::with_capacity(n + 1);
97 for i in 0..=n {
98 let bearing = start_bearing + i as f64 * step;
99 points.push(destination(center, bearing, radius_m));
100 }
101 points
102}
103
104// ---------------------------------------------------------------------------
105// Sector (pie slice)
106// ---------------------------------------------------------------------------
107
108/// Generate a sector (pie slice) as a closed polygon ring.
109///
110/// The sector starts at the centre, sweeps from `start_bearing` to
111/// `end_bearing` along the arc, and closes back to the centre.
112pub fn sector(
113 center: &GeoCoord,
114 radius_m: f64,
115 start_bearing: f64,
116 end_bearing: f64,
117 segments: usize,
118) -> Vec<GeoCoord> {
119 let arc_points = arc(center, radius_m, start_bearing, end_bearing, segments);
120 let mut ring = Vec::with_capacity(arc_points.len() + 2);
121 ring.push(*center);
122 ring.extend(arc_points);
123 ring.push(*center); // close
124 ring
125}
126
127// ---------------------------------------------------------------------------
128// Ellipse
129// ---------------------------------------------------------------------------
130
131/// Generate an ellipse as a closed polygon ring.
132///
133/// # Arguments
134///
135/// - `center` -- centre of the ellipse.
136/// - `semi_major_m` -- semi-major axis in meters (along `rotation`).
137/// - `semi_minor_m` -- semi-minor axis in meters (perpendicular).
138/// - `rotation_deg` -- rotation of the semi-major axis in degrees
139/// clockwise from north.
140/// - `segments` -- number of segments (clamped to min 4).
141pub fn ellipse(
142 center: &GeoCoord,
143 semi_major_m: f64,
144 semi_minor_m: f64,
145 rotation_deg: f64,
146 segments: usize,
147) -> Vec<GeoCoord> {
148 let n = segments.max(4);
149 let step = std::f64::consts::TAU / n as f64;
150 let rot = rotation_deg.to_radians();
151 let mut ring = Vec::with_capacity(n + 1);
152 for i in 0..n {
153 let angle = i as f64 * step;
154 // Parametric ellipse in local frame.
155 let lx = semi_major_m * angle.cos();
156 let ly = semi_minor_m * angle.sin();
157 // Rotate into geographic bearing.
158 let bearing = (lx * rot.sin() + ly * rot.cos()).atan2(lx * rot.cos() - ly * rot.sin());
159 let dist = (lx * lx + ly * ly).sqrt();
160 ring.push(destination(center, bearing.to_degrees(), dist));
161 }
162 ring.push(ring[0]); // close
163 ring
164}
165
166// ---------------------------------------------------------------------------
167// Rectangle
168// ---------------------------------------------------------------------------
169
170/// Generate an axis-aligned geographic rectangle as a closed polygon ring.
171///
172/// # Arguments
173///
174/// - `sw` -- southwest corner (min lat, min lon).
175/// - `ne` -- northeast corner (max lat, max lon).
176///
177/// Returns a CCW ring: SW → SE → NE → NW → SW.
178pub fn rectangle(sw: &GeoCoord, ne: &GeoCoord) -> Vec<GeoCoord> {
179 vec![
180 GeoCoord::from_lat_lon(sw.lat, sw.lon),
181 GeoCoord::from_lat_lon(sw.lat, ne.lon),
182 GeoCoord::from_lat_lon(ne.lat, ne.lon),
183 GeoCoord::from_lat_lon(ne.lat, sw.lon),
184 GeoCoord::from_lat_lon(sw.lat, sw.lon), // close
185 ]
186}
187
188// ---------------------------------------------------------------------------
189// Regular polygon
190// ---------------------------------------------------------------------------
191
192/// Generate a regular N-gon as a closed polygon ring.
193///
194/// # Arguments
195///
196/// - `center` -- centre of the polygon.
197/// - `radius_m` -- circumscribed circle radius in meters.
198/// - `sides` -- number of sides (clamped to min 3).
199/// - `rotation_deg` -- rotation of the first vertex in degrees
200/// clockwise from north.
201pub fn regular_polygon(
202 center: &GeoCoord,
203 radius_m: f64,
204 sides: usize,
205 rotation_deg: f64,
206) -> Vec<GeoCoord> {
207 let n = sides.max(3);
208 let step = 360.0 / n as f64;
209 let mut ring = Vec::with_capacity(n + 1);
210 for i in 0..n {
211 let bearing = rotation_deg + i as f64 * step;
212 ring.push(destination(center, bearing, radius_m));
213 }
214 ring.push(ring[0]); // close
215 ring
216}
217
218// ---------------------------------------------------------------------------
219// Line along (great-circle sampling)
220// ---------------------------------------------------------------------------
221
222/// Sample `count` evenly-spaced points along the great-circle path
223/// between `from` and `to`.
224///
225/// Returns `count` points including both endpoints. If `count < 2`,
226/// returns just `[from, to]`.
227pub fn line_along(from: &GeoCoord, to: &GeoCoord, count: usize) -> Vec<GeoCoord> {
228 let n = count.max(2);
229 let mut points = Vec::with_capacity(n);
230 for i in 0..n {
231 let fraction = i as f64 / (n - 1) as f64;
232 points.push(crate::geometry_ops::interpolate_great_circle(from, to, fraction));
233 }
234 points
235}
236
237// ---------------------------------------------------------------------------
238// Tests
239// ---------------------------------------------------------------------------
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn circle_has_correct_vertex_count() {
247 let center = GeoCoord::from_lat_lon(0.0, 0.0);
248 let ring = circle(¢er, 1000.0, 64);
249 // 64 segments + 1 closing vertex.
250 assert_eq!(ring.len(), 65);
251 // First and last should match.
252 assert!((ring[0].lat - ring[64].lat).abs() < 1e-10);
253 assert!((ring[0].lon - ring[64].lon).abs() < 1e-10);
254 }
255
256 #[test]
257 fn circle_radius_is_approximately_correct() {
258 let center = GeoCoord::from_lat_lon(0.0, 0.0);
259 let radius = 10_000.0; // 10 km
260 let ring = circle(¢er, radius, 64);
261 // Check that each vertex is approximately `radius` meters from centre.
262 for v in &ring[..64] {
263 let d = crate::geometry_ops::haversine(¢er, v);
264 assert!(
265 (d - radius).abs() < 1.0,
266 "expected ~{radius}m, got {d}m"
267 );
268 }
269 }
270
271 #[test]
272 fn arc_spans_correct_angles() {
273 let center = GeoCoord::from_lat_lon(0.0, 0.0);
274 let points = arc(¢er, 1000.0, 0.0, 90.0, 4);
275 // 4 segments + 1 = 5 points.
276 assert_eq!(points.len(), 5);
277 // First point should be roughly north.
278 assert!(points[0].lat > center.lat);
279 // Last point should be roughly east.
280 assert!(points[4].lon > center.lon);
281 }
282
283 #[test]
284 fn rectangle_has_five_vertices() {
285 let sw = GeoCoord::from_lat_lon(0.0, 0.0);
286 let ne = GeoCoord::from_lat_lon(1.0, 1.0);
287 let ring = rectangle(&sw, &ne);
288 assert_eq!(ring.len(), 5);
289 assert_eq!(ring[0].lat, ring[4].lat);
290 assert_eq!(ring[0].lon, ring[4].lon);
291 }
292
293 #[test]
294 fn regular_polygon_triangle() {
295 let center = GeoCoord::from_lat_lon(0.0, 0.0);
296 let ring = regular_polygon(¢er, 1000.0, 3, 0.0);
297 assert_eq!(ring.len(), 4); // 3 + closing
298 }
299
300 #[test]
301 fn sector_includes_center() {
302 let center = GeoCoord::from_lat_lon(10.0, 20.0);
303 let ring = sector(¢er, 5000.0, 0.0, 90.0, 8);
304 // First vertex should be the center.
305 assert_eq!(ring[0].lat, center.lat);
306 assert_eq!(ring[0].lon, center.lon);
307 // Last vertex should also be the center (closing).
308 let last = ring.last().unwrap();
309 assert_eq!(last.lat, center.lat);
310 assert_eq!(last.lon, center.lon);
311 }
312
313 #[test]
314 fn ellipse_has_correct_vertex_count() {
315 let center = GeoCoord::from_lat_lon(0.0, 0.0);
316 let ring = ellipse(¢er, 2000.0, 1000.0, 45.0, 32);
317 assert_eq!(ring.len(), 33); // 32 + closing
318 }
319
320 #[test]
321 fn line_along_endpoints() {
322 let a = GeoCoord::from_lat_lon(0.0, 0.0);
323 let b = GeoCoord::from_lat_lon(10.0, 0.0);
324 let pts = line_along(&a, &b, 5);
325 assert_eq!(pts.len(), 5);
326 assert!((pts[0].lat - a.lat).abs() < 1e-10);
327 assert!((pts[4].lat - b.lat).abs() < 0.01);
328 }
329
330 #[test]
331 fn circle_minimum_segments() {
332 let center = GeoCoord::from_lat_lon(0.0, 0.0);
333 let ring = circle(¢er, 1000.0, 1); // requested 1, clamped to 4
334 assert_eq!(ring.len(), 5); // 4 + closing
335 }
336}