rustial-engine 0.0.1

Framework-agnostic 2.5D map engine for rustial
Documentation
//! Image overlay layer — a georeferenced raster image displayed as a
//! textured quadrilateral on the map.
//!
//! This implements the MapLibre / Mapbox `image` source type: a single
//! raster image pinned to four geographic corner coordinates, rendered
//! as a textured quad in world space.
//!
//! ## Usage
//!
//! ```rust,ignore
//! use rustial_engine::{ImageOverlayLayer, GeoCoord};
//!
//! let corners = [
//!     GeoCoord::from_lat_lon(40.0, -74.0),  // top-left
//!     GeoCoord::from_lat_lon(40.0, -73.0),  // top-right
//!     GeoCoord::from_lat_lon(39.0, -73.0),  // bottom-right
//!     GeoCoord::from_lat_lon(39.0, -74.0),  // bottom-left
//! ];
//! let rgba_bytes: Vec<u8> = vec![255; 256 * 256 * 4]; // RGBA8
//! let layer = ImageOverlayLayer::new("satellite", corners, 256, 256, rgba_bytes);
//! ```

use crate::camera_projection::CameraProjection;
use crate::layer::{Layer, LayerId, LayerKind};
use rustial_math::GeoCoord;
use std::any::Any;
use std::sync::Arc;

// ---------------------------------------------------------------------------
// ImageOverlayData — per-frame output consumed by renderers
// ---------------------------------------------------------------------------

/// Renderer-ready image overlay data produced by [`ImageOverlayLayer`].
///
/// Contains the world-space quad vertices, texture coordinates, image
/// data reference, and blending parameters needed by the GPU pipeline.
#[derive(Debug, Clone)]
pub struct ImageOverlayData {
    /// Layer id that produced this overlay.
    pub layer_id: LayerId,
    /// Four world-space corner positions `[x, y, z]` (TL, TR, BR, BL).
    pub corners: [[f64; 3]; 4],
    /// Image width in pixels.
    pub width: u32,
    /// Image height in pixels.
    pub height: u32,
    /// RGBA8 pixel data (length = `width * height * 4`).
    pub data: Arc<Vec<u8>>,
    /// Overlay opacity (0.0 = transparent, 1.0 = opaque).
    pub opacity: f32,
}

// ---------------------------------------------------------------------------
// ImageOverlayLayer
// ---------------------------------------------------------------------------

/// A georeferenced raster image rendered as a textured quadrilateral.
///
/// This is the Rustial equivalent of MapLibre / Mapbox's `image` source.
/// The image is pinned to four geographic corner coordinates and rendered
/// as a textured quad in the active camera projection.
///
/// ## Coordinate order
///
/// Corners are specified in **TL → TR → BR → BL** (clockwise) order,
/// matching the MapLibre `coordinates` array convention.
#[derive(Clone)]
pub struct ImageOverlayLayer {
    id: LayerId,
    name: String,
    visible: bool,
    opacity: f32,
    /// Geographic corner coordinates (TL, TR, BR, BL).
    coordinates: [GeoCoord; 4],
    /// Image width in pixels.
    width: u32,
    /// Image height in pixels.
    height: u32,
    /// RGBA8 pixel data.
    data: Arc<Vec<u8>>,
    /// Monotonically increasing generation counter for change detection.
    generation: u64,
}

impl std::fmt::Debug for ImageOverlayLayer {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ImageOverlayLayer")
            .field("id", &self.id)
            .field("name", &self.name)
            .field("visible", &self.visible)
            .field("opacity", &self.opacity)
            .field("width", &self.width)
            .field("height", &self.height)
            .field("data_len", &self.data.len())
            .finish()
    }
}

impl ImageOverlayLayer {
    /// Create a new image overlay layer.
    ///
    /// `corners` must be in TL → TR → BR → BL order.
    /// `data` must be RGBA8 pixel data of length `width * height * 4`.
    pub fn new(
        name: impl Into<String>,
        coordinates: [GeoCoord; 4],
        width: u32,
        height: u32,
        data: Vec<u8>,
    ) -> Self {
        debug_assert_eq!(
            data.len(),
            (width * height * 4) as usize,
            "RGBA8 data length must equal width * height * 4"
        );
        Self {
            id: LayerId::next(),
            name: name.into(),
            visible: true,
            opacity: 1.0,
            coordinates,
            width,
            height,
            data: Arc::new(data),
            generation: 0,
        }
    }

    /// Geographic corners (TL, TR, BR, BL).
    #[inline]
    pub fn coordinates(&self) -> &[GeoCoord; 4] {
        &self.coordinates
    }

    /// Update the geographic corners.
    pub fn set_coordinates(&mut self, coordinates: [GeoCoord; 4]) {
        self.coordinates = coordinates;
        self.generation = self.generation.wrapping_add(1);
    }

    /// Replace the image pixel data.
    ///
    /// `data` must be RGBA8 of length `width * height * 4`.
    pub fn update_image(&mut self, width: u32, height: u32, data: Vec<u8>) {
        debug_assert_eq!(
            data.len(),
            (width * height * 4) as usize,
            "RGBA8 data length must equal width * height * 4"
        );
        self.width = width;
        self.height = height;
        self.data = Arc::new(data);
        self.generation = self.generation.wrapping_add(1);
    }

    /// Monotonic generation counter, bumped on coordinate or image changes.
    #[inline]
    pub fn generation(&self) -> u64 {
        self.generation
    }

    /// Image dimensions `(width, height)`.
    #[inline]
    pub fn dimensions(&self) -> (u32, u32) {
        (self.width, self.height)
    }

    /// Produce renderer-ready overlay data by projecting geographic
    /// corners into the active world-space coordinate system.
    pub fn to_overlay_data(&self, projection: CameraProjection) -> ImageOverlayData {
        let corners = [
            project_corner(&self.coordinates[0], projection),
            project_corner(&self.coordinates[1], projection),
            project_corner(&self.coordinates[2], projection),
            project_corner(&self.coordinates[3], projection),
        ];
        ImageOverlayData {
            layer_id: self.id,
            corners,
            width: self.width,
            height: self.height,
            data: Arc::clone(&self.data),
            opacity: self.opacity,
        }
    }
}

fn project_corner(coord: &GeoCoord, projection: CameraProjection) -> [f64; 3] {
    let w = projection.project(coord);
    [w.position.x, w.position.y, w.position.z]
}

// ---------------------------------------------------------------------------
// Layer trait implementation
// ---------------------------------------------------------------------------

impl Layer for ImageOverlayLayer {
    fn id(&self) -> LayerId {
        self.id
    }

    fn name(&self) -> &str {
        &self.name
    }

    fn kind(&self) -> LayerKind {
        LayerKind::Custom
    }

    fn visible(&self) -> bool {
        self.visible
    }

    fn set_visible(&mut self, visible: bool) {
        self.visible = visible;
    }

    fn opacity(&self) -> f32 {
        self.opacity
    }

    fn set_opacity(&mut self, opacity: f32) {
        self.opacity = opacity.clamp(0.0, 1.0);
    }

    fn as_any(&self) -> &dyn Any {
        self
    }

    fn as_any_mut(&mut self) -> &mut dyn Any {
        self
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn sample_corners() -> [GeoCoord; 4] {
        [
            GeoCoord::from_lat_lon(40.0, -74.0),
            GeoCoord::from_lat_lon(40.0, -73.0),
            GeoCoord::from_lat_lon(39.0, -73.0),
            GeoCoord::from_lat_lon(39.0, -74.0),
        ]
    }

    fn sample_rgba(w: u32, h: u32) -> Vec<u8> {
        vec![128u8; (w * h * 4) as usize]
    }

    #[test]
    fn new_layer_has_correct_dimensions() {
        let layer = ImageOverlayLayer::new("test", sample_corners(), 64, 64, sample_rgba(64, 64));
        assert_eq!(layer.dimensions(), (64, 64));
        assert_eq!(layer.data.len(), 64 * 64 * 4);
        assert!(layer.visible());
        assert_eq!(layer.opacity(), 1.0);
    }

    #[test]
    fn set_coordinates_bumps_generation() {
        let mut layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
        let g0 = layer.generation();
        layer.set_coordinates([
            GeoCoord::from_lat_lon(50.0, -75.0),
            GeoCoord::from_lat_lon(50.0, -74.0),
            GeoCoord::from_lat_lon(49.0, -74.0),
            GeoCoord::from_lat_lon(49.0, -75.0),
        ]);
        assert_eq!(layer.generation(), g0 + 1);
    }

    #[test]
    fn update_image_bumps_generation() {
        let mut layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
        let g0 = layer.generation();
        layer.update_image(8, 8, sample_rgba(8, 8));
        assert_eq!(layer.generation(), g0 + 1);
        assert_eq!(layer.dimensions(), (8, 8));
    }

    #[test]
    fn to_overlay_data_produces_world_space_corners() {
        let layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
        let data = layer.to_overlay_data(CameraProjection::WebMercator);
        // All four corners should be distinct in world space.
        for i in 0..4 {
            for j in (i + 1)..4 {
                let dx = (data.corners[i][0] - data.corners[j][0]).abs();
                let dy = (data.corners[i][1] - data.corners[j][1]).abs();
                assert!(
                    dx > 1.0 || dy > 1.0,
                    "corners {i} and {j} are too close: {dx}, {dy}"
                );
            }
        }
    }

    #[test]
    fn overlay_data_shares_arc_with_layer() {
        let layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
        let data = layer.to_overlay_data(CameraProjection::WebMercator);
        assert!(Arc::ptr_eq(&layer.data, &data.data));
    }

    #[test]
    fn opacity_clamps_to_valid_range() {
        let mut layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
        layer.set_opacity(2.0);
        assert_eq!(layer.opacity(), 1.0);
        layer.set_opacity(-1.0);
        assert_eq!(layer.opacity(), 0.0);
    }

    #[test]
    fn equirectangular_projection_produces_different_coordinates() {
        let layer = ImageOverlayLayer::new("test", sample_corners(), 4, 4, sample_rgba(4, 4));
        let merc = layer.to_overlay_data(CameraProjection::WebMercator);
        let eq = layer.to_overlay_data(CameraProjection::Equirectangular);
        // At least one corner pair should differ between projections.
        let differs = merc
            .corners
            .iter()
            .zip(eq.corners.iter())
            .any(|(a, b)| (a[0] - b[0]).abs() > 0.01 || (a[1] - b[1]).abs() > 0.01);
        assert!(
            differs,
            "WebMercator and Equirectangular should produce different corner positions"
        );
    }
}