#![allow(clippy::ptr_arg)]
#![allow(clippy::needless_range_loop)]
use std::collections::BTreeSet;
use crate::json::StateLit;
use crate::json::{Problem, Puzzle};
use itertools::Itertools;
use svg::Node;
use svg::node::element;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum DecorationKind {
SudokuGrid,
BlankInputVal(i64),
ClueInCorner,
WallBelow(i64),
}
struct Decorations {
flags: BTreeSet<DecorationKind>,
}
impl Decorations {
pub fn new(kind: &str, decs: &[String]) -> Decorations {
let mut flags: BTreeSet<DecorationKind> = BTreeSet::new();
if !decs.is_empty() {
for dec in decs {
if dec == "sudoku_grid" {
flags.insert(DecorationKind::SudokuGrid);
} else if let Some(val_str) = dec.strip_prefix("blank_input_val=") {
if let Ok(val) = val_str.parse::<i64>() {
flags.insert(DecorationKind::BlankInputVal(val));
}
} else if dec == "clue_in_corner" {
flags.insert(DecorationKind::ClueInCorner);
} else if let Some(val_str) = dec.strip_prefix("wall_below=")
&& let Ok(val) = val_str.parse::<i64>()
{
flags.insert(DecorationKind::WallBelow(val));
}
}
return Decorations { flags };
}
let kind = kind.to_lowercase();
if kind == "sudoku" || kind == "killer sudoku" || kind == "miracle" || kind == "x-sums" {
flags.insert(DecorationKind::SudokuGrid);
flags.insert(DecorationKind::BlankInputVal(0));
} else if kind == "binairo" {
flags.insert(DecorationKind::BlankInputVal(2));
} else if kind == "mosaic" {
flags.insert(DecorationKind::BlankInputVal(-1));
flags.insert(DecorationKind::ClueInCorner);
}
Decorations { flags }
}
fn sudoku_grid(&self) -> bool {
self.flags.contains(&DecorationKind::SudokuGrid)
}
fn blank_input_val(&self) -> Option<i64> {
self.flags.iter().find_map(|f| {
if let DecorationKind::BlankInputVal(v) = f {
Some(*v)
} else {
None
}
})
}
fn clue_in_corner(&self) -> bool {
self.flags.contains(&DecorationKind::ClueInCorner)
}
fn wall_below(&self) -> Option<i64> {
self.flags.iter().find_map(|f| {
if let DecorationKind::WallBelow(v) = f {
Some(*v)
} else {
None
}
})
}
}
pub struct PuzzleDraw {
base_width: f64,
mid_width: f64,
thick_width: f64,
decorations: Decorations,
}
impl Default for PuzzleDraw {
fn default() -> Self {
Self::new("")
}
}
impl PuzzleDraw {
#[must_use]
pub fn new(kind: &str) -> Self {
PuzzleDraw {
base_width: 0.005,
mid_width: 0.01,
thick_width: 0.02,
decorations: Decorations::new(kind, &[]),
}
}
#[must_use]
pub fn new_with_decs(kind: &str, decs: &[String]) -> Self {
PuzzleDraw {
base_width: 0.005,
mid_width: 0.01,
thick_width: 0.02,
decorations: Decorations::new(kind, decs),
}
}
}
impl PuzzleDraw {
#[must_use]
pub fn draw_puzzle(&self, puzjson: &Problem) -> svg::Document {
let puzzle = &puzjson.puzzle;
let mut out = self.draw_grid(puzzle);
let mut cells = self.make_cells(puzzle);
if let Some(start_grid) = &puzzle.start_grid {
self.fill_fixed_state(&mut cells, start_grid);
}
if let Some(state) = &puzjson.state
&& let Some(knowledge_grid) = &state.knowledge_grid
{
self.fill_knowledge(&mut cells, &puzzle.start_grid, knowledge_grid);
}
if let Some(state) = &puzjson.state
&& let Some(blocked) = &state.blocked_cells
{
self.fill_blocked_cells(&mut cells, puzzle, blocked);
}
out.append(self.draw_thermometers(puzzle));
let mut cellgrp = element::Group::new();
for row in cells {
for c in row {
cellgrp.append(c);
}
}
out.append(cellgrp);
out.append(self.draw_less_than(puzzle));
out.append(self.draw_cage_sums(puzzle));
let out = self.fill_outside_labels(out, puzzle);
let mut final_grp = element::Group::new();
final_grp.assign("transform", "translate(50,50) scale(400)");
final_grp.append(out);
let doc = svg::Document::new()
.set("viewBox", (0, 0, 500, 500))
.set("width", 500)
.set("height", 500)
.set("class", "puzzle");
doc.add(final_grp)
}
fn fill_outside_labels(&self, mut grid: element::Group, p: &Puzzle) -> element::Group {
let mut label_group = element::Group::new();
label_group.assign("class", "labels");
let step = 1.0 / std::cmp::min(p.width, p.height) as f64;
let mut puz_bounds = (0.0, step * (p.width as f64), 0.0, step * (p.height as f64));
let label_groups = [
&p.top_labels,
&p.bottom_labels,
&p.left_labels,
&p.right_labels,
];
#[allow(clippy::type_complexity)]
let label_positions: Vec<(
Box<dyn Fn(usize) -> i64>,
Box<dyn Fn(usize) -> i64>,
Box<dyn Fn(&mut (f64, f64, f64, f64))>,
)> = vec![
(
Box::new(|i| i as i64),
Box::new(|_| -1),
Box::new(|bounds| bounds.0 -= step),
),
(
Box::new(|i| i as i64),
Box::new(|_| p.height),
Box::new(|bounds| bounds.1 += step),
),
(
Box::new(|_| -1),
Box::new(|i| i as i64),
Box::new(|bounds| bounds.2 -= step),
),
(
Box::new(|_| p.width),
Box::new(|i| i as i64),
Box::new(|bounds| bounds.3 += step),
),
];
for (labels, position) in label_groups.iter().zip(label_positions.iter()) {
if let Some(labels) = labels {
position.2(&mut puz_bounds);
for (i, label) in labels.iter().enumerate() {
let mut node = svg::node::element::Text::new(label);
node.assign("font-size", 1);
node.assign("transform", "translate(0.2, 0.9)");
let mut g = make_cell(position.0(i), position.1(i), step);
g.append(node);
label_group.append(g);
}
}
}
grid.append(label_group);
let max_scale = f64::min(
1.0 / (-puz_bounds.0 + puz_bounds.1),
1.0 / (-puz_bounds.2 + puz_bounds.3),
);
let mut resized_grid = element::Group::new();
resized_grid.assign(
"transform",
format!(
"translate({},{}) scale({},{})",
-puz_bounds.0, -puz_bounds.2, max_scale, max_scale
),
);
resized_grid.append(grid);
resized_grid
}
fn fixed_cell_is_used(&self, cell: Option<i64>) -> bool {
cell.is_some_and(|c| Some(c) != self.decorations.blank_input_val())
}
fn fill_fixed_state(
&self,
cells: &mut Vec<Vec<element::Group>>,
contents: &Vec<Vec<Option<i64>>>,
) {
let wall_below = self.decorations.wall_below();
for i in 0..contents.len() {
for j in 0..contents[i].len() {
let val = contents[i][j];
if let Some(wb) = wall_below
&& val.is_some_and(|v| v < wb)
{
let mut outer = element::Rectangle::new();
outer.assign("width", 1);
outer.assign("height", 1);
outer.assign("fill", "#666666");
outer.assign("class", "wall-cell");
cells[i][j].append(outer);
let mut inner = element::Rectangle::new();
inner.assign("x", 0.1);
inner.assign("y", 0.1);
inner.assign("width", 0.8);
inner.assign("height", 0.8);
inner.assign("fill", "white");
cells[i][j].append(inner);
if let Some(v) = val
&& v >= 0
{
let mut node = svg::node::element::Text::new(v.to_string());
node.assign("font-size", 0.5);
node.assign("x", 0.5);
node.assign("y", 0.65);
node.assign("dominant-baseline", "middle");
node.assign("text-anchor", "middle");
cells[i][j].append(node);
}
continue;
}
if self.fixed_cell_is_used(val) {
let cell = val.unwrap();
let s = cell.to_string();
let mut node = svg::node::element::Text::new(s);
if self.decorations.clue_in_corner() {
node.assign("font-size", 0.35);
node.assign("x", 0.05);
node.assign("y", 0.38);
} else {
node.assign("font-size", 1);
node.assign("transform", "translate(0.2, 0.9)");
}
cells[i][j].append(node);
}
}
}
}
fn fill_knowledge(
&self,
cells: &mut Vec<Vec<element::Group>>,
fixed_contents: &Option<Vec<Vec<Option<i64>>>>,
contents: &Vec<Vec<Option<Vec<StateLit>>>>,
) {
for i in 0..contents.len() {
for j in 0..contents[i].len() {
if !self.decorations.clue_in_corner()
&& fixed_contents
.as_ref()
.is_some_and(|c| self.fixed_cell_is_used(c[i][j]))
{
continue;
}
if let Some(cell) = &contents[i][j] {
let sqrt_length = (cell.len() as f64).sqrt().ceil() as usize;
let little_step = 0.9 / sqrt_length as f64;
for a in 0..sqrt_length {
for b in 0..sqrt_length {
if a * sqrt_length + b < cell.len() {
let state = &cell[a * sqrt_length + b];
let s = state.val.to_string();
let mut group = svg::node::element::Group::new();
group.assign(
"transform",
format!(
"translate({}, {})",
0.05 + (b as f64 * little_step),
0.05 + (a as f64 + 1.0) * little_step
),
);
let mut rect = svg::node::element::Rectangle::new();
rect.assign("width", little_step);
rect.assign("height", little_step);
rect.assign("y", -little_step);
rect.assign("class", "litbox");
group.append(rect);
let mut node = svg::node::element::Text::new(s);
node.assign("font-size", little_step);
node.assign("x", little_step / 2.0);
node.assign("y", -little_step / 3.0);
node.assign("dominant-baseline", "middle");
node.assign("text-anchor", "middle");
group.append(node);
let id = format!(
"D_{}_{}_{}",
i + 1,
j + 1,
cell[a * sqrt_length + b].val
);
group.assign("id", id.clone());
group.assign("name", id);
group.assign("hx-post", "/clickLiteral");
group.assign("hx-target", "#mainSpace");
group.assign("class", "literal");
let mut classes = vec!["literal".to_owned()];
if let Some(extra_classes) = &state.classes {
classes.extend(extra_classes.iter().cloned());
}
group.assign("class", classes.iter().join(" "));
cells[i][j].append(group);
}
}
}
}
}
}
}
fn fill_blocked_cells(
&self,
cells: &mut Vec<Vec<element::Group>>,
_puzzle: &Puzzle,
blocked: &[[i64; 2]],
) {
for &[r, c] in blocked {
let i = r as usize;
let j = c as usize;
if i >= cells.len() || j >= cells[i].len() {
continue;
}
let mut bg = element::Rectangle::new();
bg.assign("width", 1);
bg.assign("height", 1);
bg.assign("fill", "#cccccc");
bg.assign("opacity", 0.5);
cells[i][j].append(bg);
let mut text = svg::node::element::Text::new("?");
text.assign("font-size", 0.7);
text.assign("x", 0.5);
text.assign("y", 0.75);
text.assign("dominant-baseline", "middle");
text.assign("text-anchor", "middle");
text.assign("fill", "#888888");
text.assign("class", "litundeducable");
cells[i][j].append(text);
}
}
fn draw_grid(&self, puzzle: &Puzzle) -> element::Group {
let mut topgrp = element::Group::new();
let mut grp = element::Group::new();
let width = usize::try_from(puzzle.width).expect("negative width?");
let height = usize::try_from(puzzle.height).expect("negative height?");
let cages = &puzzle.cages;
let step = 1.0 / std::cmp::min(width, height) as f64;
let colours_list = [
"#85586f", "#d6efed", "#957dad", "#ac7d88", "#b7d3df", "#e0bbe4", "#deb6ab", "#c9bbcf",
"#fec8d8", "#f8ecd1", "#898aa6", "#ffdfd3", "#c4dfaa", "#f5f0bb", "#e6e1cd", "#d6b1dd",
];
let mut cagegrp = element::Group::new();
if let Some(cages) = &cages {
let colours: BTreeSet<_> = cages.iter().flatten().filter_map(|cell| *cell).collect();
for i in 0..width {
for j in 0..height {
if let Some(cell) = cages[j][i] {
let col = colours.iter().position(|&c| c == cell).unwrap();
let i_f = i as f64;
let j_f = j as f64;
let path = format!(
"M {} {} H {} V {} H {} Z",
step * i_f,
step * j_f,
step * (i_f + 1.0),
step * (j_f + 1.0),
step * i_f
);
let mut p = element::Path::new();
p.assign("d", path);
p.assign("fill", colours_list[col]);
cagegrp.append(p);
}
}
}
}
grp.append(cagegrp);
let mut outlinegrp = element::Group::new();
for i in 0..=width {
for j in 0..height {
let mut stroke = self.base_width;
if i == 0 || i == width {
stroke = self.thick_width;
} else {
if self.decorations.sudoku_grid() && i % 3 == 0 {
stroke = self.mid_width;
}
if let Some(cages) = cages
&& cages[j][i] != cages[j][i - 1]
{
stroke = self.thick_width;
}
}
let i_f = i as f64;
let j_f = j as f64;
let path = format!(
"M {} {} L {} {}",
step * i_f,
step * j_f,
step * i_f,
step * (j_f + 1.0)
);
let mut p = element::Path::new();
p.assign("d", path);
p.assign("stroke", "black");
p.assign("stroke-width", stroke);
p.assign("stroke-linecap", "round");
outlinegrp = outlinegrp.add(p);
}
}
for i in 0..width {
for j in 0..=height {
let mut stroke = self.base_width;
if j == 0 || j == height {
stroke = self.thick_width;
} else {
if self.decorations.sudoku_grid() && j % 3 == 0 {
stroke = self.mid_width;
}
if let Some(cages) = cages
&& cages[j][i] != cages[j - 1][i]
{
stroke = self.thick_width;
}
}
let i_f = i as f64;
let j_f = j as f64;
let path = format!(
"M {} {} L {} {}",
step * i_f,
step * j_f,
step * (i_f + 1.0),
step * j_f
);
let mut p = element::Path::new();
p.assign("d", path);
p.assign("stroke", "black");
p.assign("stroke-width", stroke);
p.assign("stroke-linecap", "round");
outlinegrp.append(p);
}
}
grp.append(outlinegrp);
topgrp.append(grp);
topgrp
}
fn draw_thermometers(&self, puzzle: &Puzzle) -> element::Group {
let mut grp = element::Group::new();
let therms = match &puzzle.thermometers {
Some(t) => t,
None => return grp,
};
let step = 1.0 / std::cmp::min(puzzle.width, puzzle.height) as f64;
let radius = step * 0.38;
let tube_width = step * 0.5;
let outline_extra = step * 0.06;
let fill_color = "#d8d8d8";
let outline_color = "#888888";
let mut outlines = element::Group::new();
let mut fills = element::Group::new();
for therm in therms {
if therm.is_empty() {
continue;
}
let points: String = therm
.iter()
.map(|[r, c]| format!("{},{}", step * (*c as f64 + 0.5), step * (*r as f64 + 0.5)))
.collect::<Vec<_>>()
.join(" ");
if therm.len() > 1 {
let mut poly_outline = element::Polyline::new();
poly_outline.assign("points", points.clone());
poly_outline.assign("fill", "none");
poly_outline.assign("stroke", outline_color);
poly_outline.assign("stroke-width", tube_width + outline_extra);
poly_outline.assign("stroke-linecap", "round");
poly_outline.assign("stroke-linejoin", "round");
outlines.append(poly_outline);
let mut poly_fill = element::Polyline::new();
poly_fill.assign("points", points);
poly_fill.assign("fill", "none");
poly_fill.assign("stroke", fill_color);
poly_fill.assign("stroke-width", tube_width);
poly_fill.assign("stroke-linecap", "round");
poly_fill.assign("stroke-linejoin", "round");
fills.append(poly_fill);
}
let [br, bc] = therm[0];
let cx = step * (bc as f64 + 0.5);
let cy = step * (br as f64 + 0.5);
let mut bulb_outline = element::Circle::new();
bulb_outline.assign("cx", cx);
bulb_outline.assign("cy", cy);
bulb_outline.assign("r", radius + outline_extra / 2.0);
bulb_outline.assign("fill", outline_color);
outlines.append(bulb_outline);
let mut bulb_fill = element::Circle::new();
bulb_fill.assign("cx", cx);
bulb_fill.assign("cy", cy);
bulb_fill.assign("r", radius);
bulb_fill.assign("fill", fill_color);
fills.append(bulb_fill);
}
grp.append(outlines);
grp.append(fills);
grp
}
fn draw_less_than(&self, puzzle: &Puzzle) -> element::Group {
let mut grp = element::Group::new();
let pairs = match &puzzle.less_than {
Some(p) => p,
None => return grp,
};
let step = 1.0 / std::cmp::min(puzzle.width, puzzle.height) as f64;
let s = step * 0.14; let stroke_w = step * 0.03;
for &[r1, c1, r2, c2] in pairs {
if (r2 - r1).abs() + (c2 - c1).abs() != 1 {
continue;
}
let (ax, ay, mx, my, bx, by);
if r1 == r2 {
let cx = step * (c1.min(c2) as f64 + 1.0);
let cy = step * (r1 as f64 + 0.5);
if c1 < c2 {
ax = cx + s;
ay = cy - s;
mx = cx - s;
my = cy;
bx = cx + s;
by = cy + s;
} else {
ax = cx - s;
ay = cy - s;
mx = cx + s;
my = cy;
bx = cx - s;
by = cy + s;
}
} else {
let cx = step * (c1 as f64 + 0.5);
let cy = step * (r1.min(r2) as f64 + 1.0);
if r1 < r2 {
ax = cx - s;
ay = cy + s;
mx = cx;
my = cy - s;
bx = cx + s;
by = cy + s;
} else {
ax = cx - s;
ay = cy - s;
mx = cx;
my = cy + s;
bx = cx + s;
by = cy - s;
}
}
for (x1, y1, x2, y2) in [(ax, ay, mx, my), (mx, my, bx, by)] {
let mut line = element::Line::new();
line.assign("x1", x1);
line.assign("y1", y1);
line.assign("x2", x2);
line.assign("y2", y2);
line.assign("stroke", "#444444");
line.assign("stroke-width", stroke_w);
line.assign("stroke-linecap", "round");
grp.append(line);
}
}
grp
}
fn draw_cage_sums(&self, puzzle: &Puzzle) -> element::Group {
let mut grp = element::Group::new();
let cages = match &puzzle.cages {
Some(c) => c,
None => return grp,
};
let cage_sums = match &puzzle.cage_sums {
Some(s) => s,
None => return grp,
};
let step = 1.0 / std::cmp::min(puzzle.width, puzzle.height) as f64;
let font_size = step * 0.28;
let height = usize::try_from(puzzle.height).expect("negative height");
let width = usize::try_from(puzzle.width).expect("negative width");
let mut cage_topleft: std::collections::BTreeMap<i64, (usize, usize)> =
std::collections::BTreeMap::new();
for r in 0..height {
for c in 0..width {
if let Some(Some(cage_id)) = cages.get(r).and_then(|row| row.get(c)) {
cage_topleft.entry(*cage_id).or_insert((r, c));
}
}
}
for (cage_id, (r, c)) in cage_topleft {
let idx = (cage_id - 1) as usize;
if idx >= cage_sums.len() {
continue;
}
let sum = cage_sums[idx];
let x = step * c as f64 + step * 0.04;
let y = step * r as f64 + font_size + step * 0.03;
let mut text = svg::node::element::Text::new(sum.to_string());
text.assign("x", x);
text.assign("y", y);
text.assign("font-size", font_size);
text.assign("fill", "#111111");
grp.append(text);
}
grp
}
fn make_cells(&self, puzzle: &Puzzle) -> Vec<Vec<element::Group>> {
let step = 1.0 / std::cmp::min(puzzle.width, puzzle.height) as f64;
let mut out = Vec::new();
for i in 0..puzzle.height {
out.push(vec![]);
for j in 0..puzzle.width {
let g = make_cell(i, j, step);
out.last_mut().unwrap().push(g);
}
}
out
}
}
fn make_cell(i: i64, j: i64, step: f64) -> element::Group {
let i_f = i as f64;
let j_f = j as f64;
let mut g = element::Group::new();
g.assign("id", format!("C_{}_{}", i + 1, j + 1));
g.assign(
"transform",
format!(
"translate({} {}) scale({})",
step * (j_f + 0.05),
step * (i_f + 0.05),
step * 0.9
),
);
let mut bg = element::Rectangle::new();
bg.assign("class", "cell-bg");
bg.assign("width", 1);
bg.assign("height", 1);
g.append(bg);
g
}
#[cfg(test)]
mod tests {
use std::fs::File;
use test_log::test;
use crate::{json::Problem, web::puzsvg::PuzzleDraw};
#[test]
fn test_svg_sudoku() -> anyhow::Result<()> {
let file = File::open("./tst/sudoku.json")?;
let problem: Problem = serde_json::from_reader(file)?;
let puz_draw = PuzzleDraw::new(&problem.puzzle.kind);
let svg = puz_draw.draw_puzzle(&problem);
let svg_str = svg.to_string();
assert!(!svg_str.is_empty(), "SVG output must not be empty");
assert!(svg_str.contains("<svg"), "SVG output must contain <svg tag");
assert!(
svg_str.contains("rect"),
"SVG output must contain rect elements"
);
Ok(())
}
#[test]
fn test_svg_sudoku_has_sudoku_grid_lines() -> anyhow::Result<()> {
let file = File::open("./tst/sudoku.json")?;
let problem: Problem = serde_json::from_reader(file)?;
let puz_draw = PuzzleDraw::new(&problem.puzzle.kind);
let svg = puz_draw.draw_puzzle(&problem);
let svg_str = svg.to_string();
assert!(
svg_str.contains("stroke-width"),
"Sudoku SVG must include stroke-width attributes for grid lines"
);
Ok(())
}
#[test]
fn test_svg_minesweeper() -> anyhow::Result<()> {
let puz = crate::problem::util::test_utils::build_puzzleparse(
"./tst/minesweeper.eprime",
"./tst/minesweeperPrinted.param",
);
let puzzle = crate::json::Puzzle::new_from_puzzle(&puz)?;
let problem = Problem {
puzzle,
state: None,
};
let puz_draw = PuzzleDraw::new(&problem.puzzle.kind);
let svg = puz_draw.draw_puzzle(&problem);
let svg_str = svg.to_string();
assert!(!svg_str.is_empty());
assert!(svg_str.contains("<svg"));
Ok(())
}
}