multi-puzzle-solver 0.9.18__py3-none-any.whl → 0.9.20__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: multi-puzzle-solver
3
- Version: 0.9.18
3
+ Version: 0.9.20
4
4
  Summary: Efficient solvers for numerous popular and esoteric logic puzzles using CP-SAT
5
5
  Author: Ar-Kareem
6
6
  Project-URL: Homepage, https://github.com/Ar-Kareem/puzzle_solver
@@ -299,6 +299,16 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
299
299
  <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/unequal_solved.png" alt="Unequal" width="140">
300
300
  </a>
301
301
  </td>
302
+ <td align="center">
303
+ <a href="#norinori-puzzle-type-38"><b>Norinori</b><br><br>
304
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/norinori_solved.png" alt="Norinori" width="140">
305
+ </a>
306
+ </td>
307
+ <td align="center">
308
+ <a href="#slitherlink-puzzle-type-39"><b>Slitherlink</b><br><br>
309
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/slitherlink_solved.png" alt="Slitherlink" width="140">
310
+ </a>
311
+ </td>
302
312
  </tr>
303
313
  </table>
304
314
 
@@ -351,6 +361,8 @@ These are all the puzzles that are implemented in this repo. <br> Click on any o
351
361
  - [Galaxies (Puzzle Type #35)](#galaxies-puzzle-type-35)
352
362
  - [Slant (Puzzle Type #36)](#slant-puzzle-type-36)
353
363
  - [Unequal (Puzzle Type #37)](#unequal-puzzle-type-37)
364
+ - [Norinori (Puzzle Type #38)](#norinori-puzzle-type-38)
365
+ - [Slitherlink (Puzzle Type #39)](#slitherlink-puzzle-type-39)
354
366
  - [Why SAT / CP-SAT?](#why-sat--cp-sat)
355
367
  - [Testing](#testing)
356
368
  - [Contributing](#contributing)
@@ -3083,6 +3095,8 @@ Applying the solution to the puzzle visually:
3083
3095
 
3084
3096
  ## Unequal (Puzzle Type #37)
3085
3097
 
3098
+ Also called "Futoshiki" or Renzoku"
3099
+
3086
3100
  * [**Play online**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/unequal.html)
3087
3101
 
3088
3102
  * [**Instructions**](https://www.chiark.greenend.org.uk/~sgtatham/puzzles/doc/unequal.html#unequal)
@@ -3117,6 +3131,7 @@ Code to utilize this package and solve the puzzle:
3117
3131
  Note: For an NxM board you need an (2N-1)x(2M-1) array because the puzzle involves input in between the cells. Each numbered cell has neighbors horizontally to represent ">", "<", and "|" (where "|" represents adjacency) and vertically to represent "∧", "∨" and "-" (where "-" represents adjacency). The "X" in the input are unused corners that shouldnt contain anything (just a corner). The numbers should never appear orthogonal to an "X", only diagonally to it. vice-versa for the comparison operators.
3118
3132
 
3119
3133
  ```python
3134
+ from puzzle_solver import unequal_solver as solver
3120
3135
  board = np.array([
3121
3136
  [' ', ' ', ' ', ' ', '9', ' ', '1', ' ', '7', '>', ' ', '>', ' ', ' ', ' ', ' ', ' ', '>', ' '],
3122
3137
  [' ', 'X', 'V', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', '∧', 'X', ' ', 'X', ' ', 'X', ' '],
@@ -3170,6 +3185,206 @@ Applying the solution to the puzzle visually:
3170
3185
 
3171
3186
  ---
3172
3187
 
3188
+ ## Norinori (Puzzle Type #38)
3189
+
3190
+ * [**Play online**](https://www.puzzle-norinori.com)
3191
+
3192
+ * [**Solver Code**][38]
3193
+
3194
+ <details>
3195
+ <summary><strong>Rules</strong></summary>
3196
+
3197
+ You have to shade some of the cells in such a way that:
3198
+ - Exactly 2 cells are shaded in each region.
3199
+ - Each shaded cell should be a part of a domino*. Dominoes can cross the region borders.
3200
+ - The dominoes cannot touch each other except diagonally.
3201
+
3202
+ * A domino is a shape made of 2 shaded cells next to each other (1x2 or 2x1).
3203
+
3204
+ </details>
3205
+
3206
+ **Unsolved puzzle**
3207
+
3208
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/norinori_unsolved.png" alt="Norinori unsolved" width="500">
3209
+
3210
+ Code to utilize this package and solve the puzzle:
3211
+
3212
+ ```python
3213
+ from puzzle_solver import norinori_solver as solver
3214
+ board = np.array([
3215
+ ['00', '01', '01', '01', '01', '02', '03', '03', '04', '04', '04', '05', '05', '05', '06', '07', '08', '08', '09', '09'],
3216
+ ['00', '00', '01', '01', '01', '02', '03', '04', '04', '10', '11', '11', '05', '06', '06', '07', '08', '08', '12', '12'],
3217
+ ['13', '13', '13', '01', '01', '03', '03', '10', '10', '10', '11', '14', '05', '14', '07', '07', '07', '12', '12', '12'],
3218
+ ['13', '15', '13', '16', '16', '16', '17', '17', '17', '18', '18', '14', '14', '14', '07', '07', '07', '07', '07', '12'],
3219
+ ['13', '15', '15', '16', '19', '19', '17', '17', '17', '18', '18', '18', '14', '20', '07', '07', '21', '21', '21', '21'],
3220
+ ['13', '19', '19', '19', '19', '19', '17', '22', '22', '22', '22', '18', '14', '20', '20', '07', '21', '23', '23', '21'],
3221
+ ['24', '24', '25', '25', '25', '25', '26', '27', '27', '27', '28', '28', '20', '20', '29', '29', '30', '30', '31', '31'],
3222
+ ['24', '24', '25', '32', '33', '33', '26', '27', '27', '34', '28', '35', '35', '36', '36', '29', '37', '30', '31', '31'],
3223
+ ['38', '32', '32', '32', '33', '27', '27', '27', '27', '34', '28', '28', '35', '35', '29', '29', '37', '37', '31', '37'],
3224
+ ['38', '38', '32', '39', '33', '40', '34', '34', '34', '34', '28', '35', '35', '35', '41', '37', '37', '37', '37', '37'],
3225
+ ['42', '38', '39', '39', '40', '40', '43', '43', '34', '44', '28', '35', '45', '45', '41', '41', '41', '41', '46', '46'],
3226
+ ['42', '42', '39', '47', '47', '40', '40', '44', '44', '44', '48', '48', '48', '48', '48', '41', '49', '49', '49', '46'],
3227
+ ['50', '50', '39', '39', '40', '40', '40', '40', '51', '51', '51', '52', '48', '48', '53', '41', '54', '54', '49', '46'],
3228
+ ['50', '39', '39', '55', '55', '40', '40', '40', '56', '51', '51', '52', '53', '48', '53', '41', '41', '54', '49', '46'],
3229
+ ['39', '39', '39', '57', '56', '56', '56', '56', '56', '56', '53', '53', '53', '53', '53', '58', '58', '58', '59', '59'],
3230
+ ['60', '39', '39', '57', '57', '61', '61', '61', '62', '56', '56', '63', '63', '63', '63', '63', '59', '59', '59', '59'],
3231
+ ['60', '64', '65', '65', '61', '61', '66', '66', '62', '62', '62', '67', '63', '63', '68', '69', '69', '69', '69', '69'],
3232
+ ['60', '64', '65', '65', '65', '65', '66', '70', '70', '70', '70', '67', '67', '71', '68', '69', '72', '73', '73', '69'],
3233
+ ['60', '60', '60', '65', '66', '66', '66', '66', '74', '75', '75', '75', '67', '71', '68', '68', '72', '73', '73', '73'],
3234
+ ['76', '76', '76', '76', '76', '77', '77', '74', '74', '74', '74', '67', '67', '71', '71', '71', '72', '73', '78', '78']
3235
+ ])
3236
+ binst = solver.Board(board=board)
3237
+ solutions = binst.solve_and_print()
3238
+ ```
3239
+ **Script Output**
3240
+
3241
+ ```python
3242
+ Solution found
3243
+ [
3244
+ [ 'X', ' ', ' ', ' ', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', 'X', 'X' ],
3245
+ [ 'X', ' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', ' ', ' ' ],
3246
+ [ ' ', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', 'X', ' ', ' ', ' ', 'X', ' ', ' ' ],
3247
+ [ 'X', 'X', ' ', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', ' ', ' ', ' ', ' ', ' ', 'X', ' ', 'X' ],
3248
+ [ ' ', ' ', 'X', 'X', ' ', ' ', ' ', ' ', ' ', ' ', ' ', 'X', 'X', ' ', ' ', 'X', 'X', ' ', ' ', 'X' ],
3249
+ [ ' ', 'X', ' ', ' ', 'X', ' ', ' ', 'X', 'X', ' ', ' ', ' ', ' ', 'X', 'X', ' ', ' ', 'X', 'X', ' ' ],
3250
+ [ ' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', ' ', ' ', 'X', 'X', ' ', ' ', 'X' ],
3251
+ [ 'X', ' ', 'X', 'X', ' ', ' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', 'X', 'X', ' ', ' ', 'X', ' ', 'X' ],
3252
+ [ 'X', ' ', ' ', ' ', 'X', ' ', ' ', ' ', 'X', ' ', ' ', ' ', ' ', ' ', ' ', 'X', ' ', 'X', ' ', ' ' ],
3253
+ [ ' ', 'X', 'X', ' ', 'X', ' ', ' ', ' ', 'X', ' ', 'X', 'X', ' ', ' ', ' ', 'X', ' ', ' ', ' ', ' ' ],
3254
+ [ 'X', ' ', ' ', ' ', ' ', ' ', 'X', 'X', ' ', 'X', ' ', ' ', 'X', 'X', ' ', ' ', ' ', 'X', 'X', ' ' ],
3255
+ [ 'X', ' ', ' ', 'X', 'X', ' ', ' ', ' ', ' ', 'X', ' ', ' ', ' ', ' ', 'X', ' ', 'X', ' ', ' ', ' ' ],
3256
+ [ ' ', 'X', 'X', ' ', ' ', ' ', ' ', 'X', 'X', ' ', ' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', 'X', 'X' ],
3257
+ [ 'X', ' ', ' ', 'X', 'X', ' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', 'X', ' ', ' ' ],
3258
+ [ 'X', ' ', ' ', ' ', ' ', ' ', 'X', ' ', ' ', 'X', ' ', ' ', ' ', 'X', ' ', 'X', ' ', 'X', ' ', ' ' ],
3259
+ [ ' ', ' ', ' ', 'X', 'X', ' ', ' ', 'X', 'X', ' ', ' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', ' ', 'X' ],
3260
+ [ ' ', 'X', ' ', ' ', ' ', 'X', 'X', ' ', ' ', 'X', ' ', 'X', ' ', ' ', 'X', ' ', 'X', ' ', ' ', 'X' ],
3261
+ [ ' ', 'X', ' ', 'X', 'X', ' ', ' ', 'X', ' ', 'X', ' ', ' ', 'X', 'X', ' ', ' ', ' ', 'X', 'X', ' ' ],
3262
+ [ 'X', ' ', 'X', ' ', ' ', ' ', ' ', 'X', ' ', ' ', 'X', 'X', ' ', ' ', 'X', ' ', 'X', ' ', ' ', ' ' ],
3263
+ [ 'X', ' ', 'X', ' ', ' ', 'X', 'X', ' ', 'X', 'X', ' ', ' ', ' ', ' ', 'X', ' ', 'X', ' ', 'X', 'X' ],
3264
+ ]
3265
+ Solutions found: 1
3266
+ status: OPTIMAL
3267
+ Time taken: 0.04 seconds
3268
+ ```
3269
+
3270
+ **Solved puzzle**
3271
+
3272
+ Applying the solution to the puzzle visually:
3273
+
3274
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/norinori_solved.png" alt="Norinori solved" width="500">
3275
+
3276
+ ---
3277
+
3278
+ ## Slitherlink (Puzzle Type #39)
3279
+
3280
+ Also known as Fences and Loop the Loop
3281
+
3282
+ * [**Play online**](https://www.puzzle-loop.com)
3283
+
3284
+ * [**Solver Code**][39]
3285
+
3286
+ <details>
3287
+ <summary><strong>Rules</strong></summary>
3288
+
3289
+ You have to draw lines between the dots to form a single loop without crossings or branches. The numbers indicate how many lines surround it.
3290
+
3291
+ A line forming a single loop without crossings or branches means that every corner has either 2 or 0 lines touching it.
3292
+
3293
+ </details>
3294
+
3295
+ **Unsolved puzzle**
3296
+
3297
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/slitherlink_unsolved.png" alt="Slitherlink unsolved" width="500">
3298
+
3299
+ Code to utilize this package and solve the puzzle:
3300
+
3301
+ ```python
3302
+ from puzzle_solver import slitherlink_solver as solver
3303
+ board = np.array([
3304
+ ['3', ' ', ' ', '2', ' ', ' ', ' ', ' ', ' ', '3', ' ', ' ', ' ', ' ', ' ', '3', ' ', ' ', '1', ' '],
3305
+ [' ', ' ', '3', ' ', '3', ' ', ' ', ' ', '3', ' ', '2', '2', ' ', '2', ' ', '2', '2', ' ', '2', '3'],
3306
+ ['2', '2', ' ', ' ', ' ', '2', '1', ' ', '1', '1', ' ', ' ', '3', '1', ' ', '2', ' ', ' ', ' ', '2'],
3307
+ [' ', ' ', '2', ' ', ' ', '2', '2', ' ', ' ', ' ', '3', ' ', ' ', ' ', ' ', ' ', '2', '2', '3', ' '],
3308
+ ['1', '2', '1', ' ', ' ', ' ', '2', '1', ' ', '3', '2', ' ', '3', '2', '2', '3', ' ', '3', '2', '2'],
3309
+ [' ', '3', '2', '2', '1', '2', ' ', '3', ' ', ' ', ' ', ' ', '2', '2', '3', ' ', '1', '1', ' ', '2'],
3310
+ ['1', ' ', ' ', ' ', ' ', ' ', '2', ' ', ' ', '2', ' ', '1', '3', ' ', ' ', ' ', ' ', '2', '2', '2'],
3311
+ [' ', '3', ' ', '2', '0', '1', '2', '1', ' ', '1', '3', ' ', '2', ' ', ' ', '2', ' ', '2', '1', ' '],
3312
+ ['2', ' ', ' ', ' ', '2', ' ', '3', ' ', ' ', ' ', ' ', '2', ' ', ' ', '1', '2', ' ', ' ', '1', '3'],
3313
+ [' ', ' ', '1', ' ', ' ', ' ', ' ', '2', '0', ' ', '1', ' ', '2', ' ', '0', ' ', '2', ' ', '3', '2'],
3314
+ [' ', '3', ' ', '3', ' ', '1', '3', ' ', '3', ' ', '2', ' ', ' ', '2', '2', '2', '3', ' ', ' ', ' '],
3315
+ ['3', ' ', ' ', ' ', ' ', ' ', ' ', '0', '2', '1', ' ', ' ', '2', ' ', ' ', '1', ' ', '0', '2', ' '],
3316
+ [' ', ' ', ' ', ' ', ' ', ' ', '3', ' ', '3', '2', '3', ' ', ' ', '2', ' ', '1', ' ', ' ', ' ', ' '],
3317
+ ['2', '2', ' ', '3', '0', ' ', ' ', '3', ' ', ' ', '2', ' ', ' ', ' ', ' ', '2', '2', ' ', '3', ' '],
3318
+ [' ', '2', '0', ' ', ' ', '3', ' ', '1', ' ', ' ', '2', ' ', '2', '2', ' ', ' ', ' ', '2', ' ', '2'],
3319
+ [' ', ' ', '1', '3', '1', ' ', ' ', ' ', ' ', ' ', '2', ' ', '2', '1', ' ', '1', '2', '2', ' ', ' '],
3320
+ ['2', ' ', '2', '2', ' ', '1', '3', ' ', '2', ' ', '3', '1', '2', ' ', '3', '2', ' ', '1', '1', ' '],
3321
+ [' ', ' ', '2', ' ', '1', ' ', ' ', ' ', '2', ' ', ' ', ' ', '2', ' ', '1', '0', ' ', ' ', ' ', '3'],
3322
+ [' ', '2', ' ', ' ', '2', ' ', '2', '3', '2', ' ', '2', '2', ' ', '3', '2', '2', '3', '3', '1', ' '],
3323
+ ['0', '0', ' ', '3', '2', ' ', ' ', ' ', ' ', ' ', '2', '1', '2', '1', ' ', ' ', ' ', '2', '1', ' '],
3324
+ ])
3325
+ binst = solver.Board(board=board)
3326
+ solutions = binst.solve_and_print()
3327
+ ```
3328
+ **Script Output**
3329
+
3330
+ ```python
3331
+ Solution found
3332
+ 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1
3333
+ 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9
3334
+ ┌───┐ ┌───────┐ ┌───────────────────┐ ┌───────┐ ┌───┐ ┌───────────┐
3335
+ 0│ 3 │ · │ · 2 │ · │ · · · · 3 │ · · │ · · │ · │ 3 │ · │ · 1 · │
3336
+ │ │ └───┐ │ │ ┌───────┐ ┌───┘ ┌───┘ ┌───┘ │ │ └───┐ ┌───┘
3337
+ 1│ · │ · 3 │ · │ 3 │ · │ · · │ 3 │ · 2 │ 2 · │ 2 · │ 2 │ 2 · │ 2 │ 3
3338
+ │ │ ┌───┘ └───┘ │ └───┘ ┌───┘ ┌───┘ │ └───────┘ └───┐
3339
+ 2│ 2 │ 2 │ · · · 2 │ 1 · 1 1 │ · · │ 3 1 · │ 2 · · · 2 │
3340
+ │ │ │ ┌───────────┘ ┌───┐ └───┐ └───────┐ └───────┐ ┌───┐ │
3341
+ 3│ · │ · │ 2 │ · · 2 2 │ · │ · · 3 │ · · · │ · · 2 │ 2 │ 3 │ · │
3342
+ │ └───┘ └───────────────┘ └───┐ ┌───┘ ┌───┐ │ ┌───┐ │ │ │ │
3343
+ 4│ 1 2 1 · · · 2 1 · │ 3 │ 2 · │ 3 │ 2 │ 2 │ 3 │ · │ 3 │ 2 │ 2 │
3344
+ │ ┌───────────────────────────┐ └───┘ ┌───┘ │ │ │ │ └───┘ │ │
3345
+ 5│ · │ 3 2 2 1 2 · 3 │ · · · │ · 2 │ 2 │ 3 │ · │ 1 1 · │ 2 │
3346
+ │ └───────────┐ ┌───────────┘ ┌───────┘ ┌───┘ └───┘ │ ┌───┘ │
3347
+ 6│ 1 · · · │ · │ · 2 · · │ 2 · 1 │ 3 · · · │ · 2 │ 2 2 │
3348
+ │ ┌───────────┘ └───────────────┘ ┌───┐ └───────────┐ └───────┘ ┌───┘
3349
+ 7│ · │ 3 · 2 0 1 2 1 · 1 │ 3 │ · 2 · · │ 2 · 2 1 │ ·
3350
+ │ └───┐ ┌───┐ ┌───┐ ┌───┐ │ └───────────┐ └───┐ ┌───┐ └───┐
3351
+ 8│ 2 · │ · │ · │ 2 · │ 3 │ · │ · │ · │ · 2 · · │ 1 2 │ · │ · │ 1 3 │
3352
+ └───┐ └───┘ └───┐ │ └───┘ └───┘ ┌───┐ ┌───┘ └───┘ │ ┌───┘
3353
+ 9 · │ · 1 · · │ · │ · 2 0 · 1 │ · │ 2 │ · 0 · 2 · │ 3 │ 2
3354
+ └───┐ ┌───────┘ │ ┌───┐ ┌───┐ │ │ │ ┌───────┐ └───┘
3355
+ 10 · 3 │ · │ 3 · 1 │ 3 │ · │ 3 │ · │ 2 │ · │ · │ 2 2 │ 2 3 │ · · ·
3356
+ ┌───────┘ └───────┐ └───┘ └───┘ │ │ │ └───────┘ ┌───┘ ┌───┐
3357
+ 11│ 3 · · · · │ · · 0 2 1 │ · │ · │ 2 · · 1 │ · 0 2 │ · │
3358
+ └───┐ ┌───────┐ │ ┌───┐ ┌───┐ │ │ └───┐ ┌───┐ └───┐ ┌───┘ │
3359
+ 12 · │ · │ · · │ · │ · │ 3 │ · │ 3 │ 2 │ 3 │ · · │ 2 │ · │ 1 · │ · │ · · │
3360
+ ┌───┘ │ ┌───┘ └───┘ │ │ │ └───┘ ┌───┘ │ │ ┌───┘ └───┐ │
3361
+ 13│ 2 2 │ · │ 3 0 · · │ 3 │ · │ · 2 · │ · · │ · │ 2 │ 2 · 3 │ · │
3362
+ │ ┌───┘ └───┐ ┌───┐ └───┘ └───────────┘ ┌───┘ │ │ ┌───────┘ │
3363
+ 14│ · │ 2 0 · │ · │ 3 │ · 1 · · 2 · 2 │ 2 · │ · │ · │ 2 · 2 │
3364
+ │ │ ┌───┘ │ │ ┌───────────────────┘ └───┘ │ ┌───────┘
3365
+ 15│ · │ · 1 │ 3 1 │ · │ · · │ · · 2 · 2 1 · 1 2 │ 2 │ · ·
3366
+ │ └───┐ └───┐ │ └───┐ │ ┌───────────────────┐ ┌───┘ └───────┐
3367
+ 16│ 2 · │ 2 2 │ · │ 1 3 │ · │ 2 · │ 3 1 2 · 3 │ 2 │ · 1 1 · │
3368
+ └───┐ └───┐ └───┘ ┌───┘ └───┐ └───┐ ┌───────────┘ └───────┐ ┌───┘
3369
+ 17 · │ · 2 │ · 1 · │ · · 2 │ · · │ · │ 2 · 1 0 · · │ · │ 3
3370
+ └───┐ └───┐ ┌───┘ ┌───┐ │ │ │ ┌───┐ ┌───┐ │ └───┐
3371
+ 18 · 2 │ · · │ 2 │ · 2 │ 3 │ 2 │ · 2 │ 2 │ · │ 3 │ 2 2 │ 3 │ 3 │ 1 · │
3372
+ └───┐ │ │ ┌───┘ │ └───────┘ └───┘ └───────┘ └───┘ │
3373
+ 19 0 0 · │ 3 │ 2 │ · │ · · │ · · 2 1 2 1 · · · 2 1 · │
3374
+ └───┘ └───┘ └───────────────────────────────────────────────┘
3375
+ Solutions found: 1
3376
+ status: OPTIMAL
3377
+ Time taken: 2.39 seconds
3378
+ ```
3379
+
3380
+ **Solved puzzle**
3381
+
3382
+ Applying the solution to the puzzle visually:
3383
+
3384
+ <img src="https://raw.githubusercontent.com/Ar-Kareem/puzzle_solver/master/images/slitherlink_solved.png" alt="Slitherlink solved" width="500">
3385
+
3386
+ ---
3387
+
3173
3388
  ---
3174
3389
 
3175
3390
  ## Why SAT / CP-SAT?
@@ -3258,3 +3473,5 @@ Issues and PRs welcome!
3258
3473
  [35]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/galaxies "puzzle_solver/src/puzzle_solver/puzzles/galaxies at master · Ar-Kareem/puzzle_solver · GitHub"
3259
3474
  [36]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/slant "puzzle_solver/src/puzzle_solver/puzzles/slant at master · Ar-Kareem/puzzle_solver · GitHub"
3260
3475
  [37]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/unequal "puzzle_solver/src/puzzle_solver/puzzles/unequal at master · Ar-Kareem/puzzle_solver · GitHub"
3476
+ [38]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/norinori "puzzle_solver/src/puzzle_solver/puzzles/norinori at master · Ar-Kareem/puzzle_solver · GitHub"
3477
+ [39]: https://github.com/Ar-Kareem/puzzle_solver/tree/master/src/puzzle_solver/puzzles/slitherlink "puzzle_solver/src/puzzle_solver/puzzles/slitherlink at master · Ar-Kareem/puzzle_solver · GitHub"
@@ -1,4 +1,4 @@
1
- puzzle_solver/__init__.py,sha256=nqXtO2JuY0r7gNM1HwP_5apXmLX7OeriApzXNaJZjDA,2704
1
+ puzzle_solver/__init__.py,sha256=EuBuJfRIA3IGQExkQ5h4BNYXWHLzdZh3j65ocCMy_64,2855
2
2
  puzzle_solver/core/utils.py,sha256=_LA81kHrsgvqPvq7RISBeaurXmYMKAU9N6qmV8n0G7s,8063
3
3
  puzzle_solver/core/utils_ortools.py,sha256=2xEL9cMEKmNhRD9lhr2nGdZ3Lbmc9cnHY8xv6iLhUr0,10542
4
4
  puzzle_solver/puzzles/aquarium/aquarium.py,sha256=BUfkAS2d9eG3TdMoe1cOGGeNYgKUebRvn-z9nsC9gvE,5708
@@ -26,17 +26,18 @@ puzzle_solver/puzzles/map/map.py,sha256=sxc57tapB8Tsgam-yoDitln1o-EB_SbIYvO6WEYy
26
26
  puzzle_solver/puzzles/minesweeper/minesweeper.py,sha256=LiQVOGkWCsc1WtX8CdPgL_WwAcaeUFuoi5_eqH8U2Og,5876
27
27
  puzzle_solver/puzzles/mosaic/mosaic.py,sha256=QX_nVpVKQg8OfaUcqFk9tKqsDyVqvZc6-XWvfI3YcSw,2175
28
28
  puzzle_solver/puzzles/nonograms/nonograms.py,sha256=1jmDTOCnmivmBlwtMDyyk3TVqH5IjapzLn7zLQ4qubk,6056
29
- puzzle_solver/puzzles/norinori/norinori.py,sha256=Z2c0iEn7a6S6gaaJlvNMNNbAQwpztNLB0LTH_XVgu74,12269
29
+ puzzle_solver/puzzles/norinori/norinori.py,sha256=uC8vXAw35xsTmpmTeKqYW7tbcssms9LCcXFBONtV2Ng,4743
30
30
  puzzle_solver/puzzles/pearl/pearl.py,sha256=OhzpMYpxqvR3GCd5NH4ETT0NO4X753kRi6p5omYLChM,6798
31
31
  puzzle_solver/puzzles/range/range.py,sha256=rruvD5ZSaOgvQuX6uGV_Dkr82nSiWZ5kDz03_j7Tt24,4425
32
32
  puzzle_solver/puzzles/signpost/signpost.py,sha256=-0_S6ycwzwlUf9-ZhP127Rgo5gMBOHiTM6t08dLLDac,3869
33
33
  puzzle_solver/puzzles/singles/singles.py,sha256=3wACiUa1Vmh2ce6szQ2hPjyBuH7aHiQ888p4R2jFkW4,3342
34
34
  puzzle_solver/puzzles/slant/slant.py,sha256=xF-N4PuXYfx638NP1f1mi6YncIZB4mLtXtdS79XyPbg,6122
35
35
  puzzle_solver/puzzles/slant/parse_map/parse_map.py,sha256=dxnALSDXe9wU0uSD0QEXnzoh1q801mj1ePTNLtG0n60,4796
36
+ puzzle_solver/puzzles/slitherlink/slitherlink.py,sha256=jw-Buwzo_eZADL45zD5-Hs8HaT3AU4dZn6eifCUPnhA,11701
36
37
  puzzle_solver/puzzles/star_battle/star_battle.py,sha256=IX6w4H3sifN01kPPtrAVRCK0Nl_xlXXSHvJKw8K1EuE,3718
37
38
  puzzle_solver/puzzles/star_battle/star_battle_shapeless.py,sha256=lj05V0Y3A3NjMo1boMkPIwBhMtm6SWydjgAMeCf5EIo,225
38
39
  puzzle_solver/puzzles/stitches/stitches.py,sha256=iK8t02q43gH3FPbuIDn4dK0sbaOgZOnw8yHNRNvNuIU,6534
39
- puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=dbd7DoqjR4qkMQAeD_lPpuLB_KCFCIHfDauTbjB3W80,8880
40
+ puzzle_solver/puzzles/stitches/parse_map/parse_map.py,sha256=1LNJkIqpcz1LvY0H0uRedABQWm44dgNf9XeQuKm36WM,10275
40
41
  puzzle_solver/puzzles/sudoku/sudoku.py,sha256=M_pry7XyKKzlfCF5rFi02lyOrj5GWZzXnDAxmD3NXvI,3588
41
42
  puzzle_solver/puzzles/tents/tents.py,sha256=iyVK2WXfIT5j_9qqlQg0WmwvixwXlZSsHGK3XA-KpII,6283
42
43
  puzzle_solver/puzzles/thermometers/thermometers.py,sha256=nsvJZkm7G8FALT27bpaB0lv5E_AWawqmvapQI8QcYXw,4015
@@ -46,7 +47,7 @@ puzzle_solver/puzzles/undead/undead.py,sha256=IrCUfzQFBem658P5KKqldG7vd2TugTHehc
46
47
  puzzle_solver/puzzles/unequal/unequal.py,sha256=ExY2XDCrqROCDpRLfHo8uVr1zuli1QvbCdNCiDhlCac,6978
47
48
  puzzle_solver/puzzles/unruly/unruly.py,sha256=sDF0oKT50G-NshyW2DYrvAgD9q9Ku9ANUyNhGSAu7cQ,3827
48
49
  puzzle_solver/utils/visualizer.py,sha256=tsX1yEKwmwXBYuBJpx_oZGe2UUt1g5yV73G3UbtmvtE,6817
49
- multi_puzzle_solver-0.9.18.dist-info/METADATA,sha256=q9i19rwenN_tEUfhsP8ayNEVfeKcRTm4xK4AmWyzw9M,163560
50
- multi_puzzle_solver-0.9.18.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
51
- multi_puzzle_solver-0.9.18.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
52
- multi_puzzle_solver-0.9.18.dist-info/RECORD,,
50
+ multi_puzzle_solver-0.9.20.dist-info/METADATA,sha256=R36gUfx8jHLCnOMt4QEYf-wtBYskwk46IxxPJGMapjI,180363
51
+ multi_puzzle_solver-0.9.20.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
52
+ multi_puzzle_solver-0.9.20.dist-info/top_level.txt,sha256=exwVUQa-anK9vYrpKzBPvH8bX43iElWI4VeNiAyBGJY,14
53
+ multi_puzzle_solver-0.9.20.dist-info/RECORD,,
puzzle_solver/__init__.py CHANGED
@@ -18,12 +18,14 @@ from puzzle_solver.puzzles.map import map as map_solver
18
18
  from puzzle_solver.puzzles.minesweeper import minesweeper as minesweeper_solver
19
19
  from puzzle_solver.puzzles.mosaic import mosaic as mosaic_solver
20
20
  from puzzle_solver.puzzles.nonograms import nonograms as nonograms_solver
21
+ from puzzle_solver.puzzles.norinori import norinori as norinori_solver
21
22
  from puzzle_solver.puzzles.lits import lits as lits_solver
22
23
  from puzzle_solver.puzzles.pearl import pearl as pearl_solver
23
24
  from puzzle_solver.puzzles.range import range as range_solver
24
25
  from puzzle_solver.puzzles.signpost import signpost as signpost_solver
25
26
  from puzzle_solver.puzzles.singles import singles as singles_solver
26
27
  from puzzle_solver.puzzles.slant import slant as slant_solver
28
+ from puzzle_solver.puzzles.slitherlink import slitherlink as slitherlink_solver
27
29
  from puzzle_solver.puzzles.star_battle import star_battle as star_battle_solver
28
30
  from puzzle_solver.puzzles.star_battle import star_battle_shapeless as star_battle_shapeless_solver
29
31
  from puzzle_solver.puzzles.stitches import stitches as stitches_solver
@@ -38,4 +40,4 @@ from puzzle_solver.puzzles.unruly import unruly as unruly_solver
38
40
 
39
41
  from puzzle_solver.puzzles.inertia.parse_map.parse_map import main as inertia_image_parser
40
42
 
41
- __version__ = '0.9.18'
43
+ __version__ = '0.9.20'
@@ -1,133 +1,32 @@
1
- import json
2
- import time
3
1
  from dataclasses import dataclass
4
- from typing import Optional, Union
5
2
 
6
- from ortools.sat.python import cp_model
7
3
  import numpy as np
4
+ from ortools.sat.python import cp_model
8
5
 
9
- from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_pos, in_bounds, Direction, get_next_pos
10
- from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
11
-
12
-
13
- # a shape on the 2d board is just a set of positions
14
- Shape = frozenset[Pos]
15
-
16
-
17
- def polyominoes(N):
18
- """Generate all polyominoes of size N. Every rotation and reflection is considered different and included in the result.
19
- Translation is not considered different and is removed from the result (otherwise the result would be infinite).
20
-
21
- Below is the number of unique polyominoes of size N (not including rotations and reflections) and the lenth of the returned result (which includes all rotations and reflections)
22
- N name #shapes #results
23
- 1 monomino 1 1
24
- 2 domino 1 2
25
- 3 tromino 2 6
26
- 4 tetromino 5 19
27
- 5 pentomino 12 63
28
- 6 hexomino 35 216
29
- 7 heptomino 108 760
30
- 8 octomino 369 2,725
31
- 9 nonomino 1,285 9,910
32
- 10 decomino 4,655 36,446
33
- 11 undecomino 17,073 135,268
34
- 12 dodecomino 63,600 505,861
35
- Source: https://en.wikipedia.org/wiki/Polyomino
36
-
37
- Args:
38
- N (int): The size of the polyominoes to generate.
39
-
40
- Returns:
41
- set[(frozenset[Pos], int)]: A set of all polyominoes of size N (rotated and reflected up to D4 symmetry) along with a unique ID for each polyomino.
42
- """
43
- assert N >= 1, 'N cannot be less than 1'
44
- # need a frozenset because regular sets are not hashable
45
- shapes: set[Shape] = {frozenset({Pos(0, 0)})}
46
- for i in range(1, N):
47
- next_shapes: set[Shape] = set()
48
- for s in shapes:
49
- # frontier: all 4-neighbors of existing cells not already in the shape
50
- frontier = {get_next_pos(pos, direction)
51
- for pos in s
52
- for direction in Direction
53
- if get_next_pos(pos, direction) not in s}
54
- for cell in frontier:
55
- t = s | {cell}
56
- # normalize by translation only: shift so min x,y is (0,0). This removes translational symmetries.
57
- minx = min(pos.x for pos in t)
58
- miny = min(pos.y for pos in t)
59
- t0 = frozenset(Pos(x=pos.x - minx, y=pos.y - miny) for pos in t)
60
- next_shapes.add(t0)
61
- shapes = next_shapes
62
- # shapes is now complete, now classify up to D4 symmetry (rotations/reflections), translations ignored
63
- mats = (
64
- ( 1, 0, 0, 1), # regular
65
- (-1, 0, 0, 1), # reflect about x
66
- ( 1, 0, 0,-1), # reflect about y
67
- (-1, 0, 0,-1), # reflect about x and y
68
- # trnaspose then all 4 above
69
- ( 0, 1, 1, 0), ( 0, 1, -1, 0), ( 0,-1, 1, 0), ( 0,-1, -1, 0),
70
- )
71
- # compute canonical representative for each shape (lexicographically smallest normalized transform)
72
- shape_to_canon: dict[Shape, tuple[Pos, ...]] = {}
73
- for s in shapes:
74
- reps: list[tuple[Pos, ...]] = []
75
- for a, b, c, d in mats:
76
- pts = {Pos(x=a*p.x + b*p.y, y=c*p.x + d*p.y) for p in s}
77
- minx = min(p.x for p in pts)
78
- miny = min(p.y for p in pts)
79
- rep = tuple(sorted(Pos(x=p.x - minx, y=p.y - miny) for p in pts))
80
- reps.append(rep)
81
- canon = min(reps)
82
- shape_to_canon[s] = canon
83
-
84
- canon_set = set(shape_to_canon.values())
85
- canon_to_id = {canon: i for i, canon in enumerate(sorted(canon_set))}
86
- result = {(s, canon_to_id[shape_to_canon[s]]) for s in shapes}
87
- return result
88
-
89
-
90
- @dataclass(frozen=True)
91
- class SingleSolution:
92
- assignment: dict[Pos, Union[str, int]]
93
- all_other_variables: dict
94
-
95
- def get_hashable_solution(self) -> str:
96
- result = []
97
- for pos, v in self.assignment.items():
98
- result.append((pos.x, pos.y, v))
99
- return json.dumps(result, sort_keys=True)
100
-
6
+ from puzzle_solver.core.utils import Pos, Shape, get_all_pos, get_char, set_char, polyominoes, in_bounds, get_next_pos, Direction
7
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint
101
8
 
102
9
 
103
10
  @dataclass
104
11
  class ShapeOnBoard:
105
12
  is_active: cp_model.IntVar
106
- shape: Shape
107
- shape_id: int
13
+ orientation: str
108
14
  body: set[Pos]
109
- disallow_same_shape: set[Pos]
15
+ disallow: set[Pos]
110
16
 
111
17
 
112
18
  class Board:
113
- def __init__(self, board: np.array, polyomino_degrees: int = 4):
19
+ def __init__(self, board: np.ndarray):
114
20
  assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
115
- self.V = board.shape[0]
116
- self.H = board.shape[1]
117
- assert all((str(c.item()).isdecimal() for c in np.nditer(board))), 'board must contain only digits'
118
21
  self.board = board
119
- self.polyomino_degrees = polyomino_degrees
120
- self.polyominoes = polyominoes(self.polyomino_degrees)
121
-
22
+ self.V, self.H = board.shape
23
+ assert all((c == ' ') or str(c).isdecimal() for c in np.nditer(board)), "board must contain space or digits"
122
24
  self.block_numbers = set([int(c.item()) for c in np.nditer(board)])
123
- self.blocks = {i: set() for i in self.block_numbers}
124
- for cell in get_all_pos(self.V, self.H):
125
- self.blocks[int(get_char(self.board, cell))].add(cell)
25
+ self.blocks = {i: [pos for pos in get_all_pos(self.V, self.H) if int(get_char(self.board, pos)) == i] for i in self.block_numbers}
126
26
 
127
27
  self.model = cp_model.CpModel()
128
28
  self.model_vars: dict[Pos, cp_model.IntVar] = {}
129
- self.connected_components: dict[Pos, cp_model.IntVar] = {}
130
- self.shapes_on_board: list[ShapeOnBoard] = [] # will contain every possible shape on the board based on polyomino degrees
29
+ self.shapes_on_board: list[ShapeOnBoard] = []
131
30
 
132
31
  self.create_vars()
133
32
  self.init_shapes_on_board()
@@ -136,120 +35,67 @@ class Board:
136
35
  def create_vars(self):
137
36
  for pos in get_all_pos(self.V, self.H):
138
37
  self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
139
- # print('base vars:', len(self.model_vars))
140
38
 
141
39
  def init_shapes_on_board(self):
142
- for idx, (shape, shape_id) in enumerate(self.polyominoes):
143
- for translate in get_all_pos(self.V, self.H): # body of shape is translated to be at pos
144
- body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape}
145
- if any(not in_bounds(p, self.V, self.H) for p in body):
146
- continue
147
- # shape must be fully contained in one block
148
- if len(set(get_char(self.board, p) for p in body)) > 1:
149
- continue
150
- # 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
151
- disallow_same_shape = set(get_next_pos(p, direction) for p in body for direction in Direction)
152
- disallow_same_shape -= body
153
- self.shapes_on_board.append(ShapeOnBoard(
154
- is_active=self.model.NewBoolVar(f'{idx}:{translate}:is_active'),
155
- shape=shape,
156
- shape_id=shape_id,
157
- body=body,
158
- disallow_same_shape=disallow_same_shape,
159
- ))
160
- # print('shapes on board:', len(self.shapes_on_board))
40
+ for pos in get_all_pos(self.V, self.H):
41
+ shape = self.get_shape(pos, 'horizontal')
42
+ if shape is not None:
43
+ self.shapes_on_board.append(shape)
44
+ shape = self.get_shape(pos, 'vertical')
45
+ if shape is not None:
46
+ self.shapes_on_board.append(shape)
161
47
 
162
48
  def add_all_constraints(self):
163
- # RULES:
164
- # 1- You have to place one tetromino in each region in such a way that:
165
- # 2- 2 tetrominoes of matching types cannot touch each other horizontally or vertically. Rotations and reflections count as matching.
166
- # 3- The shaded cells should form a single connected area.
167
- # 4- 2x2 shaded areas are not allowed
168
-
169
- # each cell must be part of a shape, every shape must be fully on the board. Core constraint, otherwise shapes on the board make no sense.
170
- self.only_allow_shapes_on_board()
171
-
172
- self.force_one_shape_per_block() # Rule #1
173
- self.disallow_same_shape_touching() # Rule #2
174
- self.fc = force_connected_component(self.model, self.model_vars) # Rule #3
175
- # print('force connected vars:', len(fc))
176
- shape_2_by_2 = frozenset({Pos(0, 0), Pos(0, 1), Pos(1, 0), Pos(1, 1)})
177
- self.disallow_shape(shape_2_by_2) # Rule #4
178
-
179
-
180
- def only_allow_shapes_on_board(self):
181
- for shape_on_board in self.shapes_on_board:
182
- # if shape is active then all its body cells must be active
183
- self.model.Add(sum(self.model_vars[p] for p in shape_on_board.body) == len(shape_on_board.body)).OnlyEnforceIf(shape_on_board.is_active)
184
- # each cell must be part of a shape
185
- for p in get_all_pos(self.V, self.H):
186
- shapes_on_p = [s for s in self.shapes_on_board if p in s.body]
187
- self.model.Add(sum(s.is_active for s in shapes_on_p) == 1).OnlyEnforceIf(self.model_vars[p])
188
-
189
- def force_one_shape_per_block(self):
190
- # You have to place exactly one tetromino in each region
191
- for block_i in self.block_numbers:
192
- shapes_on_block = [s for s in self.shapes_on_board if s.body & self.blocks[block_i]]
193
- assert all(s.body.issubset(self.blocks[block_i]) for s in shapes_on_block), 'expected all shapes on block to be fully contained in the block'
194
- # print(f'shapes on block {block_i} has {len(shapes_on_block)} shapes')
195
- self.model.Add(sum(s.is_active for s in shapes_on_block) == 1)
196
-
197
- def disallow_same_shape_touching(self):
198
- # if shape is active then it must not touch any other shape of the same type
199
- for shape_on_board in self.shapes_on_board:
200
- similar_shapes = [s for s in self.shapes_on_board if s.shape_id == shape_on_board.shape_id]
201
- for s in similar_shapes:
202
- if shape_on_board.disallow_same_shape & s.body: # this shape disallows having s be on the board
203
- self.model.Add(s.is_active == 0).OnlyEnforceIf(shape_on_board.is_active)
204
-
205
- def disallow_shape(self, shape_to_disallow: Shape):
206
- # for every position in the board, force sum of body < len(body)
207
- for translate in get_all_pos(self.V, self.H):
208
- cur_body = {get_pos(x=p.x + translate.x, y=p.y + translate.y) for p in shape_to_disallow}
209
- if any(not in_bounds(p, self.V, self.H) for p in cur_body):
210
- continue
211
- self.model.Add(sum(self.model_vars[p] for p in cur_body) < len(cur_body))
212
-
213
-
214
-
215
-
216
- def solve_and_print(self, verbose: bool = True, max_solutions: Optional[int] = None, verbose_callback: Optional[bool] = None):
217
- if verbose_callback is None:
218
- verbose_callback = verbose
219
- def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
49
+ # if a piece is active then all its body is active and the disallow is inactive
50
+ for shape in self.shapes_on_board:
51
+ for pos in shape.body:
52
+ self.model.Add(self.model_vars[pos] == 1).OnlyEnforceIf(shape.is_active)
53
+ for pos in shape.disallow:
54
+ self.model.Add(self.model_vars[pos] == 0).OnlyEnforceIf(shape.is_active)
55
+ # if a spot is active then exactly one piece (with a body there) is active
56
+ for pos in get_all_pos(self.V, self.H):
57
+ pieces_on_pos = [shape for shape in self.shapes_on_board if pos in shape.body]
58
+ # if pos is on then exactly one shape is active. if pos is off then 0 shapes are active.
59
+ self.model.Add(sum(shape.is_active for shape in pieces_on_pos) == self.model_vars[pos])
60
+ # every region must have exactly 2 spots active.
61
+ for block in self.blocks.values():
62
+ self.model.Add(sum(self.model_vars[pos] for pos in block) == 2)
63
+
64
+ def get_shape(self, pos: Pos, orientation: str) -> Shape:
65
+ assert orientation in ['horizontal', 'vertical'], 'orientation must be horizontal or vertical'
66
+ if orientation == 'horizontal':
67
+ body = {pos, get_next_pos(pos, Direction.RIGHT)}
68
+ else:
69
+ body = {pos, get_next_pos(pos, Direction.DOWN)}
70
+ if any(not in_bounds(p, self.V, self.H) for p in body):
71
+ return None
72
+ disallow = set(get_next_pos(p, direction) for p in body for direction in Direction)
73
+ disallow = {p for p in disallow if p not in body and in_bounds(p, self.V, self.H)}
74
+ shape_on_board = ShapeOnBoard(
75
+ is_active=self.model.NewBoolVar(f'horizontal:{pos}'),
76
+ orientation='horizontal',
77
+ body=body,
78
+ disallow=disallow,
79
+ )
80
+ return shape_on_board
81
+
82
+
83
+ def solve_and_print(self, verbose: bool = True):
84
+ def board_to_solution(board: "Board", solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
220
85
  assignment: dict[Pos, int] = {}
221
- for pos, var in board.model_vars.items():
222
- assignment[pos] = solver.Value(var)
223
- all_other_variables = {
224
- 'fc': {k: solver.Value(v) for k, v in board.fc.items()}
225
- }
226
- return SingleSolution(assignment=assignment, all_other_variables=all_other_variables)
86
+ for pos in get_all_pos(self.V, self.H):
87
+ if solver.Value(self.model_vars[pos]) == 1:
88
+ assignment[pos] = get_char(self.board, pos)
89
+ return SingleSolution(assignment=assignment)
227
90
  def callback(single_res: SingleSolution):
228
91
  print("Solution found")
229
- res = np.full((self.V, self.H), ' ', dtype=str)
230
- for pos, val in single_res.assignment.items():
231
- c = 'X' if val == 1 else ' '
92
+ res = np.full((self.V, self.H), ' ', dtype=object)
93
+ for pos in get_all_pos(self.V, self.H):
94
+ c = get_char(self.board, pos)
95
+ c = 'X' if pos in single_res.assignment else ' '
232
96
  set_char(res, pos, c)
233
- print('[\n' + '\n'.join([' ' + str(res[row].tolist()) + ',' for row in range(self.V)]) + '\n]')
234
- pass
235
- return generic_solve_all(self, board_to_solution, callback=callback if verbose_callback else None, verbose=verbose, max_solutions=max_solutions)
236
-
237
- def solve_then_constrain(self, verbose: bool = True):
238
- tic = time.time()
239
- all_solutions = []
240
- while True:
241
- solutions = self.solve_and_print(verbose=False, verbose_callback=verbose, max_solutions=1)
242
- if len(solutions) == 0:
243
- break
244
- all_solutions.extend(solutions)
245
- assignment = solutions[0].assignment
246
- # constrain the board to not return the same solution again
247
- lits = [self.model_vars[p].Not() if assignment[p] == 1 else self.model_vars[p] for p in assignment.keys()]
248
- self.model.AddBoolOr(lits)
249
- self.model.ClearHints()
250
- for k, v in solutions[0].all_other_variables['fc'].items():
251
- self.model.AddHint(self.fc[k], v)
252
- print(f'Solutions found: {len(all_solutions)}')
253
- toc = time.time()
254
- print(f'Time taken: {toc - tic:.2f} seconds')
255
- return all_solutions
97
+ print('[')
98
+ for row in res:
99
+ print(" [ '" + "', '".join(row.tolist()) + "' ],")
100
+ print(']')
101
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,248 @@
1
+ import numpy as np
2
+ from collections import defaultdict
3
+ from ortools.sat.python import cp_model
4
+
5
+ from puzzle_solver.core.utils import Pos, get_all_pos, get_char, set_char, get_pos, Direction, get_row_pos, get_col_pos, get_next_pos, in_bounds, get_opposite_direction
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
7
+
8
+
9
+ CellBorder = tuple[Pos, Direction]
10
+ Corner = Pos
11
+
12
+
13
+ class Board:
14
+ def __init__(self, board: np.array):
15
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
16
+ assert all(c.item() == ' ' or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only spaces or digits'
17
+ self.V = board.shape[0]
18
+ self.H = board.shape[1]
19
+ self.board = board
20
+ self.cell_borders_to_corners: dict[CellBorder, set[Corner]] = defaultdict(set) # for every cell border, a set of all corners it is connected to
21
+ self.corners_to_cell_borders: dict[Corner, set[CellBorder]] = defaultdict(set) # opposite direction
22
+
23
+ self.model = cp_model.CpModel()
24
+ self.model_vars: dict[CellBorder, cp_model.IntVar] = {} # one entry for every unique variable in the model
25
+ self.cell_borders: dict[CellBorder, cp_model.IntVar] = {} # for every position and direction, one entry for that edge (thus the same edge variables are used in opposite directions of neighboring cells)
26
+ self.corner_vars: dict[Corner, set[cp_model.IntVar]] = defaultdict(set) # for every corner, one entry for each edge that touches the corner (i.e. 4 per corner unless on the border)
27
+
28
+ self.create_vars()
29
+ self.add_all_constraints()
30
+
31
+ def create_vars(self):
32
+ for pos in get_all_pos(self.V, self.H):
33
+ for direction in [Direction.RIGHT, Direction.DOWN]:
34
+ self.add_var(pos, direction)
35
+ for pos in get_row_pos(0, self.H):
36
+ self.add_var(pos, Direction.UP)
37
+ for pos in get_col_pos(0, self.V):
38
+ self.add_var(pos, Direction.LEFT)
39
+
40
+ def add_var(self, pos: Pos, direction: Direction):
41
+ cell_border = (pos, direction)
42
+ v = self.model.NewBoolVar(f'main:{cell_border}')
43
+ self.model_vars[cell_border] = v
44
+ self.add_cell_border_var(cell_border, v)
45
+ self.add_corner_vars(cell_border, v)
46
+
47
+ def add_cell_border_var(self, cell_border: CellBorder, var: cp_model.IntVar):
48
+ """An edge belongs to two cells unless its on the border in which case it only belongs to one."""
49
+ pos, direction = cell_border
50
+ self.cell_borders[cell_border] = var
51
+ next_pos = get_next_pos(pos, direction)
52
+ if in_bounds(next_pos, self.V, self.H):
53
+ self.cell_borders[(next_pos, get_opposite_direction(direction))] = var
54
+
55
+ def add_corner_vars(self, cell_border: CellBorder, var: cp_model.IntVar):
56
+ """
57
+ An edge always belongs to two corners. Note that the cell xi,yi has the 4 corners (xi,yi), (xi+1,yi), (xi,yi+1), (xi+1,yi+1). (memorize these 4 coordinates or the function wont make sense)
58
+ Thus corner index is +1 of board coordinates.
59
+ Never check for bounds here because an edge ALWAYS touches two corners AND because the +1 will make in_bounds return False when its still in bounds.
60
+ """
61
+ pos, direction = cell_border
62
+ if direction == Direction.LEFT: # it touches me and (xi,yi+1)
63
+ corner1 = pos
64
+ corner2 = get_next_pos(pos, Direction.DOWN)
65
+ elif direction == Direction.UP: # it touches me and (xi+1,yi)
66
+ corner1 = pos
67
+ corner2 = get_next_pos(pos, Direction.RIGHT)
68
+ elif direction == Direction.RIGHT: # it touches (xi+1,yi) and (xi+1,yi+1)
69
+ corner1 = get_next_pos(pos, Direction.RIGHT)
70
+ corner2 = get_next_pos(corner1, Direction.DOWN)
71
+ elif direction == Direction.DOWN: # it touches (xi,yi+1) and (xi+1,yi+1)
72
+ corner1 = get_next_pos(pos, Direction.DOWN)
73
+ corner2 = get_next_pos(corner1, Direction.RIGHT)
74
+ else:
75
+ raise ValueError(f'Invalid direction: {direction}')
76
+ self.corner_vars[corner1].add(var)
77
+ self.corner_vars[corner2].add(var)
78
+ self.cell_borders_to_corners[cell_border].add(corner1)
79
+ self.cell_borders_to_corners[cell_border].add(corner2)
80
+ self.corners_to_cell_borders[corner1].add(cell_border)
81
+ self.corners_to_cell_borders[corner2].add(cell_border)
82
+
83
+ def add_all_constraints(self):
84
+ for pos in get_all_pos(self.V, self.H): # enforce cells with numbers
85
+ variables = [self.cell_borders[(pos, direction)] for direction in Direction if (pos, direction) in self.cell_borders]
86
+ val = get_char(self.board, pos)
87
+ if not val.isdecimal():
88
+ continue
89
+ self.model.Add(sum(variables) == int(val))
90
+ for corner in self.corner_vars: # a corder always has 0 or 2 active edges
91
+ g = self.model.NewBoolVar(f'corner_gate_{corner}')
92
+ self.model.Add(sum(self.corner_vars[corner]) == 0).OnlyEnforceIf(g.Not())
93
+ self.model.Add(sum(self.corner_vars[corner]) == 2).OnlyEnforceIf(g)
94
+ # single connected component
95
+ def is_neighbor(cb1: CellBorder, cb2: CellBorder) -> bool:
96
+ cb1_corners = self.cell_borders_to_corners[cb1]
97
+ cb2_corners = self.cell_borders_to_corners[cb2]
98
+ return len(cb1_corners & cb2_corners) > 0
99
+ force_connected_component(self.model, self.model_vars, is_neighbor=is_neighbor)
100
+
101
+
102
+
103
+
104
+ def solve_and_print(self, verbose: bool = True):
105
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
106
+ assignment: dict[Pos, str] = {}
107
+ for (pos, direction), var in board.model_vars.items():
108
+ if solver.value(var) == 1:
109
+ if pos not in assignment:
110
+ assignment[pos] = ''
111
+ assignment[pos] += direction.name[0]
112
+ return SingleSolution(assignment=assignment)
113
+ def callback(single_res: SingleSolution):
114
+ print("Solution found")
115
+ res = np.full((self.V, self.H), ' ', dtype=object)
116
+ for pos in get_all_pos(self.V, self.H):
117
+ if pos not in single_res.assignment:
118
+ continue
119
+ c = ''.join(sorted(single_res.assignment[pos]))
120
+ set_char(res, pos, c)
121
+ print(render_grid(cell_flags=res, center_char=lambda c, r: self.board[r, c] if self.board[r, c] != ' ' else '·'))
122
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose, max_solutions=999)
123
+
124
+
125
+
126
+
127
+
128
+ def render_grid(cell_flags: np.ndarray = None,
129
+ H: np.ndarray = None,
130
+ V: np.ndarray = None,
131
+ mark_centers: bool = True,
132
+ center_char: str = '·',
133
+ show_axes: bool = True,
134
+ scale_x: int = 2) -> str:
135
+ """
136
+ AI generated this because I don't currently care about the details of rendering to the terminal and I did it in a quick and dirty way while the AI made it in a pretty way, and this looks good during my development.
137
+ cell_flags: np.ndarray of shape (N, N) with characters 'U', 'D', 'L', 'R' to represent the edges of the cells.
138
+ OR:
139
+ H: (N+1, N) horizontal edges between corners
140
+ V: (N, N+1) vertical edges between corners
141
+ scale_x: horizontal stretch factor (>=1). Try 2 or 3 for squarer cells.
142
+ """
143
+ if cell_flags is not None:
144
+ N = cell_flags.shape[0]
145
+ H = np.zeros((N+1, N), dtype=bool)
146
+ V = np.zeros((N, N+1), dtype=bool)
147
+ for r in range(N):
148
+ for c in range(N):
149
+ s = cell_flags[r, c]
150
+ if 'U' in s: H[r, c] = True # edge between (r,c) and (r, c+1) above the cell
151
+ if 'D' in s: H[r+1, c] = True # edge below the cell
152
+ if 'L' in s: V[r, c] = True # edge left of the cell
153
+ if 'R' in s: V[r, c+1] = True # edge right of the cell
154
+ assert H is not None and V is not None, 'H and V must be provided'
155
+ # Bitmask for corner connections
156
+ U, R, D, L = 1, 2, 4, 8
157
+ JUNCTION = {
158
+ 0: ' ',
159
+ U: '│', D: '│', U|D: '│',
160
+ L: '─', R: '─', L|R: '─',
161
+ U|R: '└', R|D: '┌', D|L: '┐', L|U: '┘',
162
+ U|D|L: '┤', U|D|R: '├', L|R|U: '┴', L|R|D: '┬',
163
+ U|R|D|L: '┼',
164
+ }
165
+
166
+ assert scale_x >= 1
167
+ N = V.shape[0]
168
+ assert H.shape == (N+1, N) and V.shape == (N, N+1)
169
+
170
+ rows = 2*N + 1
171
+ cols = 2*N*scale_x + 1 # stretched width
172
+ canvas = [[' ']*cols for _ in range(rows)]
173
+
174
+ def x_corner(c): # x of corner column c
175
+ return (2*c) * scale_x
176
+ def x_between(c,k): # kth in-between column (1..scale_x) between c and c+1 corners
177
+ return (2*c) * scale_x + k
178
+
179
+ # horizontal edges: fill the stretched band between corners with '─'
180
+ for r in range(N+1):
181
+ rr = 2*r
182
+ for c in range(N):
183
+ if H[r, c]:
184
+ # previously: for k in range(1, scale_x*2, 2):
185
+ for k in range(1, scale_x*2): # 1..(2*scale_x-1), no gaps
186
+ canvas[rr][x_between(c, k)] = '─'
187
+
188
+ # vertical edges: draw at the corner columns (no horizontal stretching needed)
189
+ for r in range(N):
190
+ rr = 2*r + 1
191
+ for c in range(N+1):
192
+ if V[r, c]:
193
+ canvas[rr][x_corner(c)] = '│'
194
+
195
+ # junctions at corners
196
+ for r in range(N+1):
197
+ rr = 2*r
198
+ for c in range(N+1):
199
+ m = 0
200
+ if r > 0 and V[r-1, c]: m |= U
201
+ if c < N and H[r, c]: m |= R
202
+ if r < N and V[r, c]: m |= D
203
+ if c > 0 and H[r, c-1]: m |= L
204
+ canvas[rr][x_corner(c)] = JUNCTION[m]
205
+
206
+ # centers (help count exact widths/heights)
207
+ if mark_centers:
208
+ for r in range(N):
209
+ rr = 2*r + 1
210
+ for c in range(N):
211
+ # center lies midway across the stretched span
212
+ xc = x_corner(c) + scale_x # middle-ish; works for any integer scale_x
213
+ canvas[rr][xc] = center_char if isinstance(center_char, str) else center_char(c, r)
214
+
215
+ # turn canvas rows into strings
216
+ art_rows = [''.join(row) for row in canvas]
217
+
218
+ if not show_axes:
219
+ return '\n'.join(art_rows)
220
+
221
+ # ── Axes ────────────────────────────────────────────────────────────────
222
+ gut = max(2, len(str(N-1))) # left gutter width
223
+ gutter = ' ' * gut
224
+ top_tens = list(gutter + ' ' * cols)
225
+ top_ones = list(gutter + ' ' * cols)
226
+
227
+ for c in range(N):
228
+ xc_center = x_corner(c) + scale_x
229
+ if N >= 10:
230
+ top_tens[gut + xc_center] = str((c // 10) % 10)
231
+ top_ones[gut + xc_center] = str(c % 10)
232
+
233
+ # tiny corner labels
234
+ if gut >= 2:
235
+ top_tens[gut-2:gut] = list(' ')
236
+ top_ones[gut-2:gut] = list(' ')
237
+
238
+ labeled = []
239
+ for r, line in enumerate(art_rows):
240
+ if r % 2 == 1: # cell-center row
241
+ label = str(r//2).rjust(gut)
242
+ else:
243
+ label = ' ' * gut
244
+ labeled.append(label + line)
245
+
246
+ return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
247
+
248
+
@@ -3,6 +3,7 @@
3
3
  Look at the ./input_output/ directory for examples of input images and output json files.
4
4
  The output json is used in the test_solve.py file to test the solver.
5
5
  """
6
+ # import json
6
7
  from pathlib import Path
7
8
  import numpy as np
8
9
  cv = None
@@ -72,6 +73,8 @@ def mean_consecutives(arr: np.ndarray) -> np.ndarray:
72
73
  return np.array(sums) // np.array(counts)
73
74
 
74
75
  def dfs(x, y, out, output, current_num):
76
+ # if current_num == '48':
77
+ # print('dfs', x, y, current_num)
75
78
  if x < 0 or x >= out.shape[1] or y < 0 or y >= out.shape[0]:
76
79
  return
77
80
  if out[y, x] != ' ':
@@ -136,6 +139,8 @@ def main(image):
136
139
  cell = src[hidx1:hidx2, vidx1:vidx2]
137
140
  mid_x = cell.shape[1] // 2
138
141
  mid_y = cell.shape[0] // 2
142
+ # if j > height - 4 and i > width - 6:
143
+ # show_wait_destroy(f"cell_{i}_{j}", cell)
139
144
  # show_wait_destroy(f"cell_{i}_{j}", cell)
140
145
  cell = cv.bitwise_not(cell) # invert colors
141
146
  top = cell[0:10, mid_y-5:mid_y+5]
@@ -156,10 +161,18 @@ def main(image):
156
161
  axs[1, 0].set_title('Right')
157
162
  axs[1, 1].hist(list(hists['bottom'].values()), bins=100)
158
163
  axs[1, 1].set_title('Bottom')
164
+ global_target = None
165
+ # global_target = 28_000
159
166
  target_top = np.mean(list(hists['top'].values()))
160
167
  target_left = np.mean(list(hists['left'].values()))
161
168
  target_right = np.mean(list(hists['right'].values()))
162
169
  target_bottom = np.mean(list(hists['bottom'].values()))
170
+ if global_target is not None:
171
+ target_top = global_target
172
+ target_left = global_target
173
+ target_right = global_target
174
+ target_bottom = global_target
175
+
163
176
  axs[0, 0].axvline(target_top, color='red')
164
177
  axs[0, 1].axvline(target_left, color='red')
165
178
  axs[1, 0].axvline(target_right, color='red')
@@ -185,12 +198,18 @@ def main(image):
185
198
  print(' Sums: ', hists['top'][j, i], hists['left'][j, i], hists['right'][j, i], hists['bottom'][j, i])
186
199
 
187
200
  current_count = 0
188
- out = np.full_like(output['top'], ' ', dtype='U2')
201
+ z_fill = 2
202
+ out = np.full_like(output['top'], ' ', dtype='U32')
189
203
  for j in range(out.shape[0]):
204
+ if current_count > 99:
205
+ z_fill = 3
190
206
  for i in range(out.shape[1]):
191
207
  if out[j, i] == ' ':
192
- dfs(i, j, out, output, str(current_count).zfill(2))
208
+ if current_count == 48:
209
+ print(f"current_count: {current_count}, x: {i}, y: {j}")
210
+ dfs(i, j, out, output, str(current_count).zfill(z_fill))
193
211
  current_count += 1
212
+ print(out)
194
213
 
195
214
  with open(output_path, 'w') as f:
196
215
  f.write('[\n')
@@ -202,6 +221,18 @@ def main(image):
202
221
  f.write(']')
203
222
  print('output json: ', output_path)
204
223
 
224
+ # with open(output_path.parent / 'debug.json', 'w') as f:
225
+ # debug_pos = {}
226
+ # for j in range(out.shape[0]):
227
+ # for i in range(out.shape[1]):
228
+ # out_str = ''
229
+ # out_str += 'T' if output['top'][j, i] else ''
230
+ # out_str += 'L' if output['left'][j, i] else ''
231
+ # out_str += 'R' if output['right'][j, i] else ''
232
+ # out_str += 'B' if output['bottom'][j, i] else ''
233
+ # debug_pos[f'{j}_{i}'] = out_str
234
+ # json.dump(debug_pos, f, indent=2)
235
+
205
236
  if __name__ == '__main__':
206
237
  # to run this script and visualize the output, in the root run:
207
238
  # python .\src\puzzle_solver\puzzles\stitches\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
@@ -209,4 +240,6 @@ if __name__ == '__main__':
209
240
  # main(Path(__file__).parent / 'input_output' / 'weekly_oct_3rd_2025.png')
210
241
  # main(Path(__file__).parent / 'input_output' / 'star_battle_67f73ff90cd8cdb4b3e30f56f5261f4968f5dac940bc6.png')
211
242
  # main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
212
- main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
243
+ # main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
244
+ # main(Path(__file__).parent / 'input_output' / 'norinori_501d93110d6b4b818c268378973afbf268f96cfa8d7b4.png')
245
+ main(Path(__file__).parent / 'input_output' / 'norinori_OTo0LDc0Miw5MTU.png')