multi-puzzle-solver 0.9.26__py3-none-any.whl → 0.9.30__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of multi-puzzle-solver might be problematic. Click here for more details.

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