multi-puzzle-solver 1.0.4__py3-none-any.whl → 1.0.7__py3-none-any.whl

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

Potentially problematic release.


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

Files changed (42) hide show
  1. {multi_puzzle_solver-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/METADATA +1075 -556
  2. multi_puzzle_solver-1.0.7.dist-info/RECORD +74 -0
  3. puzzle_solver/__init__.py +5 -1
  4. puzzle_solver/core/utils.py +17 -1
  5. puzzle_solver/core/utils_visualizer.py +257 -201
  6. puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
  7. puzzle_solver/puzzles/aquarium/aquarium.py +8 -23
  8. puzzle_solver/puzzles/battleships/battleships.py +39 -53
  9. puzzle_solver/puzzles/binairo/binairo.py +2 -2
  10. puzzle_solver/puzzles/black_box/black_box.py +6 -70
  11. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +4 -2
  12. puzzle_solver/puzzles/filling/filling.py +11 -34
  13. puzzle_solver/puzzles/galaxies/galaxies.py +4 -2
  14. puzzle_solver/puzzles/heyawake/heyawake.py +72 -14
  15. puzzle_solver/puzzles/kakurasu/kakurasu.py +5 -13
  16. puzzle_solver/puzzles/kakuro/kakuro.py +6 -2
  17. puzzle_solver/puzzles/lits/lits.py +4 -2
  18. puzzle_solver/puzzles/mosaic/mosaic.py +8 -18
  19. puzzle_solver/puzzles/nonograms/nonograms.py +80 -85
  20. puzzle_solver/puzzles/nonograms/nonograms_colored.py +221 -0
  21. puzzle_solver/puzzles/norinori/norinori.py +5 -12
  22. puzzle_solver/puzzles/nurikabe/nurikabe.py +6 -2
  23. puzzle_solver/puzzles/palisade/palisade.py +8 -22
  24. puzzle_solver/puzzles/pearl/pearl.py +15 -27
  25. puzzle_solver/puzzles/pipes/pipes.py +2 -1
  26. puzzle_solver/puzzles/range/range.py +19 -55
  27. puzzle_solver/puzzles/rectangles/rectangles.py +4 -2
  28. puzzle_solver/puzzles/shingoki/shingoki.py +62 -105
  29. puzzle_solver/puzzles/singles/singles.py +6 -2
  30. puzzle_solver/puzzles/slant/slant.py +13 -19
  31. puzzle_solver/puzzles/slitherlink/slitherlink.py +2 -2
  32. puzzle_solver/puzzles/star_battle/star_battle.py +5 -2
  33. puzzle_solver/puzzles/stitches/stitches.py +8 -21
  34. puzzle_solver/puzzles/sudoku/sudoku.py +5 -11
  35. puzzle_solver/puzzles/tapa/tapa.py +6 -2
  36. puzzle_solver/puzzles/tents/tents.py +50 -80
  37. puzzle_solver/puzzles/tracks/tracks.py +19 -66
  38. puzzle_solver/puzzles/unruly/unruly.py +17 -49
  39. puzzle_solver/puzzles/yin_yang/yin_yang.py +3 -10
  40. multi_puzzle_solver-1.0.4.dist-info/RECORD +0 -72
  41. {multi_puzzle_solver-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/WHEEL +0 -0
  42. {multi_puzzle_solver-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/top_level.txt +0 -0
@@ -3,9 +3,9 @@ from dataclasses import dataclass
3
3
  import numpy as np
4
4
  from ortools.sat.python import cp_model
5
5
 
6
- from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, set_char, in_bounds, get_next_pos, Direction
6
+ from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, in_bounds, get_next_pos, Direction
7
7
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
8
- from puzzle_solver.core.utils_visualizer import id_board_to_wall_board, render_grid
8
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
9
9
 
10
10
 
11
11
  @dataclass
@@ -90,14 +90,7 @@ class Board:
90
90
  return SingleSolution(assignment=assignment)
91
91
  def callback(single_res: SingleSolution):
92
92
  print("Solution found")
93
- # res = np.full((self.V, self.H), ' ', dtype=object)
94
- # for pos in get_all_pos(self.V, self.H):
95
- # c = get_char(self.board, pos)
96
- # c = 'X' if pos in single_res.assignment else ' '
97
- # set_char(res, pos, c)
98
- # print('[')
99
- # for row in res:
100
- # print(" [ '" + "', '".join(row.tolist()) + "' ],")
101
- # print(']')
102
- print(render_grid(id_board_to_wall_board(self.board), center_char=lambda r, c: 'X' if (Pos(x=c, y=r) in single_res.assignment) else ' '))
93
+ print(combined_function(self.V, self.H,
94
+ cell_flags=id_board_to_wall_fn(self.board),
95
+ is_shaded=lambda r, c: Pos(x=c, y=r) in single_res.assignment))
103
96
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -5,7 +5,7 @@ from ortools.sat.python import cp_model
5
5
 
6
6
  from puzzle_solver.core.utils import Pos, get_all_pos, get_neighbors4, get_pos, in_bounds, get_char, polyominoes, Shape, Direction, get_next_pos
7
7
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
8
- from puzzle_solver.core.utils_visualizer import render_shaded_grid
8
+ from puzzle_solver.core.utils_visualizer import combined_function
9
9
 
10
10
 
11
11
  @dataclass
@@ -122,5 +122,9 @@ class Board:
122
122
  return SingleSolution(assignment=assignment)
123
123
  def callback(single_res: SingleSolution):
124
124
  print("Solution found")
125
- print(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])))
125
+ print(combined_function(self.V, self.H,
126
+ is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
127
+ center_char=lambda r, c: str(self.board[r, c]),
128
+ text_on_shaded_cells=False
129
+ ))
126
130
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -6,12 +6,10 @@ from ortools.sat.python import cp_model
6
6
 
7
7
  from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, set_char, in_bounds, get_next_pos, Direction, polyominoes
8
8
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
- from puzzle_solver.core.utils_visualizer import id_board_to_wall_board, render_grid
9
+ from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
10
10
 
11
11
 
12
-
13
- # a shape on the 2d board is just a set of positions
14
- Shape = frozenset[Pos]
12
+ Shape = frozenset[Pos] # a shape on the 2d board is just a set of positions
15
13
 
16
14
  @dataclass(frozen=True)
17
15
  class ShapeOnBoard:
@@ -36,7 +34,6 @@ def get_valid_translations(shape: Shape, board: np.array) -> set[Pos]:
36
34
  # min x/y is always 0
37
35
  max_x = max(p[0] for p in shape_list)
38
36
  max_y = max(p[1] for p in shape_list)
39
-
40
37
  for dy in range(0, board.shape[0] - max_y):
41
38
  for dx in range(0, board.shape[1] - max_x):
42
39
  body = tuple((p[0] + dx, p[1] + dy) for p in shape_list)
@@ -48,7 +45,6 @@ def get_valid_translations(shape: Shape, board: np.array) -> set[Pos]:
48
45
  yield frozenset(get_pos(x=p[0], y=p[1]) for p in body)
49
46
 
50
47
 
51
-
52
48
  class Board:
53
49
  def __init__(self, board: np.array, region_size: int):
54
50
  assert region_size >= 1 and isinstance(region_size, int), 'region_size must be an integer greater than or equal to 1'
@@ -59,7 +55,6 @@ class Board:
59
55
  self.region_size = region_size
60
56
  self.region_count = (self.V * self.H) // self.region_size
61
57
  assert self.region_count * self.region_size == self.V * self.H, f'region_size must be a factor of the board size, got {self.region_size} and {self.region_count}'
62
-
63
58
  self.polyominoes = polyominoes(self.region_size)
64
59
 
65
60
  self.model = cp_model.CpModel()
@@ -74,9 +69,7 @@ class Board:
74
69
  uid = len(self.shapes_on_board)
75
70
  shape_on_board = ShapeOnBoard(
76
71
  is_active=self.model.NewBoolVar(f'{uid}:is_active'),
77
- shape=shape,
78
- shape_id=uid,
79
- body=body,
72
+ shape=shape, shape_id=uid, body=body
80
73
  )
81
74
  self.shapes_on_board.append(shape_on_board)
82
75
  for pos in body:
@@ -88,18 +81,11 @@ class Board:
88
81
 
89
82
  def solve_and_print(self, verbose: bool = True):
90
83
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
91
- 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
- board = np.where(self.board == ' ', '·', self.board)
104
- print(render_grid(id_board_to_wall_board(id_board), center_char=board))
88
+ print(combined_function(self.V, self.H,
89
+ cell_flags=id_board_to_wall_fn(np.array([[single_res.assignment[get_pos(x=c, y=r)] for c in range(self.H)] for r in range(self.V)])),
90
+ center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else '·'))
105
91
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -4,16 +4,16 @@ import numpy as np
4
4
  from ortools.sat.python import cp_model
5
5
  from ortools.sat.python.cp_model import LinearExpr as lxp
6
6
 
7
- from puzzle_solver.core.utils import Pos, get_all_pos, set_char, in_bounds, Direction, get_next_pos, get_char, get_opposite_direction
7
+ from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, Direction, get_next_pos, get_char, get_opposite_direction, get_pos, set_char
8
8
  from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
9
+ from puzzle_solver.core.utils_visualizer import combined_function
9
10
 
10
11
 
11
12
  class Board:
12
13
  def __init__(self, board: np.ndarray):
13
14
  assert board.ndim == 2 and board.shape[0] > 0 and board.shape[1] > 0, f'board must be 2d, got {board.ndim}'
14
- assert all(i.item() in [' ', 'B', 'W'] for i in np.nditer(board)), f'board must be space, B, or W, got {list(np.nditer(board))}'
15
- self.V = board.shape[0]
16
- self.H = board.shape[1]
15
+ assert all(c.item().strip() in ['', 'B', 'W'] for c in np.nditer(board)), f'board must be space, B, or W, got {list(np.nditer(board))}'
16
+ self.V, self.H = board.shape
17
17
  self.board = board
18
18
  self.model = cp_model.CpModel()
19
19
  self.cell_active: dict[Pos, cp_model.IntVar] = {}
@@ -36,11 +36,10 @@ class Board:
36
36
  def force_wb_constraints(self):
37
37
  for pos in get_all_pos(self.V, self.H):
38
38
  c = get_char(self.board, pos)
39
- if c == 'B':
40
- # must be active
41
- self.model.Add(self.cell_active[pos] == 1)
42
- # black circle must be a corner not connected directly to another corner
43
- # must be a corner
39
+ if not c.strip():
40
+ continue
41
+ self.model.Add(self.cell_active[pos] == 1) # cell must be active
42
+ if c == 'B': # black circle must be a corner not connected directly to another corner
44
43
  self.model.Add(self.cell_direction[(pos, Direction.UP)] != self.cell_direction[(pos, Direction.DOWN)])
45
44
  self.model.Add(self.cell_direction[(pos, Direction.LEFT)] != self.cell_direction[(pos, Direction.RIGHT)])
46
45
  # must not be connected directly to another corner
@@ -49,11 +48,7 @@ class Board:
49
48
  if not in_bounds(q, self.V, self.H):
50
49
  continue
51
50
  self.model.AddImplication(self.cell_direction[(pos, direction)], self.cell_direction[(q, direction)])
52
- elif c == 'W':
53
- # must be active
54
- self.model.Add(self.cell_active[pos] == 1)
55
- # white circle must be a straight which is connected to at least one corner
56
- # must be straight
51
+ elif c == 'W': # white circle must be a straight which is connected to at least one corner
57
52
  self.model.Add(self.cell_direction[(pos, Direction.UP)] == self.cell_direction[(pos, Direction.DOWN)])
58
53
  self.model.Add(self.cell_direction[(pos, Direction.LEFT)] == self.cell_direction[(pos, Direction.RIGHT)])
59
54
  # must be connected to at least one corner (i.e. UP-RIGHT or UP-LEFT or DOWN-RIGHT or DOWN-LEFT or RIGHT-UP or RIGHT-DOWN or LEFT-UP or LEFT-DOWN)
@@ -94,26 +89,19 @@ class Board:
94
89
  return False
95
90
  force_connected_component(self.model, self.cell_direction, is_neighbor=is_neighbor)
96
91
 
97
-
98
92
  def solve_and_print(self, verbose: bool = True):
99
93
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
100
94
  assignment: dict[Pos, str] = defaultdict(str)
101
95
  for (pos, direction), var in board.cell_direction.items():
102
96
  assignment[pos] += direction.name[0] if solver.BooleanValue(var) else ''
103
- for pos in get_all_pos(self.V, self.H):
104
- if len(assignment[pos]) == 0:
105
- assignment[pos] = ' '
106
- else:
107
- assignment[pos] = ''.join(sorted(assignment[pos]))
108
97
  return SingleSolution(assignment=assignment)
109
98
  def callback(single_res: SingleSolution):
110
99
  print("Solution found")
111
- res = np.full((self.V, self.H), ' ', dtype=object)
112
- pretty_dict = {'DU': '┃ ', 'LR': '━━', 'DL': '━┒', 'DR': '┏━', 'RU': '┗━', 'LU': '━┛', ' ': ' '}
100
+ output_board = np.full((self.V, self.H), '', dtype=str)
113
101
  for pos in get_all_pos(self.V, self.H):
114
- c = get_char(self.board, pos)
115
- c = single_res.assignment[pos]
116
- c = pretty_dict[c]
117
- set_char(res, pos, c)
118
- print(res)
102
+ if get_char(self.board, pos) in ['B', 'W']: # if the main board has a white or black pearl, put it in the output
103
+ set_char(output_board, pos, get_char(self.board, pos))
104
+ if not single_res.assignment[pos].strip(): # if the cell does not the line through it, put a dot
105
+ set_char(output_board, pos, '.')
106
+ print(combined_function(self.V, self.H, show_grid=False, special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)], center_char=lambda r, c: output_board[r, c]))
119
107
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=20)
@@ -4,6 +4,7 @@ from ortools.sat.python.cp_model import LinearExpr as lxp
4
4
 
5
5
  from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, Direction, get_next_pos, get_opposite_direction
6
6
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
7
+ from puzzle_solver.core.utils_visualizer import combined_function
7
8
 
8
9
 
9
10
  class Board:
@@ -77,5 +78,5 @@ class Board:
77
78
  res = np.full((self.V, self.H), ' ', dtype=object)
78
79
  for pos in get_all_pos(self.V, self.H):
79
80
  set_char(res, pos, single_res.assignment[pos])
80
- print(res)
81
+ print(combined_function(self.V, self.H, show_grid=False, show_axes=True, special_content=lambda r, c: res[r, c], center_char=lambda r, c: 'O' if len(res[r, c]) == 1 else '')),
81
82
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -1,8 +1,9 @@
1
1
  import numpy as np
2
2
  from ortools.sat.python import cp_model
3
3
 
4
- from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_neighbors4, in_bounds, Direction, get_next_pos, get_char
4
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, get_neighbors4, in_bounds, Direction, get_next_pos, get_char
5
5
  from puzzle_solver.core.utils_ortools import and_constraint, generic_solve_all, SingleSolution, force_connected_component
6
+ from puzzle_solver.core.utils_visualizer import combined_function
6
7
 
7
8
 
8
9
  def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
@@ -18,88 +19,51 @@ def get_ray(pos: Pos, V: int, H: int, direction: Direction) -> list[Pos]:
18
19
  class Board:
19
20
  def __init__(self, clues: np.ndarray):
20
21
  assert clues.ndim == 2 and clues.shape[0] > 0 and clues.shape[1] > 0, f'clues must be 2d, got {clues.ndim}'
21
- assert all(isinstance(i.item(), int) and i.item() >= -1 for i in np.nditer(clues)), f'clues must be -1 or >= 0, got {list(np.nditer(clues))}'
22
- self.V = clues.shape[0]
23
- self.H = clues.shape[1]
22
+ assert all(str(i.item()).strip() == '' or str(i.item()).strip().isdecimal() for i in np.nditer(clues)), f'clues must be empty or a decimal number, got {list(np.nditer(clues))}'
23
+ self.V, self.H = clues.shape
24
24
  self.clues = clues
25
- self.model = cp_model.CpModel()
26
25
 
27
- # Core vars
28
- self.b: dict[Pos, cp_model.IntVar] = {} # 1=black, 0=white
29
- self.w: dict[Pos, cp_model.IntVar] = {} # 1=white, 0=black
26
+ self.model = cp_model.CpModel()
27
+ self.b: dict[Pos, cp_model.IntVar] = {}
28
+ self.w: dict[Pos, cp_model.IntVar] = {}
30
29
 
31
30
  self.create_vars()
32
31
  self.add_all_constraints()
33
32
 
34
33
  def create_vars(self):
35
- # Cell color vars
36
34
  for pos in get_all_pos(self.V, self.H):
37
35
  self.b[pos] = self.model.NewBoolVar(f"b[{pos}]")
38
- self.w[pos] = self.model.NewBoolVar(f"w[{pos}]")
39
- self.model.AddExactlyOne([self.b[pos], self.w[pos]])
36
+ self.w[pos] = self.b[pos].Not()
40
37
 
41
38
  def add_all_constraints(self):
42
39
  self.no_adjacent_blacks()
43
- self.white_connectivity_percolation()
44
40
  self.range_clues()
41
+ force_connected_component(self.model, self.w)
45
42
 
46
43
  def no_adjacent_blacks(self):
47
- cache = set()
48
44
  for p in get_all_pos(self.V, self.H):
49
45
  for q in get_neighbors4(p, self.V, self.H):
50
- if (p, q) in cache:
51
- continue
52
- cache.add((p, q))
53
46
  self.model.Add(self.b[p] + self.b[q] <= 1)
54
47
 
55
-
56
- def white_connectivity_percolation(self):
57
- force_connected_component(self.model, self.w)
58
-
59
48
  def range_clues(self):
60
- # For each numbered cell c with value k:
61
- # - Force it white (cannot be black)
62
- # - Build visibility chains in four directions (excluding the cell itself)
63
- # - Sum of visible whites = 1 (itself) + sum(chains) == k
64
- for pos in get_all_pos(self.V, self.H):
65
- k = get_char(self.clues, pos)
66
- if k == -1:
49
+ for pos in get_all_pos(self.V, self.H): # For each numbered cell c with value k
50
+ k = str(get_char(self.clues, pos)).strip()
51
+ if not k:
67
52
  continue
68
- # Numbered cell must be white
69
- self.model.Add(self.b[pos] == 0)
70
-
71
- # Build visibility chains per direction (exclude self)
53
+ self.model.Add(self.w[pos] == 1) # Force it white
72
54
  vis_vars: list[cp_model.IntVar] = []
73
- for direction in Direction:
55
+ for direction in Direction: # Build visibility chains in four direction
74
56
  ray = get_ray(pos, self.V, self.H, direction) # cells outward
75
- if not ray:
76
- continue
77
- # Chain: v0 = w[ray[0]]; vt = w[ray[t]] & vt-1
78
- prev = None
79
- for idx, cell in enumerate(ray):
57
+ for idx in range(len(ray)):
80
58
  v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
59
+ and_constraint(self.model, target=v, cs=[self.w[p] for p in ray[:idx+1]])
81
60
  vis_vars.append(v)
82
- if idx == 0:
83
- # v0 == w[cell]
84
- self.model.Add(v == self.w[cell])
85
- else:
86
- and_constraint(self.model, target=v, cs=[self.w[cell], prev])
87
- prev = v
88
-
89
- # 1 (self) + sum(vis_vars) == k
90
- self.model.Add(1 + sum(vis_vars) == k)
61
+ self.model.Add(1 + sum(vis_vars) == int(k)) # Sum of visible whites = 1 (itself) + sum(chains) == k
91
62
 
92
63
  def solve_and_print(self, verbose: bool = True):
93
64
  def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
94
- assignment: dict[Pos, int] = {}
95
- for pos, var in board.b.items():
96
- assignment[pos] = solver.Value(var)
97
- return SingleSolution(assignment=assignment)
65
+ return SingleSolution(assignment={pos: solver.Value(board.b[pos]) for pos in get_all_pos(board.V, board.H)})
98
66
  def callback(single_res: SingleSolution):
99
67
  print("Solution:")
100
- res = np.full((self.V, self.H), ' ', dtype=object)
101
- for pos in get_all_pos(self.V, self.H):
102
- c = 'B' if single_res.assignment[pos] == 1 else ' '
103
- set_char(res, pos, c)
104
- print(res)
68
+ print(combined_function(self.V, self.H, is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1, center_char=lambda r, c: self.clues[r, c].strip(), text_on_shaded_cells=False))
105
69
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -6,7 +6,7 @@ from ortools.sat.python import cp_model
6
6
 
7
7
  from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, set_char, get_char, Direction, get_next_pos
8
8
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
9
- from puzzle_solver.core.utils_visualizer import render_grid
9
+ from puzzle_solver.core.utils_visualizer import combined_function
10
10
 
11
11
 
12
12
  def factor_pairs(N: int, upper_limit_i: int, upper_limit_j: int):
@@ -121,6 +121,8 @@ class Board:
121
121
  set_char(res, pos, get_char(res, pos) + 'U')
122
122
  if bottom_pos not in single_res.assignment or single_res.assignment[bottom_pos] != cur:
123
123
  set_char(res, pos, get_char(res, pos) + 'D')
124
- print(render_grid(res, center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else ' '))
124
+ print(combined_function(self.V, self.H,
125
+ cell_flags=lambda r, c: res[r, c],
126
+ center_char=lambda r, c: self.board[r, c] if self.board[r, c] != ' ' else ' '))
125
127
 
126
128
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -1,35 +1,20 @@
1
- import 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
10
- from puzzle_solver.core.utils_visualizer import render_grid
6
+ from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, get_char, in_bounds, set_char, get_pos, get_opposite_direction
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, force_connected_component, and_constraint, SingleSolution
8
+ from puzzle_solver.core.utils_visualizer import combined_function
11
9
 
12
10
 
13
- @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(render_grid(res, center_char='.'))
107
+ print("Solution found")
108
+ output_board = np.full((self.V, self.H), '', dtype=object)
109
+ for pos in get_all_pos(self.V, self.H):
110
+ if get_char(self.board, pos)[-1] in ['B', 'W']: # if the main board has a white or black pearl, put it in the output
111
+ set_char(output_board, pos, get_char(self.board, pos))
112
+ if not single_res.assignment[pos].strip(): # if the cell does not the line through it, put a dot
113
+ set_char(output_board, pos, '.')
114
+ print(combined_function(self.V, self.H, show_grid=False, special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)], center_char=lambda r, c: output_board[r, c]))
158
115
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -3,7 +3,7 @@ from ortools.sat.python import cp_model
3
3
 
4
4
  from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_neighbors4, get_all_pos_to_idx_dict, get_row_pos, get_col_pos, get_pos
5
5
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
6
- from puzzle_solver.core.utils_visualizer import render_shaded_grid
6
+ from puzzle_solver.core.utils_visualizer import combined_function
7
7
 
8
8
 
9
9
  class Board:
@@ -45,5 +45,9 @@ class Board:
45
45
  return SingleSolution(assignment={pos: 1 if solver.Value(val) == 1 else 0 for pos, val in board.B.items()})
46
46
  def callback(single_res: SingleSolution):
47
47
  print("Solution found")
48
- print(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]))
48
+ print(combined_function(self.V, self.H,
49
+ is_shaded=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1,
50
+ center_char=lambda r, c: self.board[r, c],
51
+ text_on_shaded_cells=False
52
+ ))
49
53
  return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)