shortestpath 0.10.0

Shortest Path is an experimental library finding the shortest path from A to B.
Documentation
// Copyright (C) 2025 Christian Mauduit <ufoot@ufoot.org>

//! Image-based 2D source implementation.
//!
//! This module provides the ability to load pathfinding grids from image files
//! (PNG, JPEG, etc.). Dark pixels are interpreted as walls, light pixels as free cells.

use super::cell_type::*;
use super::source_2d::*;
use crate::errors::*;
use crate::mesh_2d::{Shape2D};
use image::{DynamicImage, GenericImageView, Pixel};
use std::path::Path;

/// A 2D grid source that reads from an image file.
///
/// Pixels are converted to FREE or WALL cells based on their brightness.
/// By default, pixels darker than 50% gray are considered walls.
///
/// # Example
///
/// ```no_run
/// use shortestpath::mesh_source::{Source2DFromImage, Source2D, CellType};
///
/// // Load from a PNG file
/// let source = Source2DFromImage::from_path("map.png").unwrap();
/// assert_eq!(source.width(), 100);
/// assert_eq!(source.height(), 100);
///
/// // Dark pixels become walls, light pixels become free
/// let cell = source.get(10, 10).unwrap();
/// ```
#[derive(Debug, Clone)]
pub struct Source2DFromImage {
    width: usize,
    height: usize,
    cells: Vec<Vec<CellType>>,
}

impl Source2DFromImage {
    /// Default brightness threshold for determining walls (0.0-1.0).
    ///
    /// Pixels with brightness below this value are considered walls.
    pub const DEFAULT_THRESHOLD: f32 = 0.5;

    /// Creates a Source2DFromImage from an image file path.
    ///
    /// # Arguments
    ///
    /// * `path` - Path to the image file (PNG, JPEG, etc.)
    ///
    /// # Example
    ///
    /// ```no_run
    /// use shortestpath::mesh_source::Source2DFromImage;
    ///
    /// let source = Source2DFromImage::from_path("maze.png").unwrap();
    /// ```
    pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
        let img = image::open(path)
            .map_err(|e| Error::report_bug(&format!("Failed to load image: {e}")))?;
        Self::from_image(&img, Self::DEFAULT_THRESHOLD)
    }

    /// Creates a Source2DFromImage from an image file path with a custom threshold.
    ///
    /// # Arguments
    ///
    /// * `path` - Path to the image file
    /// * `threshold` - Brightness threshold (0.0-1.0). Pixels darker than this are walls.
    ///
    /// # Example
    ///
    /// ```no_run
    /// use shortestpath::mesh_source::Source2DFromImage;
    ///
    /// // Use a lower threshold - only very dark pixels are walls
    /// let source = Source2DFromImage::from_path_with_threshold("map.png", 0.25).unwrap();
    /// ```
    pub fn from_path_with_threshold<P: AsRef<Path>>(path: P, threshold: f32) -> Result<Self> {
        let img = image::open(path)
            .map_err(|e| Error::report_bug(&format!("Failed to load image: {e}")))?;
        Self::from_image(&img, threshold)
    }

    /// Creates a Source2DFromImage from a DynamicImage.
    ///
    /// # Arguments
    ///
    /// * `img` - The image to convert
    /// * `threshold` - Brightness threshold (0.0-1.0). Pixels darker than this are walls.
    pub fn from_image(img: &DynamicImage, threshold: f32) -> Result<Self> {
        let width = img.width() as usize;
        let height = img.height() as usize;

        let mut cells = Vec::with_capacity(height);
        for y in 0..height {
            let mut row = Vec::with_capacity(width);
            for x in 0..width {
                let pixel = img.get_pixel(x as u32, y as u32);
                let luma_u8 = pixel.to_luma()[0];
                let luma = luma_u8 as f32 / 255.0; // Normalize to 0.0-1.0
                let cell_type = if luma < threshold {
                    CellType::WALL
                } else {
                    CellType::FLOOR
                };
                row.push(cell_type);
            }
            cells.push(row);
        }

        Ok(Self {
            width,
            height,
            cells,
        })
    }
}

impl Source2D for Source2DFromImage {
    fn get(&self, x: usize, y: usize) -> Result<CellType> {
        if y >= self.height || x >= self.width {
            return Err(Error::invalid_xy(x, y));
        }
        Ok(self.cells[y][x])
    }

    fn width(&self) -> usize {
        self.width
    }

    fn height(&self) -> usize {
        self.height
    }
}

impl Shape2D for Source2DFromImage {
    fn shape(&self) -> (usize, usize) {
        (self.width, self.height)
    }
}

impl std::fmt::Display for Source2DFromImage {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        writeln!(f, "Source2DFromImage ({}x{}):", self.width(), self.height())?;
        write!(f, "{}", crate::mesh_source::repr::repr_source_2d(self))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use image::{ImageBuffer, Luma};

    #[test]
    fn test_from_image_basic() {
        // Create a simple 4x3 test image
        let mut img = ImageBuffer::new(4, 3);

        // Row 0: all white (free)
        img.put_pixel(0, 0, Luma([255]));
        img.put_pixel(1, 0, Luma([255]));
        img.put_pixel(2, 0, Luma([255]));
        img.put_pixel(3, 0, Luma([255]));

        // Row 1: mixed
        img.put_pixel(0, 1, Luma([255])); // white = free
        img.put_pixel(1, 1, Luma([0]));   // black = wall
        img.put_pixel(2, 1, Luma([0]));   // black = wall
        img.put_pixel(3, 1, Luma([255])); // white = free

        // Row 2: all white (free)
        img.put_pixel(0, 2, Luma([255]));
        img.put_pixel(1, 2, Luma([255]));
        img.put_pixel(2, 2, Luma([255]));
        img.put_pixel(3, 2, Luma([255]));

        let dyn_img = DynamicImage::ImageLuma8(img);
        let source = Source2DFromImage::from_image(&dyn_img, 0.5).unwrap();

        assert_eq!(source.width(), 4);
        assert_eq!(source.height(), 3);

        // Check specific cells
        assert_eq!(source.get(0, 0).unwrap(), CellType::FLOOR);
        assert_eq!(source.get(1, 1).unwrap(), CellType::WALL);
        assert_eq!(source.get(2, 1).unwrap(), CellType::WALL);
        assert_eq!(source.get(3, 1).unwrap(), CellType::FLOOR);
    }

    #[test]
    fn test_threshold() {
        let mut img = ImageBuffer::new(2, 1);
        img.put_pixel(0, 0, Luma([100])); // Below threshold 0.5 (100/255 ≈ 0.39) = wall
        img.put_pixel(1, 0, Luma([150])); // Above threshold 0.5 (150/255 ≈ 0.59) = free

        let dyn_img = DynamicImage::ImageLuma8(img);
        let source = Source2DFromImage::from_image(&dyn_img, 0.5).unwrap();

        assert_eq!(source.get(0, 0).unwrap(), CellType::WALL);
        assert_eq!(source.get(1, 0).unwrap(), CellType::FLOOR);
    }

    #[test]
    fn test_out_of_bounds() {
        let img = ImageBuffer::new(2, 2);
        let dyn_img = DynamicImage::ImageLuma8(img);
        let source = Source2DFromImage::from_image(&dyn_img, 0.5).unwrap();

        assert!(source.get(2, 0).is_err());
        assert!(source.get(0, 2).is_err());
        assert!(source.get(3, 3).is_err());
    }

    #[test]
    fn test_from_path_testdata() {
        use crate::mesh_source::repr_source_2d;
        use crate::mesh_source::testutil::testdata_path;

        let path = testdata_path("2d/a-32x24.jpg");
        let source = Source2DFromImage::from_path(&path).unwrap();

        // Verify dimensions match filename
        assert_eq!(source.width(), 32);
        assert_eq!(source.height(), 24);

        // Get the string representation
        let repr = repr_source_2d(&source);

        // Expected pattern - the image contains a shape in the upper-left area
        let expected = "\
................................
................................
................................
................................
................................
...##############.#.............
...#############................
...#############................
...############..#..............
...############..#..............
...###########..##..............
...###########..###.............
...###########..................
...##########..####.............
................................
................................
................................
................................
................................
................................
................................
................................
................................
................................
";

        assert_eq!(repr, expected, "Image representation should match expected pattern");
    }

    #[test]
    fn test_from_path_with_threshold_testdata() {
        use crate::mesh_source::testutil::testdata_path;

        let path = testdata_path("2d/a-32x24.jpg");

        // Test with different thresholds
        let source_low = Source2DFromImage::from_path_with_threshold(&path, 0.1).unwrap();
        let source_high = Source2DFromImage::from_path_with_threshold(&path, 0.9).unwrap();

        // Lower threshold means more free cells (fewer pixels below threshold)
        let mut free_count_low = 0;
        let mut free_count_high = 0;

        for y in 0..source_low.height() {
            for x in 0..source_low.width() {
                if source_low.get(x, y).unwrap() == CellType::FLOOR {
                    free_count_low += 1;
                }
                if source_high.get(x, y).unwrap() == CellType::FLOOR {
                    free_count_high += 1;
                }
            }
        }

        // Higher threshold should result in fewer free cells
        assert!(
            free_count_low >= free_count_high,
            "Lower threshold should have more or equal free cells than higher threshold"
        );
    }
}