ballin 0.1.0

A colorful interactive physics simulator with thousands of balls, but in your terminal.
Documentation
//! Physics collider generation for shapes.
//!
//! This module creates rapier2d colliders from shape definitions.
//! Colliders are generated as convex hulls from the shape's vertices,
//! scaled and transformed to match the physics world.

use rapier2d::prelude::*;

use super::ascii_art::get_ascii_art;
use super::types::Shape;

/// Scale factor from character cells to physics units.
/// Each character cell is approximately 1 physics unit.
const CHAR_TO_PHYSICS_SCALE: f32 = 1.0;

/// Creates a physics collider for the given shape.
///
/// The collider is created as a fixed (static) rigid body with a convex hull
/// collider matching the shape's geometry. The collider is properly positioned
/// and rotated according to the shape's properties.
///
/// # Arguments
///
/// * `shape` - The shape to create a collider for
/// * `rigid_body_set` - The physics world's rigid body set
/// * `collider_set` - The physics world's collider set
///
/// # Returns
///
/// A tuple of (RigidBodyHandle, ColliderHandle) for the created physics objects.
///
/// # Note for Python developers
///
/// Unlike Python, Rust requires explicit mutable borrows (`&mut`) when modifying
/// data structures. The handles returned are essentially IDs that can be used
/// to look up the created objects later.
pub fn create_shape_collider(
    shape: &Shape,
    rigid_body_set: &mut RigidBodySet,
    collider_set: &mut ColliderSet,
) -> (RigidBodyHandle, ColliderHandle) {
    let (x, y) = shape.position();
    let rotation_rad = shape.rotation_radians();

    // Create a fixed (static) rigid body at the shape's position
    let rigid_body = RigidBodyBuilder::fixed()
        .translation(vector![x, y])
        .rotation(rotation_rad)
        .build();

    let body_handle = rigid_body_set.insert(rigid_body);

    // Get the collision vertices for this shape type
    let ascii_art = get_ascii_art(shape.shape_type());
    let vertices = ascii_art.collision_vertices(shape.shape_type());

    // Convert vertices to rapier2d points, applying scale
    let points: Vec<Point<Real>> = vertices
        .iter()
        .map(|(vx, vy)| point![vx * CHAR_TO_PHYSICS_SCALE, vy * CHAR_TO_PHYSICS_SCALE])
        .collect();

    // Create collider from convex hull
    // If convex hull fails (degenerate shape), fall back to a ball collider
    let collider = if let Some(hull) = ColliderBuilder::convex_hull(&points) {
        hull.friction(0.3).restitution(0.7).build()
    } else {
        // Fallback: use a ball collider with radius based on shape size
        let radius =
            (ascii_art.width().max(ascii_art.height()) as f32 / 2.0) * CHAR_TO_PHYSICS_SCALE;
        ColliderBuilder::ball(radius)
            .friction(0.3)
            .restitution(0.7)
            .build()
    };

    let collider_handle = collider_set.insert_with_parent(collider, body_handle, rigid_body_set);

    (body_handle, collider_handle)
}

/// Updates the physics transform for a shape (position and rotation).
///
/// Call this after modifying a shape's position or rotation to sync
/// the physics representation.
///
/// # Arguments
///
/// * `shape` - The shape with updated transform
/// * `rigid_body_set` - The physics world's rigid body set
pub fn update_shape_transform(shape: &Shape, rigid_body_set: &mut RigidBodySet) {
    if let Some(handle) = shape.rigid_body_handle() {
        if let Some(body) = rigid_body_set.get_mut(handle) {
            let (x, y) = shape.position();
            let rotation = shape.rotation_radians();

            // Create isometry (position + rotation)
            let isometry = Isometry::new(vector![x, y], rotation);
            body.set_position(isometry, true);
        }
    }
}

/// Removes a shape's collider from the physics world.
///
/// # Arguments
///
/// * `shape` - The shape to remove from physics
/// * `rigid_body_set` - The physics world's rigid body set
/// * `collider_set` - The physics world's collider set
/// * `island_manager` - The physics world's island manager
/// * `impulse_joint_set` - The physics world's impulse joint set
/// * `multibody_joint_set` - The physics world's multibody joint set
pub fn remove_shape_collider(
    shape: &Shape,
    rigid_body_set: &mut RigidBodySet,
    collider_set: &mut ColliderSet,
    island_manager: &mut IslandManager,
    impulse_joint_set: &mut ImpulseJointSet,
    multibody_joint_set: &mut MultibodyJointSet,
) {
    // Remove collider first
    if let Some(collider_handle) = shape.collider_handle() {
        collider_set.remove(collider_handle, island_manager, rigid_body_set, true);
    }

    // Remove rigid body
    if let Some(body_handle) = shape.rigid_body_handle() {
        rigid_body_set.remove(
            body_handle,
            island_manager,
            collider_set,
            impulse_joint_set,
            multibody_joint_set,
            true,
        );
    }
}

/// Checks if a point (in physics coordinates) is inside or near a shape.
///
/// This is used for click detection to select shapes.
///
/// # Arguments
///
/// * `shape` - The shape to test
/// * `point_x` - X coordinate of the point
/// * `point_y` - Y coordinate of the point
/// * `rigid_body_set` - The physics world's rigid body set
/// * `collider_set` - The physics world's collider set
///
/// # Returns
///
/// `true` if the point is inside or within a small margin of the shape.
pub fn point_in_shape(
    shape: &Shape,
    point_x: f32,
    point_y: f32,
    collider_set: &ColliderSet,
) -> bool {
    if let Some(collider_handle) = shape.collider_handle() {
        if let Some(collider) = collider_set.get(collider_handle) {
            // Use rapier's built-in point containment test
            let point = point![point_x, point_y];

            // Check if point is inside the collider
            // We use a small margin for easier selection
            let margin = 0.5;
            return collider
                .shape()
                .contains_local_point(&collider.position().inverse_transform_point(&point))
                || {
                    // Also check if within margin distance
                    let proj = collider.shape().project_local_point(
                        &collider.position().inverse_transform_point(&point),
                        true,
                    );
                    proj.point.coords.norm() < margin
                };
        }
    }
    false
}

/// Returns the bounding box of a shape in physics coordinates.
///
/// # Arguments
///
/// * `shape` - The shape to get bounds for
///
/// # Returns
///
/// `(min_x, min_y, max_x, max_y)` tuple representing the axis-aligned bounding box.
pub fn shape_bounds(shape: &Shape) -> (f32, f32, f32, f32) {
    let (x, y) = shape.position();
    let ascii_art = get_ascii_art(shape.shape_type());

    // Get half-dimensions
    let half_width = (ascii_art.width() as f32 / 2.0) * CHAR_TO_PHYSICS_SCALE;
    let half_height = (ascii_art.height() as f32 / 2.0) * CHAR_TO_PHYSICS_SCALE;

    // For rotated shapes, use a conservative AABB (expanded for rotation)
    let max_dim = half_width.max(half_height);

    (x - max_dim, y - max_dim, x + max_dim, y + max_dim)
}

/// Checks if two shapes overlap (or are too close).
///
/// Uses bounding box intersection with a minimum separation distance.
///
/// # Arguments
///
/// * `shape1` - First shape
/// * `shape2` - Second shape
/// * `min_separation` - Minimum distance between shapes
///
/// # Returns
///
/// `true` if shapes overlap or are closer than `min_separation`.
pub fn shapes_overlap(shape1: &Shape, shape2: &Shape, min_separation: f32) -> bool {
    let (min_x1, min_y1, max_x1, max_y1) = shape_bounds(shape1);
    let (min_x2, min_y2, max_x2, max_y2) = shape_bounds(shape2);

    // Expand bounds by minimum separation
    let expanded_min_x1 = min_x1 - min_separation;
    let expanded_min_y1 = min_y1 - min_separation;
    let expanded_max_x1 = max_x1 + min_separation;
    let expanded_max_y1 = max_y1 + min_separation;

    // Check AABB intersection
    !(expanded_max_x1 < min_x2
        || expanded_min_x1 > max_x2
        || expanded_max_y1 < min_y2
        || expanded_min_y1 > max_y2)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::shapes::types::ShapeType as MyShapeType;

    #[test]
    fn test_shape_bounds() {
        let shape = Shape::new(1, MyShapeType::Square, 10.0, 10.0);
        let (min_x, min_y, max_x, max_y) = shape_bounds(&shape);

        // Square is 6 chars wide, so half-width is 3
        // Bounds should be approximately (7, 7, 13, 13)
        assert!(min_x < 10.0);
        assert!(max_x > 10.0);
        assert!(min_y < 10.0);
        assert!(max_y > 10.0);
    }

    #[test]
    fn test_shapes_overlap_distant() {
        let shape1 = Shape::new(1, MyShapeType::Square, 0.0, 0.0);
        let shape2 = Shape::new(2, MyShapeType::Square, 100.0, 100.0);

        assert!(!shapes_overlap(&shape1, &shape2, 4.0));
    }

    #[test]
    fn test_shapes_overlap_close() {
        let shape1 = Shape::new(1, MyShapeType::Square, 10.0, 10.0);
        let shape2 = Shape::new(2, MyShapeType::Square, 12.0, 10.0);

        assert!(shapes_overlap(&shape1, &shape2, 4.0));
    }
}