diff --git a/cruciverb-core/src/grid/mod.rs b/cruciverb-core/src/grid/mod.rs new file mode 100644 index 0000000..2cc9b04 --- /dev/null +++ b/cruciverb-core/src/grid/mod.rs @@ -0,0 +1,114 @@ +pub mod pattern; +pub mod slot; +pub mod symmetry; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Cell { + Black, + Empty, + Letter(char), +} + +impl Cell { + pub fn is_black(&self) -> bool { + matches!(self, Cell::Black) + } + + pub fn is_white(&self) -> bool { + !self.is_black() + } + + pub fn letter(&self) -> Option { + match self { + Cell::Letter(c) => Some(*c), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Grid { + pub width: usize, + pub height: usize, + cells: Vec, +} + +impl Grid { + pub fn new(width: usize, height: usize) -> Self { + Self { + width, + height, + cells: vec![Cell::Empty; width * height], + } + } + + pub fn new_black(width: usize, height: usize) -> Self { + Self { + width, + height, + cells: vec![Cell::Black; width * height], + } + } + + pub fn get(&self, row: usize, col: usize) -> Cell { + self.cells[row * self.width + col] + } + + pub fn set(&mut self, row: usize, col: usize, cell: Cell) { + self.cells[row * self.width + col] = cell; + } + + pub fn in_bounds(&self, row: usize, col: usize) -> bool { + row < self.height && col < self.width + } + + pub fn black_count(&self) -> usize { + self.cells.iter().filter(|c| c.is_black()).count() + } + + pub fn white_count(&self) -> usize { + self.cells.iter().filter(|c| c.is_white()).count() + } + + pub fn density(&self) -> f64 { + self.black_count() as f64 / self.cells.len() as f64 + } + + pub fn total_cells(&self) -> usize { + self.cells.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_grid_is_all_empty() { + let g = Grid::new(5, 5); + for r in 0..5 { + for c in 0..5 { + assert_eq!(g.get(r, c), Cell::Empty); + } + } + } + + #[test] + fn set_and_get() { + let mut g = Grid::new(3, 3); + g.set(1, 2, Cell::Black); + g.set(0, 0, Cell::Letter('A')); + assert_eq!(g.get(1, 2), Cell::Black); + assert_eq!(g.get(0, 0), Cell::Letter('A')); + assert_eq!(g.get(0, 1), Cell::Empty); + } + + #[test] + fn density_calculation() { + let mut g = Grid::new(2, 2); + g.set(0, 0, Cell::Black); + assert!((g.density() - 0.25).abs() < f64::EPSILON); + } +} diff --git a/cruciverb-core/src/grid/slot.rs b/cruciverb-core/src/grid/slot.rs new file mode 100644 index 0000000..bf10fe1 --- /dev/null +++ b/cruciverb-core/src/grid/slot.rs @@ -0,0 +1,186 @@ +use serde::{Deserialize, Serialize}; + +use super::Grid; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Direction { + Across, + Down, +} + +#[derive(Debug, Clone)] +pub struct Intersection { + pub other_slot: usize, + pub self_pos: usize, + pub other_pos: usize, +} + +#[derive(Debug, Clone)] +pub struct Slot { + pub index: usize, + pub row: usize, + pub col: usize, + pub direction: Direction, + pub length: usize, + pub intersections: Vec, +} + +impl Slot { + pub fn cells(&self) -> Vec<(usize, usize)> { + (0..self.length) + .map(|i| match self.direction { + Direction::Across => (self.row, self.col + i), + Direction::Down => (self.row + i, self.col), + }) + .collect() + } +} + +pub fn extract_slots(grid: &Grid, min_length: usize) -> Vec { + let mut slots = Vec::new(); + + // across slots + for r in 0..grid.height { + let mut c = 0; + while c < grid.width { + if grid.get(r, c).is_white() { + let start = c; + while c < grid.width && grid.get(r, c).is_white() { + c += 1; + } + let len = c - start; + if len >= min_length { + slots.push(Slot { + index: slots.len(), + row: r, + col: start, + direction: Direction::Across, + length: len, + intersections: Vec::new(), + }); + } + } else { + c += 1; + } + } + } + + // down slots + for c in 0..grid.width { + let mut r = 0; + while r < grid.height { + if grid.get(r, c).is_white() { + let start = r; + while r < grid.height && grid.get(r, c).is_white() { + r += 1; + } + let len = r - start; + if len >= min_length { + slots.push(Slot { + index: slots.len(), + row: start, + col: c, + direction: Direction::Down, + length: len, + intersections: Vec::new(), + }); + } + } else { + r += 1; + } + } + } + + compute_intersections(&mut slots); + slots +} + +fn compute_intersections(slots: &mut Vec) { + let n = slots.len(); + // build a map of (row, col) -> (slot_index, position_in_slot) + let mut cell_map: std::collections::HashMap<(usize, usize), Vec<(usize, usize)>> = + std::collections::HashMap::new(); + + for slot in slots.iter() { + for (pos, (r, c)) in slot.cells().iter().enumerate() { + cell_map.entry((*r, *c)).or_default().push((slot.index, pos)); + } + } + + let mut intersections: Vec> = vec![Vec::new(); n]; + + for entries in cell_map.values() { + if entries.len() == 2 { + let (s1, p1) = entries[0]; + let (s2, p2) = entries[1]; + intersections[s1].push(Intersection { + other_slot: s2, + self_pos: p1, + other_pos: p2, + }); + intersections[s2].push(Intersection { + other_slot: s1, + self_pos: p2, + other_pos: p1, + }); + } + } + + for (i, slot) in slots.iter_mut().enumerate() { + slot.intersections = std::mem::take(&mut intersections[i]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::grid::Cell; + + fn make_mini_grid() -> Grid { + // 3x3 grid with center black: + // . . . + // . # . + // . . . + let mut g = Grid::new(3, 3); + g.set(1, 1, Cell::Black); + g + } + + #[test] + fn extract_across_slots() { + let g = make_mini_grid(); + let slots = extract_slots(&g, 2); + let across: Vec<_> = slots + .iter() + .filter(|s| s.direction == Direction::Across) + .collect(); + assert_eq!(across.len(), 2); // top row (3) and no middle across (1+1 too short) + assert!(across.iter().any(|s| s.row == 0 && s.length == 3)); + assert!(across.iter().any(|s| s.row == 2 && s.length == 3)); + } + + #[test] + fn extract_down_slots() { + let g = make_mini_grid(); + let slots = extract_slots(&g, 2); + let down: Vec<_> = slots + .iter() + .filter(|s| s.direction == Direction::Down) + .collect(); + assert_eq!(down.len(), 2); // left col (3) and right col (3) + assert!(down.iter().any(|s| s.col == 0 && s.length == 3)); + assert!(down.iter().any(|s| s.col == 2 && s.length == 3)); + } + + #[test] + fn intersections_found() { + let g = Grid::new(3, 3); // fully white + let slots = extract_slots(&g, 3); + // 3 across + 3 down = 6 slots, but only rows/cols of length 3 + // actually: 3 across (one per row) + 3 down (one per col) + for slot in &slots { + assert!(!slot.intersections.is_empty()); + } + } +}