ballin 0.1.2

A colorful interactive physics simulator with thousands of balls, but in your terminal.
Documentation
//! ASCII art representations for shapes.
//!
//! Each shape is rendered using ASCII characters within the size constraints
//! of 4-6 characters height and 4-7 characters width. Characters that could
//! be confused with balls/Braille (`.`, `:`, `;`) are avoided.

use super::types::ShapeType;

/// ASCII art representation of a shape.
///
/// Contains the character grid and metadata for rendering and physics.
#[derive(Debug, Clone)]
pub struct ShapeAsciiArt {
    /// The lines of ASCII characters making up the shape.
    /// Each line may have different lengths.
    lines: Vec<&'static str>,

    /// Width in characters.
    width: u16,

    /// Height in characters.
    height: u16,
}

impl ShapeAsciiArt {
    /// Creates a new ASCII art representation.
    fn new(lines: Vec<&'static str>) -> Self {
        let height = lines.len() as u16;
        let width = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0) as u16;
        Self {
            lines,
            width,
            height,
        }
    }

    /// Returns the ASCII art lines.
    pub fn lines(&self) -> &[&'static str] {
        &self.lines
    }

    /// Returns the width in characters.
    pub fn width(&self) -> u16 {
        self.width
    }

    /// Returns the height in characters.
    pub fn height(&self) -> u16 {
        self.height
    }

    /// Returns the character at the given position, or None if out of bounds
    /// or a space character.
    ///
    /// # Arguments
    ///
    /// * `x` - Column position (0-indexed)
    /// * `y` - Row position (0-indexed)
    pub fn char_at(&self, x: u16, y: u16) -> Option<char> {
        self.lines
            .get(y as usize)
            .and_then(|line| line.chars().nth(x as usize))
            .filter(|&c| c != ' ')
    }

    /// Returns an iterator over all non-space characters with their positions.
    ///
    /// Yields `(x, y, char)` tuples for each visible character.
    pub fn chars_with_positions(&self) -> impl Iterator<Item = (u16, u16, char)> + '_ {
        self.lines.iter().enumerate().flat_map(|(y, line)| {
            line.chars()
                .enumerate()
                .filter(|(_, c)| *c != ' ')
                .map(move |(x, c)| (x as u16, y as u16, c))
        })
    }

    /// Returns the vertices that define the collision polygon for this shape.
    ///
    /// Vertices are in local coordinates centered at (0, 0), with each
    /// character cell being 1.0 units. These can be scaled and transformed
    /// for physics collider creation.
    ///
    /// Note: For Python developers - this is similar to defining a polygon's
    /// points for collision detection, but Rust requires explicit lifetime
    /// annotations when returning references.
    pub fn collision_vertices(&self, shape_type: ShapeType) -> Vec<(f32, f32)> {
        // Return pre-defined collision vertices based on shape type
        // Coordinates are centered at (0, 0) for easier rotation
        // Physics uses Y-up coordinate system (positive Y = top of screen)
        // Vertices must be in counter-clockwise order for rapier2d convex hull
        match shape_type {
            ShapeType::Circle => {
                // Approximate circle with 12 vertices for better circular shape
                // Matches the 7x7 ASCII art (radius ~3.5)
                // Generated CCW starting from right (+X direction)
                let radius = 3.5;
                (0..12)
                    .map(|i| {
                        let angle = (i as f32) * std::f32::consts::PI * 2.0 / 12.0;
                        (radius * angle.cos(), radius * angle.sin())
                    })
                    .collect()
            }
            ShapeType::Triangle => {
                // Triangle pointing up - tip at top of screen
                // In physics coords (Y-up): top = positive Y, bottom = negative Y
                // Vertices in counter-clockwise order
                vec![
                    (0.0, 2.5),   // Top point (high Y = top of screen)
                    (-3.5, -2.5), // Bottom left (low Y = bottom of screen)
                    (3.5, -2.5),  // Bottom right
                ]
            }
            ShapeType::Square => {
                // Square - matches 5-line ASCII art (7 chars wide, 5 tall)
                // Vertices in counter-clockwise order starting from top-left
                vec![
                    (-3.5, 2.5),  // Top left
                    (-3.5, -2.5), // Bottom left (CCW)
                    (3.5, -2.5),  // Bottom right
                    (3.5, 2.5),   // Top right
                ]
            }
            ShapeType::Star => {
                // 5-pointed star - use convex hull approximation for collision
                // Since rapier needs convex shapes, vertices form the star outline
                // Start from top point (angle = PI/2 in Y-up coords)
                let outer_radius = 3.5;
                let inner_radius = 1.5;
                (0..10)
                    .map(|i| {
                        // Alternate between outer and inner radius
                        let r = if i % 2 == 0 {
                            outer_radius
                        } else {
                            inner_radius
                        };
                        // Start from top (+Y direction), go counter-clockwise
                        let angle =
                            std::f32::consts::PI / 2.0 - (i as f32) * std::f32::consts::PI / 5.0;
                        (r * angle.cos(), r * angle.sin())
                    })
                    .collect()
            }
            ShapeType::LineStraight => {
                // Horizontal line (5 wide x 2 tall)
                // CCW order
                vec![
                    (-2.5, 1.0),  // Top left
                    (-2.5, -1.0), // Bottom left
                    (2.5, -1.0),  // Bottom right
                    (2.5, 1.0),   // Top right
                ]
            }
            ShapeType::LineVertical => {
                // Vertical line (2 wide x 5 tall)
                // CCW order
                vec![
                    (-1.0, 2.5),  // Top left
                    (-1.0, -2.5), // Bottom left
                    (1.0, -2.5),  // Bottom right
                    (1.0, 2.5),   // Top right
                ]
            }
        }
    }
}

/// Returns the ASCII art for the given shape type.
///
/// All shapes are fully filled with ASCII characters for solid collision.
/// Characters are chosen to avoid confusion with balls/Braille (`.`, `:`, `;`).
/// Circle is allowed to exceed normal size limits for better visual appearance.
pub fn get_ascii_art(shape_type: ShapeType) -> ShapeAsciiArt {
    match shape_type {
        // Larger circle (7x7) for better circular appearance
        ShapeType::Circle => ShapeAsciiArt::new(vec![
            "  ###  ", " ##### ", "#######", "#######", "#######", " ##### ", "  ###  ",
        ]),
        // Filled triangle
        ShapeType::Triangle => ShapeAsciiArt::new(vec![
            "   ^   ", "  /X\\  ", " /XXX\\ ", "/XXXXX\\", "+-----+",
        ]),
        // Filled square
        ShapeType::Square => {
            ShapeAsciiArt::new(vec!["+-----+", "|XXXXX|", "|XXXXX|", "|XXXXX|", "+-----+"])
        }
        // Proper 5-pointed star shape
        ShapeType::Star => ShapeAsciiArt::new(vec![
            "   X   ", "  XXX  ", "XXXXXXX", " XXXXX ", " XX XX ", "XX   XX",
        ]),
        // Horizontal line (5 wide x 2 tall)
        ShapeType::LineStraight => ShapeAsciiArt::new(vec!["#####", "#####"]),
        // Vertical line (2 wide x 5 tall)
        ShapeType::LineVertical => ShapeAsciiArt::new(vec!["##", "##", "##", "##", "##"]),
    }
}

/// Returns rotated ASCII art for the given shape type and rotation.
///
/// Rotation is applied in 90-degree increments. For most shapes, this
/// involves character substitution rather than actual geometric rotation.
///
/// # Arguments
///
/// * `shape_type` - The type of shape
/// * `rotation_degrees` - Rotation in degrees (0, 90, 180, 270)
pub fn get_rotated_ascii_art(shape_type: ShapeType, rotation_degrees: i32) -> ShapeAsciiArt {
    // Normalize rotation to 0, 90, 180, 270
    let rotation = rotation_degrees.rem_euclid(360);

    // For symmetrical shapes or shapes with rotation variants
    match shape_type {
        // Circle is rotationally symmetric
        ShapeType::Circle => get_ascii_art(shape_type),

        // Square is rotationally symmetric
        ShapeType::Square => get_ascii_art(shape_type),

        // Triangle rotations (filled)
        ShapeType::Triangle => match rotation {
            90 => ShapeAsciiArt::new(vec!["+--\\  ", "|XXX> ", "|XXXX>", "|XXX> ", "+--/  "]),
            180 => ShapeAsciiArt::new(vec![
                "+-----+", "\\XXXXX/", " \\XXX/ ", "  \\X/  ", "   v   ",
            ]),
            270 => ShapeAsciiArt::new(vec!["  /--+", " <XXX|", "<XXXX|", " <XXX|", "  \\--+"]),
            _ => get_ascii_art(shape_type),
        },

        // Star rotations - show rotated variants
        // 5-pointed star has approximate 72-degree symmetry, but we show 90-degree variants
        ShapeType::Star => match rotation {
            90 => ShapeAsciiArt::new(vec![
                "XX     ", " XX X  ", "  XXXXX", " XXXXX ", " XX X  ", "XX     ",
            ]),
            180 => ShapeAsciiArt::new(vec![
                "XX   XX", " XX XX ", " XXXXX ", "XXXXXXX", "  XXX  ", "   X   ",
            ]),
            270 => ShapeAsciiArt::new(vec![
                "     XX", "  X XX ", "XXXXX  ", " XXXXX ", "  X XX ", "     XX",
            ]),
            _ => get_ascii_art(shape_type),
        },

        // Horizontal line rotated 90/270 becomes vertical
        ShapeType::LineStraight => match rotation {
            90 | 270 => get_ascii_art(ShapeType::LineVertical),
            _ => get_ascii_art(shape_type),
        },

        // Vertical line rotated 90/270 becomes horizontal
        ShapeType::LineVertical => match rotation {
            90 | 270 => get_ascii_art(ShapeType::LineStraight),
            _ => get_ascii_art(shape_type),
        },
    }
}

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

    #[test]
    fn test_circle_dimensions() {
        let art = get_ascii_art(ShapeType::Circle);
        // Circle is now 7x7 for better appearance
        assert_eq!(art.height(), 7, "Height: {}", art.height());
        assert_eq!(art.width(), 7, "Width: {}", art.width());
    }

    #[test]
    fn test_all_shapes_have_content() {
        for shape_type in ShapeType::all() {
            let art = get_ascii_art(shape_type);
            // Lines can be 2 units in one dimension (5x2 horizontal, 2x5 vertical)
            assert!(
                art.height() >= 2,
                "{:?} height {} too small",
                shape_type,
                art.height()
            );
            assert!(
                art.width() >= 2,
                "{:?} width {} too small",
                shape_type,
                art.width()
            );
            // Should have non-space characters
            let chars: Vec<_> = art.chars_with_positions().collect();
            assert!(
                !chars.is_empty(),
                "{:?} has no visible characters",
                shape_type
            );
        }
    }

    #[test]
    fn test_chars_with_positions() {
        let art = get_ascii_art(ShapeType::Square);
        let chars: Vec<_> = art.chars_with_positions().collect();
        // Should have non-space characters
        assert!(!chars.is_empty());
        // First char should be '+' at (0, 0)
        assert!(chars.contains(&(0, 0, '+')));
    }

    #[test]
    fn test_collision_vertices() {
        let art = get_ascii_art(ShapeType::Square);
        let vertices = art.collision_vertices(ShapeType::Square);
        assert_eq!(vertices.len(), 4); // Square has 4 vertices

        let triangle_vertices = art.collision_vertices(ShapeType::Triangle);
        assert_eq!(triangle_vertices.len(), 3); // Triangle has 3 vertices
    }
}