multi-puzzle-solver 1.0.6__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.

@@ -1,7 +1,8 @@
1
- puzzle_solver/__init__.py,sha256=OBCEdCUd7-i81fEAhMa5DSirGbfhID5IwVk1jTkNxC4,5298
1
+ puzzle_solver/__init__.py,sha256=CiplXqjf9w0kuDmtvive0gM8hkZEIRQNgaK1A_X_ZwE,5390
2
2
  puzzle_solver/core/utils.py,sha256=FE0106dfQRsgCn2FRBvRq5zILLK7-Z3cPHkAlBWUX0w,8785
3
3
  puzzle_solver/core/utils_ortools.py,sha256=ACV3HgKWpEUTt1lpqsPryK1DeZpu7kdWQKEWTLJ2tfs,10384
4
4
  puzzle_solver/core/utils_visualizer.py,sha256=AmVeBuEMaJzVY2zBbRoONlC9x3AGLKcdaNn8mD-nrLs,23347
5
+ puzzle_solver/puzzles/abc_view/abc_view.py,sha256=Qr0rZKmKQ2teStHjQ5VPQ4k-XptsjJAlZ1WXWk5Aax4,4570
5
6
  puzzle_solver/puzzles/aquarium/aquarium.py,sha256=dGqYEWMoh4di5DN4sd-GtYb6QeTpVYFQJHBkrrmrudQ,5649
6
7
  puzzle_solver/puzzles/battleships/battleships.py,sha256=U4xJ_NJC2baHvfaAfJ01YEBjixq9gD0h8GP9L1V-_oM,7223
7
8
  puzzle_solver/puzzles/binairo/binairo.py,sha256=qKvpixLIBUcugAyJgpBGHV-9q_4nzA1ZOxeDFnltsXA,6843
@@ -21,7 +22,7 @@ puzzle_solver/puzzles/flood_it/parse_map/parse_map.py,sha256=m7gcpvN3THZdYLowdR_
21
22
  puzzle_solver/puzzles/galaxies/galaxies.py,sha256=IiKPU3fz5Aokhj5OjeT5jd_vdNuWnLzjZylGOTepsNU,5600
22
23
  puzzle_solver/puzzles/galaxies/parse_map/parse_map.py,sha256=XmFqVN_oRfq9AZFWy5ViUJ2Szjgx-srrRkFPJXEEyFo,9358
23
24
  puzzle_solver/puzzles/guess/guess.py,sha256=MpyrF6YVu0S1fzX-BllwxGKRGacWJpeLbNn5GetuEyo,10792
24
- puzzle_solver/puzzles/heyawake/heyawake.py,sha256=U0nWSX8SMDmaLwA0K555ru_mMX2MLjCiZVNSz8T0Ksg,5687
25
+ puzzle_solver/puzzles/heyawake/heyawake.py,sha256=cBD0xHYvVOBAOiwAB_K9sjf1Hv3zL4GBiq1Dml27F6g,9085
25
26
  puzzle_solver/puzzles/inertia/inertia.py,sha256=-Y5fr7aK20zwmGHsZql7pYCq1kyMZglvkVZ6uIDf1HA,5658
26
27
  puzzle_solver/puzzles/inertia/tsp.py,sha256=mAhlSjCWespASeN8uLZ0JkYDw-ZqFEpal6NM-ubpCXw,15313
27
28
  puzzle_solver/puzzles/inertia/parse_map/parse_map.py,sha256=x0d64gTBd0HC2lO5uOpX2VKWfwj8rRiz0mQM_lqNmWs,8457
@@ -38,13 +39,13 @@ puzzle_solver/puzzles/nonograms/nonograms.py,sha256=Q-VHI0IPR2igccnE617HPThj5tnB
38
39
  puzzle_solver/puzzles/nonograms/nonograms_colored.py,sha256=Qy3CGNNs0MoZQv-qHyMMP8-fK7s7f_utK-Rc1hQaX_A,10750
39
40
  puzzle_solver/puzzles/norinori/norinori.py,sha256=ZEDWrD7zvEuqXOdXGOrELh1n_mWzhzZa3chs6Zqd3Pc,4570
40
41
  puzzle_solver/puzzles/nurikabe/nurikabe.py,sha256=hX0VcjPwO8PfY2kiIpQV45FWIvKRosFebk588tp5wzk,6603
41
- puzzle_solver/puzzles/palisade/palisade.py,sha256=q_uQbp6Ef0RXcELMX97msurQk_F53_AlZdEfKVq-NlI,5075
42
+ puzzle_solver/puzzles/palisade/palisade.py,sha256=Stvgw0k_sml9Dj5RMRVf0EmU5a_eGf1fn65IXelR6Wk,4802
42
43
  puzzle_solver/puzzles/pearl/pearl.py,sha256=slPVCzPObQLNk4EYqe55YR4JeRCUs07Mjdks1fWKZSY,6696
43
44
  puzzle_solver/puzzles/pipes/pipes.py,sha256=2HDHCWhD-fLYTRoJsx15gOrsgt_SbjlGF2QDS5UX6m8,4680
44
45
  puzzle_solver/puzzles/range/range.py,sha256=sJVMKzoT5unihMKurriAUTGLY0f7OQXSZfSHWezPPkw,3387
45
46
  puzzle_solver/puzzles/rectangles/rectangles.py,sha256=6MuJHyw7woIljlqxt76zfhw8F2_2biKMFM7oiXdXZsg,7010
46
47
  puzzle_solver/puzzles/shakashaka/shakashaka.py,sha256=PRpg_qI7XA3ysAo_g1TRJsT3VwB5Vial2UcFyBOMwKQ,9571
47
- puzzle_solver/puzzles/shingoki/shingoki.py,sha256=u-r6nLlF4WAlSUsQxNMjaPPdojFOIe7eJgiSgLa883s,7496
48
+ puzzle_solver/puzzles/shingoki/shingoki.py,sha256=Sra6i4kpVzIK6CabhK9yDVDLBuZiNS4daPimtnTgQu0,6777
48
49
  puzzle_solver/puzzles/signpost/signpost.py,sha256=38LlMvP5Fx4qrTXmw4aNCt3yUbG3fhdSk6-YXmhAHFg,3861
49
50
  puzzle_solver/puzzles/singles/singles.py,sha256=AugO2Gnd_OEyrxXUnqg3oPypdmeiFais_fu19-gm4Eg,2945
50
51
  puzzle_solver/puzzles/slant/slant.py,sha256=l4q9g0BfqQsA6zsySiemJC5iFsqsO6LoqTUqcTEqYEk,5897
@@ -67,7 +68,7 @@ puzzle_solver/puzzles/unruly/unruly.py,sha256=_C6FhYm9rqwhlQa6TMTxYr3rWcP_QS-E93
67
68
  puzzle_solver/puzzles/yin_yang/yin_yang.py,sha256=D0JacUdK5yPrfScmGqX-p8144VbwxfDgIaqF8hwLXlM,5039
68
69
  puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py,sha256=drjfoHqmFf6U-ZQUwrBbfGINRxDQpgbvy4U3D9QyMhM,6617
69
70
  puzzle_solver/utils/visualizer.py,sha256=T2g5We9J3tkhyXWoN2OrIDIJDjt6w5sDd2ksOub0ZI8,6819
70
- multi_puzzle_solver-1.0.6.dist-info/METADATA,sha256=L-BnSETryC5gEqJYM6b-bSm2iGPkvJYWg47lHzRiwSw,450669
71
- multi_puzzle_solver-1.0.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
72
- multi_puzzle_solver-1.0.6.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
73
- multi_puzzle_solver-1.0.6.dist-info/RECORD,,
71
+ multi_puzzle_solver-1.0.7.dist-info/METADATA,sha256=WfWXNWvZZPMkq8SmkWShIMpG6S4dDZcvayWDFIyM9-A,452620
72
+ multi_puzzle_solver-1.0.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
73
+ multi_puzzle_solver-1.0.7.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
74
+ multi_puzzle_solver-1.0.7.dist-info/RECORD,,
puzzle_solver/__init__.py CHANGED
@@ -1,3 +1,4 @@
1
+ from puzzle_solver.puzzles.abc_view import abc_view as abc_view_solver
1
2
  from puzzle_solver.puzzles.aquarium import aquarium as aquarium_solver
2
3
  from puzzle_solver.puzzles.battleships import battleships as battleships_solver
3
4
  from puzzle_solver.puzzles.binairo import binairo as binairo_solver
@@ -58,6 +59,7 @@ from puzzle_solver.puzzles.yin_yang import yin_yang as yin_yang_solver
58
59
  from puzzle_solver.puzzles.inertia.parse_map.parse_map import main as inertia_image_parser
59
60
 
60
61
  __all__ = [
62
+ abc_view_solver,
61
63
  aquarium_solver,
62
64
  battleships_solver,
63
65
  binairo_solver,
@@ -117,4 +119,4 @@ __all__ = [
117
119
  inertia_image_parser,
118
120
  ]
119
121
 
120
- __version__ = '1.0.6'
122
+ __version__ = '1.0.7'
@@ -0,0 +1,75 @@
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_char, get_pos, get_row_pos, get_col_pos
5
+ from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution
6
+ from puzzle_solver.core.utils_visualizer import combined_function
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array, top: np.array, left: np.array, bottom: np.array, right: np.array, characters: list[str]):
11
+ self.BLANK = 'BLANK'
12
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
13
+ self.characters_no_blank = characters
14
+ self.characters = characters + [self.BLANK]
15
+ assert all(c.strip() in self.characters or c.strip() == '' for c in board.flatten()), f'board must contain characters in {self.characters}'
16
+ assert all(c.strip() in self.characters or c.strip() == '' for c in np.concatenate([top, left, bottom, right])), f'top, bottom, left, and right must contain only characters in {self.characters}'
17
+ self.board = board
18
+ self.V, self.H = board.shape
19
+ assert top.shape == (self.H,) and bottom.shape == (self.H,) and left.shape == (self.V,) and right.shape == (self.V,), 'top, bottom, left, and right must be 1d arrays of length board width and height'
20
+ self.top = top
21
+ self.left = left
22
+ self.bottom = bottom
23
+ self.right = right
24
+
25
+ self.model = cp_model.CpModel()
26
+ self.model_vars: dict[tuple[Pos, str], cp_model.IntVar] = {}
27
+ self.create_vars()
28
+ self.add_all_constraints()
29
+
30
+ def create_vars(self):
31
+ for pos in get_all_pos(self.V, self.H):
32
+ for character in self.characters:
33
+ self.model_vars[pos, character] = self.model.NewBoolVar(f'{pos}:{character}')
34
+
35
+ def add_all_constraints(self):
36
+ for pos in get_all_pos(self.V, self.H):
37
+ self.model.AddExactlyOne([self.model_vars[pos, character] for character in self.characters])
38
+ c = get_char(self.board, pos).strip() # force the clue if on the board
39
+ if not c:
40
+ continue
41
+ self.model.Add(self.model_vars[pos, c] == 1)
42
+
43
+ # each row and column must have exactly one of each character, except for BLANK
44
+ for row in range(self.V):
45
+ for character in self.characters_no_blank:
46
+ self.model.AddExactlyOne([self.model_vars[pos, character] for pos in get_row_pos(row, self.H)])
47
+ for col in range(self.H):
48
+ for character in self.characters_no_blank:
49
+ self.model.AddExactlyOne([self.model_vars[pos, character] for pos in get_col_pos(col, self.V)])
50
+
51
+ # a character clue on that side means the first character that appears on the side is the clue
52
+ for i, top_char in enumerate(self.top):
53
+ self.force_first_character(list(get_col_pos(i, self.V)), top_char)
54
+ for i, bottom_char in enumerate(self.bottom):
55
+ self.force_first_character(list(get_col_pos(i, self.V))[::-1], bottom_char)
56
+ for i, left_char in enumerate(self.left):
57
+ self.force_first_character(list(get_row_pos(i, self.H)), left_char)
58
+ for i, right_char in enumerate(self.right):
59
+ self.force_first_character(list(get_row_pos(i, self.H))[::-1], right_char)
60
+
61
+ def force_first_character(self, pos_list: list[Pos], target_character: str):
62
+ if not target_character:
63
+ return
64
+ for i, pos in enumerate(pos_list):
65
+ is_first_char = self.model.NewBoolVar(f'{i}:{target_character}:is_first_char')
66
+ and_constraint(self.model, is_first_char, [self.model_vars[pos, self.BLANK] for pos in pos_list[:i]] + [self.model_vars[pos_list[i], self.BLANK].Not()])
67
+ self.model.Add(self.model_vars[pos, target_character] == 1).OnlyEnforceIf(is_first_char)
68
+
69
+ def solve_and_print(self, verbose: bool = True):
70
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
71
+ return SingleSolution(assignment={pos: char for (pos, char), var in board.model_vars.items() if solver.Value(var) == 1 and char != board.BLANK})
72
+ def callback(single_res: SingleSolution):
73
+ print("Solution found")
74
+ print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ''), text_on_shaded_cells=False))
75
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -1,9 +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, get_neighbors4, get_pos, get_char
4
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_col_pos, get_neighbors4, get_pos, get_char, get_row_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 combined_function
6
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
7
7
 
8
8
 
9
9
  def return_3_consecutives(int_list: list[int]) -> list[tuple[int, int]]:
@@ -20,6 +20,17 @@ def return_3_consecutives(int_list: list[int]) -> list[tuple[int, int]]:
20
20
  out.append((begin_idx, end_idx))
21
21
  return out
22
22
 
23
+
24
+ def get_diagonal(pos1: Pos, pos2: Pos) -> list[Pos]:
25
+ assert pos1 != pos2, 'positions must be different'
26
+ dx = pos1.x - pos2.x
27
+ dy = pos1.y - pos2.y
28
+ assert abs(dx) == abs(dy), 'positions must be on a diagonal'
29
+ sdx = 1 if dx > 0 else -1
30
+ sdy = 1 if dy > 0 else -1
31
+ return [get_pos(x=pos2.x + i*sdx, y=pos2.y + i*sdy) for i in range(abs(dx) + 1)]
32
+
33
+
23
34
  class Board:
24
35
  def __init__(self, board: np.array, region_to_clue: dict[str, int]):
25
36
  assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
@@ -59,6 +70,10 @@ class Board:
59
70
  force_connected_component(self.model, self.W)
60
71
  # A straight (orthogonal) line of connected white cells cannot span across more than 2 regions.
61
72
  self.disallow_white_lines_spanning_3_regions()
73
+ # straight diagonal black lines from side wall to horizontal wall are not allowed; because they would disconnect the white cells
74
+ self.disallow_full_black_diagonal()
75
+ # disallow a diagonal black line coming out of a wall of length N then coming back in on the same wall; because it would disconnect the white cells
76
+ self.disallow_zigzag_on_wall()
62
77
 
63
78
  def disallow_white_lines_spanning_3_regions(self):
64
79
  # A straight (orthogonal) line of connected white cells cannot span across more than 2 regions.
@@ -77,22 +92,61 @@ class Board:
77
92
  pos_list = [get_pos(x=col_num, y=y) for y in range(begin_idx, end_idx+1)]
78
93
  self.model.AddBoolOr([self.B[p] for p in pos_list])
79
94
 
80
- def solve_and_print(self, verbose: bool = True):
95
+ def disallow_full_black_diagonal(self):
96
+ corners_dx_dy = [
97
+ ((0, 0), 1, 1),
98
+ ((self.H-1, 0), -1, 1),
99
+ ((0, self.V-1), 1, -1),
100
+ ((self.H-1, self.V-1), -1, -1),
101
+ ]
102
+ for (corner_x, corner_y), dx, dy in corners_dx_dy:
103
+ for delta in range(1, min(self.V, self.H)):
104
+ pos1 = get_pos(x=corner_x, y=corner_y + delta*dy)
105
+ pos2 = get_pos(x=corner_x + delta*dx, y=corner_y)
106
+ diagonal_line = get_diagonal(pos1, pos2)
107
+ self.model.AddBoolOr([self.W[p] for p in diagonal_line])
108
+
109
+ def disallow_zigzag_on_wall(self):
110
+ for pos in get_row_pos(0, self.H): # top line
111
+ for end_x in range(pos.x + 2, self.H, 2): # end pos is even distance away from start pos
112
+ end_pos = get_pos(x=end_x, y=pos.y)
113
+ dx = end_x - pos.x
114
+ mid_pos = get_pos(x=pos.x + dx//2, y=pos.y + dx//2)
115
+ diag_1 = get_diagonal(pos, mid_pos) # from top wall to bottom triangle tip "\"
116
+ diag_2 = get_diagonal(end_pos, mid_pos) # from bottom triangle tip to top wall "/"
117
+ self.model.AddBoolOr([self.W[p] for p in diag_1 + diag_2])
118
+ for pos in get_row_pos(self.V-1, self.H): # bottom line
119
+ for end_x in range(pos.x + 2, self.H, 2): # end pos is even distance away from start pos
120
+ end_pos = get_pos(x=end_x, y=pos.y)
121
+ dx = end_x - pos.x
122
+ mid_pos = get_pos(x=pos.x + dx//2, y=pos.y - dx//2)
123
+ diag_1 = get_diagonal(pos, mid_pos) # from bottom wall to top triangle tip "/"
124
+ diag_2 = get_diagonal(end_pos, mid_pos) # from top triangle tip to bottom wall "\"
125
+ self.model.AddBoolOr([self.W[p] for p in diag_1 + diag_2])
126
+ for pos in get_col_pos(0, self.V): # left line
127
+ for end_y in range(pos.y + 2, self.V, 2): # end pos is even distance away from start pos
128
+ end_pos = get_pos(x=pos.x, y=end_y)
129
+ dy = end_y - pos.y
130
+ mid_pos = get_pos(x=pos.x + dy//2, y=pos.y + dy//2)
131
+ diag_1 = get_diagonal(pos, mid_pos) # from left wall to right triangle tip "\"
132
+ diag_2 = get_diagonal(end_pos, mid_pos) # from right triangle tip to left wall "/"
133
+ self.model.AddBoolOr([self.W[p] for p in diag_1 + diag_2])
134
+ for pos in get_col_pos(self.H-1, self.V): # right line
135
+ for end_y in range(pos.y + 2, self.V, 2): # end pos is even distance away from start pos
136
+ end_pos = get_pos(x=pos.x, y=end_y)
137
+ dy = end_y - pos.y
138
+ mid_pos = get_pos(x=pos.x - dy//2, y=pos.y + dy//2)
139
+ diag_1 = get_diagonal(pos, mid_pos) # from right wall to left triangle tip "/"
140
+
141
+ def solve_and_print(self, verbose: bool = True, max_solutions: int = 20):
81
142
  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)
143
+ return SingleSolution(assignment={pos: solver.Value(var) for pos, var in board.B.items()})
86
144
  def callback(single_res: SingleSolution):
87
145
  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
146
  print(combined_function(self.V, self.H,
147
+ cell_flags=id_board_to_wall_fn(self.board),
94
148
  is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
95
149
  center_char=lambda r, c: self.region_to_clue.get(int(self.board[r, c]), ''),
96
150
  text_on_shaded_cells=False
97
151
  ))
98
- return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=1)
152
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=max_solutions)
@@ -9,9 +9,7 @@ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
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,19 +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
- assignment: dict[Pos, int] = {}
92
- for shape in board.shapes_on_board:
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
- id_board = np.full((self.V, self.H), ' ', dtype=object)
100
- for pos in get_all_pos(self.V, self.H):
101
- region_idx = single_res.assignment[pos]
102
- set_char(id_board, pos, region_idx)
103
88
  print(combined_function(self.V, self.H,
104
- cell_flags=id_board_to_wall_fn(id_board),
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)])),
105
90
  center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else '·'))
106
91
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -1,35 +1,20 @@
1
- import json
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, get_neighbors4, get_next_pos, get_char, in_bounds
9
- from puzzle_solver.core.utils_ortools import generic_solve_all, force_connected_component, and_constraint
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
10
8
  from puzzle_solver.core.utils_visualizer import combined_function
11
9
 
12
10
 
13
- @dataclass(frozen=True)
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.edge_vars: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
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
- for neighbor in get_neighbors4(pos, self.V, self.H):
59
- if (neighbor, pos) in self.edge_vars: # already added in opposite direction
60
- self.edge_vars[(pos, neighbor)] = self.edge_vars[(neighbor, pos)]
61
- else: # new edge
62
- self.edge_vars[(pos, neighbor)] = self.model.NewBoolVar(f'{pos}-{neighbor}')
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.enforce_corner_color(pos, self.board_colors[pos])
78
- self.enforce_corner_number(pos, self.board_numbers[pos])
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 enforce_corner_color(self, pos: Pos, pos_color: str):
86
- assert pos_color in ['W', 'B'], f'Invalid color: {pos_color}'
87
- pos_r = get_next_pos(pos, Direction.RIGHT)
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(var_r == var_l)
97
- self.model.Add(var_u == var_d)
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(var_r == 0).OnlyEnforceIf([var_l])
100
- self.model.Add(var_l == 0).OnlyEnforceIf([var_r])
101
- self.model.Add(var_u == 0).OnlyEnforceIf([var_d])
102
- self.model.Add(var_d == 0).OnlyEnforceIf([var_u])
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
- def enforce_corner_number(self, pos: Pos, pos_number: int):
107
- # The numbers in the circles show the sum of the lengths of the 2 straight lines going out of that circle.
108
- # Build visibility chains per direction (exclude self)
109
- vis_vars: list[cp_model.IntVar] = []
110
- for direction in Direction:
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[tuple[Pos, Pos], int] = {}
132
- for (pos, neighbor), var in board.edge_vars.items():
133
- assignment[(pos, neighbor)] = solver.Value(var)
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
- nonlocal tic
137
- print(f"Solution found in {time.time() - tic:.2f} seconds")
138
- tic = time.time()
139
- res = np.full((self.V - 1, self.H - 1), ' ', dtype=object)
140
- for (pos, neighbor), v in single_res.assignment.items():
141
- if v == 0:
142
- continue
143
- min_x = min(pos.x, neighbor.x)
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(combined_function(self.V - 1, self.H - 1, cell_flags=lambda r, c: res[r, c], center_char=lambda r, c: '.'))
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)