multi-puzzle-solver 1.1.8__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.
- multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
- multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
- multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
- multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
- puzzle_solver/__init__.py +184 -0
- puzzle_solver/core/utils.py +298 -0
- puzzle_solver/core/utils_ortools.py +333 -0
- puzzle_solver/core/utils_visualizer.py +575 -0
- puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
- puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
- puzzle_solver/puzzles/area_51/area_51.py +159 -0
- puzzle_solver/puzzles/battleships/battleships.py +139 -0
- puzzle_solver/puzzles/binairo/binairo.py +98 -0
- puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
- puzzle_solver/puzzles/black_box/black_box.py +243 -0
- puzzle_solver/puzzles/branches/branches.py +64 -0
- puzzle_solver/puzzles/bridges/bridges.py +104 -0
- puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
- puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
- puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
- puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
- puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
- puzzle_solver/puzzles/clouds/clouds.py +81 -0
- puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
- puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
- puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
- puzzle_solver/puzzles/filling/filling.py +94 -0
- puzzle_solver/puzzles/flip/flip.py +64 -0
- puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
- puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
- puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
- puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
- puzzle_solver/puzzles/guess/guess.py +232 -0
- puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
- puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
- puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
- puzzle_solver/puzzles/inertia/inertia.py +121 -0
- puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
- puzzle_solver/puzzles/inertia/tsp.py +400 -0
- puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
- puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
- puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
- puzzle_solver/puzzles/keen/keen.py +76 -0
- puzzle_solver/puzzles/kropki/kropki.py +94 -0
- puzzle_solver/puzzles/light_up/light_up.py +58 -0
- puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
- puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
- puzzle_solver/puzzles/lits/lits.py +138 -0
- puzzle_solver/puzzles/magnets/magnets.py +96 -0
- puzzle_solver/puzzles/map/map.py +56 -0
- puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
- puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
- puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
- puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
- puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
- puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
- puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
- puzzle_solver/puzzles/norinori/norinori.py +96 -0
- puzzle_solver/puzzles/number_path/number_path.py +76 -0
- puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
- puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
- puzzle_solver/puzzles/palisade/palisade.py +91 -0
- puzzle_solver/puzzles/pearl/pearl.py +107 -0
- puzzle_solver/puzzles/pipes/pipes.py +82 -0
- puzzle_solver/puzzles/range/range.py +59 -0
- puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
- puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
- puzzle_solver/puzzles/rooms/rooms.py +75 -0
- puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
- puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
- puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
- puzzle_solver/puzzles/signpost/signpost.py +93 -0
- puzzle_solver/puzzles/singles/singles.py +53 -0
- puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
- puzzle_solver/puzzles/slant/slant.py +111 -0
- puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
- puzzle_solver/puzzles/snail/snail.py +97 -0
- puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
- puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
- puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
- puzzle_solver/puzzles/stitches/stitches.py +96 -0
- puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
- puzzle_solver/puzzles/suguru/suguru.py +55 -0
- puzzle_solver/puzzles/suko/suko.py +54 -0
- puzzle_solver/puzzles/tapa/tapa.py +97 -0
- puzzle_solver/puzzles/tatami/tatami.py +64 -0
- puzzle_solver/puzzles/tents/tents.py +80 -0
- puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
- puzzle_solver/puzzles/towers/towers.py +89 -0
- puzzle_solver/puzzles/tracks/tracks.py +88 -0
- puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
- puzzle_solver/puzzles/troix/dumplings.py +7 -0
- puzzle_solver/puzzles/troix/troix.py +75 -0
- puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
- puzzle_solver/puzzles/undead/undead.py +130 -0
- puzzle_solver/puzzles/unequal/unequal.py +128 -0
- puzzle_solver/puzzles/unruly/unruly.py +54 -0
- puzzle_solver/puzzles/vectors/vectors.py +94 -0
- puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
- puzzle_solver/puzzles/walls/walls.py +52 -0
- puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
- puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
- puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
- puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
- puzzle_solver/utils/visualizer.py +155 -0
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
|
|
4
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, get_pos, get_row_pos, get_col_pos
|
|
5
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
6
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def spiral_from_topleft(matrix, N: int):
|
|
10
|
+
res = []
|
|
11
|
+
top, bottom = 0, N - 1
|
|
12
|
+
left, right = 0, N - 1
|
|
13
|
+
while top <= bottom and left <= right:
|
|
14
|
+
for c in range(left, right + 1): # go right
|
|
15
|
+
res.append(matrix[get_pos(x=c, y=top)])
|
|
16
|
+
top += 1
|
|
17
|
+
if top > bottom:
|
|
18
|
+
break
|
|
19
|
+
for r in range(top, bottom + 1): # go down
|
|
20
|
+
res.append(matrix[get_pos(x=right, y=r)])
|
|
21
|
+
right -= 1
|
|
22
|
+
if left > right:
|
|
23
|
+
break
|
|
24
|
+
for c in range(right, left - 1, -1): # go left
|
|
25
|
+
res.append(matrix[get_pos(x=c, y=bottom)])
|
|
26
|
+
bottom -= 1
|
|
27
|
+
if top > bottom:
|
|
28
|
+
break
|
|
29
|
+
for r in range(bottom, top - 1, -1): # go up
|
|
30
|
+
res.append(matrix[get_pos(x=left, y=r)])
|
|
31
|
+
left += 1
|
|
32
|
+
return res
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_eq_val(model: cp_model.CpModel, int_var: cp_model.IntVar, val: int) -> cp_model.IntVar:
|
|
36
|
+
eq_var = model.NewBoolVar(f'{int_var}:{val}:eq')
|
|
37
|
+
model.Add(int_var == val).OnlyEnforceIf(eq_var)
|
|
38
|
+
model.Add(int_var != val).OnlyEnforceIf(eq_var.Not())
|
|
39
|
+
return eq_var
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Board:
|
|
43
|
+
def __init__(self, board: np.array):
|
|
44
|
+
self.V, self.H = board.shape
|
|
45
|
+
assert self.V == self.H and self.V >= 3, f'board must be square, got {self.V}x{self.H}'
|
|
46
|
+
self.board = board
|
|
47
|
+
|
|
48
|
+
self.model = cp_model.CpModel()
|
|
49
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
50
|
+
self.create_vars()
|
|
51
|
+
self.add_all_constraints()
|
|
52
|
+
|
|
53
|
+
def create_vars(self):
|
|
54
|
+
for pos in get_all_pos(self.V, self.H):
|
|
55
|
+
self.model_vars[pos] = self.model.NewIntVar(0, 4, f'{pos}')
|
|
56
|
+
|
|
57
|
+
def add_all_constraints(self):
|
|
58
|
+
for pos in get_all_pos(self.V, self.H): # force clues
|
|
59
|
+
c = get_char(self.board, pos).strip()
|
|
60
|
+
if c:
|
|
61
|
+
self.model.Add(self.model_vars[pos] == int(c))
|
|
62
|
+
for v in [1, 2, 3]:
|
|
63
|
+
for row in range(self.V):
|
|
64
|
+
self.model.AddExactlyOne([get_eq_val(self.model, self.model_vars[pos], v) for pos in get_row_pos(row, self.H)])
|
|
65
|
+
for col in range(self.H):
|
|
66
|
+
self.model.AddExactlyOne([get_eq_val(self.model, self.model_vars[pos], v) for pos in get_col_pos(col, self.V)])
|
|
67
|
+
vars_ = spiral_from_topleft(self.model_vars, self.V)
|
|
68
|
+
self.add_snail_pattern(vars_)
|
|
69
|
+
|
|
70
|
+
def add_snail_pattern(self, vars_):
|
|
71
|
+
"""Enforce on vars_ (each in {0,1,2,3}) that, ignoring 0s, the nonzero values must follow the repeating pattern 1 -> 2 -> 3 -> 1 -> 2 -> 3 -> ..."""
|
|
72
|
+
# States: 0 = expect 1, 1 = expect 2, 2 = expect 3
|
|
73
|
+
start = 0
|
|
74
|
+
accept = [3] # we can stop after expecting 1 or 2 or 3
|
|
75
|
+
transitions = [ # (*tail*, *transition*, *head*)
|
|
76
|
+
# zeros don't change the state
|
|
77
|
+
(0, 0, 0),
|
|
78
|
+
(1, 0, 1),
|
|
79
|
+
(2, 0, 2),
|
|
80
|
+
(3, 0, 3),
|
|
81
|
+
# pattern 1 -> 2 -> 3 -> 1 ...
|
|
82
|
+
(0, 1, 1), # State 0: saw "1" -> Go to state 1 (which will expect 2)
|
|
83
|
+
(1, 2, 2), # State 1: saw "2" -> Go to state 2 (which will expect 3)
|
|
84
|
+
(2, 3, 3), # State 2: saw "3" -> Go to state 3 (which will expect 1)
|
|
85
|
+
(3, 1, 1), # State 3: saw "1" -> Go to state 1 (which will expect 2)
|
|
86
|
+
]
|
|
87
|
+
self.model.AddAutomaton(vars_, start, accept, transitions)
|
|
88
|
+
|
|
89
|
+
def solve_and_print(self, verbose: bool = True):
|
|
90
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
91
|
+
return SingleSolution(assignment={pos: solver.Value(v) for pos, v in board.model_vars.items()})
|
|
92
|
+
def callback(single_res: SingleSolution):
|
|
93
|
+
print("Solution found")
|
|
94
|
+
print(combined_function(self.V, self.H,
|
|
95
|
+
center_char=lambda r, c: str(single_res.assignment[get_pos(x=c, y=r)]).replace('0', ' '),
|
|
96
|
+
))
|
|
97
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
|
|
4
|
+
from puzzle_solver.core.utils import Direction8, Pos, get_all_pos, get_char, get_pos, get_row_pos, get_col_pos, Direction, get_next_pos, in_bounds
|
|
5
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
6
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Board:
|
|
10
|
+
def __init__(self, board: np.array):
|
|
11
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
12
|
+
assert all(c.item() in ['L', 'R', 'U', 'D', 'O', ' '] for c in np.nditer(board)), 'board must contain only L, R, U, D, O, or space'
|
|
13
|
+
self.STATES = list(Direction) + ['O']
|
|
14
|
+
self.board = board
|
|
15
|
+
self.V, self.H = board.shape
|
|
16
|
+
assert self.V == 6 and self.H == 6, f'board must be 6x6, got {self.V}x{self.H}'
|
|
17
|
+
self.model = cp_model.CpModel()
|
|
18
|
+
self.model_vars: dict[tuple[Pos, str], cp_model.IntVar] = {}
|
|
19
|
+
self.create_vars()
|
|
20
|
+
self.add_all_constraints()
|
|
21
|
+
|
|
22
|
+
def create_vars(self):
|
|
23
|
+
for pos in get_all_pos(self.V, self.H):
|
|
24
|
+
for direction in self.STATES:
|
|
25
|
+
self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
|
|
26
|
+
self.model.AddExactlyOne([self.model_vars[(pos, direction)] for direction in self.STATES])
|
|
27
|
+
|
|
28
|
+
def add_all_constraints(self):
|
|
29
|
+
for pos in get_all_pos(self.V, self.H): # force clues
|
|
30
|
+
c = get_char(self.board, pos)
|
|
31
|
+
c = {'L': Direction.LEFT, 'R': Direction.RIGHT, 'U': Direction.UP, 'D': Direction.DOWN, 'O': 'O'}.get(c, None)
|
|
32
|
+
if c is not None:
|
|
33
|
+
self.model.Add(self.model_vars[(pos, c)] == 1)
|
|
34
|
+
for row in range(self.V): # each row, 1 of each direction and 2 O's
|
|
35
|
+
for direction in Direction:
|
|
36
|
+
self.model.AddExactlyOne([self.model_vars[(pos, direction)] for pos in get_row_pos(row, self.H)])
|
|
37
|
+
for col in range(self.H): # each column, 1 of each direction and 2 O's
|
|
38
|
+
for direction in Direction:
|
|
39
|
+
self.model.AddExactlyOne([self.model_vars[(pos, direction)] for pos in get_col_pos(col, self.V)])
|
|
40
|
+
for pos in get_all_pos(self.V, self.H):
|
|
41
|
+
for direction in Direction:
|
|
42
|
+
self.apply_orientation_rule(pos, direction)
|
|
43
|
+
|
|
44
|
+
def apply_orientation_rule(self, pos: Pos, direction: Direction):
|
|
45
|
+
# if cell is direction (for example L), then the cell to its left must not be R, and the cell to its up-right and down-right must also not be R
|
|
46
|
+
# and the cell to its up-right can't be U and the cell to its down-right can't be D. You have to see the triangles visually for it to make sense.
|
|
47
|
+
assert direction in Direction, f'direction must be in Direction, got {direction}'
|
|
48
|
+
if direction == Direction.LEFT:
|
|
49
|
+
disallow_pairs = [
|
|
50
|
+
(get_next_pos(pos, Direction8.LEFT), Direction.RIGHT),
|
|
51
|
+
(get_next_pos(pos, Direction8.UP_RIGHT), Direction.RIGHT),
|
|
52
|
+
(get_next_pos(pos, Direction8.DOWN_RIGHT), Direction.RIGHT),
|
|
53
|
+
(get_next_pos(pos, Direction8.UP_RIGHT), Direction.UP),
|
|
54
|
+
(get_next_pos(pos, Direction8.DOWN_RIGHT), Direction.DOWN),
|
|
55
|
+
]
|
|
56
|
+
elif direction == Direction.RIGHT:
|
|
57
|
+
disallow_pairs = [
|
|
58
|
+
(get_next_pos(pos, Direction8.RIGHT), Direction.LEFT),
|
|
59
|
+
(get_next_pos(pos, Direction8.UP_LEFT), Direction.LEFT),
|
|
60
|
+
(get_next_pos(pos, Direction8.DOWN_LEFT), Direction.LEFT),
|
|
61
|
+
(get_next_pos(pos, Direction8.UP_LEFT), Direction.UP),
|
|
62
|
+
(get_next_pos(pos, Direction8.DOWN_LEFT), Direction.DOWN),
|
|
63
|
+
]
|
|
64
|
+
elif direction == Direction.UP:
|
|
65
|
+
disallow_pairs = [
|
|
66
|
+
(get_next_pos(pos, Direction8.UP), Direction.DOWN),
|
|
67
|
+
(get_next_pos(pos, Direction8.DOWN_LEFT), Direction.DOWN),
|
|
68
|
+
(get_next_pos(pos, Direction8.DOWN_RIGHT), Direction.DOWN),
|
|
69
|
+
(get_next_pos(pos, Direction8.DOWN_LEFT), Direction.LEFT),
|
|
70
|
+
(get_next_pos(pos, Direction8.DOWN_RIGHT), Direction.RIGHT),
|
|
71
|
+
]
|
|
72
|
+
elif direction == Direction.DOWN:
|
|
73
|
+
disallow_pairs = [
|
|
74
|
+
(get_next_pos(pos, Direction8.DOWN), Direction.UP),
|
|
75
|
+
(get_next_pos(pos, Direction8.UP_LEFT), Direction.UP),
|
|
76
|
+
(get_next_pos(pos, Direction8.UP_RIGHT), Direction.UP),
|
|
77
|
+
(get_next_pos(pos, Direction8.UP_LEFT), Direction.LEFT),
|
|
78
|
+
(get_next_pos(pos, Direction8.UP_RIGHT), Direction.RIGHT),
|
|
79
|
+
]
|
|
80
|
+
else:
|
|
81
|
+
raise ValueError(f'invalid direction: {direction}')
|
|
82
|
+
disallow_pairs = [d_pair for d_pair in disallow_pairs if in_bounds(d_pair[0], self.V, self.H)]
|
|
83
|
+
for d_pos, d_direction in disallow_pairs:
|
|
84
|
+
self.model.Add(self.model_vars[(d_pos, d_direction)] == 0).OnlyEnforceIf(self.model_vars[(pos, direction)])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def solve_and_print(self, verbose: bool = True):
|
|
88
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
89
|
+
return SingleSolution(assignment={pos: 'O' if direction == 'O' else direction.name[0] for (pos, direction), var in board.model_vars.items() if solver.Value(var) == 1})
|
|
90
|
+
def callback(single_res: SingleSolution):
|
|
91
|
+
print("Solution found")
|
|
92
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ' ')))
|
|
93
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
|
|
4
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_neighbors8, get_row_pos, get_col_pos
|
|
5
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
6
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Board:
|
|
10
|
+
def __init__(self, board: np.array, star_count: int = 1, shapeless: bool = False):
|
|
11
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
12
|
+
assert star_count >= 1 and isinstance(star_count, int), 'star_count must be an integer greater than or equal to 1'
|
|
13
|
+
self.board = board
|
|
14
|
+
self.V, self.H = board.shape
|
|
15
|
+
self.N = self.V * self.H
|
|
16
|
+
self.star_count = star_count
|
|
17
|
+
self.shapeless = shapeless
|
|
18
|
+
if not shapeless:
|
|
19
|
+
assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
|
|
20
|
+
self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
|
|
21
|
+
self.blocks = {i: [pos for pos in get_all_pos(self.V, self.H) if int(get_char(self.board, pos)) == i] for i in self.block_numbers}
|
|
22
|
+
else:
|
|
23
|
+
assert all((str(c.item()) in [' ', 'B'] for c in np.nditer(board))), 'board must contain only digits'
|
|
24
|
+
self.model = cp_model.CpModel()
|
|
25
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
26
|
+
|
|
27
|
+
self.create_vars()
|
|
28
|
+
self.add_all_constraints()
|
|
29
|
+
|
|
30
|
+
def create_vars(self):
|
|
31
|
+
for pos in get_all_pos(self.V, self.H):
|
|
32
|
+
self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
|
|
33
|
+
|
|
34
|
+
def add_all_constraints(self):
|
|
35
|
+
# N stars per row / column
|
|
36
|
+
for row in range(self.V):
|
|
37
|
+
self.model.Add(sum(self.model_vars[pos] for pos in get_row_pos(row, H=self.H)) == self.star_count)
|
|
38
|
+
for col in range(self.H):
|
|
39
|
+
self.model.Add(sum(self.model_vars[pos] for pos in get_col_pos(col, V=self.V)) == self.star_count)
|
|
40
|
+
if self.shapeless: # shapeless version = no blocks but disallow black cells
|
|
41
|
+
for pos in get_all_pos(self.V, self.H):
|
|
42
|
+
if get_char(self.board, pos) == 'B':
|
|
43
|
+
self.model.Add(self.model_vars[pos] == 0)
|
|
44
|
+
else: # shaped version = blocks
|
|
45
|
+
for block_i in self.block_numbers:
|
|
46
|
+
self.model.Add(sum(self.model_vars[pos] for pos in self.blocks[block_i]) == self.star_count)
|
|
47
|
+
# stars can't be adjacent
|
|
48
|
+
for pos in get_all_pos(self.V, self.H):
|
|
49
|
+
for neighbor in get_neighbors8(pos, V=self.V, H=self.H):
|
|
50
|
+
self.model.Add(self.model_vars[neighbor] == 0).OnlyEnforceIf(self.model_vars[pos])
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def solve_and_print(self, verbose: bool = True):
|
|
54
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
55
|
+
assignment: dict[Pos, int] = {}
|
|
56
|
+
for pos, var in board.model_vars.items():
|
|
57
|
+
assignment[pos] = solver.value(var)
|
|
58
|
+
return SingleSolution(assignment=assignment)
|
|
59
|
+
def callback(single_res: SingleSolution):
|
|
60
|
+
print("Solution found")
|
|
61
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
62
|
+
for pos in get_all_pos(self.V, self.H):
|
|
63
|
+
if single_res.assignment[pos] == 1:
|
|
64
|
+
set_char(res, pos, 'X')
|
|
65
|
+
else:
|
|
66
|
+
b = get_char(self.board, pos)
|
|
67
|
+
if b == 'B':
|
|
68
|
+
set_char(res, pos, ' ')
|
|
69
|
+
else:
|
|
70
|
+
set_char(res, pos, '.')
|
|
71
|
+
print(combined_function(self.V, self.H,
|
|
72
|
+
cell_flags=id_board_to_wall_fn(self.board),
|
|
73
|
+
center_char=lambda r, c: res[r][c]
|
|
74
|
+
))
|
|
75
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This file is a simple helper that parses the images from https://www.puzzle-stitches.com/ and converts them to a json file.
|
|
3
|
+
Look at the ./input_output/ directory for examples of input images and output json files.
|
|
4
|
+
The output json is used in the test_solve.py file to test the solver.
|
|
5
|
+
"""
|
|
6
|
+
# import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import numpy as np
|
|
9
|
+
cv = None
|
|
10
|
+
Image = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def extract_lines(bw):
|
|
14
|
+
# Create the images that will use to extract the horizontal and vertical lines
|
|
15
|
+
horizontal = np.copy(bw)
|
|
16
|
+
vertical = np.copy(bw)
|
|
17
|
+
|
|
18
|
+
cols = horizontal.shape[1]
|
|
19
|
+
horizontal_size = cols // 9
|
|
20
|
+
# Create structure element for extracting horizontal lines through morphology operations
|
|
21
|
+
horizontalStructure = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
|
|
22
|
+
horizontal = cv.erode(horizontal, horizontalStructure)
|
|
23
|
+
horizontal = cv.dilate(horizontal, horizontalStructure)
|
|
24
|
+
horizontal_means = np.mean(horizontal, axis=1)
|
|
25
|
+
horizontal_cutoff = np.percentile(horizontal_means, 50)
|
|
26
|
+
# location where the horizontal lines are
|
|
27
|
+
horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
|
|
28
|
+
# print(f"horizontal_idx: {horizontal_idx}")
|
|
29
|
+
# height = len(horizontal_idx)
|
|
30
|
+
show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
|
|
31
|
+
|
|
32
|
+
rows = vertical.shape[0]
|
|
33
|
+
verticalsize = rows // 9
|
|
34
|
+
# Create structure element for extracting vertical lines through morphology operations
|
|
35
|
+
verticalStructure = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
|
|
36
|
+
vertical = cv.erode(vertical, verticalStructure)
|
|
37
|
+
vertical = cv.dilate(vertical, verticalStructure)
|
|
38
|
+
vertical_means = np.mean(vertical, axis=0)
|
|
39
|
+
vertical_cutoff = np.percentile(vertical_means, 50)
|
|
40
|
+
vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
|
|
41
|
+
# print(f"vertical_idx: {vertical_idx}")
|
|
42
|
+
# width = len(vertical_idx)
|
|
43
|
+
# print(f"height: {height}, width: {width}")
|
|
44
|
+
# print(f"vertical_means: {vertical_means}")
|
|
45
|
+
show_wait_destroy("vertical", vertical) # this has the vertical lines
|
|
46
|
+
|
|
47
|
+
vertical = cv.bitwise_not(vertical)
|
|
48
|
+
# show_wait_destroy("vertical_bit", vertical)
|
|
49
|
+
|
|
50
|
+
return horizontal_idx, vertical_idx
|
|
51
|
+
|
|
52
|
+
def show_wait_destroy(winname, img):
|
|
53
|
+
cv.imshow(winname, img)
|
|
54
|
+
cv.moveWindow(winname, 500, 0)
|
|
55
|
+
cv.waitKey(0)
|
|
56
|
+
cv.destroyWindow(winname)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def mean_consecutives(arr: np.ndarray) -> np.ndarray:
|
|
60
|
+
"""if a sequence of values is consecutive, then average the values"""
|
|
61
|
+
sums = []
|
|
62
|
+
counts = []
|
|
63
|
+
for i in range(len(arr)):
|
|
64
|
+
if i == 0:
|
|
65
|
+
sums.append(arr[i])
|
|
66
|
+
counts.append(1)
|
|
67
|
+
elif arr[i] == arr[i-1] + 1:
|
|
68
|
+
sums[-1] += arr[i]
|
|
69
|
+
counts[-1] += 1
|
|
70
|
+
else:
|
|
71
|
+
sums.append(arr[i])
|
|
72
|
+
counts.append(1)
|
|
73
|
+
return np.array(sums) // np.array(counts)
|
|
74
|
+
|
|
75
|
+
def dfs(x, y, out, output, current_num):
|
|
76
|
+
# if current_num == '48':
|
|
77
|
+
# print('dfs', x, y, current_num)
|
|
78
|
+
if x < 0 or x >= out.shape[1] or y < 0 or y >= out.shape[0]:
|
|
79
|
+
return
|
|
80
|
+
if out[y, x] != ' ':
|
|
81
|
+
return
|
|
82
|
+
out[y, x] = current_num
|
|
83
|
+
if output['top'][y, x] == 0:
|
|
84
|
+
dfs(x, y-1, out, output, current_num)
|
|
85
|
+
if output['left'][y, x] == 0:
|
|
86
|
+
dfs(x-1, y, out, output, current_num)
|
|
87
|
+
if output['right'][y, x] == 0:
|
|
88
|
+
dfs(x+1, y, out, output, current_num)
|
|
89
|
+
if output['bottom'][y, x] == 0:
|
|
90
|
+
dfs(x, y+1, out, output, current_num)
|
|
91
|
+
|
|
92
|
+
def main(image):
|
|
93
|
+
global Image
|
|
94
|
+
global cv
|
|
95
|
+
import matplotlib.pyplot as plt
|
|
96
|
+
from PIL import Image as Image_module
|
|
97
|
+
import cv2 as cv_module
|
|
98
|
+
Image = Image_module
|
|
99
|
+
cv = cv_module
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
image_path = Path(image)
|
|
103
|
+
output_path = image_path.parent / (image_path.stem + '.json')
|
|
104
|
+
src = cv.imread(image, cv.IMREAD_COLOR)
|
|
105
|
+
assert src is not None, f'Error opening image: {image}. Parent exists: {image_path.parent.exists()}'
|
|
106
|
+
if len(src.shape) != 2:
|
|
107
|
+
gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
|
|
108
|
+
else:
|
|
109
|
+
gray = src
|
|
110
|
+
# now the image is in grayscale
|
|
111
|
+
|
|
112
|
+
# Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
|
|
113
|
+
gray = cv.bitwise_not(gray)
|
|
114
|
+
bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
|
|
115
|
+
cv.THRESH_BINARY, 15, -2)
|
|
116
|
+
# show_wait_destroy("binary", bw)
|
|
117
|
+
|
|
118
|
+
# show_wait_destroy("src", src)
|
|
119
|
+
horizontal_idx, vertical_idx = extract_lines(bw)
|
|
120
|
+
horizontal_idx = mean_consecutives(horizontal_idx)
|
|
121
|
+
vertical_idx = mean_consecutives(vertical_idx)
|
|
122
|
+
mean_vertical_dist = np.mean(np.diff(vertical_idx))
|
|
123
|
+
mean_horizontal_dist = np.mean(np.diff(horizontal_idx))
|
|
124
|
+
height = len(horizontal_idx)
|
|
125
|
+
width = len(vertical_idx)
|
|
126
|
+
print(f"height: {height}, width: {width}")
|
|
127
|
+
print(f"horizontal_idx: {horizontal_idx}")
|
|
128
|
+
print(f"vertical_idx: {vertical_idx}")
|
|
129
|
+
hists = {'top': {}, 'left': {}, 'right': {}, 'bottom': {}}
|
|
130
|
+
j_idx = 0
|
|
131
|
+
i_len = 0
|
|
132
|
+
j_len = 0
|
|
133
|
+
for j in range(height - 1):
|
|
134
|
+
i_idx = 0
|
|
135
|
+
for i in range(width - 1):
|
|
136
|
+
hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
|
|
137
|
+
vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
|
|
138
|
+
hidx1 = max(0, hidx1 - 2)
|
|
139
|
+
hidx2 = min(src.shape[0], hidx2 + 4)
|
|
140
|
+
vidx1 = max(0, vidx1 - 2)
|
|
141
|
+
vidx2 = min(src.shape[1], vidx2 + 4)
|
|
142
|
+
if (hidx2 - hidx1) < mean_horizontal_dist * 0.5 or (vidx2 - vidx1) < mean_vertical_dist * 0.5:
|
|
143
|
+
continue
|
|
144
|
+
print(f"j_idx: {j_idx}, i_idx: {i_idx}")
|
|
145
|
+
cell = src[hidx1:hidx2, vidx1:vidx2]
|
|
146
|
+
# print(f"cell_shape: {cell.shape}, mean_horizontal_dist: {mean_horizontal_dist}, mean_vertical_dist: {mean_vertical_dist}")
|
|
147
|
+
mid_x = cell.shape[1] // 2
|
|
148
|
+
mid_y = cell.shape[0] // 2
|
|
149
|
+
# if j > height - 4 and i > width - 6:
|
|
150
|
+
# show_wait_destroy(f"cell_{i}_{j}", cell)
|
|
151
|
+
# show_wait_destroy(f"cell_{i}_{j}", cell)
|
|
152
|
+
cell = cv.bitwise_not(cell) # invert colors
|
|
153
|
+
top = cell[0:10, mid_y-5:mid_y+5]
|
|
154
|
+
hists['top'][j_idx, i_idx] = np.sum(top)
|
|
155
|
+
left = cell[mid_x-5:mid_x+5, 0:10]
|
|
156
|
+
hists['left'][j_idx, i_idx] = np.sum(left)
|
|
157
|
+
right = cell[mid_x-5:mid_x+5, -10:]
|
|
158
|
+
hists['right'][j_idx, i_idx] = np.sum(right)
|
|
159
|
+
bottom = cell[-10:, mid_y-5:mid_y+5]
|
|
160
|
+
hists['bottom'][j_idx, i_idx] = np.sum(bottom)
|
|
161
|
+
i_idx += 1
|
|
162
|
+
i_len = max(i_len, i_idx)
|
|
163
|
+
if i_idx > 0:
|
|
164
|
+
j_idx += 1
|
|
165
|
+
j_len = max(j_len, j_idx)
|
|
166
|
+
|
|
167
|
+
fig, axs = plt.subplots(2, 2)
|
|
168
|
+
axs[0, 0].hist(list(hists['top'].values()), bins=100)
|
|
169
|
+
axs[0, 0].set_title('Top')
|
|
170
|
+
axs[0, 1].hist(list(hists['left'].values()), bins=100)
|
|
171
|
+
axs[0, 1].set_title('Left')
|
|
172
|
+
axs[1, 0].hist(list(hists['right'].values()), bins=100)
|
|
173
|
+
axs[1, 0].set_title('Right')
|
|
174
|
+
axs[1, 1].hist(list(hists['bottom'].values()), bins=100)
|
|
175
|
+
axs[1, 1].set_title('Bottom')
|
|
176
|
+
global_target = None
|
|
177
|
+
# global_target = 28_000
|
|
178
|
+
target_top = np.mean(list(hists['top'].values()))
|
|
179
|
+
target_left = np.mean(list(hists['left'].values()))
|
|
180
|
+
target_right = np.mean(list(hists['right'].values()))
|
|
181
|
+
target_bottom = np.mean(list(hists['bottom'].values()))
|
|
182
|
+
if global_target is not None:
|
|
183
|
+
target_top = global_target
|
|
184
|
+
target_left = global_target
|
|
185
|
+
target_right = global_target
|
|
186
|
+
target_bottom = global_target
|
|
187
|
+
|
|
188
|
+
axs[0, 0].axvline(target_top, color='red')
|
|
189
|
+
axs[0, 1].axvline(target_left, color='red')
|
|
190
|
+
axs[1, 0].axvline(target_right, color='red')
|
|
191
|
+
axs[1, 1].axvline(target_bottom, color='red')
|
|
192
|
+
# plt.show()
|
|
193
|
+
# 1/0
|
|
194
|
+
arr = np.zeros((j_len, i_len), dtype=object)
|
|
195
|
+
output = {'top': arr.copy(), 'left': arr.copy(), 'right': arr.copy(), 'bottom': arr.copy()}
|
|
196
|
+
print(f"target_top: {target_top}, target_left: {target_left}, target_right: {target_right}, target_bottom: {target_bottom}, j_len: {j_len}, i_len: {i_len}")
|
|
197
|
+
for j in range(j_len):
|
|
198
|
+
for i in range(i_len):
|
|
199
|
+
if hists['top'][j, i] > target_top:
|
|
200
|
+
output['top'][j, i] = 1
|
|
201
|
+
if hists['left'][j, i] > target_left:
|
|
202
|
+
output['left'][j, i] = 1
|
|
203
|
+
if hists['right'][j, i] > target_right:
|
|
204
|
+
output['right'][j, i] = 1
|
|
205
|
+
if hists['bottom'][j, i] > target_bottom:
|
|
206
|
+
output['bottom'][j, i] = 1
|
|
207
|
+
print(f"cell_{j}_{i}", end=': ')
|
|
208
|
+
print('T' if output['top'][j, i] else '', end='')
|
|
209
|
+
print('L' if output['left'][j, i] else '', end='')
|
|
210
|
+
print('R' if output['right'][j, i] else '', end='')
|
|
211
|
+
print('B' if output['bottom'][j, i] else '', end='')
|
|
212
|
+
print(' Sums: ', hists['top'][j, i], hists['left'][j, i], hists['right'][j, i], hists['bottom'][j, i])
|
|
213
|
+
|
|
214
|
+
current_count = 0
|
|
215
|
+
z_fill = 2
|
|
216
|
+
out = np.full_like(output['top'], ' ', dtype='U32')
|
|
217
|
+
for j in range(out.shape[0]):
|
|
218
|
+
if current_count > 99:
|
|
219
|
+
z_fill = 3
|
|
220
|
+
for i in range(out.shape[1]):
|
|
221
|
+
if out[j, i] == ' ':
|
|
222
|
+
if current_count == 48:
|
|
223
|
+
print(f"current_count: {current_count}, x: {i}, y: {j}")
|
|
224
|
+
dfs(i, j, out, output, str(current_count).zfill(z_fill))
|
|
225
|
+
current_count += 1
|
|
226
|
+
print(out)
|
|
227
|
+
|
|
228
|
+
with open(output_path, 'w') as f:
|
|
229
|
+
f.write('[\n')
|
|
230
|
+
for i, row in enumerate(out):
|
|
231
|
+
f.write(' ' + str(row.tolist()).replace("'", '"'))
|
|
232
|
+
if i != len(out) - 1:
|
|
233
|
+
f.write(',')
|
|
234
|
+
f.write('\n')
|
|
235
|
+
f.write(']')
|
|
236
|
+
print('output json: ', output_path)
|
|
237
|
+
|
|
238
|
+
# with open(output_path.parent / 'debug.json', 'w') as f:
|
|
239
|
+
# debug_pos = {}
|
|
240
|
+
# for j in range(out.shape[0]):
|
|
241
|
+
# for i in range(out.shape[1]):
|
|
242
|
+
# out_str = ''
|
|
243
|
+
# out_str += 'T' if output['top'][j, i] else ''
|
|
244
|
+
# out_str += 'L' if output['left'][j, i] else ''
|
|
245
|
+
# out_str += 'R' if output['right'][j, i] else ''
|
|
246
|
+
# out_str += 'B' if output['bottom'][j, i] else ''
|
|
247
|
+
# debug_pos[f'{j}_{i}'] = out_str
|
|
248
|
+
# json.dump(debug_pos, f, indent=2)
|
|
249
|
+
|
|
250
|
+
if __name__ == '__main__':
|
|
251
|
+
# to run this script and visualize the output, in the root run:
|
|
252
|
+
# python .\src\puzzle_solver\puzzles\stitches\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
|
|
253
|
+
# main(Path(__file__).parent / 'input_output' / 'MTM6OSw4MjEsNDAx.png')
|
|
254
|
+
# main(Path(__file__).parent / 'input_output' / 'weekly_oct_3rd_2025.png')
|
|
255
|
+
# main(Path(__file__).parent / 'input_output' / 'star_battle_67f73ff90cd8cdb4b3e30f56f5261f4968f5dac940bc6.png')
|
|
256
|
+
# main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
|
|
257
|
+
# main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
|
|
258
|
+
# main(Path(__file__).parent / 'input_output' / 'norinori_501d93110d6b4b818c268378973afbf268f96cfa8d7b4.png')
|
|
259
|
+
# main(Path(__file__).parent / 'input_output' / 'norinori_OTo0LDc0Miw5MTU.png')
|
|
260
|
+
# main(Path(__file__).parent / 'input_output' / 'heyawake_MDoxNiwxNDQ=.png')
|
|
261
|
+
# main(Path(__file__).parent / 'input_output' / 'heyawake_MTQ6ODQ4LDEzOQ==.png')
|
|
262
|
+
# main(Path(__file__).parent / 'input_output' / 'sudoku_jigsaw.png')
|
|
263
|
+
# main(Path(__file__).parent / 'input_output' / 'Screenshot 2025-11-01 025846.png')
|
|
264
|
+
# main(Path(__file__).parent / 'input_output' / 'Screenshot 2025-11-01 035658.png')
|
|
265
|
+
# main(Path(__file__).parent / 'input_output' / 'Screenshot 2025-11-01 044110.png')
|
|
266
|
+
# main(Path(__file__).parent / 'input_output' / 'Screenshot 2025-11-03 020828.png')
|
|
267
|
+
main(Path(__file__).parent / 'input_output' / 'ripple_effect_unsolved.png')
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from typing import Union
|
|
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_char, get_next_pos, Direction, get_row_pos, get_col_pos, in_bounds, get_opposite_direction, get_pos
|
|
7
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint
|
|
8
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Board:
|
|
12
|
+
def __init__(self, board: np.array, top: np.array, side: np.array, connection_count=1):
|
|
13
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
14
|
+
self.V = board.shape[0]
|
|
15
|
+
self.H = board.shape[1]
|
|
16
|
+
assert top.ndim == 1 and top.shape[0] == self.H, 'top must be a 1d array of length board width'
|
|
17
|
+
assert side.ndim == 1 and side.shape[0] == self.V, 'side must be a 1d array of length board height'
|
|
18
|
+
assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
|
|
19
|
+
assert isinstance(connection_count, int) and connection_count >= 1, f'connection count must be int and >= 1, got {connection_count}'
|
|
20
|
+
self.board = board
|
|
21
|
+
self.top = top
|
|
22
|
+
self.side = side
|
|
23
|
+
self.connection_count = connection_count
|
|
24
|
+
self.top_empties = [self.H - i for i in self.top]
|
|
25
|
+
self.side_empties = [self.V - i for i in self.side]
|
|
26
|
+
self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
|
|
27
|
+
self.blocks = {i: [pos for pos in get_all_pos(self.V, self.H) if int(get_char(self.board, pos)) == i] for i in self.block_numbers}
|
|
28
|
+
# keys are (block_i, block_j) where block_i < block_j to avoid double counting
|
|
29
|
+
# values are sets of (pos_a, direction_a, pos_b, direction_b) where the two blocks meet
|
|
30
|
+
self.block_neighbors: dict[tuple[int, int], set[tuple[Pos, Direction, Pos, Direction]]] = {}
|
|
31
|
+
self.valid_stitches: set[tuple[Pos, Pos]] = set() # records all pairs of positions that can have a stitch
|
|
32
|
+
for pos in get_all_pos(self.V, self.H):
|
|
33
|
+
block_i = int(get_char(self.board, pos))
|
|
34
|
+
for direction in Direction:
|
|
35
|
+
neighbor = get_next_pos(pos, direction)
|
|
36
|
+
if not in_bounds(neighbor, self.V, self.H):
|
|
37
|
+
continue
|
|
38
|
+
block_j = int(get_char(self.board, neighbor))
|
|
39
|
+
if block_i < block_j: # avoid double counting
|
|
40
|
+
opposite_direction = get_opposite_direction(direction)
|
|
41
|
+
self.block_neighbors.setdefault((block_i, block_j), set()).add((pos, direction, neighbor, opposite_direction))
|
|
42
|
+
self.valid_stitches.add((pos, neighbor))
|
|
43
|
+
self.valid_stitches.add((neighbor, pos))
|
|
44
|
+
|
|
45
|
+
self.model = cp_model.CpModel()
|
|
46
|
+
self.model_vars: dict[tuple[Pos, Union[Direction, None]], cp_model.IntVar] = {}
|
|
47
|
+
self.create_vars()
|
|
48
|
+
self.add_all_constraints()
|
|
49
|
+
|
|
50
|
+
def create_vars(self):
|
|
51
|
+
for pos in get_all_pos(self.V, self.H):
|
|
52
|
+
for direction in Direction:
|
|
53
|
+
self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
|
|
54
|
+
self.model_vars[(pos, None)] = self.model.NewBoolVar(f'{pos}:empty')
|
|
55
|
+
|
|
56
|
+
def add_all_constraints(self):
|
|
57
|
+
# every position has exactly 1 state
|
|
58
|
+
for pos in get_all_pos(self.V, self.H):
|
|
59
|
+
state = [self.model_vars[(pos, direction)] for direction in Direction]
|
|
60
|
+
state.append(self.model_vars[(pos, None)])
|
|
61
|
+
self.model.AddExactlyOne(state)
|
|
62
|
+
# If a position points at X (and this is a valid pair) then X has to point at me
|
|
63
|
+
for pos in get_all_pos(self.V, self.H):
|
|
64
|
+
for direction in Direction:
|
|
65
|
+
neighbor = get_next_pos(pos, direction)
|
|
66
|
+
if not in_bounds(neighbor, self.V, self.H) or (pos, neighbor) not in self.valid_stitches: # this is not a valid stitch
|
|
67
|
+
self.model.Add(self.model_vars[(pos, direction)] == 0)
|
|
68
|
+
continue
|
|
69
|
+
opposite_direction = get_opposite_direction(direction)
|
|
70
|
+
self.model.Add(self.model_vars[(pos, direction)] == self.model_vars[(neighbor, opposite_direction)])
|
|
71
|
+
|
|
72
|
+
# all blocks connected exactly N times (N usually 1 but can be 2 or 3)
|
|
73
|
+
for connections in self.block_neighbors.values():
|
|
74
|
+
is_connected_list = []
|
|
75
|
+
for pos_a, direction_a, pos_b, direction_b in connections:
|
|
76
|
+
v = self.model.NewBoolVar(f'{pos_a}:{direction_a}->{pos_b}:{direction_b}')
|
|
77
|
+
and_constraint(self.model, v, [self.model_vars[pos_a, direction_a], self.model_vars[pos_b, direction_b]])
|
|
78
|
+
is_connected_list.append(v)
|
|
79
|
+
self.model.Add(sum(is_connected_list) == self.connection_count)
|
|
80
|
+
|
|
81
|
+
# sums of top and side must match
|
|
82
|
+
for col in range(self.H):
|
|
83
|
+
self.model.Add(sum([self.model_vars[pos, None] for pos in get_col_pos(col, self.V)]) == self.top_empties[col])
|
|
84
|
+
for row in range(self.V):
|
|
85
|
+
self.model.Add(sum([self.model_vars[pos, None] for pos in get_row_pos(row, self.H)]) == self.side_empties[row])
|
|
86
|
+
|
|
87
|
+
def solve_and_print(self, verbose: bool = True):
|
|
88
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
89
|
+
return SingleSolution(assignment={pos: direction.name[0] if direction is not None else ' ' for (pos, direction), var in board.model_vars.items() if solver.Value(var) == 1 and direction is not None})
|
|
90
|
+
def callback(single_res: SingleSolution):
|
|
91
|
+
print("Solution found")
|
|
92
|
+
print(combined_function(self.V, self.H,
|
|
93
|
+
cell_flags=id_board_to_wall_fn(self.board),
|
|
94
|
+
special_content=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ''),
|
|
95
|
+
center_char=lambda r, c: 'O' if get_pos(x=c, y=r) in single_res.assignment else '.'))
|
|
96
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=9)
|