pattern walking for the fill

This commit is contained in:
2025-03-21 18:55:07 +02:00
parent b703612aba
commit 2adeac6b99
+605
View File
@@ -0,0 +1,605 @@
use rand::Rng;
use rand::SeedableRng;
use rand::rngs::StdRng;
use crate::config::GenerationConfig;
use crate::error::{CrucivError, Result};
use super::{Cell, Grid};
use super::slot::extract_slots;
use super::symmetry::mirror_coords;
const MAX_WORD_LEN_HUGE: usize = 7; // grids 25x25+
const MAX_WORD_LEN_LARGE: usize = 9; // grids 19x19+
/// Try to load a grid from pre-made templates first (fast path for 15x15 and 21x21).
/// Falls back to random generation if no templates available.
pub fn generate_pattern(config: &GenerationConfig) -> Result<Grid> {
let mut rng = match config.seed {
Some(s) => StdRng::seed_from_u64(s),
None => StdRng::from_os_rng(),
};
// try template-based generation for large grids
if config.width >= 15 && config.height >= 15 {
if let Some(grid) = load_template(config, &mut rng) {
return Ok(grid);
}
}
for _ in 0..100 {
let grid = try_generate(config, &mut rng);
if let Some(grid) = grid {
return Ok(grid);
}
}
Err(CrucivError::Generation(
"failed to generate valid grid pattern after 100 attempts".into(),
))
}
fn load_template(config: &GenerationConfig, rng: &mut StdRng) -> Option<Grid> {
let data_dir = std::env::var("DATA_DIR").unwrap_or_else(|_| "../data".into());
let filename = format!("grids_{}x{}.txt", config.width, config.height);
let path = std::path::Path::new(&data_dir).join("grid-templates").join(&filename);
let content = std::fs::read_to_string(&path).ok()?;
let templates: Vec<&str> = content.split("\n\n").filter(|s| !s.trim().is_empty()).collect();
if templates.is_empty() { return None; }
// try a few random templates
for _ in 0..20 {
let idx = rng.random_range(0..templates.len());
let template = templates[idx].trim();
let lines: Vec<&str> = template.lines().collect();
if lines.len() != config.height { continue; }
if lines[0].len() != config.width { continue; }
let mut grid = Grid::new(config.width, config.height);
for (r, line) in lines.iter().enumerate() {
for (c, ch) in line.chars().enumerate() {
if ch == '#' {
grid.set(r, c, Cell::Black);
}
}
}
if is_connected(&grid) && has_valid_words(&grid, config.min_word_length) {
let slots = extract_slots(&grid, config.min_word_length);
if !slots.is_empty() {
tracing::debug!("loaded {}x{} template (index {})", config.width, config.height, idx);
return Some(grid);
}
}
}
None
}
fn try_generate(config: &GenerationConfig, rng: &mut StdRng) -> Option<Grid> {
let w = config.width;
let h = config.height;
let max_word_len = if w >= 25 || h >= 25 { MAX_WORD_LEN_HUGE }
else if w >= 19 || h >= 19 { MAX_WORD_LEN_LARGE }
else { w.max(h) };
let min_density = if w >= 25 || h >= 25 { 0.30 }
else if w >= 19 || h >= 19 { 0.25 }
else { 0.0 };
let effective_density = config.black_cell_density.max(min_density);
// for high density (sparse puzzles), carve slots out of a black grid
// for normal density, add black cells to a white grid
let mut grid = if effective_density > 0.50 {
try_generate_sparse(w, h, effective_density, config, rng)?
} else {
try_generate_dense(w, h, effective_density, config, rng)?
};
if has_long_runs(&grid, max_word_len) {
break_long_runs(&mut grid, max_word_len, config, rng);
}
if !is_connected(&grid) || !has_valid_words(&grid, config.min_word_length) {
return None;
}
let slots = extract_slots(&grid, config.min_word_length);
if slots.is_empty() {
return None;
}
Some(grid)
}
/// Standard approach: start white, add black cells while maintaining connectivity
fn try_generate_dense(
w: usize, h: usize, density: f64, config: &GenerationConfig, rng: &mut StdRng,
) -> Option<Grid> {
let mut grid = Grid::new(w, h);
let total = w * h;
let target_black = (total as f64 * density) as usize;
let mut positions: Vec<(usize, usize)> = Vec::new();
for r in 0..h {
for c in 0..w {
positions.push((r, c));
}
}
shuffle(&mut positions, rng);
let mut placed = 0;
for &(r, c) in &positions {
if placed >= target_black { break; }
if grid.get(r, c).is_black() { continue; }
let coords = mirror_coords(r, c, w, h, config.symmetry);
let would_place = coords.iter().filter(|&&(mr, mc)| !grid.get(mr, mc).is_black()).count();
if placed + would_place > target_black + 2 { continue; }
for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Black); }
if !has_valid_words(&grid, config.min_word_length) || !is_connected(&grid) {
for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Empty); }
continue;
}
placed = grid.black_count();
}
Some(grid)
}
/// Sparse approach: start black, carve connected word slots
fn try_generate_sparse(
w: usize, h: usize, density: f64, config: &GenerationConfig, rng: &mut StdRng,
) -> Option<Grid> {
let mut grid = Grid::new_black(w, h);
let total = w * h;
let target_white = ((1.0 - density) * total as f64).max(6.0) as usize;
let min_len = config.min_word_length;
// start with one slot near the center to seed connectivity
let cr = h / 2;
let cc = w / 2;
let first_len = min_len + rng.random_range(0..=2);
let start_c = cc.saturating_sub(first_len / 2);
for i in 0..first_len {
if start_c + i < w {
let coords = mirror_coords(cr, start_c + i, w, h, config.symmetry);
for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Empty); }
}
}
// carve more slots, preferring positions that intersect existing white cells
for _ in 0..300 {
if grid.white_count() >= target_white { break; }
let horizontal = rng.random_bool(0.5);
let len = min_len + rng.random_range(0..=3);
// try to find a position that crosses an existing white cell
let mut best = None;
for _ in 0..20 {
let r = rng.random_range(0..h);
let c = rng.random_range(0..w);
let (end_r, end_c) = if horizontal { (r, c + len - 1) } else { (r + len - 1, c) };
if end_r >= h || end_c >= w { continue; }
// check if this slot touches any existing white cell
let mut touches = false;
for i in 0..len {
let (pr, pc) = if horizontal { (r, c + i) } else { (r + i, c) };
if grid.get(pr, pc).is_white() { touches = true; break; }
// also check adjacent cells
for (dr, dc) in [(-1i32,0),(1,0),(0,-1i32),(0,1)] {
let nr = pr as i32 + dr;
let nc = pc as i32 + dc;
if nr >= 0 && nc >= 0 && (nr as usize) < h && (nc as usize) < w {
if grid.get(nr as usize, nc as usize).is_white() { touches = true; break; }
}
}
if touches { break; }
}
// first slot always ok, subsequent must touch existing
if grid.white_count() <= first_len * 2 || touches {
best = Some((r, c));
break;
}
}
let Some((r, c)) = best else { continue };
for i in 0..len {
let (pr, pc) = if horizontal { (r, c + i) } else { (r + i, c) };
if pr < h && pc < w {
let coords = mirror_coords(pr, pc, w, h, config.symmetry);
for &(mr, mc) in &coords { grid.set(mr, mc, Cell::Empty); }
}
}
}
if !is_connected(&grid) || !has_valid_words(&grid, min_len) {
return None;
}
let slots = extract_slots(&grid, min_len);
if slots.len() < 2 { return None; }
Some(grid)
}
fn has_long_runs(grid: &Grid, max_len: usize) -> bool {
for r in 0..grid.height {
let mut run = 0;
for c in 0..grid.width {
if grid.get(r, c).is_white() {
run += 1;
if run > max_len { return true; }
} else {
run = 0;
}
}
}
for c in 0..grid.width {
let mut run = 0;
for r in 0..grid.height {
if grid.get(r, c).is_white() {
run += 1;
if run > max_len { return true; }
} else {
run = 0;
}
}
}
false
}
fn break_long_runs(grid: &mut Grid, max_len: usize, config: &GenerationConfig, _rng: &mut StdRng) {
for _ in 0..100 {
if !has_long_runs(grid, max_len) {
break;
}
let mut broke_something = false;
// find and break a long row run
'rows: for r in 0..grid.height {
let mut start = None;
let mut run = 0;
for c in 0..=grid.width {
if c < grid.width && grid.get(r, c).is_white() {
if start.is_none() { start = Some(c); }
run += 1;
} else {
if run > max_len {
let s = start.unwrap();
let mid = s + run / 2;
if try_place_black(grid, r, mid, config) {
broke_something = true;
break 'rows;
}
}
start = None;
run = 0;
}
}
}
// find and break a long column run
'cols: for c in 0..grid.width {
let mut start = None;
let mut run = 0;
for r in 0..=grid.height {
if r < grid.height && grid.get(r, c).is_white() {
if start.is_none() { start = Some(r); }
run += 1;
} else {
if run > max_len {
let s = start.unwrap();
let mid = s + run / 2;
if try_place_black(grid, mid, c, config) {
broke_something = true;
break 'cols;
}
}
start = None;
run = 0;
}
}
}
if !broke_something {
break;
}
}
}
fn try_place_black(grid: &mut Grid, r: usize, c: usize, config: &GenerationConfig) -> bool {
let coords = mirror_coords(r, c, grid.width, grid.height, config.symmetry);
for &(mr, mc) in &coords {
grid.set(mr, mc, Cell::Black);
}
if is_connected(grid) && has_valid_words(grid, config.min_word_length) {
true
} else {
for &(mr, mc) in &coords {
grid.set(mr, mc, Cell::Empty);
}
false
}
}
fn has_valid_words(grid: &Grid, min_len: usize) -> bool {
// every white cell must belong to at least one run of min_len+ in some direction
// first compute across run length for each cell
let w = grid.width;
let h = grid.height;
let mut across_len = vec![vec![0usize; w]; h];
for r in 0..h {
let mut start = 0;
while start < w {
if !grid.get(r, start).is_white() { start += 1; continue; }
let mut end = start;
while end < w && grid.get(r, end).is_white() { end += 1; }
let len = end - start;
for c in start..end { across_len[r][c] = len; }
start = end;
}
}
// compute down run length for each cell
let mut down_len = vec![vec![0usize; w]; h];
for c in 0..w {
let mut start = 0;
while start < h {
if !grid.get(start, c).is_white() { start += 1; continue; }
let mut end = start;
while end < h && grid.get(end, c).is_white() { end += 1; }
let len = end - start;
for r in start..end { down_len[r][c] = len; }
start = end;
}
}
// check: every white cell must be in valid-length runs in BOTH directions
// this ensures every cell is "checked" (solvable through crossings)
for r in 0..h {
for c in 0..w {
if !grid.get(r, c).is_white() { continue; }
if across_len[r][c] < min_len || down_len[r][c] < min_len {
return false;
}
}
}
// also reject any run of 2..min_len (too short to be a word but not a single crossing cell)
for r in 0..h {
let mut run = 0;
for c in 0..w {
if grid.get(r, c).is_white() {
run += 1;
} else {
if run > 0 && run < min_len {
return false;
}
run = 0;
}
}
if run > 0 && run < min_len {
return false;
}
}
for c in 0..w {
let mut run = 0;
for r in 0..h {
if grid.get(r, c).is_white() {
run += 1;
} else {
if run > 0 && run < min_len {
return false;
}
run = 0;
}
}
if run > 0 && run < min_len {
return false;
}
}
true
}
fn is_connected(grid: &Grid) -> bool {
// BFS from the first white cell - all white cells must be reachable
let mut start = None;
let mut white_count = 0;
for r in 0..grid.height {
for c in 0..grid.width {
if grid.get(r, c).is_white() {
white_count += 1;
if start.is_none() {
start = Some((r, c));
}
}
}
}
let Some((sr, sc)) = start else {
return true; // no white cells, trivially connected
};
if white_count == 0 {
return true;
}
let mut visited = vec![vec![false; grid.width]; grid.height];
let mut queue = std::collections::VecDeque::new();
queue.push_back((sr, sc));
visited[sr][sc] = true;
let mut reached = 1;
while let Some((r, c)) = queue.pop_front() {
for (dr, dc) in [(-1i32, 0), (1, 0), (0, -1i32), (0, 1)] {
let nr = r as i32 + dr;
let nc = c as i32 + dc;
if nr < 0 || nc < 0 {
continue;
}
let nr = nr as usize;
let nc = nc as usize;
if nr >= grid.height || nc >= grid.width {
continue;
}
if !visited[nr][nc] && grid.get(nr, nc).is_white() {
visited[nr][nc] = true;
reached += 1;
queue.push_back((nr, nc));
}
}
}
reached == white_count
}
fn shuffle<T>(v: &mut [T], rng: &mut StdRng) {
for i in (1..v.len()).rev() {
let j = rng.random_range(0..=i);
v.swap(i, j);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{GenerationConfig, SymmetryType};
#[test]
fn generates_5x5() {
let config = GenerationConfig {
width: 5,
height: 5,
seed: Some(42),
..Default::default()
};
let grid = generate_pattern(&config).unwrap();
assert_eq!(grid.width, 5);
assert_eq!(grid.height, 5);
assert!(is_connected(&grid));
}
#[test]
fn generates_9x9() {
let config = GenerationConfig {
width: 9,
height: 9,
seed: Some(123),
..Default::default()
};
let grid = generate_pattern(&config).unwrap();
assert!(is_connected(&grid));
assert!(has_valid_words(&grid, 3));
}
#[test]
fn generates_15x15() {
let config = GenerationConfig {
width: 15,
height: 15,
seed: Some(999),
..Default::default()
};
let grid = generate_pattern(&config).unwrap();
assert!(is_connected(&grid));
let slots = extract_slots(&grid, 3);
assert!(!slots.is_empty());
}
#[test]
fn respects_symmetry_180() {
let config = GenerationConfig {
width: 7,
height: 7,
symmetry: SymmetryType::Rotational180,
seed: Some(77),
..Default::default()
};
let grid = generate_pattern(&config).unwrap();
for r in 0..7 {
for c in 0..7 {
assert_eq!(
grid.get(r, c).is_black(),
grid.get(6 - r, 6 - c).is_black(),
"symmetry violated at ({}, {})",
r,
c
);
}
}
}
#[test]
fn respects_mirror_symmetry() {
let config = GenerationConfig {
width: 7,
height: 7,
symmetry: SymmetryType::Mirror,
seed: Some(55),
..Default::default()
};
let grid = generate_pattern(&config).unwrap();
for r in 0..7 {
for c in 0..7 {
assert_eq!(
grid.get(r, c).is_black(),
grid.get(r, 6 - c).is_black(),
"mirror symmetry violated at ({}, {})",
r,
c
);
}
}
}
#[test]
fn no_short_words() {
let config = GenerationConfig {
width: 9,
height: 9,
min_word_length: 3,
seed: Some(200),
..Default::default()
};
let grid = generate_pattern(&config).unwrap();
assert!(has_valid_words(&grid, 3));
}
#[test]
fn connectivity() {
let mut g = Grid::new(5, 5);
// split grid in half with a black column
for r in 0..5 {
g.set(r, 2, Cell::Black);
}
assert!(!is_connected(&g));
}
#[test]
fn density_in_range() {
let config = GenerationConfig {
width: 11,
height: 11,
black_cell_density: 0.25,
seed: Some(300),
..Default::default()
};
let grid = generate_pattern(&config).unwrap();
let d = grid.density();
// allow some tolerance since symmetry constraints mean we can't hit exact density
assert!(d > 0.10 && d < 0.40, "density {} out of range", d);
}
}