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

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

Potentially problematic release.


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

Files changed (42) hide show
  1. {multi_puzzle_solver-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/METADATA +1075 -556
  2. multi_puzzle_solver-1.0.7.dist-info/RECORD +74 -0
  3. puzzle_solver/__init__.py +5 -1
  4. puzzle_solver/core/utils.py +17 -1
  5. puzzle_solver/core/utils_visualizer.py +257 -201
  6. puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
  7. puzzle_solver/puzzles/aquarium/aquarium.py +8 -23
  8. puzzle_solver/puzzles/battleships/battleships.py +39 -53
  9. puzzle_solver/puzzles/binairo/binairo.py +2 -2
  10. puzzle_solver/puzzles/black_box/black_box.py +6 -70
  11. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +4 -2
  12. puzzle_solver/puzzles/filling/filling.py +11 -34
  13. puzzle_solver/puzzles/galaxies/galaxies.py +4 -2
  14. puzzle_solver/puzzles/heyawake/heyawake.py +72 -14
  15. puzzle_solver/puzzles/kakurasu/kakurasu.py +5 -13
  16. puzzle_solver/puzzles/kakuro/kakuro.py +6 -2
  17. puzzle_solver/puzzles/lits/lits.py +4 -2
  18. puzzle_solver/puzzles/mosaic/mosaic.py +8 -18
  19. puzzle_solver/puzzles/nonograms/nonograms.py +80 -85
  20. puzzle_solver/puzzles/nonograms/nonograms_colored.py +221 -0
  21. puzzle_solver/puzzles/norinori/norinori.py +5 -12
  22. puzzle_solver/puzzles/nurikabe/nurikabe.py +6 -2
  23. puzzle_solver/puzzles/palisade/palisade.py +8 -22
  24. puzzle_solver/puzzles/pearl/pearl.py +15 -27
  25. puzzle_solver/puzzles/pipes/pipes.py +2 -1
  26. puzzle_solver/puzzles/range/range.py +19 -55
  27. puzzle_solver/puzzles/rectangles/rectangles.py +4 -2
  28. puzzle_solver/puzzles/shingoki/shingoki.py +62 -105
  29. puzzle_solver/puzzles/singles/singles.py +6 -2
  30. puzzle_solver/puzzles/slant/slant.py +13 -19
  31. puzzle_solver/puzzles/slitherlink/slitherlink.py +2 -2
  32. puzzle_solver/puzzles/star_battle/star_battle.py +5 -2
  33. puzzle_solver/puzzles/stitches/stitches.py +8 -21
  34. puzzle_solver/puzzles/sudoku/sudoku.py +5 -11
  35. puzzle_solver/puzzles/tapa/tapa.py +6 -2
  36. puzzle_solver/puzzles/tents/tents.py +50 -80
  37. puzzle_solver/puzzles/tracks/tracks.py +19 -66
  38. puzzle_solver/puzzles/unruly/unruly.py +17 -49
  39. puzzle_solver/puzzles/yin_yang/yin_yang.py +3 -10
  40. multi_puzzle_solver-1.0.4.dist-info/RECORD +0 -72
  41. {multi_puzzle_solver-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/WHEEL +0 -0
  42. {multi_puzzle_solver-1.0.4.dist-info → multi_puzzle_solver-1.0.7.dist-info}/top_level.txt +0 -0
@@ -1,184 +1,54 @@
1
1
  import numpy as np
2
- from typing import Union, Callable, Optional, List, Sequence, Literal
2
+ from typing import Callable, Optional, List, Sequence, Literal
3
3
  from puzzle_solver.core.utils import Pos, get_all_pos, get_next_pos, in_bounds, set_char, get_char, Direction
4
4
 
5
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:
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
+ ) -> str:
10
18
  """
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.
19
+ Render a V x H grid that can:
20
+ draw selective edges per cell via cell_flags(r, c) containing any of 'U','D','L','R'
21
+ shade cells via is_shaded(r, c)
22
+ place centered text per cell via center_char(r, c)
23
+ • draw interior arms via special_content(r, c) returning any combo of 'U','D','L','R'
24
+ (e.g., 'UR', 'DL', 'ULRD', or '' to leave the interior unchanged).
25
+ Arms extend from the cell’s interior center toward the indicated sides.
26
+ • horizontal stretch (>=1). Interior width per cell = 2*scale_x - 1 (default 2)
27
+ • vertical stretch (>=1). Interior height per cell = scale_y (default 1)
28
+ • show_axes: bool = True, show the axes (columns on top, rows on the left).
29
+ • show_grid: bool = True, show the grid lines.
30
+
31
+ Behavior:
32
+ - If cell_flags is None, draws a full grid (all interior and outer borders present).
33
+ - 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.
34
+ - Axes are shown (columns on top, rows on the left).
35
+
36
+ Draw order:
37
+ 1) shading
38
+ 2) borders
39
+ 3) special_content (interior line arms)
40
+ 4) center_char (unless text_on_shaded_cells=False and cell is shaded)
15
41
  """
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) if callable(center_char) 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)
42
+ assert V >= 1 and H >= 1, f'V and H must be >= 1, got {V} and {H}'
43
+ assert cell_flags is None or callable(cell_flags), f'cell_flags must be None or callable, got {cell_flags}'
44
+ assert is_shaded is None or callable(is_shaded), f'is_shaded must be None or callable, got {is_shaded}'
45
+ assert center_char is None or callable(center_char), f'center_char must be None or callable, got {center_char}'
46
+ assert special_content is None or callable(special_content), f'special_content must be None or callable, got {special_content}'
116
47
 
117
- if gut >= 2:
118
- top_tens[gut-2:gut] = list(' ')
119
- top_ones[gut-2:gut] = list(' ')
48
+ # Rendering constants
49
+ fill_char: str = '' # single char for shaded interiors
50
+ empty_char: str = ' ' # single char for unshaded interiors
120
51
 
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
- empty_text: Optional[Union[str, Callable[[int, int], Optional[str]]]] = None,) -> str:
163
- """
164
- 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.
165
- Visualize a V x H grid where each cell is shaded if is_shaded(r, c) is True.
166
- The grid lines are always present.
167
-
168
- scale_x: horizontal stretch (>=1). Interior width per cell = 2*scale_x - 1.
169
- scale_y: vertical stretch (>=1). Interior height per cell = scale_y.
170
- fill_char: character to fill shaded cell interiors (single char).
171
- empty_char: background character for unshaded interiors (single char).
172
- empty_text: Optional text for unshaded cells. If a string, used for all unshaded
173
- cells. If a callable (r, c) -> str|None, used per cell. Text is
174
- centered within the interior row and truncated to fit.
175
- """
176
- scale_x: int = 2
177
- scale_y: int = 1
178
- fill_char: str = '▒'
179
- empty_char: str = ' '
180
- show_axes: bool = True
181
- assert V >= 1 and H >= 1
182
52
  assert scale_x >= 1 and scale_y >= 1
183
53
  assert len(fill_char) == 1 and len(empty_char) == 1
184
54
 
@@ -192,7 +62,29 @@ def render_shaded_grid(V: int,
192
62
  cols = x_corner(H) + 1
193
63
  canvas = [[empty_char] * cols for _ in range(rows)]
194
64
 
195
- # ── Shading first (borders will overwrite as needed) ───────────────────
65
+ # ── Edge presence arrays derived from cell_flags ──
66
+ # H_edges[r, c] is the horizontal edge between rows r and r+1 above column segment c (shape: (V+1, H))
67
+ # V_edges[r, c] is the vertical edge between cols c and c+1 left of row segment r (shape: (V, H+1))
68
+ if cell_flags is None:
69
+ # Full grid: all horizontal and vertical segments are present
70
+ H_edges = [[show_grid for _ in range(H)] for _ in range(V + 1)]
71
+ V_edges = [[show_grid for _ in range(H + 1)] for _ in range(V)]
72
+ else:
73
+ H_edges = [[False for _ in range(H)] for _ in range(V + 1)]
74
+ V_edges = [[False for _ in range(H + 1)] for _ in range(V)]
75
+ for r in range(V):
76
+ for c in range(H):
77
+ s = cell_flags(r, c) or ''
78
+ if 'U' in s:
79
+ H_edges[r ][c] = True
80
+ if 'D' in s:
81
+ H_edges[r + 1][c] = True
82
+ if 'L' in s:
83
+ V_edges[r][c ] = True
84
+ if 'R' in s:
85
+ V_edges[r][c + 1] = True
86
+
87
+ # ── Shading first (borders will overwrite) ─────────────────────────────
196
88
  shaded_map = [[False]*H for _ in range(V)]
197
89
  for r in range(V):
198
90
  top = y_border(r) + 1
@@ -204,14 +96,14 @@ def render_shaded_grid(V: int,
204
96
  right = x_corner(c + 1) - 1 # inclusive
205
97
  if left > right:
206
98
  continue
207
- shaded = bool(is_shaded(r, c))
99
+ shaded = bool(is_shaded(r, c)) if callable(is_shaded) else False
208
100
  shaded_map[r][c] = shaded
209
101
  ch = fill_char if shaded else empty_char
210
102
  for yy in range(top, bottom + 1):
211
103
  for xx in range(left, right + 1):
212
104
  canvas[yy][xx] = ch
213
105
 
214
- # ── Grid lines ─────────────────────────────────────────────────────────
106
+ # ── Grid lines (respect edge presence) ─────────────────────────────────
215
107
  U, Rb, D, Lb = 1, 2, 4, 8
216
108
  JUNCTION = {
217
109
  0: ' ',
@@ -222,20 +114,22 @@ def render_shaded_grid(V: int,
222
114
  U | Rb | D | Lb: '┼',
223
115
  }
224
116
 
225
- # Horizontal borders (every y_border row)
117
+ # Horizontal segments
226
118
  for r in range(V + 1):
227
119
  yy = y_border(r)
228
120
  for c in range(H):
229
- base = x_corner(c)
230
- for k in range(1, 2 * scale_x): # 1..(2*scale_x-1)
231
- canvas[yy][base + k] = '─'
121
+ if H_edges[r][c]:
122
+ base = x_corner(c)
123
+ for k in range(1, 2 * scale_x): # 1..(2*scale_x-1)
124
+ canvas[yy][base + k] = '─'
232
125
 
233
- # Vertical borders (every x_corner col)
234
- for c in range(H + 1):
235
- xx = x_corner(c)
236
- for r in range(V):
237
- for ky in range(1, scale_y + 1):
238
- canvas[y_border(r) + ky][xx] = '│'
126
+ # Vertical segments
127
+ for r in range(V):
128
+ for c in range(H + 1):
129
+ if V_edges[r][c]:
130
+ xx = x_corner(c)
131
+ for ky in range(1, scale_y + 1):
132
+ canvas[y_border(r) + ky][xx] = '│'
239
133
 
240
134
  # Junctions at intersections
241
135
  for r in range(V + 1):
@@ -243,14 +137,79 @@ def render_shaded_grid(V: int,
243
137
  for c in range(H + 1):
244
138
  xx = x_corner(c)
245
139
  m = 0
246
- if r > 0: m |= U
247
- if r < V: m |= D
248
- if c > 0: m |= Lb
249
- if c < H: m |= Rb
140
+ if r > 0 and V_edges[r - 1][c]:
141
+ m |= U
142
+ if r < V and V_edges[r][c]:
143
+ m |= D
144
+ if c > 0 and H_edges[r][c - 1]:
145
+ m |= Lb
146
+ if c < H and H_edges[r][c]:
147
+ m |= Rb
250
148
  canvas[yy][xx] = JUNCTION[m]
251
149
 
252
- # ── Optional per-cell text for UNshaded cells ──────────────────────────
253
- def put_center_text(r_cell: int, c_cell: int, s: str):
150
+ # ── Special interior content (arms) + cross-cell bridges ──────────────
151
+ def draw_special_arms(r_cell: int, c_cell: int, code: Optional[str]):
152
+ if not code:
153
+ return
154
+ s = set(code)
155
+ # interior box
156
+ left = x_corner(c_cell) + 1
157
+ right = x_corner(c_cell + 1) - 1
158
+ top = y_border(r_cell) + 1
159
+ bottom= y_border(r_cell + 1) - 1
160
+ if left > right or top > bottom:
161
+ return
162
+
163
+ # center of interior
164
+ cx = left + (right - left) // 2
165
+ cy = top + (bottom - top) // 2
166
+
167
+ # draw arms out from center (keep inside interior; don't touch borders)
168
+ if 'U' in s and cy - 1 >= top:
169
+ for yy in range(cy - 1, top - 1, -1):
170
+ canvas[yy][cx] = '│'
171
+ if 'D' in s and cy + 1 <= bottom:
172
+ for yy in range(cy + 1, bottom + 1):
173
+ canvas[yy][cx] = '│'
174
+ if 'L' in s and cx - 1 >= left:
175
+ for xx in range(cx - 1, left - 1, -1):
176
+ canvas[cy][xx] = '─'
177
+ if 'R' in s and cx + 1 <= right:
178
+ for xx in range(cx + 1, right + 1):
179
+ canvas[cy][xx] = '─'
180
+ if '/' in s:
181
+ for xx in range(right - left + 1):
182
+ for yy in range(top - bottom + 1):
183
+ canvas[top + yy][left + xx] = '/'
184
+ if '\\' in s:
185
+ for xx in range(right - left + 1):
186
+ for yy in range(top - bottom + 1):
187
+ canvas[top + yy][left + xx] = '\\'
188
+
189
+ # center junction
190
+ U_b, R_b, D_b, L_b = 1, 2, 4, 8
191
+ m = 0
192
+ if 'U' in s: m |= U_b
193
+ if 'D' in s: m |= D_b
194
+ if 'L' in s: m |= L_b
195
+ if 'R' in s: m |= R_b
196
+ canvas[cy][cx] = JUNCTION.get(m, ' ')
197
+
198
+ # pass 1: draw interior arms per cell
199
+ special_map = [[set() for _ in range(H)] for _ in range(V)]
200
+ if callable(special_content):
201
+ for r in range(V):
202
+ for c in range(H):
203
+ flags = set(ch for ch in str(special_content(r, c) or ''))
204
+ special_map[r][c] = flags
205
+ if flags:
206
+ draw_special_arms(r, c, ''.join(flags))
207
+
208
+ # ── Center text (drawn last so it sits atop shading/arms) ─────────────
209
+ def put_center_text(r_cell: int, c_cell: int, s: Optional[str]):
210
+ if s is None:
211
+ return
212
+ s = str(s)
254
213
  # interior box
255
214
  left = x_corner(c_cell) + 1
256
215
  right = x_corner(c_cell + 1) - 1
@@ -259,24 +218,93 @@ def render_shaded_grid(V: int,
259
218
  if left > right or top > bottom:
260
219
  return
261
220
  span_w = right - left + 1
262
- # choose middle interior row for text
263
221
  yy = top + (bottom - top) // 2
264
- s = '' if s is None else str(s)
265
222
  if len(s) > span_w:
266
- s = s[:span_w]
223
+ s = s[:span_w] # truncate to protect borders
267
224
  start = left + (span_w - len(s)) // 2
268
225
  for i, ch in enumerate(s):
269
226
  canvas[yy][start + i] = ch
270
227
 
271
- if empty_text is not None:
228
+
229
+ # helper to get interior-center coordinates
230
+ def _cell_center_rc(r_cell: int, c_cell: int):
231
+ left = x_corner(c_cell) + 1
232
+ right = x_corner(c_cell + 1) - 1
233
+ top = y_border(r_cell) + 1
234
+ bottom= y_border(r_cell + 1) - 1
235
+ if left > right or top > bottom:
236
+ return None
237
+ cx = left + (right - left) // 2
238
+ cy = top + (bottom - top) // 2
239
+ return cy, cx
240
+
241
+ # ── REPLACE your place_connector() and "pass 2" with the following ────
242
+
243
+ # PASS 2: merge/bridge on every border using bitmasks (works with or without borders)
244
+ if callable(special_content):
245
+ # vertical borders: c in [0..H], between (r,c-1) and (r,c)
246
+ for r in range(V):
247
+ # y (row) where we draw the junction on this border: the interior center row
248
+ cc = _cell_center_rc(r, 0)
249
+ if cc is None:
250
+ continue
251
+ cy = cc[0]
252
+ for c in range(H + 1):
253
+ x = x_corner(c)
254
+ mask = 0
255
+ # base: if the vertical grid line exists here, add U and D
256
+ if V_edges[r][c]:
257
+ mask |= U | D
258
+
259
+ # neighbors pointing toward this vertical border
260
+ left_flags = special_map[r][c - 1] if c - 1 >= 0 else set()
261
+ right_flags = special_map[r][c] if c < H else set()
262
+ if 'R' in left_flags:
263
+ mask |= Lb
264
+ if 'L' in right_flags:
265
+ mask |= Rb
266
+
267
+ # nothing to draw? leave whatever is already there
268
+ if mask == 0:
269
+ continue
270
+ canvas[cy][x] = JUNCTION[mask]
271
+
272
+ # horizontal borders: r in [0..V], between (r-1,c) and (r,c)
273
+ for c in range(H):
274
+ # x (col) where we draw the junction on this border: the interior center col
275
+ cc = _cell_center_rc(0, c)
276
+ if cc is None:
277
+ continue
278
+ cx = cc[1]
279
+ for r in range(V + 1):
280
+ y = y_border(r)
281
+ mask = 0
282
+ # base: if the horizontal grid line exists here, add L and R
283
+ if r <= V - 1 and H_edges[r][c]: # H_edges indexed [0..V] x [0..H-1]
284
+ mask |= Lb | Rb
285
+
286
+ # neighbors pointing toward this horizontal border
287
+ up_flags = special_map[r - 1][c] if r - 1 >= 0 else set()
288
+ down_flags = special_map[r][c] if r < V else set()
289
+ if 'D' in up_flags:
290
+ mask |= U
291
+ if 'U' in down_flags:
292
+ mask |= D
293
+
294
+ if mask == 0:
295
+ continue
296
+ canvas[y][cx] = JUNCTION[mask]
297
+
298
+ if callable(center_char):
272
299
  for r in range(V):
273
300
  for c in range(H):
274
- if not shaded_map[r][c]:
275
- s = empty_text(r, c) if callable(empty_text) else empty_text
276
- if s:
277
- put_center_text(r, c, s)
301
+ if not text_on_shaded_cells and shaded_map[r][c]:
302
+ continue
303
+ if not text_on_shaded_cells and special_map[r][c]:
304
+ continue
305
+ put_center_text(r, c, center_char(r, c))
278
306
 
279
- # ── Stringify ──────────────────────────────────────────────────────────
307
+ # ── Stringify with axes ────────────────────────────────────────────────
280
308
  art_rows = [''.join(row) for row in canvas]
281
309
  if not show_axes:
282
310
  return '\n'.join(art_rows)
@@ -308,6 +336,34 @@ def render_shaded_grid(V: int,
308
336
 
309
337
  return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
310
338
 
339
+ def id_board_to_wall_fn(id_board: np.array, border_is_wall = True) -> Callable[[int, int], str]:
340
+ """In many instances, we have a 2d array where cell values are arbitrary ids
341
+ 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.
342
+ Args:
343
+ id_board: np.array of shape (N, N) with arbitrary ids.
344
+ border_is_wall: if True, the edges of the board are considered to be walls.
345
+ Returns:
346
+ Callable[[int, int], str] that returns the walls "U", "D", "L", "R" for the cell at (r, c).
347
+ """
348
+ res = np.full((id_board.shape[0], id_board.shape[1]), '', dtype=object)
349
+ V, H = id_board.shape
350
+ def append_char(pos: Pos, s: str):
351
+ set_char(res, pos, get_char(res, pos) + s)
352
+ def handle_pos_direction(pos: Pos, direction: Direction, s: str):
353
+ pos2 = get_next_pos(pos, direction)
354
+ if in_bounds(pos2, V, H):
355
+ if get_char(id_board, pos2) != get_char(id_board, pos):
356
+ append_char(pos, s)
357
+ else:
358
+ if border_is_wall:
359
+ append_char(pos, s)
360
+ for pos in get_all_pos(V, H):
361
+ handle_pos_direction(pos, Direction.LEFT, 'L')
362
+ handle_pos_direction(pos, Direction.RIGHT, 'R')
363
+ handle_pos_direction(pos, Direction.UP, 'U')
364
+ handle_pos_direction(pos, Direction.DOWN, 'D')
365
+ return lambda r, c: res[r][c]
366
+
311
367
  CellVal = Literal["B", "W", "TL", "TR", "BL", "BR"]
312
368
  GridLike = Sequence[Sequence[CellVal]]
313
369
 
@@ -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)