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
|
+
from collections import defaultdict
|
|
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_char, get_next_pos, get_opposite_direction, get_pos, in_bounds
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Board:
|
|
13
|
+
def __init__(self, board: np.array):
|
|
14
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
15
|
+
assert all((c.item() in [' ', '#']) or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
16
|
+
number_set = set(int(c.item()) for c in np.nditer(board) if str(c.item()).isdecimal())
|
|
17
|
+
self.N = max(number_set)
|
|
18
|
+
assert number_set == set(range(1, self.N + 1)), 'numbers must be consecutive integers starting from 1'
|
|
19
|
+
self.board = board
|
|
20
|
+
self.V, self.H = board.shape
|
|
21
|
+
self.fixed_pos: dict[Pos, int] = {pos: int(get_char(self.board, pos).strip()) for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos).strip() not in ['', '#']}
|
|
22
|
+
self.board_char: dict[Pos, str] = {pos: get_char(self.board, pos).strip() for pos in get_all_pos(self.V, self.H) if get_char(self.board, pos).strip() != '#'}
|
|
23
|
+
|
|
24
|
+
self.model = cp_model.CpModel()
|
|
25
|
+
self.model_vars: dict[tuple[Pos, int], cp_model.IntVar] = {}
|
|
26
|
+
self.create_vars()
|
|
27
|
+
self.add_all_constraints()
|
|
28
|
+
|
|
29
|
+
def create_vars(self):
|
|
30
|
+
for pos in self.board_char:
|
|
31
|
+
for direction in Direction:
|
|
32
|
+
next_pos = get_next_pos(pos, direction)
|
|
33
|
+
opposite_direction = get_opposite_direction(direction)
|
|
34
|
+
if not in_bounds(next_pos, self.V, self.H):
|
|
35
|
+
continue
|
|
36
|
+
if get_char(self.board, next_pos).strip() == '#':
|
|
37
|
+
continue
|
|
38
|
+
if (next_pos, opposite_direction) in self.model_vars:
|
|
39
|
+
self.model_vars[(pos, direction)] = self.model_vars[(next_pos, opposite_direction)]
|
|
40
|
+
else:
|
|
41
|
+
self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
|
|
42
|
+
|
|
43
|
+
def is_neighbor(self, pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
|
|
44
|
+
p1, d1 = pd1
|
|
45
|
+
p2, d2 = pd2
|
|
46
|
+
if p1 == p2:
|
|
47
|
+
return True
|
|
48
|
+
p1_pointing_to_p2 = get_next_pos(p1, d1) == p2
|
|
49
|
+
if not p1_pointing_to_p2:
|
|
50
|
+
return False
|
|
51
|
+
is_fixed = d2 == 'FIXED_NODE' # pointing to a fixed node
|
|
52
|
+
return is_fixed or d2 == get_opposite_direction(d1)
|
|
53
|
+
|
|
54
|
+
def add_all_constraints(self):
|
|
55
|
+
for pos, c in self.fixed_pos.items():
|
|
56
|
+
target = 1 if (c == self.N) or (c == 1) else 2
|
|
57
|
+
self.model.Add(lxp.Sum([var for (p, _), var in self.model_vars.items() if p == pos]) == target)
|
|
58
|
+
for pos in self.board_char:
|
|
59
|
+
if pos in self.fixed_pos:
|
|
60
|
+
continue
|
|
61
|
+
self.model.Add(lxp.Sum([var for (p, _), var in self.model_vars.items() if p == pos]) == 2)
|
|
62
|
+
force_connected_component(self.model, self.model_vars, is_neighbor=self.is_neighbor)
|
|
63
|
+
self.implement_height_constraints()
|
|
64
|
+
|
|
65
|
+
def implement_height_constraints(self):
|
|
66
|
+
"""Every node has a height equal to every other non-fixed node that is connected to it. or equal to (+0 or +1) of the height of a fixed node it is connected to"""
|
|
67
|
+
nodes = [(k, v) for k, v in self.model_vars.items() if k[0] not in self.fixed_pos] # filter out fixed positions
|
|
68
|
+
fixed_nodes = [(pos, 'FIXED_NODE') for pos in self.fixed_pos]
|
|
69
|
+
node_heights = {(pos, 'FIXED_NODE'): self.model.NewConstant(c) for pos, c in self.fixed_pos.items()}
|
|
70
|
+
for k, v in nodes:
|
|
71
|
+
node_heights[k] = self.model.NewIntVar(0, self.N, f'node_height[{k}]')
|
|
72
|
+
self.model.Add(node_heights[k] == 0).OnlyEnforceIf(v.Not())
|
|
73
|
+
for (k, v) in nodes:
|
|
74
|
+
h = node_heights[k]
|
|
75
|
+
connected_nodes = [(k2, v2) for k2, v2 in nodes if self.is_neighbor(k, k2)]
|
|
76
|
+
for (k2, v2) in connected_nodes: # all pairs of non-fixed nodes that are connected to each other must have the same height
|
|
77
|
+
self.model.Add(h == node_heights[k2]).OnlyEnforceIf([v, v2])
|
|
78
|
+
connected_fixed_nodes = [node_heights[k2] for k2 in fixed_nodes if self.is_neighbor(k, k2)]
|
|
79
|
+
for h2 in connected_fixed_nodes: # connected to fixed node must have height of fixed node or 1 higher
|
|
80
|
+
zero_or_one = self.model.NewBoolVar(f'zero_or_one[{k}]') # node with height h can be connected to fixed node with height h or h+1
|
|
81
|
+
self.model.Add(zero_or_one == 0).OnlyEnforceIf([v.Not()])
|
|
82
|
+
self.model.Add(h == h2 + zero_or_one).OnlyEnforceIf([v])
|
|
83
|
+
|
|
84
|
+
def solve_and_print(self, verbose: bool = True):
|
|
85
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
86
|
+
assignment: dict[Pos, str] = defaultdict(str)
|
|
87
|
+
for pos in get_all_pos(self.V, self.H):
|
|
88
|
+
for direction in Direction:
|
|
89
|
+
if (pos, direction) in board.model_vars and solver.Value(board.model_vars[(pos, direction)]) == 1:
|
|
90
|
+
assignment[pos] += direction.name[0]
|
|
91
|
+
return SingleSolution(assignment=assignment)
|
|
92
|
+
def callback(single_res: SingleSolution):
|
|
93
|
+
print("Solution found")
|
|
94
|
+
print(combined_function(self.V, self.H, show_border_only=True, is_shaded=lambda r, c: get_char(self.board, get_pos(x=c, y=r)).strip() == '#',
|
|
95
|
+
special_content=lambda r, c: single_res.assignment[get_pos(x=c, y=r)] if get_pos(x=c, y=r) in single_res.assignment else None,
|
|
96
|
+
center_char=lambda r, c: get_char(self.board, get_pos(x=c, y=r)).strip().replace('#', '')))
|
|
97
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
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, 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 combined_function
|
|
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(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
|
+
))
|
|
130
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
from ortools.sat.python import cp_model
|
|
6
|
+
|
|
7
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_pos, in_bounds, get_next_pos, Direction, polyominoes
|
|
8
|
+
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import combined_function, id_board_to_wall_fn
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
Shape = frozenset[Pos] # a shape on the 2d board is just a set of positions
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class ShapeOnBoard:
|
|
16
|
+
is_active: cp_model.IntVar
|
|
17
|
+
shape: Shape
|
|
18
|
+
shape_id: int
|
|
19
|
+
body: set[Pos]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_valid_translations(shape: Shape, board: np.array) -> set[Pos]:
|
|
23
|
+
# give a shape and a board, return all valid translations of the shape that are fully contained in the board AND consistent with the clues on the board
|
|
24
|
+
shape_list = list(shape)
|
|
25
|
+
shape_borders = [] # will contain the number of borders for each pos in the shape; this has to be consistent with the clues on the board
|
|
26
|
+
for pos in shape_list:
|
|
27
|
+
v = 0
|
|
28
|
+
for direction in Direction:
|
|
29
|
+
next_pos = get_next_pos(pos, direction)
|
|
30
|
+
if not in_bounds(next_pos, board.shape[0], board.shape[1]) or next_pos not in shape:
|
|
31
|
+
v += 1
|
|
32
|
+
shape_borders.append(v)
|
|
33
|
+
shape_list = [(p.x, p.y) for p in shape_list]
|
|
34
|
+
# min x/y is always 0
|
|
35
|
+
max_x = max(p[0] for p in shape_list)
|
|
36
|
+
max_y = max(p[1] for p in shape_list)
|
|
37
|
+
for dy in range(0, board.shape[0] - max_y):
|
|
38
|
+
for dx in range(0, board.shape[1] - max_x):
|
|
39
|
+
body = tuple((p[0] + dx, p[1] + dy) for p in shape_list)
|
|
40
|
+
for i, p in enumerate(body):
|
|
41
|
+
c = board[p[1], p[0]]
|
|
42
|
+
if c != ' ' and c != str(shape_borders[i]): # there is a clue and it doesn't match my translated shape, skip
|
|
43
|
+
break
|
|
44
|
+
else:
|
|
45
|
+
yield frozenset(get_pos(x=p[0], y=p[1]) for p in body)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Board:
|
|
49
|
+
def __init__(self, board: np.array, region_size: int):
|
|
50
|
+
assert region_size >= 1 and isinstance(region_size, int), 'region_size must be an integer greater than or equal to 1'
|
|
51
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
52
|
+
assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
|
|
53
|
+
self.board = board
|
|
54
|
+
self.V, self.H = board.shape
|
|
55
|
+
self.region_size = region_size
|
|
56
|
+
self.region_count = (self.V * self.H) // self.region_size
|
|
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}'
|
|
58
|
+
self.polyominoes = polyominoes(self.region_size)
|
|
59
|
+
|
|
60
|
+
self.model = cp_model.CpModel()
|
|
61
|
+
self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
|
|
62
|
+
self.pos_to_shapes: dict[Pos, set[ShapeOnBoard]] = defaultdict(set)
|
|
63
|
+
self.create_vars()
|
|
64
|
+
self.add_all_constraints()
|
|
65
|
+
|
|
66
|
+
def create_vars(self):
|
|
67
|
+
for shape in self.polyominoes:
|
|
68
|
+
for body in get_valid_translations(shape, self.board):
|
|
69
|
+
uid = len(self.shapes_on_board)
|
|
70
|
+
shape_on_board = ShapeOnBoard(
|
|
71
|
+
is_active=self.model.NewBoolVar(f'{uid}:is_active'),
|
|
72
|
+
shape=shape, shape_id=uid, body=body
|
|
73
|
+
)
|
|
74
|
+
self.shapes_on_board.append(shape_on_board)
|
|
75
|
+
for pos in body:
|
|
76
|
+
self.pos_to_shapes[pos].add(shape_on_board)
|
|
77
|
+
|
|
78
|
+
def add_all_constraints(self):
|
|
79
|
+
for pos in get_all_pos(self.V, self.H): # each position has exactly one shape active
|
|
80
|
+
self.model.AddExactlyOne(shape.is_active for shape in self.pos_to_shapes[pos])
|
|
81
|
+
|
|
82
|
+
def solve_and_print(self, verbose: bool = True):
|
|
83
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
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})
|
|
86
|
+
def callback(single_res: SingleSolution):
|
|
87
|
+
print("Solution found")
|
|
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 '·'))
|
|
91
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
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 Pos, get_all_pos, in_bounds, Direction, get_next_pos, get_char, get_opposite_direction, get_pos, set_char
|
|
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
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Board:
|
|
13
|
+
def __init__(self, board: np.ndarray):
|
|
14
|
+
assert board.ndim == 2 and board.shape[0] > 0 and board.shape[1] > 0, f'board must be 2d, got {board.ndim}'
|
|
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
|
+
self.board = board
|
|
18
|
+
self.model = cp_model.CpModel()
|
|
19
|
+
self.cell_active: dict[Pos, cp_model.IntVar] = {}
|
|
20
|
+
self.cell_direction: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
|
|
21
|
+
|
|
22
|
+
self.create_vars()
|
|
23
|
+
self.add_all_constraints()
|
|
24
|
+
|
|
25
|
+
def create_vars(self):
|
|
26
|
+
for pos in get_all_pos(self.V, self.H):
|
|
27
|
+
self.cell_active[pos] = self.model.NewBoolVar(f"a[{pos}]")
|
|
28
|
+
for direction in Direction:
|
|
29
|
+
self.cell_direction[(pos, direction)] = self.model.NewBoolVar(f"b[{pos}]->({direction.name})")
|
|
30
|
+
|
|
31
|
+
def add_all_constraints(self):
|
|
32
|
+
self.force_direction_constraints()
|
|
33
|
+
self.force_wb_constraints()
|
|
34
|
+
self.force_connected_component()
|
|
35
|
+
|
|
36
|
+
def force_wb_constraints(self):
|
|
37
|
+
for pos in get_all_pos(self.V, self.H):
|
|
38
|
+
c = get_char(self.board, pos)
|
|
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
|
|
43
|
+
self.model.Add(self.cell_direction[(pos, Direction.UP)] != self.cell_direction[(pos, Direction.DOWN)])
|
|
44
|
+
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] != self.cell_direction[(pos, Direction.RIGHT)])
|
|
45
|
+
# must not be connected directly to another corner
|
|
46
|
+
for direction in Direction:
|
|
47
|
+
q = get_next_pos(pos, direction)
|
|
48
|
+
if not in_bounds(q, self.V, self.H):
|
|
49
|
+
continue
|
|
50
|
+
self.model.AddImplication(self.cell_direction[(pos, direction)], self.cell_direction[(q, direction)])
|
|
51
|
+
elif c == 'W': # white circle must be a straight which is connected to at least one corner
|
|
52
|
+
self.model.Add(self.cell_direction[(pos, Direction.UP)] == self.cell_direction[(pos, Direction.DOWN)])
|
|
53
|
+
self.model.Add(self.cell_direction[(pos, Direction.LEFT)] == self.cell_direction[(pos, Direction.RIGHT)])
|
|
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)
|
|
55
|
+
aux_list: list[cp_model.IntVar] = []
|
|
56
|
+
for direction in Direction:
|
|
57
|
+
q = get_next_pos(pos, direction)
|
|
58
|
+
if not in_bounds(q, self.V, self.H):
|
|
59
|
+
continue
|
|
60
|
+
ortho_directions = {Direction.UP, Direction.DOWN, Direction.LEFT, Direction.RIGHT} - {direction, get_opposite_direction(direction)}
|
|
61
|
+
for ortho_direction in ortho_directions:
|
|
62
|
+
aux = self.model.NewBoolVar(f"A[{pos}]<-({q})")
|
|
63
|
+
and_constraint(self.model, target=aux, cs=[self.cell_direction[(q, ortho_direction)], self.cell_direction[(pos, direction)]])
|
|
64
|
+
aux_list.append(aux)
|
|
65
|
+
self.model.Add(lxp.Sum(aux_list) >= 1)
|
|
66
|
+
|
|
67
|
+
def force_direction_constraints(self):
|
|
68
|
+
for pos in get_all_pos(self.V, self.H):
|
|
69
|
+
# cell active means exactly 2 directions are active, cell not active means no directions are active
|
|
70
|
+
s = sum([self.cell_direction[(pos, direction)] for direction in Direction])
|
|
71
|
+
self.model.Add(s == 2).OnlyEnforceIf(self.cell_active[pos])
|
|
72
|
+
self.model.Add(s == 0).OnlyEnforceIf(self.cell_active[pos].Not())
|
|
73
|
+
# X having right means the cell to its right has left and so on for all directions
|
|
74
|
+
for direction in Direction:
|
|
75
|
+
q = get_next_pos(pos, direction)
|
|
76
|
+
if in_bounds(q, self.V, self.H):
|
|
77
|
+
self.model.Add(self.cell_direction[(pos, direction)] == self.cell_direction[(q, get_opposite_direction(direction))])
|
|
78
|
+
else:
|
|
79
|
+
self.model.Add(self.cell_direction[(pos, direction)] == 0)
|
|
80
|
+
|
|
81
|
+
def force_connected_component(self):
|
|
82
|
+
def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
|
|
83
|
+
p1, d1 = pd1
|
|
84
|
+
p2, d2 = pd2
|
|
85
|
+
if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
|
|
86
|
+
return True
|
|
87
|
+
if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
|
|
88
|
+
return True
|
|
89
|
+
return False
|
|
90
|
+
force_connected_component(self.model, self.cell_direction, is_neighbor=is_neighbor)
|
|
91
|
+
|
|
92
|
+
def solve_and_print(self, verbose: bool = True):
|
|
93
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
94
|
+
assignment: dict[Pos, str] = defaultdict(str)
|
|
95
|
+
for (pos, direction), var in board.cell_direction.items():
|
|
96
|
+
assignment[pos] += direction.name[0] if solver.BooleanValue(var) else ''
|
|
97
|
+
return SingleSolution(assignment=assignment)
|
|
98
|
+
def callback(single_res: SingleSolution):
|
|
99
|
+
print("Solution found")
|
|
100
|
+
output_board = np.full((self.V, self.H), '', dtype=str)
|
|
101
|
+
for pos in get_all_pos(self.V, self.H):
|
|
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]))
|
|
107
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=20)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from ortools.sat.python import cp_model
|
|
3
|
+
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
4
|
+
|
|
5
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, Direction, get_next_pos, get_opposite_direction
|
|
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
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Board:
|
|
11
|
+
def __init__(self, board: np.array):
|
|
12
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
13
|
+
assert all(c.item().strip() in ['1', '2L', '2I', '3', '4'] for c in np.nditer(board)), 'board must contain only 1, 2L, 2I, 3, 4. Found:' + str(set(c.item().strip() for c in np.nditer(board)) - set(['1', '2L', '2I', '3', '4']))
|
|
14
|
+
self.board = board
|
|
15
|
+
self.V, self.H = board.shape
|
|
16
|
+
self.model = cp_model.CpModel()
|
|
17
|
+
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
18
|
+
|
|
19
|
+
self.create_vars()
|
|
20
|
+
self.add_all_constraints()
|
|
21
|
+
|
|
22
|
+
def create_vars(self):
|
|
23
|
+
for pos in get_all_pos(self.V, self.H):
|
|
24
|
+
for direction in Direction:
|
|
25
|
+
mirrored = (get_next_pos(pos, direction), get_opposite_direction(direction))
|
|
26
|
+
if mirrored in self.model_vars:
|
|
27
|
+
self.model_vars[(pos, direction)] = self.model_vars[mirrored]
|
|
28
|
+
else:
|
|
29
|
+
self.model_vars[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
|
|
30
|
+
|
|
31
|
+
def add_all_constraints(self):
|
|
32
|
+
for pos in get_all_pos(self.V, self.H):
|
|
33
|
+
self.force_position(pos, get_char(self.board, pos).strip())
|
|
34
|
+
# single connected component
|
|
35
|
+
self.force_connected_component()
|
|
36
|
+
|
|
37
|
+
def force_connected_component(self):
|
|
38
|
+
def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
|
|
39
|
+
p1, d1 = pd1
|
|
40
|
+
p2, d2 = pd2
|
|
41
|
+
if p1 == p2 and d1 != d2: # same position, different direction, is neighbor
|
|
42
|
+
return True
|
|
43
|
+
if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
force_connected_component(self.model, self.model_vars, is_neighbor=is_neighbor)
|
|
47
|
+
|
|
48
|
+
def force_position(self, pos: Pos, value: str):
|
|
49
|
+
# cells with 1 or 3 or 4 neighbors each only have 1 unique state under rotational symmetry
|
|
50
|
+
# cells with 2 neighbors can either be a straight line (2I) or curved line (2L)
|
|
51
|
+
if value == '1':
|
|
52
|
+
self.model.Add(lxp.sum([self.model_vars[(pos, direction)] for direction in Direction]) == 1)
|
|
53
|
+
elif value == '2L':
|
|
54
|
+
self.model.Add(self.model_vars[(pos, Direction.LEFT)] != self.model_vars[(pos, Direction.RIGHT)])
|
|
55
|
+
self.model.Add(self.model_vars[(pos, Direction.UP)] != self.model_vars[(pos, Direction.DOWN)])
|
|
56
|
+
elif value == '2I':
|
|
57
|
+
self.model.Add(self.model_vars[(pos, Direction.LEFT)] == self.model_vars[(pos, Direction.RIGHT)])
|
|
58
|
+
self.model.Add(self.model_vars[(pos, Direction.UP)] == self.model_vars[(pos, Direction.DOWN)])
|
|
59
|
+
self.model.Add(self.model_vars[(pos, Direction.UP)] != self.model_vars[(pos, Direction.RIGHT)])
|
|
60
|
+
elif value == '3':
|
|
61
|
+
self.model.Add(lxp.sum([self.model_vars[(pos, direction)] for direction in Direction]) == 3)
|
|
62
|
+
elif value == '4':
|
|
63
|
+
self.model.Add(lxp.sum([self.model_vars[(pos, direction)] for direction in Direction]) == 4)
|
|
64
|
+
else:
|
|
65
|
+
raise ValueError(f'invalid value: {value}')
|
|
66
|
+
|
|
67
|
+
def solve_and_print(self, verbose: bool = True):
|
|
68
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
69
|
+
assignment = {}
|
|
70
|
+
for pos in get_all_pos(self.V, self.H):
|
|
71
|
+
assignment[pos] = ''
|
|
72
|
+
for direction in Direction:
|
|
73
|
+
if solver.Value(board.model_vars[(pos, direction)]) == 1:
|
|
74
|
+
assignment[pos] += direction.name[0]
|
|
75
|
+
return SingleSolution(assignment=assignment)
|
|
76
|
+
def callback(single_res: SingleSolution):
|
|
77
|
+
print("Solution found")
|
|
78
|
+
res = np.full((self.V, self.H), ' ', dtype=object)
|
|
79
|
+
for pos in get_all_pos(self.V, self.H):
|
|
80
|
+
set_char(res, pos, single_res.assignment[pos])
|
|
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 '')),
|
|
82
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|
|
@@ -0,0 +1,59 @@
|
|
|
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_pos, get_neighbors4, Direction, get_char, get_ray
|
|
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
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Board:
|
|
10
|
+
def __init__(self, clues: np.ndarray):
|
|
11
|
+
assert clues.ndim == 2 and clues.shape[0] > 0 and clues.shape[1] > 0, f'clues must be 2d, got {clues.ndim}'
|
|
12
|
+
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))}'
|
|
13
|
+
self.V, self.H = clues.shape
|
|
14
|
+
self.clues = clues
|
|
15
|
+
|
|
16
|
+
self.model = cp_model.CpModel()
|
|
17
|
+
self.b: dict[Pos, cp_model.IntVar] = {}
|
|
18
|
+
self.w: dict[Pos, cp_model.IntVar] = {}
|
|
19
|
+
|
|
20
|
+
self.create_vars()
|
|
21
|
+
self.add_all_constraints()
|
|
22
|
+
|
|
23
|
+
def create_vars(self):
|
|
24
|
+
for pos in get_all_pos(self.V, self.H):
|
|
25
|
+
self.b[pos] = self.model.NewBoolVar(f"b[{pos}]")
|
|
26
|
+
self.w[pos] = self.b[pos].Not()
|
|
27
|
+
|
|
28
|
+
def add_all_constraints(self):
|
|
29
|
+
self.no_adjacent_blacks()
|
|
30
|
+
self.range_clues()
|
|
31
|
+
force_connected_component(self.model, self.w)
|
|
32
|
+
|
|
33
|
+
def no_adjacent_blacks(self):
|
|
34
|
+
for p in get_all_pos(self.V, self.H):
|
|
35
|
+
for q in get_neighbors4(p, self.V, self.H):
|
|
36
|
+
self.model.Add(self.b[p] + self.b[q] <= 1)
|
|
37
|
+
|
|
38
|
+
def range_clues(self):
|
|
39
|
+
for pos in get_all_pos(self.V, self.H): # For each numbered cell c with value k
|
|
40
|
+
k = str(get_char(self.clues, pos)).strip()
|
|
41
|
+
if not k:
|
|
42
|
+
continue
|
|
43
|
+
self.model.Add(self.w[pos] == 1) # Force it white
|
|
44
|
+
vis_vars: list[cp_model.IntVar] = []
|
|
45
|
+
for direction in Direction: # Build visibility chains in four direction
|
|
46
|
+
ray = get_ray(pos, direction, self.V, self.H) # cells outward
|
|
47
|
+
for idx in range(len(ray)):
|
|
48
|
+
v = self.model.NewBoolVar(f"vis[{pos}]->({direction.name})[{idx}]")
|
|
49
|
+
and_constraint(self.model, target=v, cs=[self.w[p] for p in ray[:idx+1]])
|
|
50
|
+
vis_vars.append(v)
|
|
51
|
+
self.model.Add(1 + sum(vis_vars) == int(k)) # Sum of visible whites = 1 (itself) + sum(chains) == k
|
|
52
|
+
|
|
53
|
+
def solve_and_print(self, verbose: bool = True):
|
|
54
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
55
|
+
return SingleSolution(assignment={pos: solver.Value(board.b[pos]) for pos in get_all_pos(board.V, board.H)})
|
|
56
|
+
def callback(single_res: SingleSolution):
|
|
57
|
+
print("Solution:")
|
|
58
|
+
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))
|
|
59
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|