multi-puzzle-solver 0.9.26__py3-none-any.whl → 0.9.30__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of multi-puzzle-solver might be problematic. Click here for more details.
- {multi_puzzle_solver-0.9.26.dist-info → multi_puzzle_solver-0.9.30.dist-info}/METADATA +982 -41
- {multi_puzzle_solver-0.9.26.dist-info → multi_puzzle_solver-0.9.30.dist-info}/RECORD +20 -12
- puzzle_solver/__init__.py +8 -1
- puzzle_solver/core/utils.py +0 -153
- puzzle_solver/core/utils_visualizer.py +523 -0
- puzzle_solver/puzzles/binairo/binairo.py +44 -16
- puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
- puzzle_solver/puzzles/heyawake/heyawake.py +94 -0
- puzzle_solver/puzzles/kakuro/kakuro.py +77 -0
- puzzle_solver/puzzles/nurikabe/nurikabe.py +126 -0
- puzzle_solver/puzzles/palisade/palisade.py +2 -1
- puzzle_solver/puzzles/rectangles/rectangles.py +2 -2
- puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
- puzzle_solver/puzzles/shingoki/shingoki.py +158 -0
- puzzle_solver/puzzles/singles/singles.py +14 -40
- puzzle_solver/puzzles/slitherlink/slitherlink.py +2 -1
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +3 -1
- puzzle_solver/puzzles/tapa/tapa.py +98 -0
- {multi_puzzle_solver-0.9.26.dist-info → multi_puzzle_solver-0.9.30.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.26.dist-info → multi_puzzle_solver-0.9.30.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
|
|
4
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_pos, set_char, get_char
|
|
5
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
6
|
+
from puzzle_solver.core.utils_visualizer import render_shaded_grid
|
|
7
|
+
|
|
8
|
+
def return_3_consecutives(int_list: list[int]) -> list[tuple[int, int]]:
|
|
9
|
+
"""Given a list of integers (mostly with duplicates), return every consecutive sequence of 3 integer changes.
|
|
10
|
+
i.e. return a list of (begin_idx, end_idx) tuples where for each r=int_list[begin_idx:end_idx] we have r[0]!=r[1] and r[-2]!=r[-1] and len(r)>=3"""
|
|
11
|
+
out = []
|
|
12
|
+
change_indices = [i for i in range(len(int_list) - 1) if int_list[i] != int_list[i+1]]
|
|
13
|
+
# notice how for every subsequence r, the subsequence begining index is in change_indices and the ending index - 1 is in change_indices
|
|
14
|
+
for i in range(len(change_indices) - 1):
|
|
15
|
+
begin_idx = change_indices[i]
|
|
16
|
+
end_idx = change_indices[i+1] + 1 # we want to include the first number in the third sequence
|
|
17
|
+
if end_idx > len(int_list):
|
|
18
|
+
continue
|
|
19
|
+
out.append((begin_idx, end_idx))
|
|
20
|
+
return out
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Board:
|
|
24
|
+
def __init__(self, board: np.array, region_to_clue: dict[str, int]):
|
|
25
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
26
|
+
assert all(str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
27
|
+
self.board = board
|
|
28
|
+
self.V, self.H = board.shape
|
|
29
|
+
self.all_regions: set[int] = {int(c.item()) for c in np.nditer(board)}
|
|
30
|
+
self.region_to_clue = {int(k): v for k, v in region_to_clue.items()}
|
|
31
|
+
assert set(self.region_to_clue.keys()).issubset(self.all_regions), f'extra regions in region_to_clue: {set(self.region_to_clue.keys()) - self.all_regions}'
|
|
32
|
+
self.region_to_pos: dict[int, set[Pos]] = {r: set() for r in self.all_regions}
|
|
33
|
+
for pos in get_all_pos(self.V, self.H):
|
|
34
|
+
rid = int(get_char(self.board, pos))
|
|
35
|
+
self.region_to_pos[rid].add(pos)
|
|
36
|
+
|
|
37
|
+
self.model = cp_model.CpModel()
|
|
38
|
+
self.B: dict[Pos, cp_model.IntVar] = {}
|
|
39
|
+
self.W: dict[Pos, cp_model.IntVar] = {}
|
|
40
|
+
|
|
41
|
+
self.create_vars()
|
|
42
|
+
self.add_all_constraints()
|
|
43
|
+
|
|
44
|
+
def create_vars(self):
|
|
45
|
+
for pos in get_all_pos(self.V, self.H):
|
|
46
|
+
self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
|
|
47
|
+
self.W[pos] = self.model.NewBoolVar(f'W:{pos}')
|
|
48
|
+
self.model.AddExactlyOne([self.B[pos], self.W[pos]])
|
|
49
|
+
|
|
50
|
+
def add_all_constraints(self):
|
|
51
|
+
# Regions with a number should contain black cells matching the number.
|
|
52
|
+
for rid, clue in self.region_to_clue.items():
|
|
53
|
+
self.model.Add(sum([self.B[p] for p in self.region_to_pos[rid]]) == clue)
|
|
54
|
+
# 2 black cells cannot be adjacent horizontally or vertically.
|
|
55
|
+
for pos in get_all_pos(self.V, self.H):
|
|
56
|
+
for neighbor in get_neighbors4(pos, self.V, self.H):
|
|
57
|
+
self.model.AddBoolOr([self.W[pos], self.W[neighbor]])
|
|
58
|
+
# All white cells should be connected in a single group.
|
|
59
|
+
force_connected_component(self.model, self.W)
|
|
60
|
+
# A straight (orthogonal) line of connected white cells cannot span across more than 2 regions.
|
|
61
|
+
self.disallow_white_lines_spanning_3_regions()
|
|
62
|
+
|
|
63
|
+
def disallow_white_lines_spanning_3_regions(self):
|
|
64
|
+
# A straight (orthogonal) line of connected white cells cannot span across more than 2 regions.
|
|
65
|
+
row_to_region: dict[int, list[int]] = {row: [] for row in range(self.V)}
|
|
66
|
+
col_to_region: dict[int, list[int]] = {col: [] for col in range(self.H)}
|
|
67
|
+
for pos in get_all_pos(self.V, self.H): # must traverse from least to most (both row and col)
|
|
68
|
+
rid = int(get_char(self.board, pos))
|
|
69
|
+
row_to_region[pos.y].append(rid)
|
|
70
|
+
col_to_region[pos.x].append(rid)
|
|
71
|
+
for row_num, row in row_to_region.items():
|
|
72
|
+
for begin_idx, end_idx in return_3_consecutives(row):
|
|
73
|
+
pos_list = [get_pos(x=x, y=row_num) for x in range(begin_idx, end_idx+1)]
|
|
74
|
+
self.model.AddBoolOr([self.B[p] for p in pos_list])
|
|
75
|
+
for col_num, col in col_to_region.items():
|
|
76
|
+
for begin_idx, end_idx in return_3_consecutives(col):
|
|
77
|
+
pos_list = [get_pos(x=col_num, y=y) for y in range(begin_idx, end_idx+1)]
|
|
78
|
+
self.model.AddBoolOr([self.B[p] for p in pos_list])
|
|
79
|
+
|
|
80
|
+
def solve_and_print(self, verbose: bool = True):
|
|
81
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
82
|
+
assignment: dict[Pos, int] = {}
|
|
83
|
+
for pos, var in board.B.items():
|
|
84
|
+
assignment[pos] = 1 if solver.Value(var) == 1 else 0
|
|
85
|
+
return SingleSolution(assignment=assignment)
|
|
86
|
+
def callback(single_res: SingleSolution):
|
|
87
|
+
print("Solution found")
|
|
88
|
+
# res = np.full((self.V, self.H), ' ', dtype=object)
|
|
89
|
+
# for pos in get_all_pos(self.V, self.H):
|
|
90
|
+
# c = 'B' if single_res.assignment[pos] == 1 else ' '
|
|
91
|
+
# set_char(res, pos, c)
|
|
92
|
+
# print(res)
|
|
93
|
+
print(render_shaded_grid(self.V, self.H, lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, empty_text=lambda r, c: self.region_to_clue.get(int(self.board[r, c]), ' ')))
|
|
94
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=1)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from typing import Iterator
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, get_pos, in_bounds, set_char, get_char, get_neighbors8
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import render_shaded_grid
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Board:
|
|
13
|
+
def __init__(self, board: np.array, row_sums: list[list[int]], col_sums: list[list[int]], N: int = 9):
|
|
14
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
15
|
+
assert all((c.item() in ['#', ' ', '1', '2', '3', '4', '5', '6', '7', '8', '9']) for c in np.nditer(board)), 'board must contain only #, space, or digits'
|
|
16
|
+
assert len(row_sums) == board.shape[0] and all(isinstance(i, list) and all(isinstance(j, int) or j == '#' for j in i) for i in row_sums), 'row_sums must be a list of lists of integers or #'
|
|
17
|
+
assert len(col_sums) == board.shape[1] and all(isinstance(i, list) and all(isinstance(j, int) or j == '#' for j in i) for i in col_sums), 'col_sums must be a list of lists of integers or #'
|
|
18
|
+
self.board = board
|
|
19
|
+
self.row_sums = row_sums
|
|
20
|
+
self.col_sums = col_sums
|
|
21
|
+
self.V, self.H = board.shape
|
|
22
|
+
self.N = N
|
|
23
|
+
self.model = cp_model.CpModel()
|
|
24
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
25
|
+
|
|
26
|
+
self.create_vars()
|
|
27
|
+
self.add_all_constraints()
|
|
28
|
+
|
|
29
|
+
def create_vars(self):
|
|
30
|
+
for pos in get_all_pos(self.V, self.H):
|
|
31
|
+
if get_char(self.board, pos) == '#':
|
|
32
|
+
continue
|
|
33
|
+
self.model_vars[pos] = self.model.NewIntVar(1, self.N, f'{pos}')
|
|
34
|
+
|
|
35
|
+
def get_consecutives(self, pos: Pos, direction: Direction) -> Iterator[list[Pos]]:
|
|
36
|
+
consecutive = []
|
|
37
|
+
while in_bounds(pos, self.V, self.H):
|
|
38
|
+
if get_char(self.board, pos) == '#':
|
|
39
|
+
if len(consecutive) > 0:
|
|
40
|
+
yield consecutive
|
|
41
|
+
consecutive = []
|
|
42
|
+
else:
|
|
43
|
+
consecutive.append(pos)
|
|
44
|
+
pos = get_next_pos(pos, direction)
|
|
45
|
+
if len(consecutive) > 0:
|
|
46
|
+
yield consecutive
|
|
47
|
+
|
|
48
|
+
def add_all_constraints(self):
|
|
49
|
+
for row in range(self.V):
|
|
50
|
+
row_consecutives = self.get_consecutives(get_pos(x=0, y=row), Direction.RIGHT)
|
|
51
|
+
for i, consecutive in enumerate(row_consecutives):
|
|
52
|
+
# print('row', row, 'i', i, 'consecutive', consecutive)
|
|
53
|
+
self.model.AddAllDifferent([self.model_vars[p] for p in consecutive])
|
|
54
|
+
clue = self.row_sums[row][i]
|
|
55
|
+
if clue != '#':
|
|
56
|
+
self.model.Add(lxp.sum([self.model_vars[p] for p in consecutive]) == clue)
|
|
57
|
+
assert len(self.row_sums[row]) == i + 1, f'row_sums[{row}] has {len(self.row_sums[row])} clues, but {i + 1} consecutive cells'
|
|
58
|
+
for col in range(self.H):
|
|
59
|
+
col_consecutives = self.get_consecutives(get_pos(x=col, y=0), Direction.DOWN)
|
|
60
|
+
for i, consecutive in enumerate(col_consecutives):
|
|
61
|
+
# print('col', col, 'i', i, 'consecutive', consecutive)
|
|
62
|
+
self.model.AddAllDifferent([self.model_vars[p] for p in consecutive])
|
|
63
|
+
clue = self.col_sums[col][i]
|
|
64
|
+
if clue != '#':
|
|
65
|
+
self.model.Add(lxp.sum([self.model_vars[p] for p in consecutive]) == clue)
|
|
66
|
+
assert len(self.col_sums[col]) == i + 1, f'col_sums[{col}] has {len(self.col_sums[col])} clues, but {i + 1} consecutive cells'
|
|
67
|
+
|
|
68
|
+
def solve_and_print(self, verbose: bool = True):
|
|
69
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
70
|
+
assignment: dict[Pos, int] = {}
|
|
71
|
+
for pos, var in board.model_vars.items():
|
|
72
|
+
assignment[pos] = solver.Value(var)
|
|
73
|
+
return SingleSolution(assignment=assignment)
|
|
74
|
+
def callback(single_res: SingleSolution):
|
|
75
|
+
print("Solution found")
|
|
76
|
+
print(render_shaded_grid(self.V, self.H, is_shaded=lambda r, c: self.board[r, c] == '#', empty_text=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)])))
|
|
77
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from ortools.sat.python import cp_model
|
|
5
|
+
|
|
6
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_pos, in_bounds, set_char, get_char, polyominoes, Shape, Direction, get_next_pos
|
|
7
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import render_shaded_grid
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ShapeOnBoard:
|
|
13
|
+
is_active: cp_model.IntVar
|
|
14
|
+
N: int
|
|
15
|
+
body: set[Pos]
|
|
16
|
+
force_water: set[Pos]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Board:
|
|
20
|
+
def __init__(self, board: np.array):
|
|
21
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
22
|
+
assert all((c.item() == ' ') or str(c.item()).isdecimal() or c.item() == '#' for c in np.nditer(board)), 'board must contain only space, #, or digits'
|
|
23
|
+
self.board = board
|
|
24
|
+
self.V, self.H = board.shape
|
|
25
|
+
self.illegal_positions: set[Pos] = {pos for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos) == '#'}
|
|
26
|
+
|
|
27
|
+
unique_numbers: set[int] = {int(c) for c in np.nditer(board) if str(c).isdecimal()}
|
|
28
|
+
self.polyominoes: dict[int, set[Shape]] = {n: polyominoes(n) for n in unique_numbers}
|
|
29
|
+
self.hints = {pos: int(get_char(self.board, pos)) for pos in get_all_pos(self.V, self.H) if str(get_char(self.board, pos)).isdecimal()}
|
|
30
|
+
self.all_hint_pos: set[Pos] = set(self.hints.keys())
|
|
31
|
+
|
|
32
|
+
self.model = cp_model.CpModel()
|
|
33
|
+
self.W: dict[Pos, cp_model.IntVar] = {}
|
|
34
|
+
self.B: dict[Pos, cp_model.IntVar] = {}
|
|
35
|
+
self.shapes_on_board: list[ShapeOnBoard] = []
|
|
36
|
+
|
|
37
|
+
self.create_vars()
|
|
38
|
+
self.add_all_constraints()
|
|
39
|
+
|
|
40
|
+
def create_vars(self):
|
|
41
|
+
for pos in self.get_all_legal_pos():
|
|
42
|
+
self.W[pos] = self.model.NewBoolVar(f'W:{pos}')
|
|
43
|
+
self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
|
|
44
|
+
self.model.AddExactlyOne([self.W[pos], self.B[pos]])
|
|
45
|
+
|
|
46
|
+
def get_all_legal_pos(self) -> set[Pos]:
|
|
47
|
+
return {pos for pos in get_all_pos(self.V, self.H) if pos not in self.illegal_positions}
|
|
48
|
+
|
|
49
|
+
def in_bounds_and_legal(self, pos: Pos) -> bool:
|
|
50
|
+
return in_bounds(pos, self.V, self.H) and pos not in self.illegal_positions
|
|
51
|
+
|
|
52
|
+
def add_all_constraints(self):
|
|
53
|
+
for pos in self.W.keys():
|
|
54
|
+
self.model.AddExactlyOne([self.W[pos], self.B[pos]])
|
|
55
|
+
|
|
56
|
+
# init shapes on board for each hint
|
|
57
|
+
for hint_pos, hint_value in self.hints.items():
|
|
58
|
+
hint_shapes = []
|
|
59
|
+
for shape in self.polyominoes[hint_value]:
|
|
60
|
+
hint_single_shape = self.init_shape_on_board(shape, hint_pos, hint_value) # a "single shape" is translated many times
|
|
61
|
+
hint_shapes.extend(hint_single_shape)
|
|
62
|
+
assert len(hint_shapes) > 0, f'no shapes found for hint {hint_pos} with value {hint_value}'
|
|
63
|
+
self.model.AddExactlyOne([s.is_active for s in hint_shapes])
|
|
64
|
+
self.shapes_on_board.extend(hint_shapes)
|
|
65
|
+
|
|
66
|
+
# if no shape is active on the spot then it must be black
|
|
67
|
+
for pos in self.get_all_legal_pos():
|
|
68
|
+
shapes_here = [s for s in self.shapes_on_board if pos in s.body]
|
|
69
|
+
self.model.AddExactlyOne([s.is_active for s in shapes_here] + [self.B[pos]])
|
|
70
|
+
|
|
71
|
+
# if a shape is active, then all its body must be white and force water must be black
|
|
72
|
+
for shape_on_board in self.shapes_on_board:
|
|
73
|
+
for pos in shape_on_board.body:
|
|
74
|
+
self.model.Add(self.W[pos] == 1).OnlyEnforceIf(shape_on_board.is_active)
|
|
75
|
+
for pos in shape_on_board.force_water:
|
|
76
|
+
self.model.Add(self.B[pos] == 1).OnlyEnforceIf(shape_on_board.is_active)
|
|
77
|
+
|
|
78
|
+
# disallow 2x2 blacks
|
|
79
|
+
for pos in get_all_pos(self.V, self.H):
|
|
80
|
+
tl = pos
|
|
81
|
+
tr = get_next_pos(pos, Direction.RIGHT)
|
|
82
|
+
bl = get_next_pos(pos, Direction.DOWN)
|
|
83
|
+
br = get_next_pos(bl, Direction.RIGHT)
|
|
84
|
+
if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
|
|
85
|
+
continue
|
|
86
|
+
self.model.AddBoolOr([self.B[tl].Not(), self.B[tr].Not(), self.B[bl].Not(), self.B[br].Not()])
|
|
87
|
+
|
|
88
|
+
# all black is single connected component
|
|
89
|
+
force_connected_component(self.model, self.B)
|
|
90
|
+
|
|
91
|
+
def init_shape_on_board(self, shape: Shape, hint_pos: Pos, hint_value: int):
|
|
92
|
+
other_hint_pos: set[Pos] = self.all_hint_pos - {hint_pos}
|
|
93
|
+
max_x = max(p.x for p in shape)
|
|
94
|
+
max_y = max(p.y for p in shape)
|
|
95
|
+
hint_shapes = []
|
|
96
|
+
for dx in range(0, max_x + 1):
|
|
97
|
+
for dy in range(0, max_y + 1):
|
|
98
|
+
body = {get_pos(x=p.x + hint_pos.x - dx, y=p.y + hint_pos.y - dy) for p in shape} # translate shape by fixed hint position then dynamic moving dx and dy
|
|
99
|
+
if hint_pos not in body: # the hint must still be in the body after translation
|
|
100
|
+
continue
|
|
101
|
+
if any(not self.in_bounds_and_legal(p) for p in body): # illegal shape
|
|
102
|
+
continue
|
|
103
|
+
water = set(p for pos in body for p in get_neighbors4(pos, self.V, self.H))
|
|
104
|
+
water -= body
|
|
105
|
+
water -= self.illegal_positions
|
|
106
|
+
if any(p in other_hint_pos for p in body) or any(w in other_hint_pos for w in water): # shape touches another hint or forces water on another hint, illegal
|
|
107
|
+
continue
|
|
108
|
+
shape_on_board = ShapeOnBoard(
|
|
109
|
+
is_active=self.model.NewBoolVar(f'{hint_pos}:{dx}:{dy}:is_active'),
|
|
110
|
+
N=hint_value,
|
|
111
|
+
body=body,
|
|
112
|
+
force_water=water,
|
|
113
|
+
)
|
|
114
|
+
hint_shapes.append(shape_on_board)
|
|
115
|
+
return hint_shapes
|
|
116
|
+
|
|
117
|
+
def solve_and_print(self, verbose: bool = True):
|
|
118
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
119
|
+
assignment: dict[Pos, int] = {}
|
|
120
|
+
for pos, var in board.B.items():
|
|
121
|
+
assignment[pos] = solver.Value(var)
|
|
122
|
+
return SingleSolution(assignment=assignment)
|
|
123
|
+
def callback(single_res: SingleSolution):
|
|
124
|
+
print("Solution found")
|
|
125
|
+
print(render_shaded_grid(self.V, self.H, lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, empty_text=lambda r, c: str(self.board[r, c])))
|
|
126
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -4,8 +4,9 @@ from collections import defaultdict
|
|
|
4
4
|
import numpy as np
|
|
5
5
|
from ortools.sat.python import cp_model
|
|
6
6
|
|
|
7
|
-
from puzzle_solver.core.utils import Pos, get_all_pos, get_pos,
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, set_char, in_bounds, get_next_pos, Direction, polyominoes
|
|
8
8
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import id_board_to_wall_board, render_grid
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
|
|
@@ -3,10 +3,10 @@ from dataclasses import dataclass
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
|
|
5
5
|
from ortools.sat.python import cp_model
|
|
6
|
-
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
7
6
|
|
|
8
|
-
from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, set_char, get_char,
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, set_char, get_char, Direction, get_next_pos
|
|
9
8
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import render_grid
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def factor_pairs(N: int, upper_limit_i: int, upper_limit_j: int):
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from ortools.sat.python import cp_model
|
|
7
|
+
|
|
8
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, get_neighbors4, in_bounds
|
|
9
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
10
|
+
from puzzle_solver.core.utils_visualizer import render_bw_tiles_split
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
TPos = tuple[int, int]
|
|
14
|
+
|
|
15
|
+
class State(Enum):
|
|
16
|
+
WHITE = 'W'
|
|
17
|
+
BLACK = 'B'
|
|
18
|
+
TOP_LEFT = 'TL'
|
|
19
|
+
TOP_RIGHT = 'TR'
|
|
20
|
+
BOTTOM_LEFT = 'BL'
|
|
21
|
+
BOTTOM_RIGHT = 'BR'
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Rectangle:
|
|
25
|
+
is_rotated: bool
|
|
26
|
+
width: int
|
|
27
|
+
height: int
|
|
28
|
+
body: frozenset[tuple[TPos, State]]
|
|
29
|
+
disallow_white: frozenset[tuple[TPos]]
|
|
30
|
+
max_x: int
|
|
31
|
+
max_y: int
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class RectangleOnBoard:
|
|
35
|
+
is_active: cp_model.IntVar
|
|
36
|
+
rectangle: Rectangle
|
|
37
|
+
body: frozenset[tuple[Pos, State]]
|
|
38
|
+
body_positions: frozenset[Pos]
|
|
39
|
+
disallow_white: frozenset[Pos]
|
|
40
|
+
translate: Pos
|
|
41
|
+
width: int
|
|
42
|
+
height: int
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def init_rectangles(V: int, H: int) -> list[Rectangle]:
|
|
46
|
+
"""Returns all possible upright and 45 degree rotated rectangles on a VxH board that are NOT translated (i.e. both min_x and min_y are always 0)"""
|
|
47
|
+
rectangles = []
|
|
48
|
+
# up right rectangles
|
|
49
|
+
for height in range(1, V+1):
|
|
50
|
+
for width in range(1, H+1):
|
|
51
|
+
body = {(x, y) for x in range(width) for y in range(height)}
|
|
52
|
+
# disallow any orthogonal adjacent white positions
|
|
53
|
+
disallow_white = set((p[0] + dxdy[0], p[1] + dxdy[1]) for p in body for dxdy in ((1,0),(-1,0),(0,1),(0,-1)))
|
|
54
|
+
disallow_white -= body
|
|
55
|
+
rectangles.append(Rectangle(
|
|
56
|
+
is_rotated=False,
|
|
57
|
+
width=width,
|
|
58
|
+
height=height,
|
|
59
|
+
body={(p, State.WHITE) for p in body},
|
|
60
|
+
disallow_white=disallow_white,
|
|
61
|
+
max_x=width-1,
|
|
62
|
+
max_y=height-1,
|
|
63
|
+
))
|
|
64
|
+
# now imagine rectangles rotated clockwise by 45 degrees
|
|
65
|
+
for height in range(1, V+1):
|
|
66
|
+
for width in range(1, H+1):
|
|
67
|
+
if width + height > V or width + height > H: # this rotated rectangle wont fit
|
|
68
|
+
continue
|
|
69
|
+
body = {}
|
|
70
|
+
tl_body = {(i, height-1-i) for i in range(height)} # top left edge
|
|
71
|
+
tr_body = {(height+i, i) for i in range(width)} # top right edge
|
|
72
|
+
br_body = {(width+height-i-1, width+i) for i in range(height)} # bottom right edge
|
|
73
|
+
bl_body = {(width-i-1, width+height-i-1) for i in range(width)} # bottom left edge
|
|
74
|
+
inner_body = set() # inner body is anything to the right of L and to the left of R
|
|
75
|
+
for y in range(width+height):
|
|
76
|
+
row_is_active = False
|
|
77
|
+
for x in range(width+height):
|
|
78
|
+
if (x, y) in tl_body or (x, y) in bl_body:
|
|
79
|
+
row_is_active = True
|
|
80
|
+
continue
|
|
81
|
+
if (x, y) in tr_body or (x, y) in br_body:
|
|
82
|
+
break
|
|
83
|
+
if row_is_active:
|
|
84
|
+
inner_body.add((x, y))
|
|
85
|
+
tl_body = {(p, State.TOP_LEFT) for p in tl_body}
|
|
86
|
+
tr_body = {(p, State.TOP_RIGHT) for p in tr_body}
|
|
87
|
+
br_body = {(p, State.BOTTOM_RIGHT) for p in br_body}
|
|
88
|
+
bl_body = {(p, State.BOTTOM_LEFT) for p in bl_body}
|
|
89
|
+
inner_body = {(p, State.WHITE) for p in inner_body}
|
|
90
|
+
rectangles.append(Rectangle(
|
|
91
|
+
is_rotated=True, width=width, height=height, body=tl_body | tr_body | br_body | bl_body | inner_body, disallow_white=set(),
|
|
92
|
+
# clear from vizualization, both width and height contribute to both dimensions since it is rotated
|
|
93
|
+
max_x=width + height - 1,
|
|
94
|
+
max_y=width + height - 1,
|
|
95
|
+
))
|
|
96
|
+
return rectangles
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Board:
|
|
100
|
+
def __init__(self, board: np.array):
|
|
101
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
102
|
+
assert all((c.item() in [' ', 'B', '0', '1', '2', '3', '4']) for c in np.nditer(board)), 'board must contain only space, B, 0, 1, 2, 3, 4'
|
|
103
|
+
self.board = board
|
|
104
|
+
self.V, self.H = board.shape
|
|
105
|
+
self.black_positions: set[Pos] = {pos for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos).strip() != ''}
|
|
106
|
+
self.black_positions_tuple: set[TPos] = {(p.x, p.y) for p in self.black_positions}
|
|
107
|
+
self.pos_to_rectangle_on_board: dict[Pos, list[RectangleOnBoard]] = defaultdict(list)
|
|
108
|
+
self.model = cp_model.CpModel()
|
|
109
|
+
self.B: dict[Pos, cp_model.IntVar] = {}
|
|
110
|
+
self.W: dict[Pos, cp_model.IntVar] = {}
|
|
111
|
+
self.rectangles_on_board: list[RectangleOnBoard] = []
|
|
112
|
+
self.init_rectangles_on_board()
|
|
113
|
+
self.create_vars()
|
|
114
|
+
self.add_all_constraints()
|
|
115
|
+
|
|
116
|
+
def init_rectangles_on_board(self):
|
|
117
|
+
rectangles = init_rectangles(self.V, self.H)
|
|
118
|
+
for rectangle in rectangles:
|
|
119
|
+
# translate
|
|
120
|
+
for dx in range(self.H - rectangle.max_x):
|
|
121
|
+
for dy in range(self.V - rectangle.max_y):
|
|
122
|
+
body: list[tuple[Pos, State]] = [None] * len(rectangle.body)
|
|
123
|
+
for i, (p, s) in enumerate(rectangle.body):
|
|
124
|
+
pp = (p[0] + dx, p[1] + dy)
|
|
125
|
+
body[i] = (pp, s)
|
|
126
|
+
if pp in self.black_positions_tuple:
|
|
127
|
+
body = None
|
|
128
|
+
break
|
|
129
|
+
if body is None:
|
|
130
|
+
continue
|
|
131
|
+
disallow_white = {Pos(x=p[0] + dx, y=p[1] + dy) for p in rectangle.disallow_white}
|
|
132
|
+
body_positions = set((Pos(x=p[0], y=p[1])) for p, _ in body)
|
|
133
|
+
rectangle_on_board = RectangleOnBoard(
|
|
134
|
+
is_active=self.model.NewBoolVar(f'{rectangle.is_rotated}:{rectangle.width}x{rectangle.height}:{dx}:{dy}:is_active'),
|
|
135
|
+
rectangle=rectangle,
|
|
136
|
+
body=set((Pos(x=p[0], y=p[1]), s) for p, s in body),
|
|
137
|
+
body_positions=body_positions,
|
|
138
|
+
disallow_white=disallow_white,
|
|
139
|
+
translate=Pos(x=dx, y=dy),
|
|
140
|
+
width=rectangle.width,
|
|
141
|
+
height=rectangle.height,
|
|
142
|
+
)
|
|
143
|
+
self.rectangles_on_board.append(rectangle_on_board)
|
|
144
|
+
for p in body_positions:
|
|
145
|
+
self.pos_to_rectangle_on_board[p].append(rectangle_on_board)
|
|
146
|
+
|
|
147
|
+
def create_vars(self):
|
|
148
|
+
for pos in get_all_pos(self.V, self.H):
|
|
149
|
+
self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
|
|
150
|
+
self.W[pos] = self.B[pos].Not()
|
|
151
|
+
if pos in self.black_positions:
|
|
152
|
+
self.model.Add(self.B[pos] == 1)
|
|
153
|
+
|
|
154
|
+
def add_all_constraints(self):
|
|
155
|
+
# every position not fixed must be part of exactly one rectangle
|
|
156
|
+
for pos in get_all_pos(self.V, self.H):
|
|
157
|
+
if pos in self.black_positions:
|
|
158
|
+
continue
|
|
159
|
+
self.model.AddExactlyOne([r.is_active for r in self.pos_to_rectangle_on_board[pos]])
|
|
160
|
+
# if a rectangle is active then all its body is black and all its disallow_white is white
|
|
161
|
+
for rectangle_on_board in self.rectangles_on_board:
|
|
162
|
+
for pos, state in rectangle_on_board.body:
|
|
163
|
+
if state == State.WHITE:
|
|
164
|
+
self.model.Add(self.W[pos] == 1).OnlyEnforceIf(rectangle_on_board.is_active)
|
|
165
|
+
else:
|
|
166
|
+
self.model.Add(self.B[pos] == 1).OnlyEnforceIf(rectangle_on_board.is_active)
|
|
167
|
+
for pos in rectangle_on_board.disallow_white:
|
|
168
|
+
if not in_bounds(pos, self.V, self.H):
|
|
169
|
+
continue
|
|
170
|
+
self.model.Add(self.B[pos] == 1).OnlyEnforceIf(rectangle_on_board.is_active)
|
|
171
|
+
# if a position has a clue, enforce it
|
|
172
|
+
for pos in get_all_pos(self.V, self.H):
|
|
173
|
+
c = get_char(self.board, pos)
|
|
174
|
+
if c.strip() != '' and c.strip().isdecimal():
|
|
175
|
+
clue = int(c.strip())
|
|
176
|
+
neighbors = [self.B[p] for p in get_neighbors4(pos, self.V, self.H) if p not in self.black_positions]
|
|
177
|
+
self.model.Add(sum(neighbors) == clue)
|
|
178
|
+
|
|
179
|
+
def solve_and_print(self, verbose: bool = True):
|
|
180
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
181
|
+
assignment: dict[Pos, int] = {}
|
|
182
|
+
for rectangle_on_board in board.rectangles_on_board:
|
|
183
|
+
if solver.Value(rectangle_on_board.is_active) == 1:
|
|
184
|
+
for p, s in rectangle_on_board.body:
|
|
185
|
+
assignment[p] = s.value
|
|
186
|
+
return SingleSolution(assignment=assignment)
|
|
187
|
+
def callback(single_res: SingleSolution):
|
|
188
|
+
print("Solution found")
|
|
189
|
+
res = np.full((self.V, self.H), 'W', dtype=object)
|
|
190
|
+
text = np.full((self.V, self.H), '', dtype=object)
|
|
191
|
+
for pos in get_all_pos(self.V, self.H):
|
|
192
|
+
if pos in single_res.assignment:
|
|
193
|
+
val = single_res.assignment[pos]
|
|
194
|
+
else:
|
|
195
|
+
c = get_char(self.board, pos)
|
|
196
|
+
if c.strip() != '':
|
|
197
|
+
val = 'B'
|
|
198
|
+
text[pos.y][pos.x] = c if c.strip() != 'B' else '.'
|
|
199
|
+
set_char(res, pos, val)
|
|
200
|
+
print(render_bw_tiles_split(res, cell_w=6, cell_h=3, borders=True, mode="text", cell_text=lambda r, c: text[r][c]))
|
|
201
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|