voronoi-go 1.0.1

Core rules and engine for Voronoi Go.
Documentation
//! Plane geometry: the shapes the board is described with, and the predicates
//! and distance queries over them.
//!
//! Two rules run through the whole module:
//!
//! - **Identity is exact.** Nothing here decides whether two points are *the
//!   same* point; that is [`PointKey`](crate::PointKey)'s job alone. The float
//!   comparisons that do appear are magnitude tests guarding a degeneracy — a
//!   zero-length segment, a point exactly at a centre — and each says so.
//! - **Nothing rounds.** [`truncate_coord`] is the sole formatter, it produces
//!   a `String`, and its output never re-enters a calculation.

mod predicates;
mod shapes;

pub use predicates::{ccw, point_in_polygon, point_in_rings, segments_intersect, signed_ring_area};
pub use shapes::{Arc, Circle, LineSegment, normalize_angle};

use crate::{EPSILON, Point};

/// Whether `point` is a position on a board `board_size` units square.
///
/// The board rectangle itself, not the alive zone inside it: this is the domain
/// of a position, and how much of that domain a stone centre may actually occupy
/// is a separate and much stricter question that [`AliveZone`](crate::AliveZone)
/// answers.
///
/// **Total by construction.** Written as two range containments rather than four
/// comparisons because a coordinate that is not a number must answer `false`
/// here, and `!(x < 0.0 || x > size)` answers `true` for it. `docs/design.md`
/// § "A position is a point on the board" is the same rule stated as an
/// invariant. A board whose size is itself not a number holds no positions at
/// all, which falls out of the same phrasing.
#[must_use]
pub fn point_is_on_board(point: Point, board_size: f64) -> bool {
    (0.0..=board_size).contains(&point.x) && (0.0..=board_size).contains(&point.y)
}

/// Whether two magnitudes agree to within [`EPSILON`].
///
/// This is for degeneracy guards — a near-tangency whose square root would
/// otherwise go negative, a divisor whose sign has become noise — and for
/// nothing else. It is **not** an identity test: use
/// [`PointKey`](crate::PointKey) for that.
#[must_use]
pub fn approx_equal(a: f64, b: f64) -> bool {
    (a - b).abs() < EPSILON
}

/// Formats a coordinate with a fixed number of decimals, for writing into a
/// path string.
///
/// **Output only.** Its result is a `String` precisely so it cannot be fed back
/// into a calculation: rounding a coordinate and then computing with it is what
/// `docs/design.md` rules out. Negative zero formats as positive zero, so a
/// coordinate's sign never depends on which side it approached from.
#[must_use]
pub fn truncate_coord(value: f64, decimals: usize) -> String {
    // Compared by bits, not by value: `-0.0 == 0.0` is true, and it is exactly
    // the two that need telling apart here.
    let value = if value.to_bits() == (-0.0_f64).to_bits() {
        0.0
    } else {
        value
    };
    format!("{value:.decimals$}")
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used, clippy::expect_used)]

    use super::{approx_equal, point_is_on_board, truncate_coord};
    use crate::{EPSILON, Point};

    const BOARD: f64 = 18.0;

    #[test]
    fn the_board_rectangle_is_closed() {
        for point in [(0.0, 0.0), (BOARD, BOARD), (0.0, BOARD), (9.0, 9.0)] {
            assert!(point_is_on_board(Point::new(point.0, point.1), BOARD));
        }
        for point in [(-1e-300, 9.0), (9.0, BOARD + 1e-9), (1e300, 9.0)] {
            assert!(!point_is_on_board(Point::new(point.0, point.1), BOARD));
        }
    }

    #[test]
    fn a_coordinate_that_is_not_a_number_is_not_on_the_board() {
        for coordinate in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
            assert!(!point_is_on_board(Point::new(coordinate, 9.0), BOARD));
            assert!(!point_is_on_board(Point::new(9.0, coordinate), BOARD));
        }
    }

    #[test]
    fn a_board_that_is_not_a_number_holds_no_positions() {
        assert!(!point_is_on_board(Point::new(9.0, 9.0), f64::NAN));
        assert!(!point_is_on_board(Point::new(0.0, 0.0), f64::NAN));
    }

    #[test]
    fn approx_equal_is_a_magnitude_test() {
        assert!(approx_equal(1.0, 1.0 + EPSILON / 2.0));
        assert!(!approx_equal(1.0, 1.0 + EPSILON * 2.0));
        assert!(approx_equal(0.0, 0.0));
    }

    #[test]
    fn truncate_coord_fixes_the_decimals() {
        assert_eq!(truncate_coord(1.0, 8), "1.00000000");
        assert_eq!(truncate_coord(1.234_567_891_5, 8), "1.23456789");
        assert_eq!(truncate_coord(-3.5, 2), "-3.50");
        assert_eq!(truncate_coord(12.0, 0), "12");
    }

    #[test]
    fn truncate_coord_normalizes_negative_zero() {
        assert_eq!(truncate_coord(-0.0, 8), "0.00000000");
        assert_eq!(truncate_coord(0.0, 8), "0.00000000");
        // A genuinely negative value keeps its sign, even when it rounds to zero.
        assert_eq!(truncate_coord(-0.001, 2), "-0.00");
    }
}