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

@@ -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)
@@ -0,0 +1,158 @@
1
+ import json
2
+ from dataclasses import dataclass
3
+ import time
4
+
5
+ import numpy as np
6
+ from ortools.sat.python import cp_model
7
+
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
10
+ from puzzle_solver.core.utils_visualizer import render_grid
11
+
12
+
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]]:
25
+ out = []
26
+ prev_pos = pos
27
+ while True:
28
+ pos = get_next_pos(pos, direction)
29
+ if not in_bounds(pos, V, H):
30
+ break
31
+ out.append((prev_pos, pos))
32
+ prev_pos = pos
33
+ return out
34
+
35
+
36
+ class Board:
37
+ def __init__(self, board: np.array):
38
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
39
+ 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
+ self.V, self.H = board.shape
42
+ self.board_numbers: dict[Pos, int] = {}
43
+ self.board_colors: dict[Pos, str] = {}
44
+ for pos in get_all_pos(self.V, self.H):
45
+ c = get_char(board, pos)
46
+ if c.strip() == '':
47
+ continue
48
+ self.board_numbers[pos] = int(c[:-1])
49
+ self.board_colors[pos] = c[-1].upper()
50
+ self.model = cp_model.CpModel()
51
+ self.edge_vars: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
52
+
53
+ self.create_vars()
54
+ self.add_all_constraints()
55
+
56
+ def create_vars(self):
57
+ 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}')
63
+
64
+ 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
+ for pos in get_all_pos(self.V, self.H):
75
+ if pos not in self.board_numbers:
76
+ 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)
84
+
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
95
+ 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)
98
+ 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])
103
+ else:
104
+ 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}]")
118
+ 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
+ self.model.Add(sum(vis_vars) == pos_number)
126
+
127
+
128
+ def solve_and_print(self, verbose: bool = True):
129
+ tic = time.time()
130
+ 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)
134
+ return SingleSolution(assignment=assignment)
135
+ 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(render_grid(res, center_char='.'))
158
+ 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, get_char, set_char, get_neighbors4, get_all_pos_to_idx_dict, get_row_pos, get_col_pos
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 render_shaded_grid
6
7
 
7
8
 
8
9
  class Board:
@@ -14,62 +15,35 @@ class Board:
14
15
  self.H = board.shape[1]
15
16
  self.N = self.V * self.H
16
17
  self.idx_of: dict[Pos, int] = get_all_pos_to_idx_dict(self.V, self.H)
17
-
18
18
  self.model = cp_model.CpModel()
19
- self.B = {} # black squares
20
- self.W = {} # white squares
19
+ self.B = {}
20
+ self.W = {}
21
21
  self.Num = {} # value of squares (Num = N + idx if black, else board[pos])
22
-
23
22
  self.create_vars()
24
23
  self.add_all_constraints()
25
24
 
26
25
  def create_vars(self):
27
26
  for pos in get_all_pos(self.V, self.H):
28
27
  self.B[pos] = self.model.NewBoolVar(f'B:{pos}')
29
- self.W[pos] = self.model.NewBoolVar(f'W:{pos}')
30
- # either black or white
31
- self.model.AddExactlyOne([self.B[pos], self.W[pos]])
28
+ self.W[pos] = self.B[pos].Not()
32
29
  self.Num[pos] = self.model.NewIntVar(0, 2*self.N, f'{pos}')
33
30
  self.model.Add(self.Num[pos] == self.N + self.idx_of[pos]).OnlyEnforceIf(self.B[pos])
34
- self.model.Add(self.Num[pos] == int(get_char(self.board, pos))).OnlyEnforceIf(self.B[pos].Not())
31
+ self.model.Add(self.Num[pos] == int(get_char(self.board, pos))).OnlyEnforceIf(self.W[pos])
35
32
 
36
33
  def add_all_constraints(self):
37
- self.no_adjacent_blacks()
38
- self.no_number_appears_twice()
39
- self.force_connected_component()
40
-
41
- def no_adjacent_blacks(self):
42
- # no two black squares are adjacent
43
- for pos in get_all_pos(self.V, self.H):
34
+ for pos in get_all_pos(self.V, self.H): # no two black squares are adjacent
44
35
  for neighbor in get_neighbors4(pos, self.V, self.H):
45
36
  self.model.Add(self.B[pos] + self.B[neighbor] <= 1)
46
-
47
- def no_number_appears_twice(self):
48
- # no number appears twice in any row or column (numbers are ignored if black)
49
- for row in range(self.V):
50
- var_list = [self.Num[pos] for pos in get_row_pos(row, self.H)]
51
- self.model.AddAllDifferent(var_list)
52
- for col in range(self.H):
53
- var_list = [self.Num[pos] for pos in get_col_pos(col, self.V)]
54
- self.model.AddAllDifferent(var_list)
55
-
56
- def force_connected_component(self):
57
- force_connected_component(self.model, self.W)
58
-
59
-
37
+ for row in range(self.V): # no number appears twice in any row (numbers are ignored if black)
38
+ self.model.AddAllDifferent([self.Num[pos] for pos in get_row_pos(row, self.H)])
39
+ for col in range(self.H): # no number appears twice in any column (numbers are ignored if black)
40
+ self.model.AddAllDifferent([self.Num[pos] for pos in get_col_pos(col, self.V)])
41
+ force_connected_component(self.model, self.W) # all white squares must be a single connected component
60
42
 
61
43
  def solve_and_print(self, verbose: bool = True):
62
44
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
63
- assignment: dict[Pos, int] = {}
64
- for pos, var in board.B.items():
65
- assignment[pos] = solver.value(var)
66
- return SingleSolution(assignment=assignment)
45
+ return SingleSolution(assignment={pos: 1 if solver.Value(val) == 1 else 0 for pos, val in board.B.items()})
67
46
  def callback(single_res: SingleSolution):
68
47
  print("Solution found")
69
- res = np.full((self.V, self.H), ' ', dtype=object)
70
- for pos in get_all_pos(self.V, self.H):
71
- c = get_char(self.board, pos)
72
- c = 'B' if single_res.assignment[pos] == 1 else ' '
73
- set_char(res, pos, c)
74
- print(res)
48
+ 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.board[r, c]))
75
49
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -242,4 +242,6 @@ if __name__ == '__main__':
242
242
  # main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
243
243
  # main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
244
244
  # main(Path(__file__).parent / 'input_output' / 'norinori_501d93110d6b4b818c268378973afbf268f96cfa8d7b4.png')
245
- main(Path(__file__).parent / 'input_output' / 'norinori_OTo0LDc0Miw5MTU.png')
245
+ # main(Path(__file__).parent / 'input_output' / 'norinori_OTo0LDc0Miw5MTU.png')
246
+ # main(Path(__file__).parent / 'input_output' / 'heyawake_MDoxNiwxNDQ=.png')
247
+ main(Path(__file__).parent / 'input_output' / 'heyawake_MTQ6ODQ4LDEzOQ==.png')
@@ -0,0 +1,98 @@
1
+ from typing import Union
2
+ from itertools import combinations
3
+
4
+ import numpy as np
5
+ from ortools.sat.python import cp_model
6
+
7
+ from puzzle_solver.core.utils import Direction8, Pos, get_all_pos, set_char, get_char, in_bounds, Direction, get_next_pos, get_pos
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
9
+ from puzzle_solver.core.utils_visualizer import render_shaded_grid
10
+
11
+
12
+ def rotated_assignments_N_nums(Xs: tuple[int, ...], target_length: int = 8) -> set[tuple[bool, ...]]:
13
+ """ Given Xs = [X1, X2, ..., Xm] (each Xi >= 1), build all unique length-`target_length`
14
+ boolean lists of the form: [ True*X1, False*N1, True*X2, False*N2, ..., True*Xm, False*Nm ]
15
+ where each Ni >= 1 and sum(Xs) + sum(Ni) = target_length,
16
+ including all `target_length` wrap-around rotations, de-duplicated.
17
+ """
18
+ assert len(Xs) >= 1, "Xs must have at least one block length."
19
+ assert all(x >= 1 for x in Xs), "All Xi must be >= 1."
20
+ assert sum(Xs) + len(Xs) <= target_length, f"sum(Xs) + len(Xs) <= target_length required; got {sum(Xs)} + {len(Xs)} > {target_length}"
21
+ num_zero_blocks = len(Xs)
22
+ total_zeros = target_length - sum(Xs)
23
+ seen: set[tuple[bool, ...]] = set()
24
+ for cut_positions in combinations(range(1, total_zeros), num_zero_blocks - 1):
25
+ cut_positions = (*cut_positions, total_zeros)
26
+ Ns = [cut_positions[0]] # length of zero blocks
27
+ for i in range(1, len(cut_positions)):
28
+ Ns.append(cut_positions[i] - cut_positions[i - 1])
29
+ base: list[bool] = []
30
+ for x, n in zip(Xs, Ns):
31
+ base.extend([True] * x)
32
+ base.extend([False] * n)
33
+ for dx in range(target_length): # all rotations (wrap-around)
34
+ rot = tuple(base[dx:] + base[:dx])
35
+ seen.add(rot)
36
+ return seen
37
+
38
+
39
+ class Board:
40
+ def __init__(self, board: np.array, separator: str = '/'):
41
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
42
+ assert all(all(str(c).strip().isdecimal() or str(c).strip() == '' for c in cell.item().split(separator)) for cell in np.nditer(board)), 'board must contain only digits and separator'
43
+ self.V, self.H = board.shape
44
+ self.board = board
45
+ self.separator = separator
46
+ self.model = cp_model.CpModel()
47
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
48
+ self.create_vars()
49
+ self.add_all_constraints()
50
+
51
+ def create_vars(self):
52
+ for pos in get_all_pos(self.V, self.H):
53
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
54
+
55
+ def add_all_constraints(self):
56
+ # 2x2 blacks are not allowed
57
+ for pos in get_all_pos(self.V, self.H):
58
+ tl = pos
59
+ tr = get_next_pos(pos, Direction.RIGHT)
60
+ bl = get_next_pos(pos, Direction.DOWN)
61
+ br = get_next_pos(bl, Direction.RIGHT)
62
+ if any(not in_bounds(p, self.V, self.H) for p in [tl, tr, bl, br]):
63
+ continue
64
+ self.model.AddBoolOr([self.model_vars[tl].Not(), self.model_vars[tr].Not(), self.model_vars[bl].Not(), self.model_vars[br].Not()])
65
+ for pos in get_all_pos(self.V, self.H):
66
+ c = get_char(self.board, pos)
67
+ if c.strip() == '':
68
+ continue
69
+ clue = tuple(int(x.strip()) for x in c.split(self.separator))
70
+ self.model.Add(self.model_vars[pos] == 0) # clue cannot be black
71
+ self.enforce_clue(pos, clue) # each clue must be satisfied
72
+ # all blacks are connected
73
+ force_connected_component(self.model, self.model_vars)
74
+
75
+ def enforce_clue(self, pos: Pos, clue: Union[int, tuple[int, int]]):
76
+ neighbors = []
77
+ for direction in [Direction8.UP, Direction8.UP_RIGHT, Direction8.RIGHT, Direction8.DOWN_RIGHT, Direction8.DOWN, Direction8.DOWN_LEFT, Direction8.LEFT, Direction8.UP_LEFT]:
78
+ n = get_next_pos(pos, direction)
79
+ neighbors.append(self.model_vars[n] if in_bounds(n, self.V, self.H) else self.model.NewConstant(False))
80
+ valid_assignments = rotated_assignments_N_nums(Xs=clue)
81
+ self.model.AddAllowedAssignments(neighbors, valid_assignments)
82
+
83
+ def solve_and_print(self, verbose: bool = True):
84
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
85
+ assignment: dict[Pos, int] = {}
86
+ for pos, var in board.model_vars.items():
87
+ assignment[pos] = solver.Value(var)
88
+ return SingleSolution(assignment=assignment)
89
+ def callback(single_res: SingleSolution):
90
+ print("Solution found")
91
+ board_justified = np.full((self.V, self.H), ' ', dtype=object)
92
+ for pos in get_all_pos(self.V, self.H):
93
+ c = get_char(self.board, pos).strip()
94
+ if len(c) > 3:
95
+ c = '...'
96
+ set_char(board_justified, pos, ' ' * (2 - len(c)) + c)
97
+ 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(board_justified[r, c])))
98
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=5)