multi-puzzle-solver 0.9.22__py3-none-any.whl → 0.9.25__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.

@@ -133,7 +133,7 @@ def get_deltas(direction: Union[Direction, Direction8]) -> Tuple[int, int]:
133
133
  raise ValueError(f'invalid direction: {direction}')
134
134
 
135
135
 
136
- def polyominoes(N):
136
+ def polyominoes(N) -> set[Shape]:
137
137
  """Generate all polyominoes of size N. Every rotation and reflection is considered different and included in the result.
138
138
  Translation is not considered different and is removed from the result (otherwise the result would be infinite).
139
139
 
@@ -165,7 +165,7 @@ def polyominoes(N):
165
165
  shapes: set[FastShape] = {frozenset({(0, 0)})}
166
166
  for i in range(1, N):
167
167
  next_shapes: set[FastShape] = set()
168
- directions = ((1,0),(-1,0),(0,1)) if i > 1 else (((1,0),(0,1))) # cannot take left on first step, if confused read: https://louridas.github.io/rwa/assignments/polyominoes/
168
+ directions = ((1,0),(-1,0),(0,1),(0,-1)) if i > 1 else (((1,0),(0,1))) # cannot take left on first step, if confused read: https://louridas.github.io/rwa/assignments/polyominoes/
169
169
  for s in shapes:
170
170
  # frontier of a single shape: all 4-neighbors of existing cells not already in the shape
171
171
  frontier = set()
@@ -188,6 +188,7 @@ def polyominoes(N):
188
188
  shapes = {frozenset(Pos(x, y) for x, y in s) for s in shapes} # regular class, not the dirty-fast one
189
189
  return shapes
190
190
 
191
+
191
192
  def polyominoes_with_shape_id(N):
192
193
  """Refer to polyominoes() for more details. This function returns a set of all polyominoes of size N (rotated and reflected up to D4 symmetry) along with a unique ID for each polyomino that is unique up to D4 symmetry.
193
194
  Args:
@@ -226,3 +227,157 @@ def polyominoes_with_shape_id(N):
226
227
  result = {(s, canon_to_id[shape_to_canon[s]]) for s in shapes}
227
228
  result = {(frozenset(Pos(x, y) for x, y in s), _id) for s, _id in result}
228
229
  return result
230
+
231
+
232
+ def render_grid(cell_flags: np.ndarray,
233
+ center_char: Union[np.ndarray, str, None] = None,
234
+ show_axes: bool = True,
235
+ scale_x: int = 2) -> str:
236
+ """
237
+ most of this function was AI generated then modified by me, I don't currently care about the details of rendering to the terminal this looked good enough during my testing.
238
+ cell_flags: np.ndarray of shape (N, N) with characters 'U', 'D', 'L', 'R' to represent the edges of the cells.
239
+ center_char: np.ndarray of shape (N, N) with the center of the cells, or a string to use for all cells, or None to not show centers.
240
+ scale_x: horizontal stretch factor (>=1). Try 2 or 3 for squarer cells.
241
+ """
242
+ assert cell_flags is not None and cell_flags.ndim == 2
243
+ R, C = cell_flags.shape
244
+
245
+ # Edge presence arrays (note the rectangular shapes)
246
+ H = np.zeros((R+1, C), dtype=bool) # horizontal edges between rows
247
+ V = np.zeros((R, C+1), dtype=bool) # vertical edges between cols
248
+ for r in range(R):
249
+ for c in range(C):
250
+ s = cell_flags[r, c]
251
+ if 'U' in s: H[r, c] = True
252
+ if 'D' in s: H[r+1, c] = True
253
+ if 'L' in s: V[r, c] = True
254
+ if 'R' in s: V[r, c+1] = True
255
+
256
+ # Bitmask for corner connections
257
+ U, Rb, D, Lb = 1, 2, 4, 8
258
+ JUNCTION = {
259
+ 0: ' ',
260
+ U: '│', D: '│', U|D: '│',
261
+ Lb: '─', Rb: '─', Lb|Rb: '─',
262
+ U|Rb: '└', Rb|D: '┌', D|Lb: '┐', Lb|U: '┘',
263
+ U|D|Lb: '┤', U|D|Rb: '├', Lb|Rb|U: '┴', Lb|Rb|D: '┬',
264
+ U|Rb|D|Lb: '┼',
265
+ }
266
+
267
+ assert scale_x >= 1
268
+ assert H.shape == (R+1, C) and V.shape == (R, C+1)
269
+
270
+ rows = 2*R + 1
271
+ cols = 2*C*scale_x + 1
272
+ canvas = [[' ']*cols for _ in range(rows)]
273
+
274
+ def x_corner(c): # x of corner column c (0..C)
275
+ return (2*c) * scale_x
276
+ def x_between(c,k): # kth in-between col (1..2*scale_x-1) between corners c and c+1
277
+ return (2*c) * scale_x + k
278
+
279
+ # horizontal edges: fill the stretched band between corners with '─'
280
+ for r in range(R+1):
281
+ rr = 2*r
282
+ for c in range(C):
283
+ if H[r, c]:
284
+ for k in range(1, scale_x*2): # 1..(2*scale_x-1)
285
+ canvas[rr][x_between(c, k)] = '─'
286
+
287
+ # vertical edges: at the corner columns
288
+ for r in range(R):
289
+ rr = 2*r + 1
290
+ for c in range(C+1):
291
+ if V[r, c]:
292
+ canvas[rr][x_corner(c)] = '│'
293
+
294
+ # junctions at every corner grid point
295
+ for r in range(R+1):
296
+ rr = 2*r
297
+ for c in range(C+1):
298
+ m = 0
299
+ if r > 0 and V[r-1, c]: m |= U
300
+ if c < C and H[r, c]: m |= Rb
301
+ if r < R and V[r, c]: m |= D
302
+ if c > 0 and H[r, c-1]: m |= Lb
303
+ canvas[rr][x_corner(c)] = JUNCTION[m]
304
+
305
+ # centers (safe for multi-character strings)
306
+ def put_center_text(rr: int, c: int, text: str):
307
+ left = x_corner(c) + 1
308
+ right = x_corner(c+1) - 1
309
+ if right < left:
310
+ return
311
+ span_width = right - left + 1
312
+ s = str(text)
313
+ if len(s) > span_width:
314
+ s = s[:span_width] # truncate to protect borders
315
+ start = left + (span_width - len(s)) // 2
316
+ for i, ch in enumerate(s):
317
+ canvas[rr][start + i] = ch
318
+
319
+ if center_char is not None:
320
+ for r in range(R):
321
+ rr = 2*r + 1
322
+ for c in range(C):
323
+ val = center_char if isinstance(center_char, str) else center_char[r, c]
324
+ put_center_text(rr, c, '' if val is None else str(val))
325
+
326
+ # rows -> strings
327
+ art_rows = [''.join(row) for row in canvas]
328
+ if not show_axes:
329
+ return '\n'.join(art_rows)
330
+
331
+ # Axes labels: row indices on the left, column indices on top (handle C, not R)
332
+ gut = max(2, len(str(R-1))) # gutter width based on row index width
333
+ gutter = ' ' * gut
334
+ top_tens = list(gutter + ' ' * cols)
335
+ top_ones = list(gutter + ' ' * cols)
336
+
337
+ for c in range(C):
338
+ xc_center = x_corner(c) + scale_x
339
+ if C >= 10:
340
+ top_tens[gut + xc_center] = str((c // 10) % 10)
341
+ top_ones[gut + xc_center] = str(c % 10)
342
+
343
+ if gut >= 2:
344
+ top_tens[gut-2:gut] = list(' ')
345
+ top_ones[gut-2:gut] = list(' ')
346
+
347
+ labeled = []
348
+ for r, line in enumerate(art_rows):
349
+ if r % 2 == 1: # cell-center row
350
+ label = str(r//2).rjust(gut)
351
+ else:
352
+ label = ' ' * gut
353
+ labeled.append(label + line)
354
+
355
+ return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
356
+
357
+ def id_board_to_wall_board(id_board: np.array, border_is_wall = True) -> np.array:
358
+ """In many instances, we have a 2d array where cell values are arbitrary ids
359
+ and we want to convert it to a 2d array where cell values are walls "U", "D", "L", "R" to represent the edges that separate me from my neighbors that have different ids.
360
+ Args:
361
+ id_board: np.array of shape (N, N) with arbitrary ids.
362
+ border_is_wall: if True, the edges of the board are considered to be walls.
363
+ Returns:
364
+ np.array of shape (N, N) with walls "U", "D", "L", "R".
365
+ """
366
+ res = np.full((id_board.shape[0], id_board.shape[1]), '', dtype=object)
367
+ V, H = id_board.shape
368
+ def append_char(pos: Pos, s: str):
369
+ set_char(res, pos, get_char(res, pos) + s)
370
+ def handle_pos_direction(pos: Pos, direction: Direction, s: str):
371
+ pos2 = get_next_pos(pos, direction)
372
+ if in_bounds(pos2, V, H):
373
+ if get_char(id_board, pos2) != get_char(id_board, pos):
374
+ append_char(pos, s)
375
+ else:
376
+ if border_is_wall:
377
+ append_char(pos, s)
378
+ for pos in get_all_pos(V, H):
379
+ handle_pos_direction(pos, Direction.LEFT, 'L')
380
+ handle_pos_direction(pos, Direction.RIGHT, 'R')
381
+ handle_pos_direction(pos, Direction.UP, 'U')
382
+ handle_pos_direction(pos, Direction.DOWN, 'D')
383
+ return res
@@ -146,16 +146,12 @@ def force_connected_component(model: cp_model.CpModel, vars_to_force: dict[Any,
146
146
  for p in keys_in_order:
147
147
  model.Add(node_height[p] > 0).OnlyEnforceIf(vs[p])
148
148
 
149
- all_new_vars: dict[str, cp_model.IntVar] = {}
150
- for k, v in is_root.items():
151
- all_new_vars[f"{prefix_name}is_root[{k}]"] = v
152
- for k, v in prefix_zero.items():
153
- all_new_vars[f"{prefix_name}prefix_zero[{k}]"] = v
154
- for k, v in node_height.items():
155
- all_new_vars[f"{prefix_name}node_height[{k}]"] = v
156
- for k, v in max_neighbor_height.items():
157
- all_new_vars[f"{prefix_name}max_neighbor_height[{k}]"] = v
158
-
149
+ all_new_vars = {
150
+ "is_root": is_root,
151
+ "prefix_zero": prefix_zero,
152
+ "node_height": node_height,
153
+ "max_neighbor_height": max_neighbor_height,
154
+ }
159
155
  return all_new_vars
160
156
 
161
157
 
@@ -0,0 +1,98 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+ from ortools.sat.python.cp_model import LinearExpr as lxp
4
+
5
+ from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, in_bounds, set_char, get_char, get_neighbors8, get_row_pos, get_col_pos
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array):
11
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
+ assert all(c.item() in [' ', 'B', 'W'] for c in np.nditer(board)), 'board must contain only space or B'
13
+ self.board = board
14
+ self.V, self.H = board.shape
15
+ self.model = cp_model.CpModel()
16
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
17
+
18
+ self.create_vars()
19
+ self.add_all_constraints()
20
+
21
+ def create_vars(self):
22
+ for pos in get_all_pos(self.V, self.H):
23
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
24
+
25
+ def add_all_constraints(self):
26
+ for pos in get_all_pos(self.V, self.H): # force clues
27
+ c = get_char(self.board, pos)
28
+ if c == 'B':
29
+ self.model.Add(self.model_vars[pos] == 1)
30
+ elif c == 'W':
31
+ self.model.Add(self.model_vars[pos] == 0)
32
+ # 1. Each row and each column must contain an equal number of white and black circles.
33
+ for row in range(self.V):
34
+ row_vars = [self.model_vars[pos] for pos in get_row_pos(row, self.H)]
35
+ self.model.Add(lxp.sum(row_vars) == len(row_vars) // 2)
36
+ for col in range(self.H):
37
+ col_vars = [self.model_vars[pos] for pos in get_col_pos(col, self.V)]
38
+ self.model.Add(lxp.sum(col_vars) == len(col_vars) // 2)
39
+ # 2. More than two circles of the same color can't be adjacent.
40
+ for pos in get_all_pos(self.V, self.H):
41
+ self.disallow_three_in_a_row(pos, Direction.RIGHT)
42
+ self.disallow_three_in_a_row(pos, Direction.DOWN)
43
+ # 3. Each row and column is unique.
44
+ # a list per row
45
+ self.force_unique([[self.model_vars[pos] for pos in get_row_pos(row, self.H)] for row in range(self.V)])
46
+ # a list per column
47
+ self.force_unique([[self.model_vars[pos] for pos in get_col_pos(col, self.V)] for col in range(self.H)])
48
+
49
+ def disallow_three_in_a_row(self, p1: Pos, direction: Direction):
50
+ p2 = get_next_pos(p1, direction)
51
+ p3 = get_next_pos(p2, direction)
52
+ if any(not in_bounds(p, self.V, self.H) for p in [p1, p2, p3]):
53
+ return
54
+ self.model.AddBoolOr([
55
+ self.model_vars[p1],
56
+ self.model_vars[p2],
57
+ self.model_vars[p3],
58
+ ])
59
+ self.model.AddBoolOr([
60
+ self.model_vars[p1].Not(),
61
+ self.model_vars[p2].Not(),
62
+ self.model_vars[p3].Not(),
63
+ ])
64
+
65
+ def force_unique(self, model_vars: list[list[cp_model.IntVar]]):
66
+ if not model_vars or len(model_vars) < 2:
67
+ return
68
+ m = len(model_vars[0])
69
+ assert m <= 61, f"Too many cells for binary encoding in int64: m={m}, model_vars={model_vars}"
70
+
71
+ codes = []
72
+ pow2 = [1 << k for k in range(m)] # weights for bit positions (LSB at index 0)
73
+ for i, l in enumerate(model_vars):
74
+ code = self.model.NewIntVar(0, (1 << m) - 1, f"code_{i}")
75
+ # Sum 2^k * r[k] == code
76
+ self.model.Add(code == sum(pow2[k] * l[k] for k in range(m)))
77
+ codes.append(code)
78
+
79
+ self.model.AddAllDifferent(codes)
80
+
81
+ def solve_and_print(self, verbose: bool = True):
82
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
83
+ assignment: dict[Pos, int] = {}
84
+ for pos, var in board.model_vars.items():
85
+ assignment[pos] = solver.Value(var)
86
+ return SingleSolution(assignment=assignment)
87
+ def callback(single_res: SingleSolution):
88
+ print("Solution found")
89
+ res = np.full((self.V, self.H), ' ', dtype=object)
90
+ for pos in get_all_pos(self.V, self.H):
91
+ c = get_char(self.board, pos)
92
+ c = 'B' if single_res.assignment[pos] == 1 else 'W'
93
+ set_char(res, pos, c)
94
+ print('[')
95
+ for row in res:
96
+ print(" [ '" + "', '".join(row.tolist()) + "' ],")
97
+ print(']')
98
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,48 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+ from ortools.sat.python.cp_model import LinearExpr as lxp
4
+
5
+ from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, get_neighbors8
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array):
11
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
+ assert board.shape[0] == board.shape[1], 'board must be square'
13
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
14
+ self.board = board
15
+ self.N = board.shape[0]
16
+ self.model = cp_model.CpModel()
17
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
18
+
19
+ self.create_vars()
20
+ self.add_all_constraints()
21
+
22
+ def create_vars(self):
23
+ for pos in get_all_pos(self.N):
24
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
25
+
26
+ def add_all_constraints(self):
27
+ for pos in get_all_pos(self.N):
28
+ c = get_char(self.board, pos)
29
+ if not str(c).isdecimal():
30
+ continue
31
+ neighbour_vars = [self.model_vars[p] for p in get_neighbors8(pos, self.N, include_self=True)]
32
+ self.model.Add(lxp.sum(neighbour_vars) == int(c))
33
+
34
+ def solve_and_print(self, verbose: bool = True):
35
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
36
+ assignment: dict[Pos, int] = {}
37
+ for pos, var in board.model_vars.items():
38
+ assignment[pos] = solver.Value(var)
39
+ return SingleSolution(assignment=assignment)
40
+ def callback(single_res: SingleSolution):
41
+ print("Solution found")
42
+ res = np.full((self.N, self.N), ' ', dtype=object)
43
+ for pos in get_all_pos(self.N):
44
+ c = get_char(self.board, pos)
45
+ c = 'B' if single_res.assignment[pos] == 1 else ' '
46
+ set_char(res, pos, c)
47
+ print(res)
48
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -1,7 +1,5 @@
1
- import json
2
- import time
3
1
  from dataclasses import dataclass
4
- from typing import Optional, Union
2
+ from typing import Optional
5
3
 
6
4
  from ortools.sat.python import cp_model
7
5
  import numpy as np
@@ -14,19 +12,6 @@ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution,
14
12
  Shape = frozenset[Pos]
15
13
 
16
14
 
17
- @dataclass(frozen=True)
18
- class SingleSolution:
19
- assignment: dict[Pos, Union[str, int]]
20
- all_other_variables: dict
21
-
22
- def get_hashable_solution(self) -> str:
23
- result = []
24
- for pos, v in self.assignment.items():
25
- result.append((pos.x, pos.y, v))
26
- return json.dumps(result, sort_keys=True)
27
-
28
-
29
-
30
15
  @dataclass
31
16
  class ShapeOnBoard:
32
17
  is_active: cp_model.IntVar
@@ -63,7 +48,6 @@ class Board:
63
48
  def create_vars(self):
64
49
  for pos in get_all_pos(self.V, self.H):
65
50
  self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
66
- # print('base vars:', len(self.model_vars))
67
51
 
68
52
  def init_shapes_on_board(self):
69
53
  for idx, (shape, shape_id) in enumerate(self.polyominoes):
@@ -84,7 +68,6 @@ class Board:
84
68
  body=body,
85
69
  disallow_same_shape=disallow_same_shape,
86
70
  ))
87
- # print('shapes on board:', len(self.shapes_on_board))
88
71
 
89
72
  def add_all_constraints(self):
90
73
  # RULES:
@@ -99,11 +82,9 @@ class Board:
99
82
  self.force_one_shape_per_block() # Rule #1
100
83
  self.disallow_same_shape_touching() # Rule #2
101
84
  self.fc = force_connected_component(self.model, self.model_vars) # Rule #3
102
- # print('force connected vars:', len(fc))
103
85
  shape_2_by_2 = frozenset({Pos(0, 0), Pos(0, 1), Pos(1, 0), Pos(1, 1)})
104
86
  self.disallow_shape(shape_2_by_2) # Rule #4
105
87
 
106
-
107
88
  def only_allow_shapes_on_board(self):
108
89
  for shape_on_board in self.shapes_on_board:
109
90
  # if shape is active then all its body cells must be active
@@ -118,7 +99,6 @@ class Board:
118
99
  for block_i in self.block_numbers:
119
100
  shapes_on_block = [s for s in self.shapes_on_board if s.body & self.blocks[block_i]]
120
101
  assert all(s.body.issubset(self.blocks[block_i]) for s in shapes_on_block), 'expected all shapes on block to be fully contained in the block'
121
- # print(f'shapes on block {block_i} has {len(shapes_on_block)} shapes')
122
102
  self.model.Add(sum(s.is_active for s in shapes_on_block) == 1)
123
103
 
124
104
  def disallow_same_shape_touching(self):
@@ -138,8 +118,6 @@ class Board:
138
118
  self.model.Add(sum(self.model_vars[p] for p in cur_body) < len(cur_body))
139
119
 
140
120
 
141
-
142
-
143
121
  def solve_and_print(self, verbose: bool = True, max_solutions: Optional[int] = None, verbose_callback: Optional[bool] = None):
144
122
  if verbose_callback is None:
145
123
  verbose_callback = verbose
@@ -147,10 +125,7 @@ class Board:
147
125
  assignment: dict[Pos, int] = {}
148
126
  for pos, var in board.model_vars.items():
149
127
  assignment[pos] = solver.Value(var)
150
- all_other_variables = {
151
- 'fc': {k: solver.Value(v) for k, v in board.fc.items()}
152
- }
153
- return SingleSolution(assignment=assignment, all_other_variables=all_other_variables)
128
+ return SingleSolution(assignment=assignment)
154
129
  def callback(single_res: SingleSolution):
155
130
  print("Solution found")
156
131
  res = np.full((self.V, self.H), ' ', dtype=str)
@@ -158,5 +133,4 @@ class Board:
158
133
  c = 'X' if val == 1 else ' '
159
134
  set_char(res, pos, c)
160
135
  print('[\n' + '\n'.join([' ' + str(res[row].tolist()) + ',' for row in range(self.V)]) + '\n]')
161
- pass
162
136
  return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
@@ -0,0 +1,104 @@
1
+ from dataclasses import dataclass
2
+ from collections import defaultdict
3
+
4
+ import numpy as np
5
+ from ortools.sat.python import cp_model
6
+
7
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, id_board_to_wall_board, render_grid, set_char, in_bounds, get_next_pos, Direction, polyominoes
8
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
+
10
+
11
+
12
+ # a shape on the 2d board is just a set of positions
13
+ Shape = frozenset[Pos]
14
+
15
+ @dataclass(frozen=True)
16
+ class ShapeOnBoard:
17
+ is_active: cp_model.IntVar
18
+ shape: Shape
19
+ shape_id: int
20
+ body: set[Pos]
21
+
22
+
23
+ def get_valid_translations(shape: Shape, board: np.array) -> set[Pos]:
24
+ # give a shape and a board, return all valid translations of the shape that are fully contained in the board AND consistent with the clues on the board
25
+ shape_list = list(shape)
26
+ shape_borders = [] # will contain the number of borders for each pos in the shape; this has to be consistent with the clues on the board
27
+ for pos in shape_list:
28
+ v = 0
29
+ for direction in Direction:
30
+ next_pos = get_next_pos(pos, direction)
31
+ if not in_bounds(next_pos, board.shape[0], board.shape[1]) or next_pos not in shape:
32
+ v += 1
33
+ shape_borders.append(v)
34
+ shape_list = [(p.x, p.y) for p in shape_list]
35
+ # min x/y is always 0
36
+ max_x = max(p[0] for p in shape_list)
37
+ max_y = max(p[1] for p in shape_list)
38
+
39
+ for dy in range(0, board.shape[0] - max_y):
40
+ for dx in range(0, board.shape[1] - max_x):
41
+ body = tuple((p[0] + dx, p[1] + dy) for p in shape_list)
42
+ for i, p in enumerate(body):
43
+ c = board[p[1], p[0]]
44
+ if c != ' ' and c != str(shape_borders[i]): # there is a clue and it doesn't match my translated shape, skip
45
+ break
46
+ else:
47
+ yield tuple(get_pos(x=p[0], y=p[1]) for p in body)
48
+
49
+
50
+
51
+ class Board:
52
+ def __init__(self, board: np.array, region_size: int):
53
+ assert region_size >= 1 and isinstance(region_size, int), 'region_size must be an integer greater than or equal to 1'
54
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
55
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
56
+ self.board = board
57
+ self.V, self.H = board.shape
58
+ self.region_size = region_size
59
+ self.region_count = (self.V * self.H) // self.region_size
60
+ 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}'
61
+
62
+ self.polyominoes = polyominoes(self.region_size)
63
+
64
+ self.model = cp_model.CpModel()
65
+ self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
66
+ self.pos_to_shapes: dict[Pos, set[ShapeOnBoard]] = defaultdict(set)
67
+ self.create_vars()
68
+ self.add_all_constraints()
69
+
70
+ def create_vars(self):
71
+ for shape in self.polyominoes:
72
+ for body in get_valid_translations(shape, self.board):
73
+ uid = len(self.shapes_on_board)
74
+ shape_on_board = ShapeOnBoard(
75
+ is_active=self.model.NewBoolVar(f'{uid}:is_active'),
76
+ shape=shape,
77
+ shape_id=uid,
78
+ body=body,
79
+ )
80
+ self.shapes_on_board.append(shape_on_board)
81
+ for pos in body:
82
+ self.pos_to_shapes[pos].add(shape_on_board)
83
+
84
+ def add_all_constraints(self):
85
+ for pos in get_all_pos(self.V, self.H): # each position has exactly one shape active
86
+ self.model.AddExactlyOne(shape.is_active for shape in self.pos_to_shapes[pos])
87
+
88
+ def solve_and_print(self, verbose: bool = True):
89
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
90
+ assignment: dict[Pos, int] = {}
91
+ for shape in board.shapes_on_board:
92
+ if solver.Value(shape.is_active) == 1:
93
+ for pos in shape.body:
94
+ assignment[pos] = shape.shape_id
95
+ return SingleSolution(assignment=assignment)
96
+ def callback(single_res: SingleSolution):
97
+ print("Solution found")
98
+ id_board = np.full((self.V, self.H), ' ', dtype=object)
99
+ for pos in get_all_pos(self.V, self.H):
100
+ region_idx = single_res.assignment[pos]
101
+ set_char(id_board, pos, region_idx)
102
+ board = np.where(self.board == ' ', '·', self.board)
103
+ print(render_grid(id_board_to_wall_board(id_board), center_char=board))
104
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,130 @@
1
+ from dataclasses import dataclass
2
+
3
+ import numpy as np
4
+
5
+ from ortools.sat.python import cp_model
6
+ from ortools.sat.python.cp_model import LinearExpr as lxp
7
+
8
+ from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, set_char, get_char, get_neighbors8, Direction, get_next_pos, render_grid
9
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
10
+
11
+
12
+ def factor_pairs(N: int, upper_limit_i: int, upper_limit_j: int):
13
+ """Return all unique pairs (a, b) such that a * b == N, with a, b <= upper_limit."""
14
+ if N <= 0 or upper_limit_i <= 0 or upper_limit_j <= 0:
15
+ return []
16
+
17
+ pairs = []
18
+ i = 1
19
+ while i * i <= N:
20
+ if N % i == 0:
21
+ j = N // i
22
+ if i <= upper_limit_i and j <= upper_limit_j:
23
+ pairs.append((i, j))
24
+ if i != j and j <= upper_limit_i and i <= upper_limit_j:
25
+ pairs.append((j, i))
26
+ i += 1
27
+ return pairs
28
+
29
+
30
+ @dataclass
31
+ class Rectangle:
32
+ active: cp_model.IntVar
33
+ N: int
34
+ clue_id: int
35
+ width: int
36
+ height: int
37
+ body: set[Pos]
38
+
39
+
40
+ class Board:
41
+ def __init__(self, board: np.array):
42
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
43
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
44
+ self.board = board
45
+ self.V, self.H = board.shape
46
+ self.clue_pos: list[Pos] = [pos for pos in get_all_pos(self.V, self.H) if str(get_char(self.board, pos)).isdecimal()]
47
+ self.clue_pos_to_id: dict[Pos, int] = {pos: i for i, pos in enumerate(self.clue_pos)}
48
+ self.clue_pos_to_value: dict[Pos, int] = {pos: int(get_char(self.board, pos)) for pos in self.clue_pos}
49
+
50
+ self.model = cp_model.CpModel()
51
+ self.model_vars: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
52
+ self.rectangles: list[Rectangle] = []
53
+
54
+ self.create_vars()
55
+ self.add_all_constraints()
56
+
57
+ def create_vars(self):
58
+ self.init_rectangles()
59
+ # for each position it belongs to exactly 1 clue
60
+ # instead of iterating over all clues, we only look at the clues that are possible for this position (by looking at the rectangles that contain this position)
61
+ for pos in get_all_pos(self.V, self.H):
62
+ possible_clue_here = {rectangle.clue_id for rectangle in self.rectangles if pos in rectangle.body} # get the clue position for any rectangle that contains this position
63
+ for possible_clue in possible_clue_here:
64
+ self.model_vars[(pos, possible_clue)] = self.model.NewBoolVar(f'{pos}:{possible_clue}')
65
+
66
+ def init_rectangles(self) -> list[Rectangle]:
67
+ self.fixed_pos: set[Pos] = set(self.clue_pos)
68
+ for pos in self.clue_pos: # for each clue on the board
69
+ clue_id = self.clue_pos_to_id[pos]
70
+ clue_num = self.clue_pos_to_value[pos]
71
+ other_fixed_pos = self.fixed_pos - {pos}
72
+ for width, height in factor_pairs(clue_num, self.V, self.H): # for each possible width x height rectangle that can fit the clue
73
+ # if the digit is at pos and we have a width x height rectangle then we can translate the rectangle "0 to width" to the left and "0 to height" to the top
74
+ for dx in range(width):
75
+ for dy in range(height):
76
+ body = {Pos(x=pos.x - dx + i, y=pos.y - dy + j) for i in range(width) for j in range(height)}
77
+ if any(not in_bounds(p, self.V, self.H) for p in body): # a rectangle cannot be out of bounds
78
+ continue
79
+ if any(p in other_fixed_pos for p in body): # a rectangle cannot contain a different clue; each clue is 1 rectangle only
80
+ continue
81
+ rectangle = Rectangle(active=self.model.NewBoolVar(f'{clue_id}'), N=clue_num, clue_id=clue_id, width=width, height=height, body=body)
82
+ self.rectangles.append(rectangle)
83
+
84
+ def add_all_constraints(self):
85
+ # each pos has only 1 rectangle active
86
+ for pos in get_all_pos(self.V, self.H):
87
+ self.model.AddExactlyOne(rectangle.active for rectangle in self.rectangles if pos in rectangle.body)
88
+ # each pos has only 1 clue active
89
+ for pos in get_all_pos(self.V, self.H):
90
+ self.model.AddExactlyOne(self.model_vars[(pos, clue_id)] for clue_id in self.clue_pos_to_id.values() if (pos, clue_id) in self.model_vars)
91
+ # a rectangle being active means all its body ponts to the clue
92
+ for rectangle in self.rectangles:
93
+ is_active = rectangle.active
94
+ for pos in rectangle.body:
95
+ self.model.Add(self.model_vars[(pos, rectangle.clue_id)] == 1).OnlyEnforceIf(is_active)
96
+
97
+ def solve_and_print(self, verbose: bool = True):
98
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
99
+ assignment: dict[Pos, int] = {}
100
+ for (i, rectangle) in enumerate(self.rectangles):
101
+ if solver.Value(rectangle.active) == 1:
102
+ for pos in rectangle.body:
103
+ assignment[pos] = f'id{rectangle.clue_id}:N={rectangle.N}:{rectangle.height}x{rectangle.width}'
104
+ return SingleSolution(assignment=assignment)
105
+ def callback(single_res: SingleSolution):
106
+ print("Solution found")
107
+ res = np.full((self.V, self.H), '', dtype=object)
108
+ id_board = np.full((self.V, self.H), '', dtype=object)
109
+ for pos in get_all_pos(self.V, self.H):
110
+ cur = single_res.assignment[pos]
111
+ set_char(id_board, pos, cur)
112
+ left_pos = get_next_pos(pos, Direction.LEFT)
113
+ right_pos = get_next_pos(pos, Direction.RIGHT)
114
+ top_pos = get_next_pos(pos, Direction.UP)
115
+ bottom_pos = get_next_pos(pos, Direction.DOWN)
116
+ if left_pos not in single_res.assignment or single_res.assignment[left_pos] != cur:
117
+ set_char(res, pos, get_char(res, pos) + 'L')
118
+ if right_pos not in single_res.assignment or single_res.assignment[right_pos] != cur:
119
+ set_char(res, pos, get_char(res, pos) + 'R')
120
+ if top_pos not in single_res.assignment or single_res.assignment[top_pos] != cur:
121
+ set_char(res, pos, get_char(res, pos) + 'U')
122
+ if bottom_pos not in single_res.assignment or single_res.assignment[bottom_pos] != cur:
123
+ set_char(res, pos, get_char(res, pos) + 'D')
124
+ # print('[')
125
+ # for row in id_board:
126
+ # print(' ', row.tolist(), end=',\n')
127
+ # print(' ])')
128
+ print(render_grid(res, center_char=self.board))
129
+
130
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)