Skip to main content

nexrad_render/
render_result.rs

1//! Render result types with metadata for geographic placement and data inspection.
2//!
3//! The [`RenderResult`] type bundles a rendered image with all the metadata a consuming
4//! application needs to accurately place it on a map, draw overlays, and inspect data values.
5
6use crate::RgbaImage;
7use nexrad_model::data::{GateStatus, SweepField};
8use nexrad_model::geo::{GeoExtent, GeoPoint, GeoPoint3D, PolarPoint, RadarCoordinateSystem};
9use std::path::Path;
10
11/// The result of a render operation.
12///
13/// Bundles the rendered RGBA image with metadata describing the pixel-to-coordinate
14/// mapping, enabling a consuming application to accurately place the image on a map,
15/// draw overlays, and query data values.
16#[derive(Debug)]
17pub struct RenderResult {
18    image: RgbaImage,
19    metadata: RenderMetadata,
20}
21
22/// Metadata describing the pixel-to-coordinate mapping of a rendered image.
23///
24/// This is everything a consumer needs to place and query the rendered image.
25#[derive(Debug, Clone)]
26pub struct RenderMetadata {
27    pub(crate) width: u32,
28    pub(crate) height: u32,
29    pub(crate) center_pixel: (f64, f64),
30    pub(crate) pixels_per_km: f64,
31    pub(crate) max_range_km: f64,
32    pub(crate) elevation_degrees: Option<f32>,
33    pub(crate) geo_extent: Option<GeoExtent>,
34    pub(crate) coord_system: Option<RadarCoordinateSystem>,
35}
36
37/// Result of querying a data value at a specific point.
38#[derive(Debug, Clone)]
39pub struct PointQuery {
40    /// The polar coordinate of the queried point.
41    pub polar: PolarPoint,
42    /// The geographic coordinate of the queried point (if coordinate system available).
43    pub geo: Option<GeoPoint3D>,
44    /// The data value at the queried point.
45    pub value: f32,
46    /// The gate status at the queried point.
47    pub status: GateStatus,
48}
49
50impl RenderResult {
51    /// Create a new render result from an image and metadata.
52    pub fn new(image: RgbaImage, metadata: RenderMetadata) -> Self {
53        Self { image, metadata }
54    }
55
56    /// The rendered RGBA image.
57    pub fn image(&self) -> &RgbaImage {
58        &self.image
59    }
60
61    /// Consume the result and return the image.
62    pub fn into_image(self) -> RgbaImage {
63        self.image
64    }
65
66    /// Metadata describing the pixel-to-coordinate mapping.
67    pub fn metadata(&self) -> &RenderMetadata {
68        &self.metadata
69    }
70
71    /// Save the rendered image to a file.
72    ///
73    /// The output format is inferred from the file extension (e.g., `.png`, `.jpg`).
74    /// This is a convenience wrapper around [`image::RgbaImage::save`].
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if the file cannot be written or the format is unsupported.
79    pub fn save<P: AsRef<Path>>(&self, path: P) -> crate::result::Result<()> {
80        self.image
81            .save(path)
82            .map_err(|e| crate::result::Error::ImageSave(e.to_string()))
83    }
84
85    /// Query the data value at a pixel coordinate.
86    ///
87    /// Uses the metadata's pixel-to-polar conversion and then looks up the value
88    /// in the provided field. Returns `None` if the pixel is outside the rendered area.
89    pub fn query_pixel(&self, field: &SweepField, x: f64, y: f64) -> Option<PointQuery> {
90        let polar = self.metadata.pixel_to_polar(x, y)?;
91        self.build_query(field, polar)
92    }
93
94    /// Query the data value at a polar coordinate.
95    pub fn query_polar(
96        &self,
97        field: &SweepField,
98        azimuth_degrees: f32,
99        range_km: f64,
100    ) -> Option<PointQuery> {
101        let polar = PolarPoint {
102            azimuth_degrees,
103            range_km,
104            elevation_degrees: self.metadata.elevation_degrees.unwrap_or(0.0),
105        };
106        self.build_query(field, polar)
107    }
108
109    /// Query the data value at a geographic coordinate.
110    ///
111    /// Requires a coordinate system in the metadata. Returns `None` if no coordinate
112    /// system is available or the point is outside the rendered area.
113    pub fn query_geo(&self, field: &SweepField, point: &GeoPoint) -> Option<PointQuery> {
114        let coord_system = self.metadata.coord_system.as_ref()?;
115        let elevation = self.metadata.elevation_degrees.unwrap_or(0.0);
116        let polar = coord_system.geo_to_polar(*point, elevation);
117        self.build_query(field, polar)
118    }
119
120    fn build_query(&self, field: &SweepField, polar: PolarPoint) -> Option<PointQuery> {
121        let (value, status) = field.value_at_polar(polar.azimuth_degrees, polar.range_km)?;
122
123        let geo = self
124            .metadata
125            .coord_system
126            .as_ref()
127            .map(|cs| cs.polar_to_geo(polar));
128
129        Some(PointQuery {
130            polar,
131            geo,
132            value,
133            status,
134        })
135    }
136}
137
138impl RenderMetadata {
139    /// Image width in pixels.
140    pub fn width(&self) -> u32 {
141        self.width
142    }
143
144    /// Image height in pixels.
145    pub fn height(&self) -> u32 {
146        self.height
147    }
148
149    /// Center of the image in pixel coordinates.
150    pub fn center_pixel(&self) -> (f64, f64) {
151        self.center_pixel
152    }
153
154    /// Scale factor: pixels per kilometer.
155    pub fn pixels_per_km(&self) -> f64 {
156        self.pixels_per_km
157    }
158
159    /// Maximum range of the rendered data in km.
160    pub fn max_range_km(&self) -> f64 {
161        self.max_range_km
162    }
163
164    /// The elevation angle of the rendered sweep (if applicable).
165    pub fn elevation_degrees(&self) -> Option<f32> {
166        self.elevation_degrees
167    }
168
169    /// Geographic extent of the rendered area (if coordinate system was provided).
170    pub fn geo_extent(&self) -> Option<&GeoExtent> {
171        self.geo_extent.as_ref()
172    }
173
174    /// The coordinate system used (if available), enabling conversion between
175    /// pixel, polar, and geographic coordinates.
176    pub fn coord_system(&self) -> Option<&RadarCoordinateSystem> {
177        self.coord_system.as_ref()
178    }
179
180    /// Convert a pixel coordinate to polar (azimuth, range) coordinates.
181    ///
182    /// Returns `None` if the pixel is outside the rendered radar coverage area.
183    pub fn pixel_to_polar(&self, x: f64, y: f64) -> Option<PolarPoint> {
184        let dx = x - self.center_pixel.0;
185        let dy = y - self.center_pixel.1;
186        let distance_px = (dx * dx + dy * dy).sqrt();
187        let range_km = distance_px / self.pixels_per_km;
188
189        if range_km > self.max_range_km {
190            return None;
191        }
192
193        let azimuth_rad = dx.atan2(-dy);
194        let azimuth_degrees = (azimuth_rad.to_degrees() + 360.0) % 360.0;
195
196        Some(PolarPoint {
197            azimuth_degrees: azimuth_degrees as f32,
198            range_km,
199            elevation_degrees: self.elevation_degrees.unwrap_or(0.0),
200        })
201    }
202
203    /// Convert polar coordinates to a pixel coordinate.
204    pub fn polar_to_pixel(&self, azimuth_degrees: f32, range_km: f64) -> (f64, f64) {
205        let az_rad = (azimuth_degrees as f64).to_radians();
206        let distance_px = range_km * self.pixels_per_km;
207        let x = self.center_pixel.0 + distance_px * az_rad.sin();
208        let y = self.center_pixel.1 - distance_px * az_rad.cos();
209        (x, y)
210    }
211
212    /// Convert a pixel coordinate to geographic coordinates.
213    ///
214    /// Requires a coordinate system. Returns `None` if no coordinate system is
215    /// available or the pixel is outside the radar coverage area.
216    pub fn pixel_to_geo(&self, x: f64, y: f64) -> Option<GeoPoint3D> {
217        let polar = self.pixel_to_polar(x, y)?;
218        let coord_system = self.coord_system.as_ref()?;
219        Some(coord_system.polar_to_geo(polar))
220    }
221
222    /// Convert geographic coordinates to a pixel coordinate.
223    ///
224    /// Requires a coordinate system. Returns `None` if no coordinate system is available.
225    pub fn geo_to_pixel(&self, point: &GeoPoint) -> Option<(f64, f64)> {
226        let coord_system = self.coord_system.as_ref()?;
227        let elevation = self.elevation_degrees.unwrap_or(0.0);
228        let polar = coord_system.geo_to_polar(*point, elevation);
229        Some(self.polar_to_pixel(polar.azimuth_degrees, polar.range_km))
230    }
231
232    /// Get the range in km for a given pixel distance from center.
233    pub fn pixel_distance_to_km(&self, pixels: f64) -> f64 {
234        pixels / self.pixels_per_km
235    }
236
237    /// Get the pixel distance from center for a given range in km.
238    pub fn km_to_pixel_distance(&self, km: f64) -> f64 {
239        km * self.pixels_per_km
240    }
241}