multi-puzzle-solver 0.9.26__py3-none-any.whl → 0.9.30__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of multi-puzzle-solver might be problematic. Click here for more details.
- {multi_puzzle_solver-0.9.26.dist-info → multi_puzzle_solver-0.9.30.dist-info}/METADATA +982 -41
- {multi_puzzle_solver-0.9.26.dist-info → multi_puzzle_solver-0.9.30.dist-info}/RECORD +20 -12
- puzzle_solver/__init__.py +8 -1
- puzzle_solver/core/utils.py +0 -153
- puzzle_solver/core/utils_visualizer.py +523 -0
- puzzle_solver/puzzles/binairo/binairo.py +44 -16
- puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
- puzzle_solver/puzzles/heyawake/heyawake.py +94 -0
- puzzle_solver/puzzles/kakuro/kakuro.py +77 -0
- puzzle_solver/puzzles/nurikabe/nurikabe.py +126 -0
- puzzle_solver/puzzles/palisade/palisade.py +2 -1
- puzzle_solver/puzzles/rectangles/rectangles.py +2 -2
- puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
- puzzle_solver/puzzles/shingoki/shingoki.py +158 -0
- puzzle_solver/puzzles/singles/singles.py +14 -40
- puzzle_solver/puzzles/slitherlink/slitherlink.py +2 -1
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py +3 -1
- puzzle_solver/puzzles/tapa/tapa.py +98 -0
- {multi_puzzle_solver-0.9.26.dist-info → multi_puzzle_solver-0.9.30.dist-info}/WHEEL +0 -0
- {multi_puzzle_solver-0.9.26.dist-info → multi_puzzle_solver-0.9.30.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import Union, Callable, Optional, List, Sequence, Literal
|
|
3
|
+
from puzzle_solver.core.utils import Pos, get_all_pos, get_next_pos, in_bounds, set_char, get_char, Direction
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def render_grid(cell_flags: np.ndarray,
|
|
7
|
+
center_char: Union[np.ndarray, str, None] = None,
|
|
8
|
+
show_axes: bool = True,
|
|
9
|
+
scale_x: int = 2) -> str:
|
|
10
|
+
"""
|
|
11
|
+
most of this function was AI generated then modified by me, I don't currently care about the details of rendering to the terminal this looked good enough during my testing.
|
|
12
|
+
cell_flags: np.ndarray of shape (N, N) with characters 'U', 'D', 'L', 'R' to represent the edges of the cells.
|
|
13
|
+
center_char: np.ndarray of shape (N, N) with the center of the cells, or a string to use for all cells, or None to not show centers.
|
|
14
|
+
scale_x: horizontal stretch factor (>=1). Try 2 or 3 for squarer cells.
|
|
15
|
+
"""
|
|
16
|
+
assert cell_flags is not None and cell_flags.ndim == 2
|
|
17
|
+
R, C = cell_flags.shape
|
|
18
|
+
|
|
19
|
+
# Edge presence arrays (note the rectangular shapes)
|
|
20
|
+
H = np.zeros((R+1, C), dtype=bool) # horizontal edges between rows
|
|
21
|
+
V = np.zeros((R, C+1), dtype=bool) # vertical edges between cols
|
|
22
|
+
for r in range(R):
|
|
23
|
+
for c in range(C):
|
|
24
|
+
s = cell_flags[r, c]
|
|
25
|
+
if 'U' in s: H[r, c] = True
|
|
26
|
+
if 'D' in s: H[r+1, c] = True
|
|
27
|
+
if 'L' in s: V[r, c] = True
|
|
28
|
+
if 'R' in s: V[r, c+1] = True
|
|
29
|
+
|
|
30
|
+
# Bitmask for corner connections
|
|
31
|
+
U, Rb, D, Lb = 1, 2, 4, 8
|
|
32
|
+
JUNCTION = {
|
|
33
|
+
0: ' ',
|
|
34
|
+
U: '│', D: '│', U|D: '│',
|
|
35
|
+
Lb: '─', Rb: '─', Lb|Rb: '─',
|
|
36
|
+
U|Rb: '└', Rb|D: '┌', D|Lb: '┐', Lb|U: '┘',
|
|
37
|
+
U|D|Lb: '┤', U|D|Rb: '├', Lb|Rb|U: '┴', Lb|Rb|D: '┬',
|
|
38
|
+
U|Rb|D|Lb: '┼',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
assert scale_x >= 1
|
|
42
|
+
assert H.shape == (R+1, C) and V.shape == (R, C+1)
|
|
43
|
+
|
|
44
|
+
rows = 2*R + 1
|
|
45
|
+
cols = 2*C*scale_x + 1
|
|
46
|
+
canvas = [[' ']*cols for _ in range(rows)]
|
|
47
|
+
|
|
48
|
+
def x_corner(c): # x of corner column c (0..C)
|
|
49
|
+
return (2*c) * scale_x
|
|
50
|
+
def x_between(c,k): # kth in-between col (1..2*scale_x-1) between corners c and c+1
|
|
51
|
+
return (2*c) * scale_x + k
|
|
52
|
+
|
|
53
|
+
# horizontal edges: fill the stretched band between corners with '─'
|
|
54
|
+
for r in range(R+1):
|
|
55
|
+
rr = 2*r
|
|
56
|
+
for c in range(C):
|
|
57
|
+
if H[r, c]:
|
|
58
|
+
for k in range(1, scale_x*2): # 1..(2*scale_x-1)
|
|
59
|
+
canvas[rr][x_between(c, k)] = '─'
|
|
60
|
+
|
|
61
|
+
# vertical edges: at the corner columns
|
|
62
|
+
for r in range(R):
|
|
63
|
+
rr = 2*r + 1
|
|
64
|
+
for c in range(C+1):
|
|
65
|
+
if V[r, c]:
|
|
66
|
+
canvas[rr][x_corner(c)] = '│'
|
|
67
|
+
|
|
68
|
+
# junctions at every corner grid point
|
|
69
|
+
for r in range(R+1):
|
|
70
|
+
rr = 2*r
|
|
71
|
+
for c in range(C+1):
|
|
72
|
+
m = 0
|
|
73
|
+
if r > 0 and V[r-1, c]: m |= U
|
|
74
|
+
if c < C and H[r, c]: m |= Rb
|
|
75
|
+
if r < R and V[r, c]: m |= D
|
|
76
|
+
if c > 0 and H[r, c-1]: m |= Lb
|
|
77
|
+
canvas[rr][x_corner(c)] = JUNCTION[m]
|
|
78
|
+
|
|
79
|
+
# centers (safe for multi-character strings)
|
|
80
|
+
def put_center_text(rr: int, c: int, text: str):
|
|
81
|
+
left = x_corner(c) + 1
|
|
82
|
+
right = x_corner(c+1) - 1
|
|
83
|
+
if right < left:
|
|
84
|
+
return
|
|
85
|
+
span_width = right - left + 1
|
|
86
|
+
s = str(text)
|
|
87
|
+
if len(s) > span_width:
|
|
88
|
+
s = s[:span_width] # truncate to protect borders
|
|
89
|
+
start = left + (span_width - len(s)) // 2
|
|
90
|
+
for i, ch in enumerate(s):
|
|
91
|
+
canvas[rr][start + i] = ch
|
|
92
|
+
|
|
93
|
+
if center_char is not None:
|
|
94
|
+
for r in range(R):
|
|
95
|
+
rr = 2*r + 1
|
|
96
|
+
for c in range(C):
|
|
97
|
+
val = center_char if isinstance(center_char, str) else center_char[r, c]
|
|
98
|
+
put_center_text(rr, c, '' if val is None else str(val))
|
|
99
|
+
|
|
100
|
+
# rows -> strings
|
|
101
|
+
art_rows = [''.join(row) for row in canvas]
|
|
102
|
+
if not show_axes:
|
|
103
|
+
return '\n'.join(art_rows)
|
|
104
|
+
|
|
105
|
+
# Axes labels: row indices on the left, column indices on top (handle C, not R)
|
|
106
|
+
gut = max(2, len(str(R-1))) # gutter width based on row index width
|
|
107
|
+
gutter = ' ' * gut
|
|
108
|
+
top_tens = list(gutter + ' ' * cols)
|
|
109
|
+
top_ones = list(gutter + ' ' * cols)
|
|
110
|
+
|
|
111
|
+
for c in range(C):
|
|
112
|
+
xc_center = x_corner(c) + scale_x
|
|
113
|
+
if C >= 10:
|
|
114
|
+
top_tens[gut + xc_center] = str((c // 10) % 10)
|
|
115
|
+
top_ones[gut + xc_center] = str(c % 10)
|
|
116
|
+
|
|
117
|
+
if gut >= 2:
|
|
118
|
+
top_tens[gut-2:gut] = list(' ')
|
|
119
|
+
top_ones[gut-2:gut] = list(' ')
|
|
120
|
+
|
|
121
|
+
labeled = []
|
|
122
|
+
for r, line in enumerate(art_rows):
|
|
123
|
+
if r % 2 == 1: # cell-center row
|
|
124
|
+
label = str(r//2).rjust(gut)
|
|
125
|
+
else:
|
|
126
|
+
label = ' ' * gut
|
|
127
|
+
labeled.append(label + line)
|
|
128
|
+
|
|
129
|
+
return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
|
|
130
|
+
|
|
131
|
+
def id_board_to_wall_board(id_board: np.array, border_is_wall = True) -> np.array:
|
|
132
|
+
"""In many instances, we have a 2d array where cell values are arbitrary ids
|
|
133
|
+
and we want to convert it to a 2d array where cell values are walls "U", "D", "L", "R" to represent the edges that separate me from my neighbors that have different ids.
|
|
134
|
+
Args:
|
|
135
|
+
id_board: np.array of shape (N, N) with arbitrary ids.
|
|
136
|
+
border_is_wall: if True, the edges of the board are considered to be walls.
|
|
137
|
+
Returns:
|
|
138
|
+
np.array of shape (N, N) with walls "U", "D", "L", "R".
|
|
139
|
+
"""
|
|
140
|
+
res = np.full((id_board.shape[0], id_board.shape[1]), '', dtype=object)
|
|
141
|
+
V, H = id_board.shape
|
|
142
|
+
def append_char(pos: Pos, s: str):
|
|
143
|
+
set_char(res, pos, get_char(res, pos) + s)
|
|
144
|
+
def handle_pos_direction(pos: Pos, direction: Direction, s: str):
|
|
145
|
+
pos2 = get_next_pos(pos, direction)
|
|
146
|
+
if in_bounds(pos2, V, H):
|
|
147
|
+
if get_char(id_board, pos2) != get_char(id_board, pos):
|
|
148
|
+
append_char(pos, s)
|
|
149
|
+
else:
|
|
150
|
+
if border_is_wall:
|
|
151
|
+
append_char(pos, s)
|
|
152
|
+
for pos in get_all_pos(V, H):
|
|
153
|
+
handle_pos_direction(pos, Direction.LEFT, 'L')
|
|
154
|
+
handle_pos_direction(pos, Direction.RIGHT, 'R')
|
|
155
|
+
handle_pos_direction(pos, Direction.UP, 'U')
|
|
156
|
+
handle_pos_direction(pos, Direction.DOWN, 'D')
|
|
157
|
+
return res
|
|
158
|
+
|
|
159
|
+
def render_shaded_grid(V: int,
|
|
160
|
+
H: int,
|
|
161
|
+
is_shaded: Callable[[int, int], bool],
|
|
162
|
+
*,
|
|
163
|
+
scale_x: int = 2,
|
|
164
|
+
scale_y: int = 1,
|
|
165
|
+
fill_char: str = '▒',
|
|
166
|
+
empty_char: str = ' ',
|
|
167
|
+
empty_text: Optional[Union[str, Callable[[int, int], Optional[str]]]] = None,
|
|
168
|
+
show_axes: bool = True) -> str:
|
|
169
|
+
"""
|
|
170
|
+
Most of this function was AI generated then modified by me, I don't currently care about the details of rendering to the terminal this looked good enough during my testing.
|
|
171
|
+
Visualize a V x H grid where each cell is shaded if is_shaded(r, c) is True.
|
|
172
|
+
The grid lines are always present.
|
|
173
|
+
|
|
174
|
+
scale_x: horizontal stretch (>=1). Interior width per cell = 2*scale_x - 1.
|
|
175
|
+
scale_y: vertical stretch (>=1). Interior height per cell = scale_y.
|
|
176
|
+
fill_char: character to fill shaded cell interiors (single char).
|
|
177
|
+
empty_char: background character for unshaded interiors (single char).
|
|
178
|
+
empty_text: Optional text for unshaded cells. If a string, used for all unshaded
|
|
179
|
+
cells. If a callable (r, c) -> str|None, used per cell. Text is
|
|
180
|
+
centered within the interior row and truncated to fit.
|
|
181
|
+
"""
|
|
182
|
+
assert V >= 1 and H >= 1
|
|
183
|
+
assert scale_x >= 1 and scale_y >= 1
|
|
184
|
+
assert len(fill_char) == 1 and len(empty_char) == 1
|
|
185
|
+
|
|
186
|
+
# ── Layout helpers ─────────────────────────────────────────────────────
|
|
187
|
+
def x_corner(c: int) -> int: # column of vertical border at grid column c (0..H)
|
|
188
|
+
return (2 * c) * scale_x
|
|
189
|
+
def y_border(r: int) -> int: # row of horizontal border at grid row r (0..V)
|
|
190
|
+
return (scale_y + 1) * r
|
|
191
|
+
|
|
192
|
+
rows = y_border(V) + 1
|
|
193
|
+
cols = x_corner(H) + 1
|
|
194
|
+
canvas = [[empty_char] * cols for _ in range(rows)]
|
|
195
|
+
|
|
196
|
+
# ── Shading first (borders will overwrite as needed) ───────────────────
|
|
197
|
+
shaded_map = [[False]*H for _ in range(V)]
|
|
198
|
+
for r in range(V):
|
|
199
|
+
top = y_border(r) + 1
|
|
200
|
+
bottom = y_border(r + 1) - 1 # inclusive
|
|
201
|
+
if top > bottom:
|
|
202
|
+
continue
|
|
203
|
+
for c in range(H):
|
|
204
|
+
left = x_corner(c) + 1
|
|
205
|
+
right = x_corner(c + 1) - 1 # inclusive
|
|
206
|
+
if left > right:
|
|
207
|
+
continue
|
|
208
|
+
shaded = bool(is_shaded(r, c))
|
|
209
|
+
shaded_map[r][c] = shaded
|
|
210
|
+
ch = fill_char if shaded else empty_char
|
|
211
|
+
for yy in range(top, bottom + 1):
|
|
212
|
+
for xx in range(left, right + 1):
|
|
213
|
+
canvas[yy][xx] = ch
|
|
214
|
+
|
|
215
|
+
# ── Grid lines ─────────────────────────────────────────────────────────
|
|
216
|
+
U, Rb, D, Lb = 1, 2, 4, 8
|
|
217
|
+
JUNCTION = {
|
|
218
|
+
0: ' ',
|
|
219
|
+
U: '│', D: '│', U | D: '│',
|
|
220
|
+
Lb: '─', Rb: '─', Lb | Rb: '─',
|
|
221
|
+
U | Rb: '└', Rb | D: '┌', D | Lb: '┐', Lb | U: '┘',
|
|
222
|
+
U | D | Lb: '┤', U | D | Rb: '├', Lb | Rb | U: '┴', Lb | Rb | D: '┬',
|
|
223
|
+
U | Rb | D | Lb: '┼',
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
# Horizontal borders (every y_border row)
|
|
227
|
+
for r in range(V + 1):
|
|
228
|
+
yy = y_border(r)
|
|
229
|
+
for c in range(H):
|
|
230
|
+
base = x_corner(c)
|
|
231
|
+
for k in range(1, 2 * scale_x): # 1..(2*scale_x-1)
|
|
232
|
+
canvas[yy][base + k] = '─'
|
|
233
|
+
|
|
234
|
+
# Vertical borders (every x_corner col)
|
|
235
|
+
for c in range(H + 1):
|
|
236
|
+
xx = x_corner(c)
|
|
237
|
+
for r in range(V):
|
|
238
|
+
for ky in range(1, scale_y + 1):
|
|
239
|
+
canvas[y_border(r) + ky][xx] = '│'
|
|
240
|
+
|
|
241
|
+
# Junctions at intersections
|
|
242
|
+
for r in range(V + 1):
|
|
243
|
+
yy = y_border(r)
|
|
244
|
+
for c in range(H + 1):
|
|
245
|
+
xx = x_corner(c)
|
|
246
|
+
m = 0
|
|
247
|
+
if r > 0: m |= U
|
|
248
|
+
if r < V: m |= D
|
|
249
|
+
if c > 0: m |= Lb
|
|
250
|
+
if c < H: m |= Rb
|
|
251
|
+
canvas[yy][xx] = JUNCTION[m]
|
|
252
|
+
|
|
253
|
+
# ── Optional per-cell text for UNshaded cells ──────────────────────────
|
|
254
|
+
def put_center_text(r_cell: int, c_cell: int, s: str):
|
|
255
|
+
# interior box
|
|
256
|
+
left = x_corner(c_cell) + 1
|
|
257
|
+
right = x_corner(c_cell + 1) - 1
|
|
258
|
+
top = y_border(r_cell) + 1
|
|
259
|
+
bottom= y_border(r_cell + 1) - 1
|
|
260
|
+
if left > right or top > bottom:
|
|
261
|
+
return
|
|
262
|
+
span_w = right - left + 1
|
|
263
|
+
# choose middle interior row for text
|
|
264
|
+
yy = top + (bottom - top) // 2
|
|
265
|
+
s = '' if s is None else str(s)
|
|
266
|
+
if len(s) > span_w:
|
|
267
|
+
s = s[:span_w]
|
|
268
|
+
start = left + (span_w - len(s)) // 2
|
|
269
|
+
for i, ch in enumerate(s):
|
|
270
|
+
canvas[yy][start + i] = ch
|
|
271
|
+
|
|
272
|
+
if empty_text is not None:
|
|
273
|
+
for r in range(V):
|
|
274
|
+
for c in range(H):
|
|
275
|
+
if not shaded_map[r][c]:
|
|
276
|
+
s = empty_text(r, c) if callable(empty_text) else empty_text
|
|
277
|
+
if s:
|
|
278
|
+
put_center_text(r, c, s)
|
|
279
|
+
|
|
280
|
+
# ── Stringify ──────────────────────────────────────────────────────────
|
|
281
|
+
art_rows = [''.join(row) for row in canvas]
|
|
282
|
+
if not show_axes:
|
|
283
|
+
return '\n'.join(art_rows)
|
|
284
|
+
|
|
285
|
+
# Axes labels: columns on top; rows on left
|
|
286
|
+
gut = max(2, len(str(V - 1)))
|
|
287
|
+
gutter = ' ' * gut
|
|
288
|
+
top_tens = list(gutter + ' ' * cols)
|
|
289
|
+
top_ones = list(gutter + ' ' * cols)
|
|
290
|
+
for c in range(H):
|
|
291
|
+
xc_center = x_corner(c) + scale_x
|
|
292
|
+
if H >= 10:
|
|
293
|
+
top_tens[gut + xc_center] = str((c // 10) % 10)
|
|
294
|
+
top_ones[gut + xc_center] = str(c % 10)
|
|
295
|
+
if gut >= 2:
|
|
296
|
+
top_tens[gut - 2:gut] = list(' ')
|
|
297
|
+
top_ones[gut - 2:gut] = list(' ')
|
|
298
|
+
|
|
299
|
+
labeled = []
|
|
300
|
+
for y, line in enumerate(art_rows):
|
|
301
|
+
mod = y % (scale_y + 1)
|
|
302
|
+
if 1 <= mod <= scale_y:
|
|
303
|
+
r = y // (scale_y + 1)
|
|
304
|
+
mid = (scale_y + 1) // 2
|
|
305
|
+
label = (str(r).rjust(gut) if mod == mid else ' ' * gut)
|
|
306
|
+
else:
|
|
307
|
+
label = ' ' * gut
|
|
308
|
+
labeled.append(label + line)
|
|
309
|
+
|
|
310
|
+
return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
|
|
311
|
+
|
|
312
|
+
CellVal = Literal["B", "W", "TL", "TR", "BL", "BR"]
|
|
313
|
+
GridLike = Sequence[Sequence[CellVal]]
|
|
314
|
+
|
|
315
|
+
def render_bw_tiles_split(
|
|
316
|
+
grid: GridLike,
|
|
317
|
+
cell_w: int = 6,
|
|
318
|
+
cell_h: int = 3,
|
|
319
|
+
borders: bool = False,
|
|
320
|
+
mode: Literal["ansi", "text"] = "ansi",
|
|
321
|
+
text_palette: Literal["solid", "hatch"] = "solid",
|
|
322
|
+
cell_text: Optional[Callable[[int, int], str]] = None) -> str:
|
|
323
|
+
"""
|
|
324
|
+
Render a VxH grid with '/' or '\\' splits and optional per-cell centered text.
|
|
325
|
+
|
|
326
|
+
`cell_text(r, c) -> str`: if returns non-empty, its first character is drawn
|
|
327
|
+
near the geometric center of cell (r,c), nudged to the black side for halves.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
V = len(grid)
|
|
331
|
+
if V == 0:
|
|
332
|
+
return ""
|
|
333
|
+
H = len(grid[0])
|
|
334
|
+
if any(len(row) != H for row in grid):
|
|
335
|
+
raise ValueError("All rows must have the same length")
|
|
336
|
+
if cell_w < 1 or cell_h < 1:
|
|
337
|
+
raise ValueError("cell_w and cell_h must be >= 1")
|
|
338
|
+
|
|
339
|
+
allowed = {"B","W","TL","TR","BL","BR"}
|
|
340
|
+
for r in range(V):
|
|
341
|
+
for c in range(H):
|
|
342
|
+
if grid[r][c] not in allowed:
|
|
343
|
+
raise ValueError(f"Invalid cell value at ({r},{c}): {grid[r][c]}")
|
|
344
|
+
|
|
345
|
+
# ── Mode setup ─────────────────────────────────────────────────────────
|
|
346
|
+
use_color = (mode == "ansi")
|
|
347
|
+
|
|
348
|
+
def sgr(bg: int | None = None, fg: int | None = None) -> str:
|
|
349
|
+
if not use_color:
|
|
350
|
+
return ""
|
|
351
|
+
parts = []
|
|
352
|
+
if fg is not None: parts.append(str(fg))
|
|
353
|
+
if bg is not None: parts.append(str(bg))
|
|
354
|
+
return ("\x1b[" + ";".join(parts) + "m") if parts else ""
|
|
355
|
+
|
|
356
|
+
RESET = "\x1b[0m" if use_color else ""
|
|
357
|
+
|
|
358
|
+
BG_BLACK, BG_WHITE = 40, 47
|
|
359
|
+
FG_BLACK, FG_WHITE = 30, 37
|
|
360
|
+
|
|
361
|
+
if text_palette == "solid":
|
|
362
|
+
TXT_BLACK, TXT_WHITE = " ", "█"
|
|
363
|
+
elif text_palette == "hatch":
|
|
364
|
+
TXT_BLACK, TXT_WHITE = "░", "▓"
|
|
365
|
+
else:
|
|
366
|
+
raise ValueError("text_palette must be 'solid' or 'hatch'")
|
|
367
|
+
|
|
368
|
+
def diag_kind_and_slash(val: CellVal):
|
|
369
|
+
if val in ("TR", "BL"):
|
|
370
|
+
return "main", "\\"
|
|
371
|
+
elif val in ("TL", "BR"):
|
|
372
|
+
return "anti", "/"
|
|
373
|
+
return None, "?"
|
|
374
|
+
|
|
375
|
+
def is_black(val: CellVal, fx: float, fy: float) -> bool:
|
|
376
|
+
if val == "B":
|
|
377
|
+
return True
|
|
378
|
+
if val == "W":
|
|
379
|
+
return False
|
|
380
|
+
kind, _ = diag_kind_and_slash(val)
|
|
381
|
+
if kind == "main": # y = x
|
|
382
|
+
return (fy < fx) if val == "TR" else (fy > fx)
|
|
383
|
+
else: # y = 1 - x
|
|
384
|
+
return (fy < 1 - fx) if val == "TL" else (fy > 1 - fx)
|
|
385
|
+
|
|
386
|
+
def on_boundary(val: CellVal, fx: float, fy: float) -> bool:
|
|
387
|
+
if val in ("B","W"):
|
|
388
|
+
return False
|
|
389
|
+
kind, _ = diag_kind_and_slash(val)
|
|
390
|
+
eps = 0.5 / max(cell_w, cell_h) # thin boundary
|
|
391
|
+
if kind == "main":
|
|
392
|
+
return abs(fy - fx) <= eps
|
|
393
|
+
else:
|
|
394
|
+
return abs(fy - (1 - fx)) <= eps
|
|
395
|
+
|
|
396
|
+
# Build one tile as a matrix of 1-char tokens (already colorized if ANSI)
|
|
397
|
+
def make_tile(val: CellVal) -> List[List[str]]:
|
|
398
|
+
rows: List[List[str]] = []
|
|
399
|
+
kind, slash_ch = diag_kind_and_slash(val)
|
|
400
|
+
|
|
401
|
+
for y in range(cell_h):
|
|
402
|
+
fy = (y + 0.5) / cell_h
|
|
403
|
+
line: List[str] = []
|
|
404
|
+
for x in range(cell_w):
|
|
405
|
+
fx = (x + 0.5) / cell_w
|
|
406
|
+
|
|
407
|
+
if val == "B":
|
|
408
|
+
line.append(sgr(bg=BG_BLACK) + " " + RESET if use_color else TXT_BLACK)
|
|
409
|
+
continue
|
|
410
|
+
if val == "W":
|
|
411
|
+
line.append(sgr(bg=BG_WHITE) + " " + RESET if use_color else TXT_WHITE)
|
|
412
|
+
continue
|
|
413
|
+
|
|
414
|
+
black_side = is_black(val, fx, fy)
|
|
415
|
+
boundary = on_boundary(val, fx, fy)
|
|
416
|
+
|
|
417
|
+
if use_color:
|
|
418
|
+
bg = BG_BLACK if black_side else BG_WHITE
|
|
419
|
+
if boundary:
|
|
420
|
+
fg = FG_WHITE if bg == BG_BLACK else FG_BLACK
|
|
421
|
+
line.append(sgr(bg=bg, fg=fg) + slash_ch + RESET)
|
|
422
|
+
else:
|
|
423
|
+
line.append(sgr(bg=bg) + " " + RESET)
|
|
424
|
+
else:
|
|
425
|
+
if boundary:
|
|
426
|
+
line.append(slash_ch)
|
|
427
|
+
else:
|
|
428
|
+
line.append(TXT_BLACK if black_side else TXT_WHITE)
|
|
429
|
+
rows.append(line)
|
|
430
|
+
return rows
|
|
431
|
+
|
|
432
|
+
# Overlay a single character centered (nudged into black side if needed)
|
|
433
|
+
def overlay_center_char(tile: List[List[str]], val: CellVal, ch: str):
|
|
434
|
+
if not ch:
|
|
435
|
+
return
|
|
436
|
+
ch = ch[0] # keep one character (user said single number)
|
|
437
|
+
cx, cy = cell_w // 2, cell_h // 2
|
|
438
|
+
fx = (cx + 0.5) / cell_w
|
|
439
|
+
fy = (cy + 0.5) / cell_h
|
|
440
|
+
|
|
441
|
+
# If center is boundary or not black, nudge horizontally toward black side
|
|
442
|
+
if val in ("TL","TR","BL","BR"):
|
|
443
|
+
kind, _ = diag_kind_and_slash(val)
|
|
444
|
+
# Determine which side is black relative to x at this y
|
|
445
|
+
if kind == "main": # boundary y=x → compare fx vs fy
|
|
446
|
+
want_right = (val == "TR") # black is to the right of boundary
|
|
447
|
+
if on_boundary(val, fx, fy) or (is_black(val, fx, fy) is False):
|
|
448
|
+
if want_right and cx + 1 < cell_w: cx += 1
|
|
449
|
+
elif not want_right and cx - 1 >= 0: cx -= 1
|
|
450
|
+
else: # boundary y=1-x → compare fx vs 1-fy
|
|
451
|
+
want_left = (val == "TL") # black is to the left of boundary
|
|
452
|
+
if on_boundary(val, fx, fy) or (is_black(val, fx, fy) is False):
|
|
453
|
+
if want_left and cx - 1 >= 0: cx -= 1
|
|
454
|
+
elif not want_left and cx + 1 < cell_w: cx += 1
|
|
455
|
+
|
|
456
|
+
# Compose the glyph for that spot
|
|
457
|
+
if use_color:
|
|
458
|
+
# Force black bg + white fg so it pops
|
|
459
|
+
token = sgr(bg=BG_BLACK, fg=FG_WHITE) + ch + RESET
|
|
460
|
+
else:
|
|
461
|
+
# In text mode, just put the raw character
|
|
462
|
+
token = ch
|
|
463
|
+
tile[cy][cx] = token
|
|
464
|
+
|
|
465
|
+
# Optional borders
|
|
466
|
+
if borders:
|
|
467
|
+
horiz = "─" * cell_w
|
|
468
|
+
top = "┌" + "┬".join(horiz for _ in range(H)) + "┐"
|
|
469
|
+
mid = "├" + "┼".join(horiz for _ in range(H)) + "┤"
|
|
470
|
+
bot = "└" + "┴".join(horiz for _ in range(H)) + "┘"
|
|
471
|
+
|
|
472
|
+
out_lines: List[str] = []
|
|
473
|
+
if borders:
|
|
474
|
+
out_lines.append(top)
|
|
475
|
+
|
|
476
|
+
for r in range(V):
|
|
477
|
+
# Build tiles for this row (so we can overlay per-cell text)
|
|
478
|
+
row_tiles: List[List[List[str]]] = []
|
|
479
|
+
for c in range(H):
|
|
480
|
+
t = make_tile(grid[r][c])
|
|
481
|
+
if cell_text is not None:
|
|
482
|
+
label = cell_text(r, c)
|
|
483
|
+
if label:
|
|
484
|
+
overlay_center_char(t, grid[r][c], label)
|
|
485
|
+
row_tiles.append(t)
|
|
486
|
+
|
|
487
|
+
# Emit tile rows
|
|
488
|
+
for y in range(cell_h):
|
|
489
|
+
if borders:
|
|
490
|
+
parts = ["│"]
|
|
491
|
+
for c in range(H):
|
|
492
|
+
parts.append("".join(row_tiles[c][y]))
|
|
493
|
+
parts.append("│")
|
|
494
|
+
out_lines.append("".join(parts))
|
|
495
|
+
else:
|
|
496
|
+
out_lines.append("".join("".join(row_tiles[c][y]) for c in range(H)))
|
|
497
|
+
|
|
498
|
+
if borders and r < V - 1:
|
|
499
|
+
out_lines.append(mid)
|
|
500
|
+
|
|
501
|
+
if borders:
|
|
502
|
+
out_lines.append(bot)
|
|
503
|
+
|
|
504
|
+
return "\n".join(out_lines) + (RESET if use_color else "")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
# demo = [
|
|
510
|
+
# ["TL","TR","BL","BR","B","W","BL","BR","B","W","TL","TR","BL","BR","B","W","BL","BR","B","W","W","BL","BR","B","W"],
|
|
511
|
+
# ["W","BL","TR","BL","TL","BR","BL","BR","W","W","W","B","TR","BL","TL","BR","BL","BR","B","W","BR","BL","BR","B","W"],
|
|
512
|
+
# ["BR","BL","TR","TL","W","B","BL","BR","B","W","BR","BL","TR","TL","W","B","BL","BR","B","W","B","BL","BR","B","W"],
|
|
513
|
+
# ]
|
|
514
|
+
# print(render_bw_tiles_split(demo, cell_w=8, cell_h=4, borders=True, mode="ansi"))
|
|
515
|
+
# art = render_bw_tiles_split(
|
|
516
|
+
# demo,
|
|
517
|
+
# cell_w=8,
|
|
518
|
+
# cell_h=4,
|
|
519
|
+
# borders=True,
|
|
520
|
+
# mode="text", # ← key change
|
|
521
|
+
# text_palette="solid" # try "solid" for stark black/white
|
|
522
|
+
# )
|
|
523
|
+
# print("```text\n" + art + "\n```")
|
|
@@ -1,17 +1,32 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
1
3
|
import numpy as np
|
|
2
4
|
from ortools.sat.python import cp_model
|
|
3
5
|
from ortools.sat.python.cp_model import LinearExpr as lxp
|
|
4
6
|
|
|
5
|
-
from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos,
|
|
7
|
+
from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, get_pos, in_bounds, get_char, get_row_pos, get_col_pos
|
|
6
8
|
from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
|
|
9
|
+
from puzzle_solver.core.utils_visualizer import render_shaded_grid
|
|
7
10
|
|
|
8
11
|
|
|
9
12
|
class Board:
|
|
10
|
-
def __init__(self, board: np.array):
|
|
13
|
+
def __init__(self, board: np.array, arith_rows: Optional[np.array] = None, arith_cols: Optional[np.array] = None, force_unique: bool = True):
|
|
11
14
|
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
12
15
|
assert all(c.item() in [' ', 'B', 'W'] for c in np.nditer(board)), 'board must contain only space or B'
|
|
13
16
|
self.board = board
|
|
14
17
|
self.V, self.H = board.shape
|
|
18
|
+
if arith_rows is not None:
|
|
19
|
+
assert arith_rows.ndim == 2, f'arith_rows must be 2d, got {arith_rows.ndim}'
|
|
20
|
+
assert arith_rows.shape == (self.V, self.H-1), f'arith_rows must be one column less than board, got {arith_rows.shape} for {board.shape}'
|
|
21
|
+
assert all(isinstance(c.item(), str) and c.item() in [' ', 'x', '='] for c in np.nditer(arith_rows)), 'arith_rows must contain only space, x, or ='
|
|
22
|
+
if arith_cols is not None:
|
|
23
|
+
assert arith_cols.ndim == 2, f'arith_cols must be 2d, got {arith_cols.ndim}'
|
|
24
|
+
assert arith_cols.shape == (self.V-1, self.H), f'arith_cols must be one column and row less than board, got {arith_cols.shape} for {board.shape}'
|
|
25
|
+
assert all(isinstance(c.item(), str) and c.item() in [' ', 'x', '='] for c in np.nditer(arith_cols)), 'arith_cols must contain only space, x, or ='
|
|
26
|
+
self.arith_rows = arith_rows
|
|
27
|
+
self.arith_cols = arith_cols
|
|
28
|
+
self.force_unique = force_unique
|
|
29
|
+
|
|
15
30
|
self.model = cp_model.CpModel()
|
|
16
31
|
self.model_vars: dict[Pos, cp_model.IntVar] = {}
|
|
17
32
|
|
|
@@ -40,11 +55,32 @@ class Board:
|
|
|
40
55
|
for pos in get_all_pos(self.V, self.H):
|
|
41
56
|
self.disallow_three_in_a_row(pos, Direction.RIGHT)
|
|
42
57
|
self.disallow_three_in_a_row(pos, Direction.DOWN)
|
|
58
|
+
|
|
43
59
|
# 3. Each row and column is unique.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
if self.force_unique:
|
|
61
|
+
# a list per row
|
|
62
|
+
self.force_unique_double_list([[self.model_vars[pos] for pos in get_row_pos(row, self.H)] for row in range(self.V)])
|
|
63
|
+
# a list per column
|
|
64
|
+
self.force_unique_double_list([[self.model_vars[pos] for pos in get_col_pos(col, self.V)] for col in range(self.H)])
|
|
65
|
+
|
|
66
|
+
# if arithmetic is provided, add constraints for it
|
|
67
|
+
if self.arith_rows is not None:
|
|
68
|
+
assert self.arith_rows.shape == (self.V, self.H-1), f'arith_rows must be one column less than board, got {self.arith_rows.shape} for {self.board.shape}'
|
|
69
|
+
for pos in get_all_pos(self.V, self.H-1):
|
|
70
|
+
c = get_char(self.arith_rows, pos)
|
|
71
|
+
if c == 'x':
|
|
72
|
+
self.model.Add(self.model_vars[pos] != self.model_vars[get_next_pos(pos, Direction.RIGHT)])
|
|
73
|
+
elif c == '=':
|
|
74
|
+
self.model.Add(self.model_vars[pos] == self.model_vars[get_next_pos(pos, Direction.RIGHT)])
|
|
75
|
+
if self.arith_cols is not None:
|
|
76
|
+
assert self.arith_cols.shape == (self.V-1, self.H), f'arith_cols must be one row less than board, got {self.arith_cols.shape} for {self.board.shape}'
|
|
77
|
+
for pos in get_all_pos(self.V-1, self.H):
|
|
78
|
+
c = get_char(self.arith_cols, pos)
|
|
79
|
+
if c == 'x':
|
|
80
|
+
self.model.Add(self.model_vars[pos] != self.model_vars[get_next_pos(pos, Direction.DOWN)])
|
|
81
|
+
elif c == '=':
|
|
82
|
+
self.model.Add(self.model_vars[pos] == self.model_vars[get_next_pos(pos, Direction.DOWN)])
|
|
83
|
+
|
|
48
84
|
|
|
49
85
|
def disallow_three_in_a_row(self, p1: Pos, direction: Direction):
|
|
50
86
|
p2 = get_next_pos(p1, direction)
|
|
@@ -62,7 +98,7 @@ class Board:
|
|
|
62
98
|
self.model_vars[p3].Not(),
|
|
63
99
|
])
|
|
64
100
|
|
|
65
|
-
def
|
|
101
|
+
def force_unique_double_list(self, model_vars: list[list[cp_model.IntVar]]):
|
|
66
102
|
if not model_vars or len(model_vars) < 2:
|
|
67
103
|
return
|
|
68
104
|
m = len(model_vars[0])
|
|
@@ -86,13 +122,5 @@ class Board:
|
|
|
86
122
|
return SingleSolution(assignment=assignment)
|
|
87
123
|
def callback(single_res: SingleSolution):
|
|
88
124
|
print("Solution found")
|
|
89
|
-
|
|
90
|
-
for pos in get_all_pos(self.V, self.H):
|
|
91
|
-
c = get_char(self.board, pos)
|
|
92
|
-
c = 'B' if single_res.assignment[pos] == 1 else 'W'
|
|
93
|
-
set_char(res, pos, c)
|
|
94
|
-
print('[')
|
|
95
|
-
for row in res:
|
|
96
|
-
print(" [ '" + "', '".join(row.tolist()) + "' ],")
|
|
97
|
-
print(']')
|
|
125
|
+
print(render_shaded_grid(self.V, self.H, lambda r, c: single_res.assignment[get_pos(x=c, y=r)] == 1))
|
|
98
126
|
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|