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.
Files changed (106) hide show
  1. multi_puzzle_solver-1.1.8.dist-info/METADATA +4326 -0
  2. multi_puzzle_solver-1.1.8.dist-info/RECORD +106 -0
  3. multi_puzzle_solver-1.1.8.dist-info/WHEEL +5 -0
  4. multi_puzzle_solver-1.1.8.dist-info/top_level.txt +1 -0
  5. puzzle_solver/__init__.py +184 -0
  6. puzzle_solver/core/utils.py +298 -0
  7. puzzle_solver/core/utils_ortools.py +333 -0
  8. puzzle_solver/core/utils_visualizer.py +575 -0
  9. puzzle_solver/puzzles/abc_view/abc_view.py +75 -0
  10. puzzle_solver/puzzles/aquarium/aquarium.py +97 -0
  11. puzzle_solver/puzzles/area_51/area_51.py +159 -0
  12. puzzle_solver/puzzles/battleships/battleships.py +139 -0
  13. puzzle_solver/puzzles/binairo/binairo.py +98 -0
  14. puzzle_solver/puzzles/binairo/binairo_plus.py +7 -0
  15. puzzle_solver/puzzles/black_box/black_box.py +243 -0
  16. puzzle_solver/puzzles/branches/branches.py +64 -0
  17. puzzle_solver/puzzles/bridges/bridges.py +104 -0
  18. puzzle_solver/puzzles/chess_range/chess_melee.py +6 -0
  19. puzzle_solver/puzzles/chess_range/chess_range.py +406 -0
  20. puzzle_solver/puzzles/chess_range/chess_solo.py +9 -0
  21. puzzle_solver/puzzles/chess_sequence/chess_sequence.py +262 -0
  22. puzzle_solver/puzzles/circle_9/circle_9.py +44 -0
  23. puzzle_solver/puzzles/clouds/clouds.py +81 -0
  24. puzzle_solver/puzzles/connect_the_dots/connect_the_dots.py +50 -0
  25. puzzle_solver/puzzles/cow_and_cactus/cow_and_cactus.py +66 -0
  26. puzzle_solver/puzzles/dominosa/dominosa.py +67 -0
  27. puzzle_solver/puzzles/filling/filling.py +94 -0
  28. puzzle_solver/puzzles/flip/flip.py +64 -0
  29. puzzle_solver/puzzles/flood_it/flood_it.py +174 -0
  30. puzzle_solver/puzzles/flood_it/parse_map/parse_map.py +197 -0
  31. puzzle_solver/puzzles/galaxies/galaxies.py +110 -0
  32. puzzle_solver/puzzles/galaxies/parse_map/parse_map.py +216 -0
  33. puzzle_solver/puzzles/guess/guess.py +232 -0
  34. puzzle_solver/puzzles/heyawake/heyawake.py +152 -0
  35. puzzle_solver/puzzles/hidden_stars/hidden_stars.py +52 -0
  36. puzzle_solver/puzzles/hidoku/hidoku.py +59 -0
  37. puzzle_solver/puzzles/inertia/inertia.py +121 -0
  38. puzzle_solver/puzzles/inertia/parse_map/parse_map.py +207 -0
  39. puzzle_solver/puzzles/inertia/tsp.py +400 -0
  40. puzzle_solver/puzzles/kakurasu/kakurasu.py +38 -0
  41. puzzle_solver/puzzles/kakuro/kakuro.py +81 -0
  42. puzzle_solver/puzzles/kakuro/krypto_kakuro.py +95 -0
  43. puzzle_solver/puzzles/keen/keen.py +76 -0
  44. puzzle_solver/puzzles/kropki/kropki.py +94 -0
  45. puzzle_solver/puzzles/light_up/light_up.py +58 -0
  46. puzzle_solver/puzzles/linesweeper/linesweeper.py +71 -0
  47. puzzle_solver/puzzles/link_a_pix/link_a_pix.py +91 -0
  48. puzzle_solver/puzzles/lits/lits.py +138 -0
  49. puzzle_solver/puzzles/magnets/magnets.py +96 -0
  50. puzzle_solver/puzzles/map/map.py +56 -0
  51. puzzle_solver/puzzles/mathema_grids/mathema_grids.py +119 -0
  52. puzzle_solver/puzzles/mathrax/mathrax.py +93 -0
  53. puzzle_solver/puzzles/minesweeper/minesweeper.py +123 -0
  54. puzzle_solver/puzzles/mosaic/mosaic.py +38 -0
  55. puzzle_solver/puzzles/n_queens/n_queens.py +71 -0
  56. puzzle_solver/puzzles/nonograms/nonograms.py +121 -0
  57. puzzle_solver/puzzles/nonograms/nonograms_colored.py +220 -0
  58. puzzle_solver/puzzles/norinori/norinori.py +96 -0
  59. puzzle_solver/puzzles/number_path/number_path.py +76 -0
  60. puzzle_solver/puzzles/numbermaze/numbermaze.py +97 -0
  61. puzzle_solver/puzzles/nurikabe/nurikabe.py +130 -0
  62. puzzle_solver/puzzles/palisade/palisade.py +91 -0
  63. puzzle_solver/puzzles/pearl/pearl.py +107 -0
  64. puzzle_solver/puzzles/pipes/pipes.py +82 -0
  65. puzzle_solver/puzzles/range/range.py +59 -0
  66. puzzle_solver/puzzles/rectangles/rectangles.py +128 -0
  67. puzzle_solver/puzzles/ripple_effect/ripple_effect.py +83 -0
  68. puzzle_solver/puzzles/rooms/rooms.py +75 -0
  69. puzzle_solver/puzzles/schurs_numbers/schurs_numbers.py +73 -0
  70. puzzle_solver/puzzles/shakashaka/shakashaka.py +201 -0
  71. puzzle_solver/puzzles/shingoki/shingoki.py +116 -0
  72. puzzle_solver/puzzles/signpost/signpost.py +93 -0
  73. puzzle_solver/puzzles/singles/singles.py +53 -0
  74. puzzle_solver/puzzles/slant/parse_map/parse_map.py +135 -0
  75. puzzle_solver/puzzles/slant/slant.py +111 -0
  76. puzzle_solver/puzzles/slitherlink/slitherlink.py +130 -0
  77. puzzle_solver/puzzles/snail/snail.py +97 -0
  78. puzzle_solver/puzzles/split_ends/split_ends.py +93 -0
  79. puzzle_solver/puzzles/star_battle/star_battle.py +75 -0
  80. puzzle_solver/puzzles/star_battle/star_battle_shapeless.py +7 -0
  81. puzzle_solver/puzzles/stitches/parse_map/parse_map.py +267 -0
  82. puzzle_solver/puzzles/stitches/stitches.py +96 -0
  83. puzzle_solver/puzzles/sudoku/sudoku.py +267 -0
  84. puzzle_solver/puzzles/suguru/suguru.py +55 -0
  85. puzzle_solver/puzzles/suko/suko.py +54 -0
  86. puzzle_solver/puzzles/tapa/tapa.py +97 -0
  87. puzzle_solver/puzzles/tatami/tatami.py +64 -0
  88. puzzle_solver/puzzles/tents/tents.py +80 -0
  89. puzzle_solver/puzzles/thermometers/thermometers.py +82 -0
  90. puzzle_solver/puzzles/towers/towers.py +89 -0
  91. puzzle_solver/puzzles/tracks/tracks.py +88 -0
  92. puzzle_solver/puzzles/trees_logic/trees_logic.py +48 -0
  93. puzzle_solver/puzzles/troix/dumplings.py +7 -0
  94. puzzle_solver/puzzles/troix/troix.py +75 -0
  95. puzzle_solver/puzzles/twiddle/twiddle.py +112 -0
  96. puzzle_solver/puzzles/undead/undead.py +130 -0
  97. puzzle_solver/puzzles/unequal/unequal.py +128 -0
  98. puzzle_solver/puzzles/unruly/unruly.py +54 -0
  99. puzzle_solver/puzzles/vectors/vectors.py +94 -0
  100. puzzle_solver/puzzles/vermicelli/vermicelli.py +74 -0
  101. puzzle_solver/puzzles/walls/walls.py +52 -0
  102. puzzle_solver/puzzles/yajilin/yajilin.py +87 -0
  103. puzzle_solver/puzzles/yin_yang/parse_map/parse_map.py +172 -0
  104. puzzle_solver/puzzles/yin_yang/yin_yang.py +103 -0
  105. puzzle_solver/utils/etc/parser/board_color_digit.py +497 -0
  106. 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)