Skip to main content

rustial_engine/layers/
image_overlay_layer.rs

1//! Image overlay layer — a georeferenced raster image displayed as a
2//! textured quadrilateral on the map.
3//!
4//! This implements the MapLibre / Mapbox `image` source type: a single
5//! raster image pinned to four geographic corner coordinates, rendered
6//! as a textured quad in world space.
7//!
8//! ## Usage
9//!
10//! ```rust,ignore
11//! use rustial_engine::{ImageOverlayLayer, GeoCoord};
12//!
13//! let corners = [
14//!     GeoCoord::from_lat_lon(40.0, -74.0),  // top-left
15//!     GeoCoord::from_lat_lon(40.0, -73.0),  // top-right
16//!     GeoCoord::from_lat_lon(39.0, -73.0),  // bottom-right
17//!     GeoCoord::from_lat_lon(39.0, -74.0),  // bottom-left
18//! ];
19//! let rgba_bytes: Vec<u8> = vec![255; 256 * 256 * 4]; // RGBA8
20//! let layer = ImageOverlayLayer::new("satellite", corners, 256, 256, rgba_bytes);
21//! ```
22
23use crate::camera_projection::CameraProjection;
24use crate::layer::{Layer, LayerId, LayerKind};
25use rustial_math::GeoCoord;
26use std::any::Any;
27use std::sync::Arc;
28
29// ---------------------------------------------------------------------------
30// ImageOverlayData — per-frame output consumed by renderers
31// ---------------------------------------------------------------------------
32
33/// Renderer-ready image overlay data produced by [`ImageOverlayLayer`].
34///
35/// Contains the world-space quad vertices, texture coordinates, image
36/// data reference, and blending parameters needed by the GPU pipeline.
37#[derive(Debug, Clone)]
38pub struct ImageOverlayData {
39    /// Layer id that produced this overlay.
40    pub layer_id: LayerId,
41    /// Four world-space corner positions `[x, y, z]` (TL, TR, BR, BL).
42    pub corners: [[f64; 3]; 4],
43    /// Image width in pixels.
44    pub width: u32,
45    /// Image height in pixels.
46    pub height: u32,
47    /// RGBA8 pixel data (length = `width * height * 4`).
48    pub data: Arc<Vec<u8>>,
49    /// Overlay opacity (0.0 = transparent, 1.0 = opaque).
50    pub opacity: f32,
51}
52
53// ---------------------------------------------------------------------------
54// ImageOverlayLayer
55// ---------------------------------------------------------------------------
56
57/// A georeferenced raster image rendered as a textured quadrilateral.
58///
59/// This is the Rustial equivalent of MapLibre / Mapbox's `image` source.
60/// The image is pinned to four geographic corner coordinates and rendered
61/// as a textured quad in the active camera projection.
62///
63/// ## Coordinate order
64///
65/// Corners are specified in **TL → TR → BR → BL** (clockwise) order,
66/// matching the MapLibre `coordinates` array convention.
67#[derive(Clone)]
68pub struct ImageOverlayLayer {
69    id: LayerId,
70    name: String,
71    visible: bool,
72    opacity: f32,
73    /// Geographic corner coordinates (TL, TR, BR, BL).
74    coordinates: [GeoCoord; 4],
75    /// Image width in pixels.
76    width: u32,
77    /// Image height in pixels.
78    height: u32,
79    /// RGBA8 pixel data.
80    data: Arc<Vec<u8>>,
81    /// Monotonically increasing generation counter for change detection.
82    generation: u64,
83}
84
85impl std::fmt::Debug for ImageOverlayLayer {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        f.debug_struct("ImageOverlayLayer")
88            .field("id", &self.id)
89            .field("name", &self.name)
90            .field("visible", &self.visible)
91            .field("opacity", &self.opacity)
92            .field("width", &self.width)
93            .field("height", &self.height)
94            .field("data_len", &self.data.len())
95            .finish()
96    }
97}
98
99impl ImageOverlayLayer {
100    /// Create a new image overlay layer.
101    ///
102    /// `corners` must be in TL → TR → BR → BL order.
103    /// `data` must be RGBA8 pixel data of length `width * height * 4`.
104    pub fn new(
105        name: impl Into<String>,
106        coordinates: [GeoCoord; 4],
107        width: u32,
108        height: u32,
109        data: Vec<u8>,
110    ) -> Self {
111        debug_assert_eq!(
112            data.len(),
113            (width * height * 4) as usize,
114            "RGBA8 data length must equal width * height * 4"
115        );
116        Self {
117            id: LayerId::next(),
118            name: name.into(),
119            visible: true,
120            opacity: 1.0,
121            coordinates,
122            width,
123            height,
124            data: Arc::new(data),
125            generation: 0,
126        }
127    }
128
129    /// Geographic corners (TL, TR, BR, BL).
130    #[inline]
131    pub fn coordinates(&self) -> &[GeoCoord; 4] {
132        &self.coordinates
133    }
134
135    /// Update the geographic corners.
136    pub fn set_coordinates(&mut self, coordinates: [GeoCoord; 4]) {
137        self.coordinates = coordinates;
138        self.generation = self.generation.wrapping_add(1);
139    }
140
141    /// Replace the image pixel data.
142    ///
143    /// `data` must be RGBA8 of length `width * height * 4`.
144    pub fn update_image(&mut self, width: u32, height: u32, data: Vec<u8>) {
145        debug_assert_eq!(
146            data.len(),
147            (width * height * 4) as usize,
148            "RGBA8 data length must equal width * height * 4"
149        );
150        self.width = width;
151        self.height = height;
152        self.data = Arc::new(data);
153        self.generation = self.generation.wrapping_add(1);
154    }
155
156    /// Monotonic generation counter, bumped on coordinate or image changes.
157    #[inline]
158    pub fn generation(&self) -> u64 {
159        self.generation
160    }
161
162    /// Image dimensions `(width, height)`.
163    #[inline]
164    pub fn dimensions(&self) -> (u32, u32) {
165        (self.width, self.height)
166    }
167
168    /// Produce renderer-ready overlay data by projecting geographic
169    /// corners into the active world-space coordinate system.
170    pub fn to_overlay_data(&self, projection: CameraProjection) -> ImageOverlayData {
171        let corners = [
172            project_corner(&self.coordinates[0], projection),
173            project_corner(&self.coordinates[1], projection),
174            project_corner(&self.coordinates[2], projection),
175            project_corner(&self.coordinates[3], projection),
176        ];
177        ImageOverlayData {
178            layer_id: self.id,
179            corners,
180            width: self.width,
181            height: self.height,
182            data: Arc::clone(&self.data),
183            opacity: self.opacity,
184        }
185    }
186}
187
188fn project_corner(coord: &GeoCoord, projection: CameraProjection) -> [f64; 3] {
189    let w = projection.project(coord);
190    [w.position.x, w.position.y, w.position.z]
191}
192
193// ---------------------------------------------------------------------------
194// Layer trait implementation
195// ---------------------------------------------------------------------------
196
197impl Layer for ImageOverlayLayer {
198    fn id(&self) -> LayerId {
199        self.id
200    }
201
202    fn name(&self) -> &str {
203        &self.name
204    }
205
206    fn kind(&self) -> LayerKind {
207        LayerKind::Custom
208    }
209
210    fn visible(&self) -> bool {
211        self.visible
212    }
213
214    fn set_visible(&mut self, visible: bool) {
215        self.visible = visible;
216    }
217
218    fn opacity(&self) -> f32 {
219        self.opacity
220    }
221
222    fn set_opacity(&mut self, opacity: f32) {
223        self.opacity = opacity.clamp(0.0, 1.0);
224    }
225
226    fn as_any(&self) -> &dyn Any {
227        self
228    }
229
230    fn as_any_mut(&mut self) -> &mut dyn Any {
231        self
232    }
233}
234
235// ---------------------------------------------------------------------------
236// Tests
237// ---------------------------------------------------------------------------
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn sample_corners() -> [GeoCoord; 4] {
244        [
245            GeoCoord::from_lat_lon(40.0, -74.0),
246            GeoCoord::from_lat_lon(40.0, -73.0),
247            GeoCoord::from_lat_lon(39.0, -73.0),
248            GeoCoord::from_lat_lon(39.0, -74.0),
249        ]
250    }
251
252    fn sample_rgba(w: u32, h: u32) -> Vec<u8> {
253        vec![128u8; (w * h * 4) as usize]
254    }
255
256    #[test]
257    fn new_layer_has_correct_dimensions() {
258        let layer = ImageOverlayLayer::new("test", sample_corners(), 64, 64, sample_rgba(64, 64));
259        assert_eq!(layer.dimensions(), (64, 64));
260        assert_eq!(layer.data.len(), 64 * 64 * 4);
261        assert!(layer.visible());
262        assert_eq!(layer.opacity(), 1.0);
263    }
264
265    #[test]
266    fn set_coordinates_bumps_generation() {
267        let mut layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
268        let g0 = layer.generation();
269        layer.set_coordinates([
270            GeoCoord::from_lat_lon(50.0, -75.0),
271            GeoCoord::from_lat_lon(50.0, -74.0),
272            GeoCoord::from_lat_lon(49.0, -74.0),
273            GeoCoord::from_lat_lon(49.0, -75.0),
274        ]);
275        assert_eq!(layer.generation(), g0 + 1);
276    }
277
278    #[test]
279    fn update_image_bumps_generation() {
280        let mut layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
281        let g0 = layer.generation();
282        layer.update_image(8, 8, sample_rgba(8, 8));
283        assert_eq!(layer.generation(), g0 + 1);
284        assert_eq!(layer.dimensions(), (8, 8));
285    }
286
287    #[test]
288    fn to_overlay_data_produces_world_space_corners() {
289        let layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
290        let data = layer.to_overlay_data(CameraProjection::WebMercator);
291        // All four corners should be distinct in world space.
292        for i in 0..4 {
293            for j in (i + 1)..4 {
294                let dx = (data.corners[i][0] - data.corners[j][0]).abs();
295                let dy = (data.corners[i][1] - data.corners[j][1]).abs();
296                assert!(
297                    dx > 1.0 || dy > 1.0,
298                    "corners {i} and {j} are too close: {dx}, {dy}"
299                );
300            }
301        }
302    }
303
304    #[test]
305    fn overlay_data_shares_arc_with_layer() {
306        let layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
307        let data = layer.to_overlay_data(CameraProjection::WebMercator);
308        assert!(Arc::ptr_eq(&layer.data, &data.data));
309    }
310
311    #[test]
312    fn opacity_clamps_to_valid_range() {
313        let mut layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
314        layer.set_opacity(2.0);
315        assert_eq!(layer.opacity(), 1.0);
316        layer.set_opacity(-1.0);
317        assert_eq!(layer.opacity(), 0.0);
318    }
319
320    #[test]
321    fn equirectangular_projection_produces_different_coordinates() {
322        let layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
323        let merc = layer.to_overlay_data(CameraProjection::WebMercator);
324        let eq = layer.to_overlay_data(CameraProjection::Equirectangular);
325        // At least one corner pair should differ between projections.
326        let differs = merc
327            .corners
328            .iter()
329            .zip(eq.corners.iter())
330            .any(|(a, b)| (a[0] - b[0]).abs() > 0.01 || (a[1] - b[1]).abs() > 0.01);
331        assert!(differs, "WebMercator and Equirectangular should produce different corner positions");
332    }
333}