multi-puzzle-solver 1.0.4__py3-none-any.whl → 1.0.7__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-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/METADATA +1075 -556
- multi_puzzle_solver-1.0.7.dist-info/RECORD +74 -0
- puzzle_solver/__init__.py +5 -1
- puzzle_solver/core/utils.py +17 -1
- puzzle_solver/core/utils_visualizer.py +257 -201
- puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
- puzzle_solver/puzzles/aquarium/aquarium.py +8 -23
- puzzle_solver/puzzles/battleships/battleships.py +39 -53
- puzzle_solver/puzzles/binairo/binairo.py +2 -2
- puzzle_solver/puzzles/black_box/black_box.py +6 -70
- puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +4 -2
- puzzle_solver/puzzles/filling/filling.py +11 -34
- puzzle_solver/puzzles/galaxies/galaxies.py +4 -2
- puzzle_solver/puzzles/heyawake/heyawake.py +72 -14
- puzzle_solver/puzzles/kakurasu/kakurasu.py +5 -13
- puzzle_solver/puzzles/kakuro/kakuro.py +6 -2
- puzzle_solver/puzzles/lits/lits.py +4 -2
- puzzle_solver/puzzles/mosaic/mosaic.py +8 -18
- puzzle_solver/puzzles/nonograms/nonograms.py +80 -85
- puzzle_solver/puzzles/nonograms/nonograms_colored.py +221 -0
- puzzle_solver/puzzles/norinori/norinori.py +5 -12
- puzzle_solver/puzzles/nurikabe/nurikabe.py +6 -2
- puzzle_solver/puzzles/palisade/palisade.py +8 -22
- puzzle_solver/puzzles/pearl/pearl.py +15 -27
- puzzle_solver/puzzles/pipes/pipes.py +2 -1
- puzzle_solver/puzzles/range/range.py +19 -55
- puzzle_solver/puzzles/rectangles/rectangles.py +4 -2
- puzzle_solver/puzzles/shingoki/shingoki.py +62 -105
- puzzle_solver/puzzles/singles/singles.py +6 -2
- puzzle_solver/puzzles/slant/slant.py +13 -19
- puzzle_solver/puzzles/slitherlink/slitherlink.py +2 -2
- puzzle_solver/puzzles/star_battle/star_battle.py +5 -2
- puzzle_solver/puzzles/stitches/stitches.py +8 -21
- puzzle_solver/puzzles/sudoku/sudoku.py +5 -11
- puzzle_solver/puzzles/tapa/tapa.py +6 -2
- puzzle_solver/puzzles/tents/tents.py +50 -80
- puzzle_solver/puzzles/tracks/tracks.py +19 -66
- puzzle_solver/puzzles/unruly/unruly.py +17 -49
- puzzle_solver/puzzles/yin_yang/yin_yang.py +3 -10
- multi_puzzle_solver-1.0.4.dist-info/RECORD +0 -72
- {multi_puzzle_solver-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/top_level.txt +0 -0
|
@@ -3,9 +3,9 @@ from dataclasses import dataclass
|
|
|
3
3
|
import numpy as np
|
|
4
4
|
from ortools.sat.python import cp_model
|
|
5
5
|
|
|
6
|
-
from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char,
|
|
6
|
+
from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, in_bounds, get_next_pos, Direction
|
|
7
7
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
8
|
-
from puzzle_solver.core.utils_visualizer import
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@dataclass
|
|
@@ -90,14 +90,7 @@ class Board:
|
|
|
90
90
|
return SingleSolution(assignment=assignment)
|
|
91
91
|
def callback(single_res: SingleSolution):
|
|
92
92
|
print("Solution found")
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
# c = 'X' if pos in single_res.assignment else ' '
|
|
97
|
-
# set_char(res, pos, c)
|
|
98
|
-
# print('[')
|
|
99
|
-
# for row in res:
|
|
100
|
-
# print(" [ '" + "', '".join(row.tolist()) + "' ],")
|
|
101
|
-
# print(']')
|
|
102
|
-
print(render_grid(id_board_to_wall_board(self.board), center_char=lambda r, c: 'X' if (Pos(x=c, y=r) in single_res.assignment) else ' '))
|
|
93
|
+
print(combined_function(self.V, self.H,
|
|
94
|
+
cell_flags=id_board_to_wall_fn(self.board),
|
|
95
|
+
is_shaded=lambda r, c: Pos(x=c, y=r) in single_res.assignment))
|
|
103
96
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -5,7 +5,7 @@ from ortools.sat.python import cp_model
|
|
|
5
5
|
|
|
6
6
|
from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_pos, in_bounds, get_char, polyominoes, Shape, Direction, get_next_pos
|
|
7
7
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
8
|
-
from puzzle_solver.core.utils_visualizer import
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
@dataclass
|
|
@@ -122,5 +122,9 @@ class Board:
|
|
|
122
122
|
return SingleSolution(assignment=assignment)
|
|
123
123
|
def callback(single_res: SingleSolution):
|
|
124
124
|
print("Solution found")
|
|
125
|
-
print(
|
|
125
|
+
print(combined_function(self.V, self.H,
|
|
126
|
+
is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
|
|
127
|
+
center_char=lambda r, c: str(self.board[r, c]),
|
|
128
|
+
text_on_shaded_cells=False
|
|
129
|
+
))
|
|
126
130
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -6,12 +6,10 @@ from ortools.sat.python import cp_model
|
|
|
6
6
|
|
|
7
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
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
# a shape on the 2d board is just a set of positions
|
|
14
|
-
Shape = frozenset[Pos]
|
|
12
|
+
Shape = frozenset[Pos] # a shape on the 2d board is just a set of positions
|
|
15
13
|
|
|
16
14
|
@dataclass(frozen=True)
|
|
17
15
|
class ShapeOnBoard:
|
|
@@ -36,7 +34,6 @@ def get_valid_translations(shape: Shape, board: np.array) -> set[Pos]:
|
|
|
36
34
|
# min x/y is always 0
|
|
37
35
|
max_x = max(p[0] for p in shape_list)
|
|
38
36
|
max_y = max(p[1] for p in shape_list)
|
|
39
|
-
|
|
40
37
|
for dy in range(0, board.shape[0] - max_y):
|
|
41
38
|
for dx in range(0, board.shape[1] - max_x):
|
|
42
39
|
body = tuple((p[0] + dx, p[1] + dy) for p in shape_list)
|
|
@@ -48,7 +45,6 @@ def get_valid_translations(shape: Shape, board: np.array) -> set[Pos]:
|
|
|
48
45
|
yield frozenset(get_pos(x=p[0], y=p[1]) for p in body)
|
|
49
46
|
|
|
50
47
|
|
|
51
|
-
|
|
52
48
|
class Board:
|
|
53
49
|
def __init__(self, board: np.array, region_size: int):
|
|
54
50
|
assert region_size >= 1 and isinstance(region_size, int), 'region_size must be an integer greater than or equal to 1'
|
|
@@ -59,7 +55,6 @@ class Board:
|
|
|
59
55
|
self.region_size = region_size
|
|
60
56
|
self.region_count = (self.V * self.H) // self.region_size
|
|
61
57
|
assert self.region_count * self.region_size == self.V * self.H, f'region_size must be a factor of the board size, got {self.region_size} and {self.region_count}'
|
|
62
|
-
|
|
63
58
|
self.polyominoes = polyominoes(self.region_size)
|
|
64
59
|
|
|
65
60
|
self.model = cp_model.CpModel()
|
|
@@ -74,9 +69,7 @@ class Board:
|
|
|
74
69
|
uid = len(self.shapes_on_board)
|
|
75
70
|
shape_on_board = ShapeOnBoard(
|
|
76
71
|
is_active=self.model.NewBoolVar(f'{uid}:is_active'),
|
|
77
|
-
shape=shape,
|
|
78
|
-
shape_id=uid,
|
|
79
|
-
body=body,
|
|
72
|
+
shape=shape, shape_id=uid, body=body
|
|
80
73
|
)
|
|
81
74
|
self.shapes_on_board.append(shape_on_board)
|
|
82
75
|
for pos in body:
|
|
@@ -88,18 +81,11 @@ class Board:
|
|
|
88
81
|
|
|
89
82
|
def solve_and_print(self, verbose: bool = True):
|
|
90
83
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
91
|
-
|
|
92
|
-
for shape in
|
|
93
|
-
if solver.Value(shape.is_active) == 1:
|
|
94
|
-
for pos in shape.body:
|
|
95
|
-
assignment[pos] = shape.shape_id
|
|
96
|
-
return SingleSolution(assignment=assignment)
|
|
84
|
+
active_shapes = [shape for shape in board.shapes_on_board if solver.Value(shape.is_active) == 1]
|
|
85
|
+
return SingleSolution(assignment={pos: shape.shape_id for shape in active_shapes for pos in shape.body})
|
|
97
86
|
def callback(single_res: SingleSolution):
|
|
98
87
|
print("Solution found")
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
set_char(id_board, pos, region_idx)
|
|
103
|
-
board = np.where(self.board == ' ', '·', self.board)
|
|
104
|
-
print(render_grid(id_board_to_wall_board(id_board), center_char=board))
|
|
88
|
+
print(combined_function(self.V, self.H,
|
|
89
|
+
cell_flags=id_board_to_wall_fn(np.array([[single_res.assignment[get_pos(x=c, y=r)] for c in range(self.H)] for r in range(self.V)])),
|
|
90
|
+
center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else '·'))
|
|
105
91
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -4,16 +4,16 @@ import numpy as np
|
|
|
4
4
|
from ortools.sat.python import cp_model
|
|
5
5
|
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
6
6
|
|
|
7
|
-
from puzzle_solver.core.utils import Pos, get_all_pos,
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, Direction, get_next_pos, get_char, get_opposite_direction, get_pos, set_char
|
|
8
8
|
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
class Board:
|
|
12
13
|
def __init__(self, board: np.ndarray):
|
|
13
14
|
assert board.ndim == 2 and board.shape[0] > 0 and board.shape[1] > 0, f'board must be 2d, got {board.ndim}'
|
|
14
|
-
assert all(
|
|
15
|
-
self.V = board.shape
|
|
16
|
-
self.H = board.shape[1]
|
|
15
|
+
assert all(c.item().strip() in ['', 'B', 'W'] for c in np.nditer(board)), f'board must be space, B, or W, got {list(np.nditer(board))}'
|
|
16
|
+
self.V, self.H = board.shape
|
|
17
17
|
self.board = board
|
|
18
18
|
self.model = cp_model.CpModel()
|
|
19
19
|
self.cell_active: dict[Pos, cp_model.IntVar] = {}
|
|
@@ -36,11 +36,10 @@ class Board:
|
|
|
36
36
|
def force_wb_constraints(self):
|
|
37
37
|
for pos in get_all_pos(self.V, self.H):
|
|
38
38
|
c = get_char(self.board, pos)
|
|
39
|
-
if c
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
# must be a corner
|
|
39
|
+
if not c.strip():
|
|
40
|
+
continue
|
|
41
|
+
self.model.Add(self.cell_active[pos] == 1) # cell must be active
|
|
42
|
+
if c == 'B': # black circle must be a corner not connected directly to another corner
|
|
44
43
|
self.model.Add(self.cell_direction[(pos, Direction.UP)] != self.cell_direction[(pos, Direction.DOWN)])
|
|
45
44
|
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] != self.cell_direction[(pos, Direction.RIGHT)])
|
|
46
45
|
# must not be connected directly to another corner
|
|
@@ -49,11 +48,7 @@ class Board:
|
|
|
49
48
|
if not in_bounds(q, self.V, self.H):
|
|
50
49
|
continue
|
|
51
50
|
self.model.AddImplication(self.cell_direction[(pos, direction)], self.cell_direction[(q, direction)])
|
|
52
|
-
elif c == 'W':
|
|
53
|
-
# must be active
|
|
54
|
-
self.model.Add(self.cell_active[pos] == 1)
|
|
55
|
-
# white circle must be a straight which is connected to at least one corner
|
|
56
|
-
# must be straight
|
|
51
|
+
elif c == 'W': # white circle must be a straight which is connected to at least one corner
|
|
57
52
|
self.model.Add(self.cell_direction[(pos, Direction.UP)] == self.cell_direction[(pos, Direction.DOWN)])
|
|
58
53
|
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] == self.cell_direction[(pos, Direction.RIGHT)])
|
|
59
54
|
# must be connected to at least one corner (i.e. UP-RIGHT or UP-LEFT or DOWN-RIGHT or DOWN-LEFT or RIGHT-UP or RIGHT-DOWN or LEFT-UP or LEFT-DOWN)
|
|
@@ -94,26 +89,19 @@ class Board:
|
|
|
94
89
|
return False
|
|
95
90
|
force_connected_component(self.model, self.cell_direction, is_neighbor=is_neighbor)
|
|
96
91
|
|
|
97
|
-
|
|
98
92
|
def solve_and_print(self, verbose: bool = True):
|
|
99
93
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
100
94
|
assignment: dict[Pos, str] = defaultdict(str)
|
|
101
95
|
for (pos, direction), var in board.cell_direction.items():
|
|
102
96
|
assignment[pos] += direction.name[0] if solver.BooleanValue(var) else ''
|
|
103
|
-
for pos in get_all_pos(self.V, self.H):
|
|
104
|
-
if len(assignment[pos]) == 0:
|
|
105
|
-
assignment[pos] = ' '
|
|
106
|
-
else:
|
|
107
|
-
assignment[pos] = ''.join(sorted(assignment[pos]))
|
|
108
97
|
return SingleSolution(assignment=assignment)
|
|
109
98
|
def callback(single_res: SingleSolution):
|
|
110
99
|
print("Solution found")
|
|
111
|
-
|
|
112
|
-
pretty_dict = {'DU': '┃ ', 'LR': '━━', 'DL': '━┒', 'DR': '┏━', 'RU': '┗━', 'LU': '━┛', ' ': ' '}
|
|
100
|
+
output_board = np.full((self.V, self.H), '', dtype=str)
|
|
113
101
|
for pos in get_all_pos(self.V, self.H):
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
print(
|
|
102
|
+
if get_char(self.board, pos) in ['B', 'W']: # if the main board has a white or black pearl, put it in the output
|
|
103
|
+
set_char(output_board, pos, get_char(self.board, pos))
|
|
104
|
+
if not single_res.assignment[pos].strip(): # if the cell does not the line through it, put a dot
|
|
105
|
+
set_char(output_board, pos, '.')
|
|
106
|
+
print(combined_function(self.V, self.H, show_grid=False, special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)], center_char=lambda r, c: output_board[r, c]))
|
|
119
107
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=20)
|
|
@@ -4,6 +4,7 @@ from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
|
4
4
|
|
|
5
5
|
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, Direction, get_next_pos, get_opposite_direction
|
|
6
6
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
7
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
class Board:
|
|
@@ -77,5 +78,5 @@ class Board:
|
|
|
77
78
|
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
78
79
|
for pos in get_all_pos(self.V, self.H):
|
|
79
80
|
set_char(res, pos, single_res.assignment[pos])
|
|
80
|
-
print(res)
|
|
81
|
+
print(combined_function(self.V, self.H, show_grid=False, show_axes=True, special_content=lambda r, c: res[r, c], center_char=lambda r, c: 'O' if len(res[r, c]) == 1 else '')),
|
|
81
82
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import numpy as np
|
|
2
2
|
from ortools.sat.python import cp_model
|
|
3
3
|
|
|
4
|
-
from puzzle_solver.core.utils import Pos, get_all_pos,
|
|
4
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, get_neighbors4, in_bounds, Direction, get_next_pos, get_char
|
|
5
5
|
from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
|
|
6
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
|
|
@@ -18,88 +19,51 @@ def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
|
|
|
18
19
|
class Board:
|
|
19
20
|
def __init__(self, clues: np.ndarray):
|
|
20
21
|
assert clues.ndim == 2 and clues.shape[0] > 0 and clues.shape[1] > 0, f'clues must be 2d, got {clues.ndim}'
|
|
21
|
-
assert all(
|
|
22
|
-
self.V = clues.shape
|
|
23
|
-
self.H = clues.shape[1]
|
|
22
|
+
assert all(str(i.item()).strip() == '' or str(i.item()).strip().isdecimal() for i in np.nditer(clues)), f'clues must be empty or a decimal number, got {list(np.nditer(clues))}'
|
|
23
|
+
self.V, self.H = clues.shape
|
|
24
24
|
self.clues = clues
|
|
25
|
-
self.model = cp_model.CpModel()
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
self.b: dict[Pos, cp_model.IntVar] = {}
|
|
29
|
-
self.w: dict[Pos, cp_model.IntVar] = {}
|
|
26
|
+
self.model = cp_model.CpModel()
|
|
27
|
+
self.b: dict[Pos, cp_model.IntVar] = {}
|
|
28
|
+
self.w: dict[Pos, cp_model.IntVar] = {}
|
|
30
29
|
|
|
31
30
|
self.create_vars()
|
|
32
31
|
self.add_all_constraints()
|
|
33
32
|
|
|
34
33
|
def create_vars(self):
|
|
35
|
-
# Cell color vars
|
|
36
34
|
for pos in get_all_pos(self.V, self.H):
|
|
37
35
|
self.b[pos] = self.model.NewBoolVar(f"b[{pos}]")
|
|
38
|
-
self.w[pos] = self.
|
|
39
|
-
self.model.AddExactlyOne([self.b[pos], self.w[pos]])
|
|
36
|
+
self.w[pos] = self.b[pos].Not()
|
|
40
37
|
|
|
41
38
|
def add_all_constraints(self):
|
|
42
39
|
self.no_adjacent_blacks()
|
|
43
|
-
self.white_connectivity_percolation()
|
|
44
40
|
self.range_clues()
|
|
41
|
+
force_connected_component(self.model, self.w)
|
|
45
42
|
|
|
46
43
|
def no_adjacent_blacks(self):
|
|
47
|
-
cache = set()
|
|
48
44
|
for p in get_all_pos(self.V, self.H):
|
|
49
45
|
for q in get_neighbors4(p, self.V, self.H):
|
|
50
|
-
if (p, q) in cache:
|
|
51
|
-
continue
|
|
52
|
-
cache.add((p, q))
|
|
53
46
|
self.model.Add(self.b[p] + self.b[q] <= 1)
|
|
54
47
|
|
|
55
|
-
|
|
56
|
-
def white_connectivity_percolation(self):
|
|
57
|
-
force_connected_component(self.model, self.w)
|
|
58
|
-
|
|
59
48
|
def range_clues(self):
|
|
60
|
-
# For each numbered cell c with value k
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
# - Sum of visible whites = 1 (itself) + sum(chains) == k
|
|
64
|
-
for pos in get_all_pos(self.V, self.H):
|
|
65
|
-
k = get_char(self.clues, pos)
|
|
66
|
-
if k == -1:
|
|
49
|
+
for pos in get_all_pos(self.V, self.H): # For each numbered cell c with value k
|
|
50
|
+
k = str(get_char(self.clues, pos)).strip()
|
|
51
|
+
if not k:
|
|
67
52
|
continue
|
|
68
|
-
|
|
69
|
-
self.model.Add(self.b[pos] == 0)
|
|
70
|
-
|
|
71
|
-
# Build visibility chains per direction (exclude self)
|
|
53
|
+
self.model.Add(self.w[pos] == 1) # Force it white
|
|
72
54
|
vis_vars: list[cp_model.IntVar] = []
|
|
73
|
-
for direction in Direction:
|
|
55
|
+
for direction in Direction: # Build visibility chains in four direction
|
|
74
56
|
ray = get_ray(pos, self.V, self.H, direction) # cells outward
|
|
75
|
-
|
|
76
|
-
continue
|
|
77
|
-
# Chain: v0 = w[ray[0]]; vt = w[ray[t]] & vt-1
|
|
78
|
-
prev = None
|
|
79
|
-
for idx, cell in enumerate(ray):
|
|
57
|
+
for idx in range(len(ray)):
|
|
80
58
|
v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
|
|
59
|
+
and_constraint(self.model, target=v, cs=[self.w[p] for p in ray[:idx+1]])
|
|
81
60
|
vis_vars.append(v)
|
|
82
|
-
|
|
83
|
-
# v0 == w[cell]
|
|
84
|
-
self.model.Add(v == self.w[cell])
|
|
85
|
-
else:
|
|
86
|
-
and_constraint(self.model, target=v, cs=[self.w[cell], prev])
|
|
87
|
-
prev = v
|
|
88
|
-
|
|
89
|
-
# 1 (self) + sum(vis_vars) == k
|
|
90
|
-
self.model.Add(1 + sum(vis_vars) == k)
|
|
61
|
+
self.model.Add(1 + sum(vis_vars) == int(k)) # Sum of visible whites = 1 (itself) + sum(chains) == k
|
|
91
62
|
|
|
92
63
|
def solve_and_print(self, verbose: bool = True):
|
|
93
64
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
94
|
-
assignment:
|
|
95
|
-
for pos, var in board.b.items():
|
|
96
|
-
assignment[pos] = solver.Value(var)
|
|
97
|
-
return SingleSolution(assignment=assignment)
|
|
65
|
+
return SingleSolution(assignment={pos: solver.Value(board.b[pos]) for pos in get_all_pos(board.V, board.H)})
|
|
98
66
|
def callback(single_res: SingleSolution):
|
|
99
67
|
print("Solution:")
|
|
100
|
-
|
|
101
|
-
for pos in get_all_pos(self.V, self.H):
|
|
102
|
-
c = 'B' if single_res.assignment[pos] == 1 else ' '
|
|
103
|
-
set_char(res, pos, c)
|
|
104
|
-
print(res)
|
|
68
|
+
print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, center_char=lambda r, c: self.clues[r, c].strip(), text_on_shaded_cells=False))
|
|
105
69
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -6,7 +6,7 @@ from ortools.sat.python import cp_model
|
|
|
6
6
|
|
|
7
7
|
from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, set_char, get_char, Direction, get_next_pos
|
|
8
8
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
-
from puzzle_solver.core.utils_visualizer import
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def factor_pairs(N: int, upper_limit_i: int, upper_limit_j: int):
|
|
@@ -121,6 +121,8 @@ class Board:
|
|
|
121
121
|
set_char(res, pos, get_char(res, pos) + 'U')
|
|
122
122
|
if bottom_pos not in single_res.assignment or single_res.assignment[bottom_pos] != cur:
|
|
123
123
|
set_char(res, pos, get_char(res, pos) + 'D')
|
|
124
|
-
print(
|
|
124
|
+
print(combined_function(self.V, self.H,
|
|
125
|
+
cell_flags=lambda r, c: res[r, c],
|
|
126
|
+
center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else ' '))
|
|
125
127
|
|
|
126
128
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -1,35 +1,20 @@
|
|
|
1
|
-
import
|
|
2
|
-
from dataclasses import dataclass
|
|
3
|
-
import time
|
|
1
|
+
from collections import defaultdict
|
|
4
2
|
|
|
5
3
|
import numpy as np
|
|
6
4
|
from ortools.sat.python import cp_model
|
|
7
5
|
|
|
8
|
-
from puzzle_solver.core.utils import Direction, Pos, get_all_pos,
|
|
9
|
-
from puzzle_solver.core.utils_ortools import generic_solve_all, force_connected_component, and_constraint
|
|
10
|
-
from puzzle_solver.core.utils_visualizer import
|
|
6
|
+
from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, get_char, in_bounds, set_char, get_pos, get_opposite_direction
|
|
7
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, force_connected_component, and_constraint, SingleSolution
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
11
9
|
|
|
12
10
|
|
|
13
|
-
|
|
14
|
-
class SingleSolution:
|
|
15
|
-
assignment: dict[tuple[Pos, Pos], int]
|
|
16
|
-
|
|
17
|
-
def get_hashable_solution(self) -> str:
|
|
18
|
-
result = []
|
|
19
|
-
for (pos, neighbor), v in self.assignment.items():
|
|
20
|
-
result.append((pos.x, pos.y, neighbor.x, neighbor.y, v))
|
|
21
|
-
return json.dumps(result, sort_keys=True)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[tuple[Pos, Pos]]:
|
|
11
|
+
def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
|
|
25
12
|
out = []
|
|
26
|
-
prev_pos = pos
|
|
27
13
|
while True:
|
|
14
|
+
out.append(pos)
|
|
28
15
|
pos = get_next_pos(pos, direction)
|
|
29
16
|
if not in_bounds(pos, V, H):
|
|
30
17
|
break
|
|
31
|
-
out.append((prev_pos, pos))
|
|
32
|
-
prev_pos = pos
|
|
33
18
|
return out
|
|
34
19
|
|
|
35
20
|
|
|
@@ -37,8 +22,8 @@ class Board:
|
|
|
37
22
|
def __init__(self, board: np.array):
|
|
38
23
|
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
39
24
|
assert all((c.item().strip() == '') or (str(c.item())[:-1].isdecimal() and c.item()[-1].upper() in ['B', 'W']) for c in np.nditer(board)), 'board must contain only space or digits and B/W'
|
|
40
|
-
|
|
41
25
|
self.V, self.H = board.shape
|
|
26
|
+
self.board = board
|
|
42
27
|
self.board_numbers: dict[Pos, int] = {}
|
|
43
28
|
self.board_colors: dict[Pos, str] = {}
|
|
44
29
|
for pos in get_all_pos(self.V, self.H):
|
|
@@ -47,112 +32,84 @@ class Board:
|
|
|
47
32
|
continue
|
|
48
33
|
self.board_numbers[pos] = int(c[:-1])
|
|
49
34
|
self.board_colors[pos] = c[-1].upper()
|
|
35
|
+
|
|
50
36
|
self.model = cp_model.CpModel()
|
|
51
|
-
self.
|
|
37
|
+
self.cell_active: dict[Pos, cp_model.IntVar] = {}
|
|
38
|
+
self.cell_direction: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
|
|
52
39
|
|
|
53
40
|
self.create_vars()
|
|
54
41
|
self.add_all_constraints()
|
|
55
42
|
|
|
56
43
|
def create_vars(self):
|
|
57
44
|
for pos in get_all_pos(self.V, self.H):
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
45
|
+
self.cell_active[pos] = self.model.NewBoolVar(f'{pos}')
|
|
46
|
+
for direction in Direction:
|
|
47
|
+
neighbor = get_next_pos(pos, direction)
|
|
48
|
+
opposite_direction = get_opposite_direction(direction)
|
|
49
|
+
if not in_bounds(neighbor, self.V, self.H):
|
|
50
|
+
self.cell_direction[(pos, direction)] = self.model.NewConstant(0)
|
|
51
|
+
continue
|
|
52
|
+
if (neighbor, opposite_direction) in self.cell_direction:
|
|
53
|
+
self.cell_direction[(pos, direction)] = self.cell_direction[(neighbor, opposite_direction)]
|
|
54
|
+
else:
|
|
55
|
+
self.cell_direction[(pos, direction)] = self.model.NewBoolVar(f'{pos}-{neighbor}')
|
|
63
56
|
|
|
64
57
|
def add_all_constraints(self):
|
|
65
|
-
# each corners must have either 0 or 2 neighbors
|
|
66
|
-
for pos in get_all_pos(self.V, self.H):
|
|
67
|
-
corner_connections = [self.edge_vars[(pos, n)] for n in get_neighbors4(pos, self.V, self.H)]
|
|
68
|
-
if pos not in self.board_numbers: # no color, either 0 or 2 edges
|
|
69
|
-
self.model.AddLinearExpressionInDomain(sum(corner_connections), cp_model.Domain.FromValues([0, 2]))
|
|
70
|
-
else: # color, must have exactly 2 edges
|
|
71
|
-
self.model.Add(sum(corner_connections) == 2)
|
|
72
|
-
|
|
73
|
-
# enforce colors
|
|
74
58
|
for pos in get_all_pos(self.V, self.H):
|
|
59
|
+
s = sum([self.cell_direction[(pos, direction)] for direction in Direction])
|
|
60
|
+
self.model.Add(s == 2).OnlyEnforceIf(self.cell_active[pos])
|
|
61
|
+
self.model.Add(s == 0).OnlyEnforceIf(self.cell_active[pos].Not())
|
|
75
62
|
if pos not in self.board_numbers:
|
|
76
63
|
continue
|
|
77
|
-
self.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
# enforce single connected component
|
|
81
|
-
def is_neighbor(edge1: tuple[Pos, Pos], edge2: tuple[Pos, Pos]) -> bool:
|
|
82
|
-
return any(c1 == c2 for c1 in edge1 for c2 in edge2)
|
|
83
|
-
force_connected_component(self.model, self.edge_vars, is_neighbor=is_neighbor)
|
|
64
|
+
self.enforce_corner_color_and_number(pos, self.board_colors[pos], self.board_numbers[pos]) # enforce colors and number
|
|
65
|
+
self.force_connected_component() # enforce single connected component
|
|
84
66
|
|
|
85
|
-
def
|
|
86
|
-
assert pos_color in ['W', 'B'], f'Invalid color: {pos_color}'
|
|
87
|
-
|
|
88
|
-
var_r = self.edge_vars[(pos, pos_r)] if (pos, pos_r) in self.edge_vars else False
|
|
89
|
-
pos_d = get_next_pos(pos, Direction.DOWN)
|
|
90
|
-
var_d = self.edge_vars[(pos, pos_d)] if (pos, pos_d) in self.edge_vars else False
|
|
91
|
-
pos_l = get_next_pos(pos, Direction.LEFT)
|
|
92
|
-
var_l = self.edge_vars[(pos, pos_l)] if (pos, pos_l) in self.edge_vars else False
|
|
93
|
-
pos_u = get_next_pos(pos, Direction.UP)
|
|
94
|
-
var_u = self.edge_vars[(pos, pos_u)] if (pos, pos_u) in self.edge_vars else False
|
|
67
|
+
def enforce_corner_color_and_number(self, pos: Pos, pos_color: str, pos_number: int):
|
|
68
|
+
assert pos_color in ['W', 'B'] and pos_number > 0, f'Invalid color or number: {pos_color}, {pos_number}'
|
|
69
|
+
self.model.Add(self.cell_active[pos] == 1)
|
|
95
70
|
if pos_color == 'W': # White circles must be passed through in a straight line
|
|
96
|
-
self.model.Add(
|
|
97
|
-
self.model.Add(
|
|
71
|
+
self.model.Add(self.cell_direction[(pos, Direction.RIGHT)] == self.cell_direction[(pos, Direction.LEFT)])
|
|
72
|
+
self.model.Add(self.cell_direction[(pos, Direction.DOWN)] == self.cell_direction[(pos, Direction.UP)])
|
|
98
73
|
elif pos_color == 'B': # Black circles must be turned upon
|
|
99
|
-
self.model.Add(
|
|
100
|
-
self.model.Add(
|
|
101
|
-
self.model.Add(
|
|
102
|
-
self.model.Add(
|
|
74
|
+
self.model.Add(self.cell_direction[(pos, Direction.RIGHT)] == 0).OnlyEnforceIf([self.cell_direction[(pos, Direction.LEFT)]])
|
|
75
|
+
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] == 0).OnlyEnforceIf([self.cell_direction[(pos, Direction.RIGHT)]])
|
|
76
|
+
self.model.Add(self.cell_direction[(pos, Direction.DOWN)] == 0).OnlyEnforceIf([self.cell_direction[(pos, Direction.UP)]])
|
|
77
|
+
self.model.Add(self.cell_direction[(pos, Direction.UP)] == 0).OnlyEnforceIf([self.cell_direction[(pos, Direction.DOWN)]])
|
|
103
78
|
else:
|
|
104
79
|
raise ValueError(f'Invalid color: {pos_color}')
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
rays = get_ray(pos, self.V, self.H, direction) # cells outward
|
|
112
|
-
if not rays:
|
|
113
|
-
continue
|
|
114
|
-
# Chain: v0 = w[ray[0]]; vt = w[ray[t]] & vt-1
|
|
115
|
-
prev = None
|
|
116
|
-
for idx, (pos1, pos2) in enumerate(rays):
|
|
117
|
-
v = self.model.NewBoolVar(f"vis[{pos1}-{pos2}]->({direction.name})[{idx}]")
|
|
80
|
+
vis_vars: list[cp_model.IntVar] = [] # The numbers in the circles show the sum of the lengths of the 2 straight lines going out of that circle.
|
|
81
|
+
for direction in Direction: # Build visibility chains in four direction
|
|
82
|
+
ray = get_ray(pos, self.V, self.H, direction) # cells outward
|
|
83
|
+
for idx in range(len(ray)):
|
|
84
|
+
v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
|
|
85
|
+
and_constraint(self.model, target=v, cs=[self.cell_direction[(p, direction)] for p in ray[:idx+1]])
|
|
118
86
|
vis_vars.append(v)
|
|
119
|
-
if idx == 0:
|
|
120
|
-
# v0 == w[cell]
|
|
121
|
-
self.model.Add(v == self.edge_vars[(pos1, pos2)])
|
|
122
|
-
else:
|
|
123
|
-
and_constraint(self.model, target=v, cs=[self.edge_vars[(pos1, pos2)], prev])
|
|
124
|
-
prev = v
|
|
125
87
|
self.model.Add(sum(vis_vars) == pos_number)
|
|
126
88
|
|
|
89
|
+
def force_connected_component(self):
|
|
90
|
+
def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
|
|
91
|
+
p1, d1 = pd1
|
|
92
|
+
p2, d2 = pd2
|
|
93
|
+
if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
|
|
94
|
+
return True
|
|
95
|
+
if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
|
|
96
|
+
return True
|
|
97
|
+
return False
|
|
98
|
+
force_connected_component(self.model, self.cell_direction, is_neighbor=is_neighbor)
|
|
127
99
|
|
|
128
100
|
def solve_and_print(self, verbose: bool = True):
|
|
129
|
-
tic = time.time()
|
|
130
101
|
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
131
|
-
assignment: dict[
|
|
132
|
-
for (pos,
|
|
133
|
-
assignment[
|
|
102
|
+
assignment: dict[Pos, str] = defaultdict(str)
|
|
103
|
+
for (pos, direction), var in board.cell_direction.items():
|
|
104
|
+
assignment[pos] += direction.name[0] if solver.BooleanValue(var) else ''
|
|
134
105
|
return SingleSolution(assignment=assignment)
|
|
135
106
|
def callback(single_res: SingleSolution):
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
min_y = min(pos.y, neighbor.y)
|
|
145
|
-
dx = abs(pos.x - neighbor.x)
|
|
146
|
-
dy = abs(pos.y - neighbor.y)
|
|
147
|
-
if min_x == self.H - 1: # only way to get right
|
|
148
|
-
res[min_y][min_x - 1] += 'R'
|
|
149
|
-
elif min_y == self.V - 1: # only way to get down
|
|
150
|
-
res[min_y - 1][min_x] += 'D'
|
|
151
|
-
elif dx == 1:
|
|
152
|
-
res[min_y][min_x] += 'U'
|
|
153
|
-
elif dy == 1:
|
|
154
|
-
res[min_y][min_x] += 'L'
|
|
155
|
-
else:
|
|
156
|
-
raise ValueError(f'Invalid position: {pos} and {neighbor}')
|
|
157
|
-
print(render_grid(res, center_char='.'))
|
|
107
|
+
print("Solution found")
|
|
108
|
+
output_board = np.full((self.V, self.H), '', dtype=object)
|
|
109
|
+
for pos in get_all_pos(self.V, self.H):
|
|
110
|
+
if get_char(self.board, pos)[-1] in ['B', 'W']: # if the main board has a white or black pearl, put it in the output
|
|
111
|
+
set_char(output_board, pos, get_char(self.board, pos))
|
|
112
|
+
if not single_res.assignment[pos].strip(): # if the cell does not the line through it, put a dot
|
|
113
|
+
set_char(output_board, pos, '.')
|
|
114
|
+
print(combined_function(self.V, self.H, show_grid=False, special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)], center_char=lambda r, c: output_board[r, c]))
|
|
158
115
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -3,7 +3,7 @@ from ortools.sat.python import cp_model
|
|
|
3
3
|
|
|
4
4
|
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_neighbors4, get_all_pos_to_idx_dict, get_row_pos, get_col_pos, get_pos
|
|
5
5
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
6
|
-
from puzzle_solver.core.utils_visualizer import
|
|
6
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class Board:
|
|
@@ -45,5 +45,9 @@ class Board:
|
|
|
45
45
|
return SingleSolution(assignment={pos: 1 if solver.Value(val) == 1 else 0 for pos, val in board.B.items()})
|
|
46
46
|
def callback(single_res: SingleSolution):
|
|
47
47
|
print("Solution found")
|
|
48
|
-
print(
|
|
48
|
+
print(combined_function(self.V, self.H,
|
|
49
|
+
is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
|
|
50
|
+
center_char=lambda r, c: self.board[r, c],
|
|
51
|
+
text_on_shaded_cells=False
|
|
52
|
+
))
|
|
49
53
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|