diff options
Diffstat (limited to 'crates/gipfel-core/src/panorama.rs')
| -rw-r--r-- | crates/gipfel-core/src/panorama.rs | 502 |
1 files changed, 502 insertions, 0 deletions
diff --git a/crates/gipfel-core/src/panorama.rs b/crates/gipfel-core/src/panorama.rs new file mode 100644 index 0000000..9e27251 --- /dev/null +++ b/crates/gipfel-core/src/panorama.rs @@ -0,0 +1,502 @@ +//! Panorama: viewpoint, hill visibility (occlusion, refraction) and the +//! link between geographic coordinates and image coordinates. +//! +//! Faithful port of gipfel's `Panorama.cxx`. + +use crate::hill::{Hill, DEG2RAD, PI}; +use crate::projection::{projection_from_type, Projection, ProjectionType, ViewParams}; + +const EARTH_RADIUS: f64 = 6371000.785; + +pub struct Panorama { + pub hills: Vec<Hill>, + /// indices into `hills`, sorted by alpha descending (rebuilt by update_angles) + pub alpha_order: Vec<usize>, + /// indices into `hills`, sorted by alpha descending + pub close: Vec<usize>, + /// indices into `hills` + pub visible: Vec<usize>, + + pub view_name: Option<String>, + /// latitude, radians + pub view_phi: f64, + /// longitude, radians + pub view_lam: f64, + /// meters + pub view_height: f64, + + pub height_dist_ratio: f64, + pub hide_value: f64, + /// Additional visibility limit: maximum angular distance (radians) from + /// the image center. The app sets this to the angle the projection maps + /// to the image edge (the lens' actual field of view, including fitted + /// distortion), so that directions outside the photo — which a distortion + /// model can fold back into the image — neither get markers nor compass + /// ticks. 0 = no additional limit (only the projection's own limit). + pub max_view_angle: f64, + + pub parms: ViewParams, + pub projection_type: ProjectionType, + proj: Box<dyn Projection>, +} + +impl Default for Panorama { + fn default() -> Self { + Self::new() + } +} + +impl Panorama { + pub fn new() -> Self { + Panorama { + hills: Vec::new(), + alpha_order: Vec::new(), + close: Vec::new(), + visible: Vec::new(), + view_name: None, + view_phi: 0.0, + view_lam: 0.0, + view_height: 0.0, + height_dist_ratio: 0.07, + hide_value: 1.2, + max_view_angle: 0.0, + parms: ViewParams::new(), + projection_type: ProjectionType::Rectilinear, + proj: projection_from_type(ProjectionType::Rectilinear), + } + } + + pub fn load_data(&mut self, path: &str) -> std::io::Result<()> { + let mut hills = crate::hill::load(path)?; + crate::hill::mark_duplicates(&mut hills, 0.00001); + self.hills = hills; + self.update_angles(); + Ok(()) + } + + pub fn add_hills(&mut self, extra: &[Hill]) { + self.hills.extend_from_slice(extra); + crate::hill::mark_duplicates(&mut self.hills, 0.00001); + self.update_angles(); + } + + pub fn remove_hills(&mut self, flags: u32) { + self.hills.retain(|m| m.flags & flags == 0); + // indices shifted; rebuild all derived lists + self.alpha_order.clear(); + self.close.clear(); + self.visible.clear(); + } + + pub fn set_viewpoint(&mut self, idx: Option<usize>) { + let Some(idx) = idx else { return }; + let m = &self.hills[idx]; + self.view_phi = m.phi; + self.view_lam = m.lam; + self.view_height = m.height; + self.view_name = Some(m.name.clone()); + self.update_angles(); + } + + /// Fit view parameters to the known hills (indices into `self.hills`). + /// `fit_distortion` enables the 7-parameter fit (k0/k1/x0) for 4+ hills; + /// otherwise the distortion params stay locked at their current values. + pub fn comp_params(&mut self, known: &[usize], fit_distortion: bool) -> Result<(), String> { + let ret = crate::lsq::comp_params(&*self.proj, &self.hills, known, &mut self.parms, fit_distortion); + if ret.is_ok() { + self.update_visible_mountains(Some(known)); + } + ret + } + + /// Like [`Panorama::comp_params`] but the observed hills are an external + /// slice (used by the golden fit tests, mirroring the C harness). + pub fn comp_params_with(&mut self, hills: &[Hill], known: &[usize], fit_distortion: bool) -> Result<(), String> { + let ret = crate::lsq::comp_params(&*self.proj, hills, known, &mut self.parms, fit_distortion); + if ret.is_ok() { + self.update_visible_mountains(None); + } + ret + } + + pub fn set_center_angle(&mut self, deg: f64) { + self.parms.a_center = deg * DEG2RAD; + self.update_visible_mountains(None); + } + + pub fn set_pitch_angle(&mut self, deg: f64) { + self.parms.a_pitch = deg * DEG2RAD; + self.update_coordinates(None); + } + + pub fn set_roll_angle(&mut self, deg: f64) { + self.parms.a_roll = deg * DEG2RAD; + self.update_coordinates(None); + } + + pub fn set_scale(&mut self, s: f64) { + self.parms.scale = s; + self.update_coordinates(None); + } + + pub fn set_distortion_params(&mut self, k0: f64, k1: f64, x0: f64) { + self.parms.k0 = k0; + self.parms.k1 = k1; + self.parms.x0 = x0; + self.update_coordinates(None); + } + + pub fn set_height_dist_ratio(&mut self, r: f64) { + self.height_dist_ratio = r; + self.update_close_mountains(); + } + + pub fn set_view_long(&mut self, deg: f64) { + self.view_lam = deg * DEG2RAD; + self.update_angles(); + } + + pub fn set_view_lat(&mut self, deg: f64) { + self.view_phi = deg * DEG2RAD; + self.update_angles(); + } + + pub fn set_view_height(&mut self, v: f64) { + self.view_height = v; + self.update_angles(); + } + + pub fn set_projection(&mut self, t: ProjectionType) { + self.projection_type = t; + self.proj = projection_from_type(t); + self.update_angles(); + } + + pub fn get_center_angle(&self) -> f64 { + self.parms.a_center / DEG2RAD + } + pub fn get_pitch_angle(&self) -> f64 { + self.parms.a_pitch / DEG2RAD + } + pub fn get_roll_angle(&self) -> f64 { + self.parms.a_roll / DEG2RAD + } + pub fn get_scale(&self) -> f64 { + self.parms.scale + } + pub fn get_view_long(&self) -> f64 { + self.view_lam / DEG2RAD + } + pub fn get_view_lat(&self) -> f64 { + self.view_phi / DEG2RAD + } + pub fn get_view_height(&self) -> f64 { + self.view_height + } + + /// Last hill whose name matches exactly (C keeps the last match). + pub fn get_pos(&self, name: &str) -> Option<usize> { + let mut ret = None; + for (i, m) in self.hills.iter().enumerate() { + if m.name == name { + ret = Some(i); + } + } + ret + } + + pub fn update_angles(&mut self) { + for i in 0..self.hills.len() { + let (phi, lam) = (self.hills[i].phi, self.hills[i].lam); + let d = self.distance(phi, lam); + self.hills[i].dist = d; + if phi != self.view_phi || lam != self.view_lam { + let a = self.alpha(i); + self.hills[i].alph = a; + } + } + + // keep `hills` in insertion order (indices stay stable) and maintain a + // separate alpha-descending order, mirroring the C pointer-array sort + let mut idx: Vec<usize> = (0..self.hills.len()).collect(); + idx.sort_by(|&a, &b| { + self.hills[b] + .alph + .partial_cmp(&self.hills[a].alph) + .unwrap_or(std::cmp::Ordering::Equal) + }); + self.alpha_order = idx; + + self.update_close_mountains(); + } + + /// Mark hills that are hidden behind a nearer, taller neighbour. + /// Faithful port including the original's `fabs(m->alph - n->alph > pi/2)` + /// oddity (comparison inside fabs, i.e. skip when m.alph - n.alph > pi/2). + pub fn mark_hidden(&mut self, hills: &[usize]) { + for i in 0..hills.len() { + let m = hills[i]; + self.hills[m].flags &= !crate::hill::FLAG_HIDDEN; + + if self.hills[m].flags & crate::hill::FLAG_DUPLIC != 0 { + continue; + } + + for j in 0..hills.len() { + let n = hills[j]; + if self.hills[n].flags & crate::hill::FLAG_DUPLIC != 0 + || self.hills[n].flags & crate::hill::FLAG_TRACK_POINT != 0 + { + continue; + } + if n == m || self.hills[m].alph - self.hills[n].alph > PI / 2.0 { + continue; + } + if self.hills[m].dist < self.hills[n].dist + || self.hills[m].a_elev > self.hills[n].a_elev + { + continue; + } + + let h = (self.hills[n].a_elev - self.hills[m].a_elev) + / (self.hills[m].alph - self.hills[n].alph).abs(); + if h.is_infinite() || h > self.hide_value { + self.hills[m].flags |= crate::hill::FLAG_HIDDEN; + } + } + } + } + + pub fn update_close_mountains(&mut self) { + self.close.clear(); + + let order = self.alpha_order.clone(); + for &i in &order { + let m = &self.hills[i]; + if m.flags & crate::hill::FLAG_TRACK_POINT != 0 + || ((m.phi != self.view_phi || m.lam != self.view_lam) + && (m.height / (m.dist * EARTH_RADIUS) > self.height_dist_ratio)) + { + let elev = self.elevation(i); + self.hills[i].a_elev = elev; + self.close.push(i); + } + } + + let close = self.close.clone(); + self.mark_hidden(&close); + self.update_visible_mountains(None); + } + + pub fn update_visible_mountains(&mut self, excluded: Option<&[usize]>) { + self.visible.clear(); + + for k in 0..self.close.len() { + let i = self.close[k]; + if self.is_visible(self.hills[i].alph) { + self.visible.push(i); + self.hills[i].flags |= crate::hill::FLAG_VISIBLE; + } else { + self.hills[i].flags &= !crate::hill::FLAG_VISIBLE; + } + } + + let owned; + let excl: Option<&[usize]> = match excluded { + Some(e) => { + owned = e.to_vec(); + Some(&owned) + } + None => None, + }; + self.update_coordinates(excl); + } + + pub fn update_coordinates(&mut self, excluded: Option<&[usize]>) { + for k in 0..self.visible.len() { + let i = self.visible[k]; + if let Some(e) = excluded { + if e.contains(&i) { + continue; + } + } + let (alph, elev) = (self.hills[i].alph, self.hills[i].a_elev); + let (x, y) = self.proj.get_coordinates(alph, elev, &self.parms); + self.hills[i].x = x; + self.hills[i].y = y; + } + } + + /// angular distance (fraction of earth radius) viewpoint -> (phi, lam) + pub fn distance(&self, phi: f64, lam: f64) -> f64 { + let d_lam = self.view_lam - lam; + + let t1 = (phi.cos() * d_lam.sin()).powi(2); + let t2 = (self.view_phi.cos() * phi.sin() + - self.view_phi.sin() * phi.cos() * d_lam.cos()) + .powi(2); + + (t1 + t2).sqrt().atan2( + self.view_phi.sin() * phi.sin() + self.view_phi.cos() * phi.cos() * d_lam.cos(), + ) + } + + pub fn alpha(&self, idx: usize) -> f64 { + let m = &self.hills[idx]; + let sin_alph = (m.lam - self.view_lam).sin() * m.phi.cos() / m.dist.sin(); + let cos_alph = + (m.phi.sin() - self.view_phi.sin() * m.dist.cos()) / (self.view_phi.cos() * m.dist.sin()); + + (sin_alph.atan2(cos_alph) + 2.0 * PI) % (2.0 * PI) + } + + /// approximation of refraction effect as described by Tom Chester at + /// <http://tchester.org/sgm/analysis/peaks/refraction.html> + pub fn refraction(&self, idx: usize) -> f64 { + let (alpha, t0) = (6.5, 10.0); + + let a = 2.9e-4 * (-self.view_height / 10000.0).exp() / (1.0 + 2.9 * t0 / 760.0); + let b = 2.9 * alpha / (760.0 * (1.0 + 2.9 * t0 / 760.0)); + let c = a * (b - 1.0 / 10.0); + + c * self.get_real_distance(idx) / (2000.0 * (1.0 + a)) + } + + pub fn elevation(&self, idx: usize) -> f64 { + let m = &self.hills[idx]; + let theta = self.refraction(idx); + + let b = m.height + self.get_earth_radius(m.phi); + let c = self.view_height + self.get_earth_radius(self.view_phi); + + ((m.dist.cos() * b - c) / (m.dist.sin() * b)).atan() - theta + } + + /// local distance to center of WGS84 ellipsoid + pub fn get_earth_radius(&self, phi: f64) -> f64 { + let a = 6378137.000f64; + let b = 6356752.315f64; + let ata = phi.tan(); + + a * (ata.powi(2) + 1.0).powf(1.0 / 2.0) + * b.abs() + * (b.powi(2) + a.powi(2) * ata.powi(2)).powf(-1.0 / 2.0) + } + + /// real (line-of-sight) distance in meters + pub fn get_real_distance(&self, idx: usize) -> f64 { + let m = &self.hills[idx]; + let a = self.view_height + self.get_earth_radius(self.view_phi); + let b = m.height + self.get_earth_radius(m.phi); + let gam = m.dist; + + (a.powi(2) + b.powi(2) - 2.0 * a * b * gam.cos()).sqrt() + } + + pub fn is_visible(&self, alph: f64) -> bool { + let mut center_dist = (alph - self.parms.a_center).abs(); + if center_dist > PI { + center_dist = 2.0 * PI - center_dist; + } + if center_dist >= self.proj.get_view_angle() { + return false; + } + self.max_view_angle <= 0.0 || center_dist < self.max_view_angle + } + + /// Forward-project (azimuth, elevation) to image-centered pixel coords. + /// `None` when the direction is not visible or the projection is + /// undefined there (mirrors C `Panorama::get_coordinates` returning 1, + /// plus the NaN guard of C `ScanImage::get_pixel_nearest`). + pub fn project_point(&self, alph: f64, elev: f64) -> Option<(f64, f64)> { + if !self.is_visible(alph) { + return None; + } + let (x, y) = self.proj.get_coordinates(alph, elev, &self.parms); + if x.is_nan() || y.is_nan() { + return None; + } + Some((x, y)) + } + + /// Parameter-only snapshot (no hill data) for resampling: same viewpoint + /// and projection parameters, but without the UI's lens-FOV visibility + /// limit (the stitch output range defines the FOV itself). + pub fn params_snapshot(&self) -> Panorama { + let mut p = Panorama::new(); + p.projection_type = self.projection_type; + p.proj = projection_from_type(self.projection_type); + p.parms = self.parms; + p.view_phi = self.view_phi; + p.view_lam = self.view_lam; + p.view_height = self.view_height; + p + } + + pub fn get_coordinates(&self, alph: f64, elev: f64) -> Option<(f64, f64)> { + if self.is_visible(alph) { + Some(self.proj.get_coordinates(alph, elev, &self.parms)) + } else { + None + } + } + + /// Export rows for the visible hills, mirroring the C `export_hills` + /// output loop: `name\theight\tx\ty\tdistance` with image-center origin. + /// When `require_export` is set, only hills with `FLAG_EXPORT` are listed. + pub fn export_hill_rows(&self, img_w: i64, img_h: i64, require_export: bool) -> Vec<String> { + let mut rows = Vec::new(); + for &i in &self.visible { + let m = &self.hills[i]; + if m.flags & crate::hill::FLAG_DUPLIC != 0 + || m.flags & crate::hill::FLAG_HIDDEN != 0 + || (require_export && m.flags & crate::hill::FLAG_EXPORT == 0) + { + continue; + } + + let x = m.x.round_ties_even() as i64 + img_w / 2; + let y = m.y.round_ties_even() as i64 + img_h / 2; + if x < 0 || x > img_w || y < 0 || y > img_h { + continue; + } + + rows.push(format!( + "{}\t{}\t{}\t{}\t{}", + m.name, + m.height.round_ties_even() as i64, + x, + y, + self.get_real_distance(i).round_ties_even() as i64 + )); + } + rows + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn max_view_angle_limits_visibility() { + let mut pan = Panorama::new(); + pan.set_center_angle(0.0); + let a30 = 30f64.to_radians(); + let a70 = 70f64.to_radians(); + + // rectilinear default limit is +-60 deg + assert!(pan.is_visible(a30)); + assert!(!pan.is_visible(a70)); + + // a tighter lens FOV (say +-35 deg) hides the 40 deg direction that + // the projection's own +-60 deg limit would allow + pan.max_view_angle = 35f64.to_radians(); + assert!(pan.is_visible(a30)); + assert!(!pan.is_visible(40f64.to_radians())); + + // a wider limit never overrides the projection's own bound + pan.max_view_angle = 80f64.to_radians(); + assert!(!pan.is_visible(a70)); + } +} |
