use core::f64::consts::{PI, TAU};
use crate::geometry::{Arc, Circle, LineSegment, approx_equal, normalize_angle};
use crate::{EPSILON, Point, STONE_DIAMETER, STONE_RADIUS};
use super::SegId;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ShapeId(u32);
impl ShapeId {
pub(super) const fn new(n: usize) -> Self {
Self(n as u32)
}
#[must_use]
pub const fn get(self) -> u32 {
self.0
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Edge {
Left,
Bottom,
Right,
Top,
}
impl Edge {
pub(super) const ALL: [Self; 4] = [Self::Left, Self::Bottom, Self::Right, Self::Top];
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Closure {
Closed,
Open,
}
#[derive(Clone, Copy, Debug)]
pub enum ShapeKind {
DeadZone(Circle),
Boundary {
board_size: f64,
edge: Edge,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Intersection {
pub entry: Point,
pub exit: Point,
}
#[derive(Clone, Copy, Debug)]
pub enum Span {
Arc(Arc),
Line(LineSegment),
}
impl Span {
#[must_use]
pub fn closest_point(self, p: Point) -> Point {
match self {
Self::Arc(arc) => arc.closest_point(p),
Self::Line(line) => line.closest_point(p),
}
}
#[must_use]
pub fn distance_to_segment(self, line: LineSegment) -> f64 {
match self {
Self::Arc(arc) => arc.distance_to_segment(line),
Self::Line(own) => own.distance_to(line),
}
}
#[cfg(any(feature = "svg", test))]
#[must_use]
pub fn start_point(self) -> Point {
match self {
Self::Arc(arc) => arc.start_point(),
Self::Line(line) => line.a,
}
}
#[cfg(any(feature = "svg", test))]
#[must_use]
pub fn end_point(self) -> Point {
match self {
Self::Arc(arc) => arc.end_point(),
Self::Line(line) => line.b,
}
}
}
impl ShapeKind {
#[must_use]
pub fn dead_zone(center: Point) -> Self {
Self::DeadZone(Circle::new(center, STONE_DIAMETER))
}
#[must_use]
pub const fn boundary(board_size: f64, edge: Edge) -> Self {
Self::Boundary { board_size, edge }
}
#[must_use]
pub const fn circle(self) -> Option<Circle> {
match self {
Self::DeadZone(circle) => Some(circle),
Self::Boundary { .. } => None,
}
}
#[must_use]
pub const fn closure(self) -> Closure {
match self {
Self::DeadZone(_) => Closure::Closed,
Self::Boundary { .. } => Closure::Open,
}
}
#[must_use]
pub fn offset_to_point(self, offset: f64) -> Point {
match self {
Self::DeadZone(circle) => Point::new(
circle.center.x + circle.radius * offset.cos(),
circle.center.y + circle.radius * offset.sin(),
),
Self::Boundary { board_size, edge } => match edge {
Edge::Left => Point::new(STONE_RADIUS, offset),
Edge::Bottom => Point::new(offset, board_size - STONE_RADIUS),
Edge::Right => Point::new(board_size - STONE_RADIUS, board_size - offset),
Edge::Top => Point::new(board_size - offset, STONE_RADIUS),
},
}
}
#[must_use]
pub fn point_to_offset(self, point: Point) -> f64 {
match self {
Self::DeadZone(circle) => {
normalize_angle((point.y - circle.center.y).atan2(point.x - circle.center.x))
}
Self::Boundary { board_size, edge } => match edge {
Edge::Left => point.y,
Edge::Bottom => point.x,
Edge::Right => board_size - point.y,
Edge::Top => board_size - point.x,
},
}
}
#[must_use]
pub fn span(self, start: f64, end: f64) -> Span {
match self {
Self::DeadZone(circle) => Span::Arc(Arc::new(circle.center, circle.radius, start, end)),
Self::Boundary { .. } => Span::Line(LineSegment::new(
self.offset_to_point(start),
self.offset_to_point(end),
)),
}
}
#[must_use]
pub fn depth(self, point: Point) -> f64 {
match self {
Self::DeadZone(circle) => circle.radius - point.distance(circle.center),
Self::Boundary { board_size, edge } => match edge {
Edge::Left => STONE_RADIUS - point.x,
Edge::Bottom => point.y - (board_size - STONE_RADIUS),
Edge::Right => point.x - (board_size - STONE_RADIUS),
Edge::Top => STONE_RADIUS - point.y,
},
}
}
#[cfg(test)]
#[must_use]
pub fn contains(self, point: Point) -> bool {
self.depth(point) > 0.0
}
#[must_use]
pub fn intersect_circle(self, other: Circle) -> Option<Intersection> {
match self {
Self::DeadZone(circle) => intersect_circles(circle, other),
Self::Boundary { board_size, edge } => intersect_boundary(board_size, edge, other),
}
}
}
fn intersect_circles(this: Circle, other: Circle) -> Option<Intersection> {
let (r1, r2) = (this.radius, other.radius);
let d = this.center.distance(other.center);
if d > r1 + r2 + EPSILON || d < (r1 - r2).abs() - EPSILON {
return None;
}
if d < EPSILON || approx_equal(d, r1 + r2) || approx_equal(d, (r1 - r2).abs()) {
return None;
}
let a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
let h = (r1 * r1 - a * a).sqrt();
let dx = other.center.x - this.center.x;
let dy = other.center.y - this.center.y;
let foot = Point::new(this.center.x + a * dx / d, this.center.y + a * dy / d);
let first = Point::new(foot.x + h * dy / d, foot.y - h * dx / d);
let second = Point::new(foot.x - h * dy / d, foot.y + h * dx / d);
let kind = ShapeKind::DeadZone(this);
let sweep = (kind.point_to_offset(second) - kind.point_to_offset(first) + TAU) % TAU;
if sweep < PI {
Some(Intersection {
entry: first,
exit: second,
})
} else {
Some(Intersection {
entry: second,
exit: first,
})
}
}
fn intersect_boundary(board_size: f64, edge: Edge, other: Circle) -> Option<Intersection> {
let center = other.center;
let radius = other.radius;
match edge {
Edge::Left | Edge::Right => {
let x = if matches!(edge, Edge::Left) {
STONE_RADIUS
} else {
board_size - STONE_RADIUS
};
let entry_sign = if matches!(edge, Edge::Left) {
-1.0
} else {
1.0
};
let discriminant = radius * radius - (x - center.x) * (x - center.x);
if discriminant <= EPSILON {
return None;
}
let h = discriminant.sqrt();
Some(Intersection {
entry: Point::new(x, center.y + entry_sign * h),
exit: Point::new(x, center.y - entry_sign * h),
})
}
Edge::Bottom | Edge::Top => {
let y = if matches!(edge, Edge::Bottom) {
board_size - STONE_RADIUS
} else {
STONE_RADIUS
};
let entry_sign = if matches!(edge, Edge::Bottom) {
-1.0
} else {
1.0
};
let discriminant = radius * radius - (y - center.y) * (y - center.y);
if discriminant <= EPSILON {
return None;
}
let h = discriminant.sqrt();
Some(Intersection {
entry: Point::new(center.x + entry_sign * h, y),
exit: Point::new(center.x - entry_sign * h, y),
})
}
}
}
#[derive(Clone, Debug)]
pub struct Shape {
pub(super) kind: ShapeKind,
pub(super) closure: Closure,
pub(super) head: Option<SegId>,
pub(super) count: usize,
}
impl Shape {
pub(super) const fn new(kind: ShapeKind) -> Self {
Self {
kind,
closure: kind.closure(),
head: None,
count: 0,
}
}
#[must_use]
pub const fn kind(&self) -> ShapeKind {
self.kind
}
#[must_use]
pub const fn closure(&self) -> Closure {
self.closure
}
#[must_use]
pub const fn head(&self) -> Option<SegId> {
self.head
}
#[must_use]
pub const fn count(&self) -> usize {
self.count
}
#[must_use]
pub const fn is_subdivided(&self) -> bool {
self.count >= 2
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used)]
use core::f64::consts::{FRAC_PI_2, PI, TAU};
use super::{Edge, ShapeKind};
use crate::geometry::{Circle, signed_ring_area};
use crate::{Point, STONE_DIAMETER, STONE_RADIUS};
const BOARD: f64 = 20.0;
fn p(x: f64, y: f64) -> Point {
Point::new(x, y)
}
fn near(actual: f64, expected: f64) {
assert!(
(actual - expected).abs() < 1e-9,
"expected {expected}, got {actual}"
);
}
fn near_point(actual: Point, expected: Point) {
near(actual.x, expected.x);
near(actual.y, expected.y);
}
#[test]
fn offsets_and_points_are_inverses_on_every_edge() {
for edge in Edge::ALL {
let shape = ShapeKind::boundary(BOARD, edge);
for offset in [-3.0, 0.0, STONE_RADIUS, 7.25, BOARD - STONE_RADIUS, 41.0] {
near(shape.point_to_offset(shape.offset_to_point(offset)), offset);
}
}
}
#[test]
fn offsets_and_points_are_inverses_on_a_dead_zone() {
let shape = ShapeKind::dead_zone(p(5.0, 7.0));
for offset in [0.0, 0.5, PI, 4.0, TAU - 0.001] {
near(shape.point_to_offset(shape.offset_to_point(offset)), offset);
}
}
#[test]
fn an_edge_starts_and_ends_at_the_inset_corners() {
near_point(
ShapeKind::boundary(BOARD, Edge::Left).offset_to_point(STONE_RADIUS),
p(1.0, 1.0),
);
near_point(
ShapeKind::boundary(BOARD, Edge::Left).offset_to_point(BOARD - STONE_RADIUS),
p(1.0, 19.0),
);
near_point(
ShapeKind::boundary(BOARD, Edge::Bottom).offset_to_point(STONE_RADIUS),
p(1.0, 19.0),
);
near_point(
ShapeKind::boundary(BOARD, Edge::Right).offset_to_point(STONE_RADIUS),
p(19.0, 19.0),
);
near_point(
ShapeKind::boundary(BOARD, Edge::Top).offset_to_point(STONE_RADIUS),
p(19.0, 1.0),
);
near_point(
ShapeKind::boundary(BOARD, Edge::Top).offset_to_point(BOARD - STONE_RADIUS),
p(1.0, 1.0),
);
}
#[test]
fn board_edges_run_opposite_to_a_dead_zone() {
let corners: Vec<Point> = Edge::ALL
.into_iter()
.map(|edge| ShapeKind::boundary(BOARD, edge).offset_to_point(STONE_RADIUS))
.collect();
let disc = ShapeKind::dead_zone(p(10.0, 10.0));
let circumference: Vec<Point> = (0..4)
.map(|step| disc.offset_to_point(f64::from(step) * FRAC_PI_2))
.collect();
let board_winding = signed_ring_area(&corners);
let disc_winding = signed_ring_area(&circumference);
assert!(board_winding * disc_winding < 0.0);
}
#[test]
fn a_dead_zone_contains_its_interior_only() {
let shape = ShapeKind::dead_zone(p(10.0, 10.0));
assert!(shape.contains(p(10.0, 10.0)));
assert!(shape.contains(p(11.9, 10.0)));
assert!(!shape.contains(p(12.0, 10.0)));
assert!(!shape.contains(p(14.0, 10.0)));
}
#[test]
fn a_boundary_contains_what_is_off_the_board() {
assert!(ShapeKind::boundary(BOARD, Edge::Left).contains(p(0.5, 10.0)));
assert!(!ShapeKind::boundary(BOARD, Edge::Left).contains(p(1.5, 10.0)));
assert!(ShapeKind::boundary(BOARD, Edge::Top).contains(p(10.0, 0.5)));
assert!(!ShapeKind::boundary(BOARD, Edge::Top).contains(p(10.0, 1.5)));
assert!(ShapeKind::boundary(BOARD, Edge::Right).contains(p(19.5, 10.0)));
assert!(!ShapeKind::boundary(BOARD, Edge::Right).contains(p(18.5, 10.0)));
assert!(ShapeKind::boundary(BOARD, Edge::Bottom).contains(p(10.0, 19.5)));
assert!(!ShapeKind::boundary(BOARD, Edge::Bottom).contains(p(10.0, 18.5)));
}
#[test]
fn two_overlapping_dead_zones_cross_twice() {
let left = ShapeKind::dead_zone(p(10.0, 10.0));
let right = Circle::new(p(12.0, 10.0), STONE_DIAMETER);
let crossing = left.intersect_circle(right).expect("they overlap");
for point in [crossing.entry, crossing.exit] {
near(point.distance(p(10.0, 10.0)), STONE_DIAMETER);
near(point.distance(p(12.0, 10.0)), STONE_DIAMETER);
}
assert_ne!(crossing.entry, crossing.exit);
}
#[test]
fn the_overlapped_arc_is_the_shorter_way_round() {
let disc = ShapeKind::dead_zone(p(10.0, 10.0));
for offset in [1.0, 2.0, 3.5, 3.9_f64] {
for angle in [0.0, 1.0, 2.5, 4.0, 5.5_f64] {
let other = Circle::new(
p(10.0 + offset * angle.cos(), 10.0 + offset * angle.sin()),
STONE_DIAMETER,
);
let crossing = disc.intersect_circle(other).expect("they overlap");
let entry = disc.point_to_offset(crossing.entry);
let exit = disc.point_to_offset(crossing.exit);
let sweep = (exit - entry + TAU) % TAU;
assert!(sweep < PI, "sweep {sweep} should be the shorter way round");
}
}
}
#[test]
fn tangency_and_coincidence_are_rejected() {
let disc = ShapeKind::dead_zone(p(10.0, 10.0));
assert!(
disc.intersect_circle(Circle::new(
p(10.0 + 2.0 * STONE_DIAMETER, 10.0),
STONE_DIAMETER
))
.is_none()
);
assert!(
disc.intersect_circle(Circle::new(p(10.0, 10.0), STONE_DIAMETER))
.is_none()
);
assert!(
disc.intersect_circle(Circle::new(p(11.0, 10.0), STONE_DIAMETER + 1.0))
.is_none()
);
assert!(
disc.intersect_circle(Circle::new(p(30.0, 10.0), STONE_DIAMETER))
.is_none()
);
assert!(
disc.intersect_circle(Circle::new(p(10.1, 10.0), 10.0))
.is_none()
);
}
#[test]
fn an_edge_enters_a_circle_at_the_lower_offset() {
for edge in Edge::ALL {
let shape = ShapeKind::boundary(BOARD, edge);
let center = match edge {
Edge::Left => p(1.5, 10.0),
Edge::Bottom => p(10.0, 18.5),
Edge::Right => p(18.5, 10.0),
Edge::Top => p(10.0, 1.5),
};
let crossing = shape
.intersect_circle(Circle::new(center, STONE_DIAMETER))
.expect("the dead zone reaches the edge");
let entry = shape.point_to_offset(crossing.entry);
let exit = shape.point_to_offset(crossing.exit);
assert!(entry < exit, "{edge:?}: entry {entry} exit {exit}");
for point in [crossing.entry, crossing.exit] {
near(point.distance(center), STONE_DIAMETER);
near_point(shape.offset_to_point(shape.point_to_offset(point)), point);
}
}
}
#[test]
fn an_edge_misses_a_circle_it_does_not_reach() {
for edge in Edge::ALL {
let shape = ShapeKind::boundary(BOARD, edge);
assert!(
shape
.intersect_circle(Circle::new(p(10.0, 10.0), STONE_DIAMETER))
.is_none()
);
}
}
#[test]
fn an_edge_grazing_a_circle_is_rejected() {
let shape = ShapeKind::boundary(BOARD, Edge::Left);
assert!(
shape
.intersect_circle(Circle::new(
p(STONE_RADIUS + STONE_DIAMETER, 10.0),
STONE_DIAMETER
))
.is_none()
);
}
#[test]
fn a_span_runs_between_its_two_offsets() {
let disc = ShapeKind::dead_zone(p(0.0, 0.0));
let arc = disc.span(0.0, FRAC_PI_2);
near_point(arc.start_point(), p(STONE_DIAMETER, 0.0));
near_point(arc.end_point(), p(0.0, STONE_DIAMETER));
let edge = ShapeKind::boundary(BOARD, Edge::Left);
let line = edge.span(STONE_RADIUS, BOARD - STONE_RADIUS);
near_point(line.start_point(), p(1.0, 1.0));
near_point(line.end_point(), p(1.0, 19.0));
}
}