rustial_engine/math/
vertical_perspective.rs1use crate::bounds::GeoBounds;
9use crate::coord::{GeoCoord, WorldCoord};
10use crate::ellipsoid::Ellipsoid;
11use crate::projection::Projection;
12use glam::DVec3;
13
14#[derive(Debug, Clone, Copy, PartialEq)]
16pub struct VerticalPerspective {
17 center: GeoCoord,
18 camera_height: f64,
19}
20
21impl VerticalPerspective {
22 pub fn new(center: GeoCoord, camera_height: f64) -> Self {
27 debug_assert!(camera_height.is_finite() && camera_height > 0.0);
28 Self {
29 center: GeoCoord::from_lat_lon(center.lat, center.lon),
30 camera_height: if camera_height.is_finite() {
31 camera_height.max(1.0)
32 } else {
33 1.0
34 },
35 }
36 }
37
38 #[inline]
40 pub fn center(&self) -> GeoCoord {
41 self.center
42 }
43
44 #[inline]
46 pub fn camera_height(&self) -> f64 {
47 self.camera_height
48 }
49
50 #[inline]
52 pub fn radius(&self) -> f64 {
53 Ellipsoid::WGS84.a
54 }
55
56 #[inline]
58 pub fn horizon_central_angle(&self) -> f64 {
59 (self.radius() / (self.radius() + self.camera_height)).acos()
60 }
61
62 pub fn project_checked(&self, geo: &GeoCoord) -> Option<WorldCoord> {
64 let r = self.radius();
65 let h = self.camera_height;
66 let (east, north, up) = self.local_basis();
67 let p = self.surface_unit(geo);
68 let cos_c = up.dot(p);
69 let visible_limit = r / (r + h);
70 if cos_c < visible_limit {
71 return None;
72 }
73
74 let scale = h / (r + h - r * cos_c);
75 let x = scale * r * east.dot(p);
76 let y = scale * r * north.dot(p);
77 Some(WorldCoord::new(x, y, geo.alt))
78 }
79
80 fn local_basis(&self) -> (DVec3, DVec3, DVec3) {
81 let lat0 = self.center.lat.to_radians();
82 let lon0 = self.center.lon.to_radians();
83 let (sin_lat0, cos_lat0) = lat0.sin_cos();
84 let (sin_lon0, cos_lon0) = lon0.sin_cos();
85
86 let east = DVec3::new(-sin_lon0, cos_lon0, 0.0);
87 let north = DVec3::new(-sin_lat0 * cos_lon0, -sin_lat0 * sin_lon0, cos_lat0);
88 let up = DVec3::new(cos_lat0 * cos_lon0, cos_lat0 * sin_lon0, sin_lat0);
89 (east, north, up)
90 }
91
92 fn surface_unit(&self, geo: &GeoCoord) -> DVec3 {
93 let lat = geo.lat.to_radians();
94 let lon = geo.lon.to_radians();
95 let (sin_lat, cos_lat) = lat.sin_cos();
96 let (sin_lon, cos_lon) = lon.sin_cos();
97 DVec3::new(cos_lat * cos_lon, cos_lat * sin_lon, sin_lat)
98 }
99}
100
101impl Projection for VerticalPerspective {
102 fn project(&self, geo: &GeoCoord) -> WorldCoord {
103 self.project_checked(geo)
104 .unwrap_or_else(|| WorldCoord::new(f64::NAN, f64::NAN, geo.alt))
105 }
106
107 fn unproject(&self, world: &WorldCoord) -> GeoCoord {
108 let r = self.radius();
109 let h = self.camera_height;
110 let (east, north, up) = self.local_basis();
111 let x = world.position.x;
112 let y = world.position.y;
113 let alt = world.position.z;
114
115 let a = x * x + y * y + h * h;
116 let b = -2.0 * h * (r + h);
117 let c = h * (2.0 * r + h);
118 let disc = b * b - 4.0 * a * c;
119 if disc < 0.0 {
120 return GeoCoord::new(f64::NAN, f64::NAN, alt);
121 }
122
123 let s = (-b - disc.sqrt()) / (2.0 * a);
124 let local = DVec3::new(s * x, s * y, r + h - s * h);
125 let ecef = east * local.x + north * local.y + up * local.z;
126 let unit = ecef.normalize_or_zero();
127 if unit.length_squared() <= 0.0 {
128 return GeoCoord::new(f64::NAN, f64::NAN, alt);
129 }
130
131 let lat = unit.z.asin().to_degrees();
132 let lon = unit.y.atan2(unit.x).to_degrees();
133 GeoCoord::new(lat, lon, alt)
134 }
135
136 fn scale_factor(&self, geo: &GeoCoord) -> f64 {
137 let r = self.radius();
138 let h = self.camera_height;
139 let p = self.surface_unit(geo);
140 let (_, _, up) = self.local_basis();
141 let cos_c = up.dot(p);
142 let denom = r + h - r * cos_c;
143 ((h * (r + h)) / (denom * denom)).max(0.0)
144 }
145
146 fn projection_bounds(&self) -> GeoBounds {
147 let angle = self.horizon_central_angle().to_degrees();
148 GeoBounds::new(
149 GeoCoord::new(
150 (self.center.lat - angle).max(-90.0),
151 -180.0,
152 0.0,
153 ),
154 GeoCoord::new(
155 (self.center.lat + angle).min(90.0),
156 180.0,
157 0.0,
158 ),
159 )
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn center_projects_to_origin() {
169 let proj = VerticalPerspective::new(GeoCoord::from_lat_lon(0.0, 0.0), 5_000_000.0);
170 let world = proj.project(&GeoCoord::from_lat_lon(0.0, 0.0));
171 assert!(world.position.x.abs() < 1e-8);
172 assert!(world.position.y.abs() < 1e-8);
173 }
174
175 #[test]
176 fn roundtrip_visible_point() {
177 let proj = VerticalPerspective::new(GeoCoord::from_lat_lon(0.0, 0.0), 8_000_000.0);
178 let geo = GeoCoord::new(10.0, 15.0, 250.0);
179 let world = proj.project_checked(&geo).expect("visible point");
180 let back = proj.unproject(&world);
181 assert!((back.lat - geo.lat).abs() < 1e-6);
182 assert!((back.lon - geo.lon).abs() < 1e-6);
183 assert!((back.alt - geo.alt).abs() < 1e-10);
184 }
185
186 #[test]
187 fn checked_projection_rejects_far_side_point() {
188 let proj = VerticalPerspective::new(GeoCoord::from_lat_lon(0.0, 0.0), 1_000_000.0);
189 assert!(proj.project_checked(&GeoCoord::from_lat_lon(0.0, 120.0)).is_none());
190 }
191
192 #[test]
193 fn projection_returns_nan_for_invisible_point() {
194 let proj = VerticalPerspective::new(GeoCoord::from_lat_lon(0.0, 0.0), 1_000_000.0);
195 let world = proj.project(&GeoCoord::from_lat_lon(0.0, 120.0));
196 assert!(world.position.x.is_nan());
197 assert!(world.position.y.is_nan());
198 }
199
200 #[test]
201 fn horizon_angle_grows_with_camera_height() {
202 let low = VerticalPerspective::new(GeoCoord::from_lat_lon(0.0, 0.0), 1_000_000.0);
203 let high = VerticalPerspective::new(GeoCoord::from_lat_lon(0.0, 0.0), 20_000_000.0);
204 assert!(high.horizon_central_angle() > low.horizon_central_angle());
205 }
206}