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,575 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import 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 combined_function(V: int,
|
|
7
|
+
H: int,
|
|
8
|
+
cell_flags: Optional[Callable[[int, int], str]] = None,
|
|
9
|
+
is_shaded: Optional[Callable[[int, int], bool]] = None,
|
|
10
|
+
center_char: Optional[Callable[[int, int], str]] = None,
|
|
11
|
+
special_content: Optional[Callable[[int, int], str]] = None,
|
|
12
|
+
text_on_shaded_cells: bool = True,
|
|
13
|
+
scale_x: int = 2,
|
|
14
|
+
scale_y: int = 1,
|
|
15
|
+
show_axes: bool = True,
|
|
16
|
+
show_grid: bool = True,
|
|
17
|
+
show_border_only: bool = False,
|
|
18
|
+
) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Render a V x H grid that can:
|
|
21
|
+
• draw selective edges per cell via cell_flags(r, c) containing any of 'U','D','L','R'
|
|
22
|
+
• shade cells via is_shaded(r, c)
|
|
23
|
+
• place centered text per cell via center_char(r, c)
|
|
24
|
+
• draw interior arms via special_content(r, c) returning any combo of 'U','D','L','R'
|
|
25
|
+
(e.g., 'UR', 'DL', 'ULRD', or '' to leave the interior unchanged).
|
|
26
|
+
Arms extend from the cell’s interior center toward the indicated sides.
|
|
27
|
+
• horizontal stretch (>=1). Interior width per cell = 2*scale_x - 1 (default 2)
|
|
28
|
+
• vertical stretch (>=1). Interior height per cell = scale_y (default 1)
|
|
29
|
+
• show_axes: bool = True, show the axes (columns on top, rows on the left).
|
|
30
|
+
• show_grid: bool = True, show the grid lines.
|
|
31
|
+
• show_border_only: bool = False, show only the border instead of the full grid.
|
|
32
|
+
|
|
33
|
+
Behavior:
|
|
34
|
+
- If cell_flags is None, draws a full grid (all interior and outer borders present).
|
|
35
|
+
- Shading is applied first, borders are drawn on top, and center text is drawn last unless text_on_shaded_cells is False in which case the text is not drawn on shaded cells.
|
|
36
|
+
- Axes are shown (columns on top, rows on the left).
|
|
37
|
+
|
|
38
|
+
Draw order:
|
|
39
|
+
1) shading
|
|
40
|
+
2) borders
|
|
41
|
+
3) special_content (interior line arms)
|
|
42
|
+
4) center_char (unless text_on_shaded_cells=False and cell is shaded)
|
|
43
|
+
"""
|
|
44
|
+
assert V >= 1 and H >= 1, f'V and H must be >= 1, got {V} and {H}'
|
|
45
|
+
assert cell_flags is None or callable(cell_flags), f'cell_flags must be None or callable, got {cell_flags}'
|
|
46
|
+
assert is_shaded is None or callable(is_shaded), f'is_shaded must be None or callable, got {is_shaded}'
|
|
47
|
+
assert center_char is None or callable(center_char), f'center_char must be None or callable, got {center_char}'
|
|
48
|
+
assert special_content is None or callable(special_content), f'special_content must be None or callable, got {special_content}'
|
|
49
|
+
|
|
50
|
+
# Rendering constants
|
|
51
|
+
fill_char: str = '▒' # single char for shaded interiors
|
|
52
|
+
empty_char: str = ' ' # single char for unshaded interiors
|
|
53
|
+
|
|
54
|
+
assert scale_x >= 1 and scale_y >= 1
|
|
55
|
+
assert len(fill_char) == 1 and len(empty_char) == 1
|
|
56
|
+
|
|
57
|
+
# ── Layout helpers ─────────────────────────────────────────────────────
|
|
58
|
+
def x_corner(c: int) -> int: # column of vertical border at grid column c (0..H)
|
|
59
|
+
return (2 * c) * scale_x
|
|
60
|
+
def y_border(r: int) -> int: # row of horizontal border at grid row r (0..V)
|
|
61
|
+
return (scale_y + 1) * r
|
|
62
|
+
|
|
63
|
+
rows = y_border(V) + 1
|
|
64
|
+
cols = x_corner(H) + 1
|
|
65
|
+
canvas = [[empty_char] * cols for _ in range(rows)]
|
|
66
|
+
|
|
67
|
+
# ── Edge presence arrays derived from cell_flags ──
|
|
68
|
+
# H_edges[r, c] is the horizontal edge between rows r and r+1 above column segment c (shape: (V+1, H))
|
|
69
|
+
# V_edges[r, c] is the vertical edge between cols c and c+1 left of row segment r (shape: (V, H+1))
|
|
70
|
+
if cell_flags is None:
|
|
71
|
+
if show_border_only:
|
|
72
|
+
assert show_grid, 'if show_border_only is True, show_grid must be True'
|
|
73
|
+
H_edges = [[(r == 0 or r == V) for c in range(H)] for r in range(V + 1)]
|
|
74
|
+
V_edges = [[(c == 0 or c == H) for c in range(H + 1)] for r in range(V)]
|
|
75
|
+
else:
|
|
76
|
+
# Full grid: all horizontal and vertical segments are present
|
|
77
|
+
H_edges = [[show_grid for _ in range(H)] for _ in range(V + 1)]
|
|
78
|
+
V_edges = [[show_grid for _ in range(H + 1)] for _ in range(V)]
|
|
79
|
+
else:
|
|
80
|
+
assert not show_border_only, 'show_border_only is not supported when cell_flags is provided'
|
|
81
|
+
assert show_grid, 'if cell_flags is provided, show_grid must be True'
|
|
82
|
+
H_edges = [[False for _ in range(H)] for _ in range(V + 1)]
|
|
83
|
+
V_edges = [[False for _ in range(H + 1)] for _ in range(V)]
|
|
84
|
+
for r in range(V):
|
|
85
|
+
for c in range(H):
|
|
86
|
+
s = cell_flags(r, c) or ''
|
|
87
|
+
if 'U' in s:
|
|
88
|
+
H_edges[r ][c] = True
|
|
89
|
+
if 'D' in s:
|
|
90
|
+
H_edges[r + 1][c] = True
|
|
91
|
+
if 'L' in s:
|
|
92
|
+
V_edges[r][c ] = True
|
|
93
|
+
if 'R' in s:
|
|
94
|
+
V_edges[r][c + 1] = True
|
|
95
|
+
|
|
96
|
+
# ── Shading first (borders will overwrite) ─────────────────────────────
|
|
97
|
+
shaded_map = [[False]*H for _ in range(V)]
|
|
98
|
+
for r in range(V):
|
|
99
|
+
top = y_border(r) + 1
|
|
100
|
+
bottom = y_border(r + 1) - 1 # inclusive
|
|
101
|
+
if top > bottom:
|
|
102
|
+
continue
|
|
103
|
+
for c in range(H):
|
|
104
|
+
left = x_corner(c) + 1
|
|
105
|
+
right = x_corner(c + 1) - 1 # inclusive
|
|
106
|
+
if left > right:
|
|
107
|
+
continue
|
|
108
|
+
shaded = bool(is_shaded(r, c)) if callable(is_shaded) else False
|
|
109
|
+
shaded_map[r][c] = shaded
|
|
110
|
+
ch = fill_char if shaded else empty_char
|
|
111
|
+
for yy in range(top, bottom + 1):
|
|
112
|
+
for xx in range(left, right + 1):
|
|
113
|
+
canvas[yy][xx] = ch
|
|
114
|
+
|
|
115
|
+
# ── Grid lines (respect edge presence) ─────────────────────────────────
|
|
116
|
+
U, Rb, D, Lb = 1, 2, 4, 8
|
|
117
|
+
JUNCTION = {
|
|
118
|
+
0: ' ',
|
|
119
|
+
U: '│', D: '│', U | D: '│',
|
|
120
|
+
Lb: '─', Rb: '─', Lb | Rb: '─',
|
|
121
|
+
U | Rb: '└', Rb | D: '┌', D | Lb: '┐', Lb | U: '┘',
|
|
122
|
+
U | D | Lb: '┤', U | D | Rb: '├', Lb | Rb | U: '┴', Lb | Rb | D: '┬',
|
|
123
|
+
U | Rb | D | Lb: '┼',
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Horizontal segments
|
|
127
|
+
for r in range(V + 1):
|
|
128
|
+
yy = y_border(r)
|
|
129
|
+
for c in range(H):
|
|
130
|
+
if H_edges[r][c]:
|
|
131
|
+
base = x_corner(c)
|
|
132
|
+
for k in range(1, 2 * scale_x): # 1..(2*scale_x-1)
|
|
133
|
+
canvas[yy][base + k] = '─'
|
|
134
|
+
|
|
135
|
+
# Vertical segments
|
|
136
|
+
for r in range(V):
|
|
137
|
+
for c in range(H + 1):
|
|
138
|
+
if V_edges[r][c]:
|
|
139
|
+
xx = x_corner(c)
|
|
140
|
+
for ky in range(1, scale_y + 1):
|
|
141
|
+
canvas[y_border(r) + ky][xx] = '│'
|
|
142
|
+
|
|
143
|
+
# Junctions at intersections
|
|
144
|
+
for r in range(V + 1):
|
|
145
|
+
yy = y_border(r)
|
|
146
|
+
for c in range(H + 1):
|
|
147
|
+
xx = x_corner(c)
|
|
148
|
+
m = 0
|
|
149
|
+
if r > 0 and V_edges[r - 1][c]:
|
|
150
|
+
m |= U
|
|
151
|
+
if r < V and V_edges[r][c]:
|
|
152
|
+
m |= D
|
|
153
|
+
if c > 0 and H_edges[r][c - 1]:
|
|
154
|
+
m |= Lb
|
|
155
|
+
if c < H and H_edges[r][c]:
|
|
156
|
+
m |= Rb
|
|
157
|
+
canvas[yy][xx] = JUNCTION[m]
|
|
158
|
+
|
|
159
|
+
# ── Special interior content (arms) + cross-cell bridges ──────────────
|
|
160
|
+
def draw_special_arms(r_cell: int, c_cell: int, code: Optional[str]):
|
|
161
|
+
if not code:
|
|
162
|
+
return
|
|
163
|
+
s = set(code)
|
|
164
|
+
# interior box
|
|
165
|
+
left = x_corner(c_cell) + 1
|
|
166
|
+
right = x_corner(c_cell + 1) - 1
|
|
167
|
+
top = y_border(r_cell) + 1
|
|
168
|
+
bottom= y_border(r_cell + 1) - 1
|
|
169
|
+
if left > right or top > bottom:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
# center of interior
|
|
173
|
+
cx = left + (right - left) // 2
|
|
174
|
+
cy = top + (bottom - top) // 2
|
|
175
|
+
|
|
176
|
+
# draw arms out from center (keep inside interior; don't touch borders)
|
|
177
|
+
if 'U' in s and cy - 1 >= top:
|
|
178
|
+
for yy in range(cy - 1, top - 1, -1):
|
|
179
|
+
canvas[yy][cx] = '│'
|
|
180
|
+
if 'D' in s and cy + 1 <= bottom:
|
|
181
|
+
for yy in range(cy + 1, bottom + 1):
|
|
182
|
+
canvas[yy][cx] = '│'
|
|
183
|
+
if 'L' in s and cx - 1 >= left:
|
|
184
|
+
for xx in range(cx - 1, left - 1, -1):
|
|
185
|
+
canvas[cy][xx] = '─'
|
|
186
|
+
if 'R' in s and cx + 1 <= right:
|
|
187
|
+
for xx in range(cx + 1, right + 1):
|
|
188
|
+
canvas[cy][xx] = '─'
|
|
189
|
+
if '/' in s:
|
|
190
|
+
for xx in range(right - left + 1):
|
|
191
|
+
for yy in range(top - bottom + 1):
|
|
192
|
+
canvas[top + yy][left + xx] = '/'
|
|
193
|
+
if '\\' in s:
|
|
194
|
+
for xx in range(right - left + 1):
|
|
195
|
+
for yy in range(top - bottom + 1):
|
|
196
|
+
canvas[top + yy][left + xx] = '\\'
|
|
197
|
+
|
|
198
|
+
# center junction
|
|
199
|
+
U_b, R_b, D_b, L_b = 1, 2, 4, 8
|
|
200
|
+
m = 0
|
|
201
|
+
if 'U' in s:
|
|
202
|
+
m |= U_b
|
|
203
|
+
if 'D' in s:
|
|
204
|
+
m |= D_b
|
|
205
|
+
if 'L' in s:
|
|
206
|
+
m |= L_b
|
|
207
|
+
if 'R' in s:
|
|
208
|
+
m |= R_b
|
|
209
|
+
canvas[cy][cx] = JUNCTION.get(m, ' ')
|
|
210
|
+
|
|
211
|
+
# pass 1: draw interior arms per cell
|
|
212
|
+
special_map = [[set() for _ in range(H)] for _ in range(V)]
|
|
213
|
+
if callable(special_content):
|
|
214
|
+
for r in range(V):
|
|
215
|
+
for c in range(H):
|
|
216
|
+
flags = set(ch for ch in str(special_content(r, c) or ''))
|
|
217
|
+
special_map[r][c] = flags
|
|
218
|
+
if flags:
|
|
219
|
+
draw_special_arms(r, c, ''.join(flags))
|
|
220
|
+
|
|
221
|
+
# ── Center text (drawn last so it sits atop shading/arms) ─────────────
|
|
222
|
+
def put_center_text(r_cell: int, c_cell: int, s: Optional[str]):
|
|
223
|
+
if s is None:
|
|
224
|
+
return
|
|
225
|
+
s = str(s)
|
|
226
|
+
# interior box
|
|
227
|
+
left = x_corner(c_cell) + 1
|
|
228
|
+
right = x_corner(c_cell + 1) - 1
|
|
229
|
+
top = y_border(r_cell) + 1
|
|
230
|
+
bottom= y_border(r_cell + 1) - 1
|
|
231
|
+
if left > right or top > bottom:
|
|
232
|
+
return
|
|
233
|
+
span_w = right - left + 1
|
|
234
|
+
yy = top + (bottom - top) // 2
|
|
235
|
+
if len(s) > span_w:
|
|
236
|
+
s = s[:span_w] # truncate to protect borders
|
|
237
|
+
start = left + (span_w - len(s)) // 2
|
|
238
|
+
for i, ch in enumerate(s):
|
|
239
|
+
canvas[yy][start + i] = ch
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# helper to get interior-center coordinates
|
|
243
|
+
def _cell_center_rc(r_cell: int, c_cell: int):
|
|
244
|
+
left = x_corner(c_cell) + 1
|
|
245
|
+
right = x_corner(c_cell + 1) - 1
|
|
246
|
+
top = y_border(r_cell) + 1
|
|
247
|
+
bottom= y_border(r_cell + 1) - 1
|
|
248
|
+
if left > right or top > bottom:
|
|
249
|
+
return None
|
|
250
|
+
cx = left + (right - left) // 2
|
|
251
|
+
cy = top + (bottom - top) // 2
|
|
252
|
+
return cy, cx
|
|
253
|
+
|
|
254
|
+
# ── REPLACE your place_connector() and "pass 2" with the following ────
|
|
255
|
+
|
|
256
|
+
# PASS 2: merge/bridge on every border using bitmasks (works with or without borders)
|
|
257
|
+
if callable(special_content):
|
|
258
|
+
# vertical borders: c in [0..H], between (r,c-1) and (r,c)
|
|
259
|
+
for r in range(V):
|
|
260
|
+
# y (row) where we draw the junction on this border: the interior center row
|
|
261
|
+
cc = _cell_center_rc(r, 0)
|
|
262
|
+
if cc is None:
|
|
263
|
+
continue
|
|
264
|
+
cy = cc[0]
|
|
265
|
+
for c in range(H + 1):
|
|
266
|
+
x = x_corner(c)
|
|
267
|
+
mask = 0
|
|
268
|
+
# base: if the vertical grid line exists here, add U and D
|
|
269
|
+
if V_edges[r][c]:
|
|
270
|
+
mask |= U | D
|
|
271
|
+
|
|
272
|
+
# neighbors pointing toward this vertical border
|
|
273
|
+
left_flags = special_map[r][c - 1] if c - 1 >= 0 else set()
|
|
274
|
+
right_flags = special_map[r][c] if c < H else set()
|
|
275
|
+
if 'R' in left_flags:
|
|
276
|
+
mask |= Lb
|
|
277
|
+
if 'L' in right_flags:
|
|
278
|
+
mask |= Rb
|
|
279
|
+
|
|
280
|
+
# nothing to draw? leave whatever is already there
|
|
281
|
+
if mask == 0:
|
|
282
|
+
continue
|
|
283
|
+
canvas[cy][x] = JUNCTION[mask]
|
|
284
|
+
|
|
285
|
+
# horizontal borders: r in [0..V], between (r-1,c) and (r,c)
|
|
286
|
+
for c in range(H):
|
|
287
|
+
# x (col) where we draw the junction on this border: the interior center col
|
|
288
|
+
cc = _cell_center_rc(0, c)
|
|
289
|
+
if cc is None:
|
|
290
|
+
continue
|
|
291
|
+
cx = cc[1]
|
|
292
|
+
for r in range(V + 1):
|
|
293
|
+
y = y_border(r)
|
|
294
|
+
mask = 0
|
|
295
|
+
# base: if the horizontal grid line exists here, add L and R
|
|
296
|
+
if r <= V - 1 and H_edges[r][c]: # H_edges indexed [0..V] x [0..H-1]
|
|
297
|
+
mask |= Lb | Rb
|
|
298
|
+
|
|
299
|
+
# neighbors pointing toward this horizontal border
|
|
300
|
+
up_flags = special_map[r - 1][c] if r - 1 >= 0 else set()
|
|
301
|
+
down_flags = special_map[r][c] if r < V else set()
|
|
302
|
+
if 'D' in up_flags:
|
|
303
|
+
mask |= U
|
|
304
|
+
if 'U' in down_flags:
|
|
305
|
+
mask |= D
|
|
306
|
+
|
|
307
|
+
if mask == 0:
|
|
308
|
+
continue
|
|
309
|
+
canvas[y][cx] = JUNCTION[mask]
|
|
310
|
+
|
|
311
|
+
if callable(center_char):
|
|
312
|
+
for r in range(V):
|
|
313
|
+
for c in range(H):
|
|
314
|
+
if not text_on_shaded_cells and shaded_map[r][c]:
|
|
315
|
+
continue
|
|
316
|
+
if not text_on_shaded_cells and special_map[r][c]:
|
|
317
|
+
continue
|
|
318
|
+
put_center_text(r, c, center_char(r, c))
|
|
319
|
+
|
|
320
|
+
# ── Stringify with axes ────────────────────────────────────────────────
|
|
321
|
+
art_rows = [''.join(row) for row in canvas]
|
|
322
|
+
if not show_axes:
|
|
323
|
+
return '\n'.join(art_rows)
|
|
324
|
+
|
|
325
|
+
# Axes labels: columns on top; rows on left
|
|
326
|
+
gut = max(2, len(str(V - 1)))
|
|
327
|
+
gutter = ' ' * gut
|
|
328
|
+
top_tens = list(gutter + ' ' * cols)
|
|
329
|
+
top_ones = list(gutter + ' ' * cols)
|
|
330
|
+
for c in range(H):
|
|
331
|
+
xc_center = x_corner(c) + scale_x
|
|
332
|
+
if H >= 10:
|
|
333
|
+
top_tens[gut + xc_center] = str((c // 10) % 10)
|
|
334
|
+
top_ones[gut + xc_center] = str(c % 10)
|
|
335
|
+
if gut >= 2:
|
|
336
|
+
top_tens[gut - 2:gut] = list(' ')
|
|
337
|
+
top_ones[gut - 2:gut] = list(' ')
|
|
338
|
+
|
|
339
|
+
labeled = []
|
|
340
|
+
for y, line in enumerate(art_rows):
|
|
341
|
+
mod = y % (scale_y + 1)
|
|
342
|
+
if 1 <= mod <= scale_y:
|
|
343
|
+
r = y // (scale_y + 1)
|
|
344
|
+
mid = (scale_y + 1) // 2
|
|
345
|
+
label = (str(r).rjust(gut) if mod == mid else ' ' * gut)
|
|
346
|
+
else:
|
|
347
|
+
label = ' ' * gut
|
|
348
|
+
labeled.append(label + line)
|
|
349
|
+
|
|
350
|
+
return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
|
|
351
|
+
|
|
352
|
+
def id_board_to_wall_fn(id_board: np.array, border_is_wall = True, border_is = None) -> Callable[[int, int], str]:
|
|
353
|
+
"""In many instances, we have a 2d array where cell values are arbitrary ids
|
|
354
|
+
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.
|
|
355
|
+
Args:
|
|
356
|
+
id_board: np.array of shape (N, N) with arbitrary ids.
|
|
357
|
+
border_is_wall: if True, the edges of the board are considered to be walls.
|
|
358
|
+
border_is: if equal to a value, the edges of the board are considered to be walls of that value.
|
|
359
|
+
Returns:
|
|
360
|
+
Callable[[int, int], str] that returns the walls "U", "D", "L", "R" for the cell at (r, c).
|
|
361
|
+
"""
|
|
362
|
+
res = np.full((id_board.shape[0], id_board.shape[1]), '', dtype=object)
|
|
363
|
+
V, H = id_board.shape
|
|
364
|
+
def append_char(pos: Pos, s: str):
|
|
365
|
+
set_char(res, pos, get_char(res, pos) + s)
|
|
366
|
+
def handle_pos_direction(pos: Pos, direction: Direction, s: str):
|
|
367
|
+
pos2 = get_next_pos(pos, direction)
|
|
368
|
+
if in_bounds(pos2, V, H):
|
|
369
|
+
if get_char(id_board, pos2) != get_char(id_board, pos):
|
|
370
|
+
append_char(pos, s)
|
|
371
|
+
else:
|
|
372
|
+
if border_is_wall or (border_is is not None and get_char(id_board, pos) == border_is):
|
|
373
|
+
append_char(pos, s)
|
|
374
|
+
for pos in get_all_pos(V, H):
|
|
375
|
+
handle_pos_direction(pos, Direction.LEFT, 'L')
|
|
376
|
+
handle_pos_direction(pos, Direction.RIGHT, 'R')
|
|
377
|
+
handle_pos_direction(pos, Direction.UP, 'U')
|
|
378
|
+
handle_pos_direction(pos, Direction.DOWN, 'D')
|
|
379
|
+
return lambda r, c: res[r][c]
|
|
380
|
+
|
|
381
|
+
CellVal = Literal["B", "W", "TL", "TR", "BL", "BR"]
|
|
382
|
+
GridLike = Sequence[Sequence[CellVal]]
|
|
383
|
+
|
|
384
|
+
def render_bw_tiles_split(
|
|
385
|
+
grid: GridLike,
|
|
386
|
+
cell_w: int = 6,
|
|
387
|
+
cell_h: int = 3,
|
|
388
|
+
borders: bool = False,
|
|
389
|
+
mode: Literal["ansi", "text"] = "ansi",
|
|
390
|
+
text_palette: Literal["solid", "hatch"] = "solid",
|
|
391
|
+
cell_text: Optional[Callable[[int, int], str]] = None) -> str:
|
|
392
|
+
"""
|
|
393
|
+
Render a VxH grid with '/' or '\\' splits and optional per-cell centered text.
|
|
394
|
+
|
|
395
|
+
`cell_text(r, c) -> str`: if returns non-empty, its first character is drawn
|
|
396
|
+
near the geometric center of cell (r,c), nudged to the black side for halves.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
V = len(grid)
|
|
400
|
+
if V == 0:
|
|
401
|
+
return ""
|
|
402
|
+
H = len(grid[0])
|
|
403
|
+
if any(len(row) != H for row in grid):
|
|
404
|
+
raise ValueError("All rows must have the same length")
|
|
405
|
+
if cell_w < 1 or cell_h < 1:
|
|
406
|
+
raise ValueError("cell_w and cell_h must be >= 1")
|
|
407
|
+
|
|
408
|
+
allowed = {"B","W","TL","TR","BL","BR"}
|
|
409
|
+
for r in range(V):
|
|
410
|
+
for c in range(H):
|
|
411
|
+
if grid[r][c] not in allowed:
|
|
412
|
+
raise ValueError(f"Invalid cell value at ({r},{c}): {grid[r][c]}")
|
|
413
|
+
|
|
414
|
+
# ── Mode setup ─────────────────────────────────────────────────────────
|
|
415
|
+
use_color = (mode == "ansi")
|
|
416
|
+
|
|
417
|
+
def sgr(bg: Optional[int] = None, fg: Optional[int] = None) -> str:
|
|
418
|
+
if not use_color:
|
|
419
|
+
return ""
|
|
420
|
+
parts = []
|
|
421
|
+
if fg is not None:
|
|
422
|
+
parts.append(str(fg))
|
|
423
|
+
if bg is not None:
|
|
424
|
+
parts.append(str(bg))
|
|
425
|
+
return ("\x1b[" + ";".join(parts) + "m") if parts else ""
|
|
426
|
+
|
|
427
|
+
RESET = "\x1b[0m" if use_color else ""
|
|
428
|
+
|
|
429
|
+
BG_BLACK, BG_WHITE = 40, 47
|
|
430
|
+
FG_BLACK, FG_WHITE = 30, 37
|
|
431
|
+
|
|
432
|
+
if text_palette == "solid":
|
|
433
|
+
TXT_BLACK, TXT_WHITE = " ", "█"
|
|
434
|
+
elif text_palette == "hatch":
|
|
435
|
+
TXT_BLACK, TXT_WHITE = "░", "▓"
|
|
436
|
+
else:
|
|
437
|
+
raise ValueError("text_palette must be 'solid' or 'hatch'")
|
|
438
|
+
|
|
439
|
+
def diag_kind_and_slash(val: CellVal):
|
|
440
|
+
if val in ("TR", "BL"):
|
|
441
|
+
return "main", "\\"
|
|
442
|
+
elif val in ("TL", "BR"):
|
|
443
|
+
return "anti", "/"
|
|
444
|
+
return None, "?"
|
|
445
|
+
|
|
446
|
+
def is_black(val: CellVal, fx: float, fy: float) -> bool:
|
|
447
|
+
if val == "B":
|
|
448
|
+
return True
|
|
449
|
+
if val == "W":
|
|
450
|
+
return False
|
|
451
|
+
kind, _ = diag_kind_and_slash(val)
|
|
452
|
+
if kind == "main": # y = x
|
|
453
|
+
return (fy < fx) if val == "TR" else (fy > fx)
|
|
454
|
+
else: # y = 1 - x
|
|
455
|
+
return (fy < 1 - fx) if val == "TL" else (fy > 1 - fx)
|
|
456
|
+
|
|
457
|
+
# Build one tile as a matrix of 1-char tokens (already colorized if ANSI)
|
|
458
|
+
def make_tile(val: CellVal) -> List[List[str]]:
|
|
459
|
+
rows: List[List[str]] = []
|
|
460
|
+
_, slash_ch = diag_kind_and_slash(val)
|
|
461
|
+
for y in range(cell_h):
|
|
462
|
+
fy = (y + 0.5) / cell_h
|
|
463
|
+
line: List[str] = []
|
|
464
|
+
prev = None
|
|
465
|
+
for x in range(cell_w):
|
|
466
|
+
fx = (x + 0.5) / cell_w
|
|
467
|
+
fx_next = (x + 1.5) / cell_w
|
|
468
|
+
|
|
469
|
+
if val == "B":
|
|
470
|
+
line.append(sgr(bg=BG_BLACK) + " " + RESET if use_color else TXT_BLACK)
|
|
471
|
+
continue
|
|
472
|
+
if val == "W":
|
|
473
|
+
line.append(sgr(bg=BG_WHITE) + " " + RESET if use_color else TXT_WHITE)
|
|
474
|
+
continue
|
|
475
|
+
|
|
476
|
+
black_side = is_black(val, fx, fy)
|
|
477
|
+
next_black_side = is_black(val, fx_next, fy)
|
|
478
|
+
boundary = False # if true places a "/" or "\" at the current position
|
|
479
|
+
if prev is not None and not prev and black_side: # prev white and cur black => boundary now
|
|
480
|
+
boundary = True
|
|
481
|
+
if black_side and not next_black_side: # cur black and next white => boundary now
|
|
482
|
+
boundary = True
|
|
483
|
+
|
|
484
|
+
if use_color:
|
|
485
|
+
bg = BG_BLACK if black_side else BG_WHITE
|
|
486
|
+
if boundary:
|
|
487
|
+
fg = FG_WHITE if bg == BG_BLACK else FG_BLACK
|
|
488
|
+
line.append(sgr(bg=bg, fg=fg) + slash_ch + RESET)
|
|
489
|
+
else:
|
|
490
|
+
line.append(sgr(bg=bg) + " " + RESET)
|
|
491
|
+
else:
|
|
492
|
+
if boundary:
|
|
493
|
+
line.append(slash_ch)
|
|
494
|
+
else:
|
|
495
|
+
line.append(TXT_BLACK if black_side else TXT_WHITE)
|
|
496
|
+
prev = black_side
|
|
497
|
+
rows.append(line)
|
|
498
|
+
return rows
|
|
499
|
+
|
|
500
|
+
# Overlay a single character centered (nudged into black side if needed)
|
|
501
|
+
def overlay_center_char(tile: List[List[str]], val: CellVal, ch: str):
|
|
502
|
+
if not ch:
|
|
503
|
+
return
|
|
504
|
+
ch = ch[0] # keep one character (user said single number)
|
|
505
|
+
cx, cy = cell_w // 2, cell_h // 2
|
|
506
|
+
cx -= 1
|
|
507
|
+
|
|
508
|
+
# Compose the glyph for that spot
|
|
509
|
+
if use_color:
|
|
510
|
+
# Force black bg + white fg so it pops
|
|
511
|
+
token = sgr(bg=BG_BLACK, fg=FG_WHITE) + ch + RESET
|
|
512
|
+
else:
|
|
513
|
+
# In text mode, just put the raw character
|
|
514
|
+
token = ch
|
|
515
|
+
tile[cy][cx] = token
|
|
516
|
+
|
|
517
|
+
# Optional borders
|
|
518
|
+
if borders:
|
|
519
|
+
horiz = "─" * cell_w
|
|
520
|
+
top = "┌" + "┬".join(horiz for _ in range(H)) + "┐"
|
|
521
|
+
mid = "├" + "┼".join(horiz for _ in range(H)) + "┤"
|
|
522
|
+
bot = "└" + "┴".join(horiz for _ in range(H)) + "┘"
|
|
523
|
+
|
|
524
|
+
out_lines: List[str] = []
|
|
525
|
+
if borders:
|
|
526
|
+
out_lines.append(top)
|
|
527
|
+
|
|
528
|
+
for r in range(V):
|
|
529
|
+
# Build tiles for this row (so we can overlay per-cell text)
|
|
530
|
+
row_tiles: List[List[List[str]]] = []
|
|
531
|
+
for c in range(H):
|
|
532
|
+
t = make_tile(grid[r][c])
|
|
533
|
+
if cell_text is not None:
|
|
534
|
+
label = cell_text(r, c)
|
|
535
|
+
if label:
|
|
536
|
+
overlay_center_char(t, grid[r][c], label)
|
|
537
|
+
row_tiles.append(t)
|
|
538
|
+
|
|
539
|
+
# Emit tile rows
|
|
540
|
+
for y in range(cell_h):
|
|
541
|
+
if borders:
|
|
542
|
+
parts = ["│"]
|
|
543
|
+
for c in range(H):
|
|
544
|
+
parts.append("".join(row_tiles[c][y]))
|
|
545
|
+
parts.append("│")
|
|
546
|
+
out_lines.append("".join(parts))
|
|
547
|
+
else:
|
|
548
|
+
out_lines.append("".join("".join(row_tiles[c][y]) for c in range(H)))
|
|
549
|
+
|
|
550
|
+
if borders and r < V - 1:
|
|
551
|
+
out_lines.append(mid)
|
|
552
|
+
|
|
553
|
+
if borders:
|
|
554
|
+
out_lines.append(bot)
|
|
555
|
+
|
|
556
|
+
return "\n".join(out_lines) + (RESET if use_color else "")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
# demo = [
|
|
562
|
+
# ["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"],
|
|
563
|
+
# ["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"],
|
|
564
|
+
# ["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"],
|
|
565
|
+
# ]
|
|
566
|
+
# print(render_bw_tiles_split(demo, cell_w=8, cell_h=4, borders=True, mode="ansi"))
|
|
567
|
+
# art = render_bw_tiles_split(
|
|
568
|
+
# demo,
|
|
569
|
+
# cell_w=8,
|
|
570
|
+
# cell_h=4,
|
|
571
|
+
# borders=True,
|
|
572
|
+
# mode="text", # ← key change
|
|
573
|
+
# text_palette="solid" # try "solid" for stark black/white
|
|
574
|
+
# )
|
|
575
|
+
# print("```text\n" + art + "\n```")
|
|
@@ -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, get_pos, get_row_pos, get_col_pos
|
|
5
|
+
from puzzle_solver.core.utils_ortools import and_constraint, 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, top: np.array, left: np.array, bottom: np.array, right: np.array, characters: list[str]):
|
|
11
|
+
self.BLANK = 'BLANK'
|
|
12
|
+
assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
|
|
13
|
+
self.characters_no_blank = characters
|
|
14
|
+
self.characters = characters + [self.BLANK]
|
|
15
|
+
assert all(c.strip() in self.characters or c.strip() == '' for c in board.flatten()), f'board must contain characters in {self.characters}'
|
|
16
|
+
assert all(c.strip() in self.characters or c.strip() == '' for c in np.concatenate([top, left, bottom, right])), f'top, bottom, left, and right must contain only characters in {self.characters}'
|
|
17
|
+
self.board = board
|
|
18
|
+
self.V, self.H = board.shape
|
|
19
|
+
assert top.shape == (self.H,) and bottom.shape == (self.H,) and left.shape == (self.V,) and right.shape == (self.V,), 'top, bottom, left, and right must be 1d arrays of length board width and height'
|
|
20
|
+
self.top = top
|
|
21
|
+
self.left = left
|
|
22
|
+
self.bottom = bottom
|
|
23
|
+
self.right = right
|
|
24
|
+
|
|
25
|
+
self.model = cp_model.CpModel()
|
|
26
|
+
self.model_vars: dict[tuple[Pos, str], cp_model.IntVar] = {}
|
|
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
|
+
for character in self.characters:
|
|
33
|
+
self.model_vars[pos, character] = self.model.NewBoolVar(f'{pos}:{character}')
|
|
34
|
+
|
|
35
|
+
def add_all_constraints(self):
|
|
36
|
+
for pos in get_all_pos(self.V, self.H):
|
|
37
|
+
self.model.AddExactlyOne([self.model_vars[pos, character] for character in self.characters])
|
|
38
|
+
c = get_char(self.board, pos).strip() # force the clue if on the board
|
|
39
|
+
if not c:
|
|
40
|
+
continue
|
|
41
|
+
self.model.Add(self.model_vars[pos, c] == 1)
|
|
42
|
+
|
|
43
|
+
# each row and column must have exactly one of each character, except for BLANK
|
|
44
|
+
for row in range(self.V):
|
|
45
|
+
for character in self.characters_no_blank:
|
|
46
|
+
self.model.AddExactlyOne([self.model_vars[pos, character] for pos in get_row_pos(row, self.H)])
|
|
47
|
+
for col in range(self.H):
|
|
48
|
+
for character in self.characters_no_blank:
|
|
49
|
+
self.model.AddExactlyOne([self.model_vars[pos, character] for pos in get_col_pos(col, self.V)])
|
|
50
|
+
|
|
51
|
+
# a character clue on that side means the first character that appears on the side is the clue
|
|
52
|
+
for i, top_char in enumerate(self.top):
|
|
53
|
+
self.force_first_character(list(get_col_pos(i, self.V)), top_char)
|
|
54
|
+
for i, bottom_char in enumerate(self.bottom):
|
|
55
|
+
self.force_first_character(list(get_col_pos(i, self.V))[::-1], bottom_char)
|
|
56
|
+
for i, left_char in enumerate(self.left):
|
|
57
|
+
self.force_first_character(list(get_row_pos(i, self.H)), left_char)
|
|
58
|
+
for i, right_char in enumerate(self.right):
|
|
59
|
+
self.force_first_character(list(get_row_pos(i, self.H))[::-1], right_char)
|
|
60
|
+
|
|
61
|
+
def force_first_character(self, pos_list: list[Pos], target_character: str):
|
|
62
|
+
if not target_character:
|
|
63
|
+
return
|
|
64
|
+
for i, pos in enumerate(pos_list):
|
|
65
|
+
is_first_char = self.model.NewBoolVar(f'{i}:{target_character}:is_first_char')
|
|
66
|
+
and_constraint(self.model, is_first_char, [self.model_vars[pos, self.BLANK] for pos in pos_list[:i]] + [self.model_vars[pos_list[i], self.BLANK].Not()])
|
|
67
|
+
self.model.Add(self.model_vars[pos, target_character] == 1).OnlyEnforceIf(is_first_char)
|
|
68
|
+
|
|
69
|
+
def solve_and_print(self, verbose: bool = True):
|
|
70
|
+
def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
|
|
71
|
+
return SingleSolution(assignment={pos: char for (pos, char), var in board.model_vars.items() if solver.Value(var) == 1 and char != board.BLANK})
|
|
72
|
+
def callback(single_res: SingleSolution):
|
|
73
|
+
print("Solution found")
|
|
74
|
+
print(combined_function(self.V, self.H, center_char=lambda r, c: single_res.assignment.get(get_pos(x=c, y=r), ''), text_on_shaded_cells=False))
|
|
75
|
+
return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
|