ubongosolve 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kota Mori
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: ubongosolve
3
+ Version: 0.1.0
4
+ Summary: Solve ubongo puzzle by constraint programming
5
+ Author-email: Kota Mori <kmori05@gmail.com>
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Topic :: Games/Entertainment :: Puzzle Games
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: matplotlib
13
+ Requires-Dist: ortools
14
+ Dynamic: license-file
15
+
16
+ # 🧩 UbongoSolve
17
+
18
+ A Python solver for [Ubongo](https://en.wikipedia.org/wiki/Ubongo)-style tile-placement puzzles using constraint programming ([OR-Tools CP-SAT](https://developers.google.com/optimization/cp/cp_solver)).
19
+
20
+ Given a set of polyomino pieces and a board, UbongoSolver finds a placement that tiles the board exactly — no gaps, no overlaps.
21
+
22
+ ## Features
23
+
24
+ - **Automatic piece orientation**: handles all rotations (0°/90°/180°/270°) and flips
25
+ - **Constraint programming engine**: powered by Google OR-Tools CP-SAT for fast, exact solving
26
+ - **Visualization**: text-based and matplotlib-based solution display with colored pieces and piece-boundary outlines
27
+ - **Built-in puzzles**: includes the [White Chocolate puzzle](https://store.hanayamatoys.co.jp/items/58611532) with a few board variants
28
+
29
+
30
+ <img src="images/output-sample-1.png" height="300" /> <img src="images/output-sample-2.png" width="250" />
31
+
32
+ ## Installation
33
+
34
+ Requires Python 3.10+.
35
+
36
+ ```bash
37
+ pip install ubongosolve
38
+ ```
39
+
40
+ ### From source
41
+
42
+ ```bash
43
+ git clone https://github.com/kota7/ubongosolve.git
44
+ cd ubongosolve
45
+ pip install -U .
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ ```python
51
+ from ubongosolve import Piece, Board, UbongoPuzzle
52
+
53
+ # Define pieces as sets of (x, y) coordinates
54
+ pieces = [
55
+ Piece([(0,0), (0,1), (0,2), (0,3), (1,3)]), # L-shape
56
+ Piece([(0,0), (1,0), (1,1), (2,1)]), # S-shape
57
+ Piece([(0,0), (0,1), (1,1)]), # mini L-shape
58
+ ]
59
+
60
+ # Define a board
61
+ board = Board([(x, y) for x in range(4) for y in range(3)])
62
+
63
+ # Solve
64
+ solver = UbongoPuzzle(pieces, board)
65
+ status = solver.solve()
66
+ print(status) # "OPTIMAL" or "FEASIBLE"
67
+
68
+ # View the solution
69
+ solver.print_solution() # text output
70
+ solver.plot_solution() # matplotlib figure
71
+ ```
72
+
73
+
74
+ ### Example: Ubongo Sample problem
75
+
76
+ ```python
77
+ from ubongosolve import UbongoPuzzle, ubongo
78
+
79
+ problem = ubongo.sample_problems[0] # Two problems are included
80
+ solver = UbongoPuzzle(problem["pieces"], problem["board"])
81
+ solver.solve()
82
+ solver.plot_solution()
83
+ ```
84
+
85
+ ### Example: White Chocolate puzzle
86
+
87
+ ```python
88
+ from ubongosolve import UbongoPuzzle, whitechocolate
89
+
90
+ # Solve the basic 5×8 board
91
+ solver = UbongoPuzzle(whitechocolate.pieces, whitechocolate.board)
92
+ solver.solve()
93
+ solver.plot_solution()
94
+
95
+ # Try a challenge board
96
+ solver = UbongoPuzzle(whitechocolate.pieces, whitechocolate.boards["challenge_6"])
97
+ solver.solve()
98
+ solver.plot_solution()
99
+ ```
100
+
101
+ ## API Reference
102
+
103
+ ### `Piece(coordinates)`
104
+
105
+ A polyomino piece defined by a set of `(x, y)` cell coordinates. Coordinates are automatically normalized so the minimum x and y are zero.
106
+
107
+ ### `Board(coordinates)`
108
+
109
+ A board defined by a set of `(x, y)` cell coordinates. Can be any shape — rectangles, L-shapes, boards with holes, split boards, etc.
110
+
111
+ ### `UbongoPuzzle(pieces, board)`
112
+
113
+ | Method / Property | Description |
114
+ |---|---|
115
+ | `solve(timeout=100)` | Solve the puzzle. Returns status string (`"OPTIMAL"`, `"FEASIBLE"`, `"INFEASIBLE"`, ...) |
116
+ | `solution` | `dict[tuple[int,int], int]` mapping each cell to its piece ID |
117
+ | `print_solution()` | Print an ASCII representation |
118
+ | `plot_solution()` | Display a matplotlib figure with colored pieces |
119
+ | `solution_as_fig` | Returns `(fig, ax)` without displaying |
120
+
121
+ ## How It Works
122
+
123
+ 1. **Piece expansion**: each piece is expanded into all distinct orientations (up to 8: 4 rotations × 2 flips).
124
+ 2. **Origin candidates**: for each orientation, all valid placements on the board are precomputed.
125
+ 3. **CP-SAT model**: boolean variables represent "piece *i* in orientation *o* is placed at origin *(x, y)*". Constraints enforce:
126
+ - Each piece is placed exactly once (one orientation + origin selected).
127
+ - Each board cell is covered by exactly one piece.
128
+ 4. **Solve**: OR-Tools CP-SAT finds a feasible assignment or proves infeasibility.
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,117 @@
1
+ # 🧩 UbongoSolve
2
+
3
+ A Python solver for [Ubongo](https://en.wikipedia.org/wiki/Ubongo)-style tile-placement puzzles using constraint programming ([OR-Tools CP-SAT](https://developers.google.com/optimization/cp/cp_solver)).
4
+
5
+ Given a set of polyomino pieces and a board, UbongoSolver finds a placement that tiles the board exactly — no gaps, no overlaps.
6
+
7
+ ## Features
8
+
9
+ - **Automatic piece orientation**: handles all rotations (0°/90°/180°/270°) and flips
10
+ - **Constraint programming engine**: powered by Google OR-Tools CP-SAT for fast, exact solving
11
+ - **Visualization**: text-based and matplotlib-based solution display with colored pieces and piece-boundary outlines
12
+ - **Built-in puzzles**: includes the [White Chocolate puzzle](https://store.hanayamatoys.co.jp/items/58611532) with a few board variants
13
+
14
+
15
+ <img src="images/output-sample-1.png" height="300" /> <img src="images/output-sample-2.png" width="250" />
16
+
17
+ ## Installation
18
+
19
+ Requires Python 3.10+.
20
+
21
+ ```bash
22
+ pip install ubongosolve
23
+ ```
24
+
25
+ ### From source
26
+
27
+ ```bash
28
+ git clone https://github.com/kota7/ubongosolve.git
29
+ cd ubongosolve
30
+ pip install -U .
31
+ ```
32
+
33
+ ## Quick Start
34
+
35
+ ```python
36
+ from ubongosolve import Piece, Board, UbongoPuzzle
37
+
38
+ # Define pieces as sets of (x, y) coordinates
39
+ pieces = [
40
+ Piece([(0,0), (0,1), (0,2), (0,3), (1,3)]), # L-shape
41
+ Piece([(0,0), (1,0), (1,1), (2,1)]), # S-shape
42
+ Piece([(0,0), (0,1), (1,1)]), # mini L-shape
43
+ ]
44
+
45
+ # Define a board
46
+ board = Board([(x, y) for x in range(4) for y in range(3)])
47
+
48
+ # Solve
49
+ solver = UbongoPuzzle(pieces, board)
50
+ status = solver.solve()
51
+ print(status) # "OPTIMAL" or "FEASIBLE"
52
+
53
+ # View the solution
54
+ solver.print_solution() # text output
55
+ solver.plot_solution() # matplotlib figure
56
+ ```
57
+
58
+
59
+ ### Example: Ubongo Sample problem
60
+
61
+ ```python
62
+ from ubongosolve import UbongoPuzzle, ubongo
63
+
64
+ problem = ubongo.sample_problems[0] # Two problems are included
65
+ solver = UbongoPuzzle(problem["pieces"], problem["board"])
66
+ solver.solve()
67
+ solver.plot_solution()
68
+ ```
69
+
70
+ ### Example: White Chocolate puzzle
71
+
72
+ ```python
73
+ from ubongosolve import UbongoPuzzle, whitechocolate
74
+
75
+ # Solve the basic 5×8 board
76
+ solver = UbongoPuzzle(whitechocolate.pieces, whitechocolate.board)
77
+ solver.solve()
78
+ solver.plot_solution()
79
+
80
+ # Try a challenge board
81
+ solver = UbongoPuzzle(whitechocolate.pieces, whitechocolate.boards["challenge_6"])
82
+ solver.solve()
83
+ solver.plot_solution()
84
+ ```
85
+
86
+ ## API Reference
87
+
88
+ ### `Piece(coordinates)`
89
+
90
+ A polyomino piece defined by a set of `(x, y)` cell coordinates. Coordinates are automatically normalized so the minimum x and y are zero.
91
+
92
+ ### `Board(coordinates)`
93
+
94
+ A board defined by a set of `(x, y)` cell coordinates. Can be any shape — rectangles, L-shapes, boards with holes, split boards, etc.
95
+
96
+ ### `UbongoPuzzle(pieces, board)`
97
+
98
+ | Method / Property | Description |
99
+ |---|---|
100
+ | `solve(timeout=100)` | Solve the puzzle. Returns status string (`"OPTIMAL"`, `"FEASIBLE"`, `"INFEASIBLE"`, ...) |
101
+ | `solution` | `dict[tuple[int,int], int]` mapping each cell to its piece ID |
102
+ | `print_solution()` | Print an ASCII representation |
103
+ | `plot_solution()` | Display a matplotlib figure with colored pieces |
104
+ | `solution_as_fig` | Returns `(fig, ax)` without displaying |
105
+
106
+ ## How It Works
107
+
108
+ 1. **Piece expansion**: each piece is expanded into all distinct orientations (up to 8: 4 rotations × 2 flips).
109
+ 2. **Origin candidates**: for each orientation, all valid placements on the board are precomputed.
110
+ 3. **CP-SAT model**: boolean variables represent "piece *i* in orientation *o* is placed at origin *(x, y)*". Constraints enforce:
111
+ - Each piece is placed exactly once (one orientation + origin selected).
112
+ - Each board cell is covered by exactly one piece.
113
+ 4. **Solve**: OR-Tools CP-SAT finds a feasible assignment or proves infeasibility.
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ubongosolve"
7
+ version = "0.1.0"
8
+ description = "Solve ubongo puzzle by constraint programming"
9
+ authors = [
10
+ {name = "Kota Mori", email = "kmori05@gmail.com"}
11
+ ]
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Topic :: Games/Entertainment :: Puzzle Games",
16
+ ]
17
+ readme = "README.md"
18
+ requires-python = ">=3.10"
19
+ dependencies = [
20
+ "matplotlib",
21
+ "ortools",
22
+ ]
23
+
24
+ [dependency-groups]
25
+ dev = [
26
+ "jupyter",
27
+ "pytest",
28
+ "twine>=6.2.0",
29
+ "wheel>=0.46.3",
30
+ ]
31
+
32
+ [tool.setuptools]
33
+ py-modules = ["ubongosolve"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,132 @@
1
+ Metadata-Version: 2.4
2
+ Name: ubongosolve
3
+ Version: 0.1.0
4
+ Summary: Solve ubongo puzzle by constraint programming
5
+ Author-email: Kota Mori <kmori05@gmail.com>
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: License :: OSI Approved :: MIT License
8
+ Classifier: Topic :: Games/Entertainment :: Puzzle Games
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: matplotlib
13
+ Requires-Dist: ortools
14
+ Dynamic: license-file
15
+
16
+ # 🧩 UbongoSolve
17
+
18
+ A Python solver for [Ubongo](https://en.wikipedia.org/wiki/Ubongo)-style tile-placement puzzles using constraint programming ([OR-Tools CP-SAT](https://developers.google.com/optimization/cp/cp_solver)).
19
+
20
+ Given a set of polyomino pieces and a board, UbongoSolver finds a placement that tiles the board exactly — no gaps, no overlaps.
21
+
22
+ ## Features
23
+
24
+ - **Automatic piece orientation**: handles all rotations (0°/90°/180°/270°) and flips
25
+ - **Constraint programming engine**: powered by Google OR-Tools CP-SAT for fast, exact solving
26
+ - **Visualization**: text-based and matplotlib-based solution display with colored pieces and piece-boundary outlines
27
+ - **Built-in puzzles**: includes the [White Chocolate puzzle](https://store.hanayamatoys.co.jp/items/58611532) with a few board variants
28
+
29
+
30
+ <img src="images/output-sample-1.png" height="300" /> <img src="images/output-sample-2.png" width="250" />
31
+
32
+ ## Installation
33
+
34
+ Requires Python 3.10+.
35
+
36
+ ```bash
37
+ pip install ubongosolve
38
+ ```
39
+
40
+ ### From source
41
+
42
+ ```bash
43
+ git clone https://github.com/kota7/ubongosolve.git
44
+ cd ubongosolve
45
+ pip install -U .
46
+ ```
47
+
48
+ ## Quick Start
49
+
50
+ ```python
51
+ from ubongosolve import Piece, Board, UbongoPuzzle
52
+
53
+ # Define pieces as sets of (x, y) coordinates
54
+ pieces = [
55
+ Piece([(0,0), (0,1), (0,2), (0,3), (1,3)]), # L-shape
56
+ Piece([(0,0), (1,0), (1,1), (2,1)]), # S-shape
57
+ Piece([(0,0), (0,1), (1,1)]), # mini L-shape
58
+ ]
59
+
60
+ # Define a board
61
+ board = Board([(x, y) for x in range(4) for y in range(3)])
62
+
63
+ # Solve
64
+ solver = UbongoPuzzle(pieces, board)
65
+ status = solver.solve()
66
+ print(status) # "OPTIMAL" or "FEASIBLE"
67
+
68
+ # View the solution
69
+ solver.print_solution() # text output
70
+ solver.plot_solution() # matplotlib figure
71
+ ```
72
+
73
+
74
+ ### Example: Ubongo Sample problem
75
+
76
+ ```python
77
+ from ubongosolve import UbongoPuzzle, ubongo
78
+
79
+ problem = ubongo.sample_problems[0] # Two problems are included
80
+ solver = UbongoPuzzle(problem["pieces"], problem["board"])
81
+ solver.solve()
82
+ solver.plot_solution()
83
+ ```
84
+
85
+ ### Example: White Chocolate puzzle
86
+
87
+ ```python
88
+ from ubongosolve import UbongoPuzzle, whitechocolate
89
+
90
+ # Solve the basic 5×8 board
91
+ solver = UbongoPuzzle(whitechocolate.pieces, whitechocolate.board)
92
+ solver.solve()
93
+ solver.plot_solution()
94
+
95
+ # Try a challenge board
96
+ solver = UbongoPuzzle(whitechocolate.pieces, whitechocolate.boards["challenge_6"])
97
+ solver.solve()
98
+ solver.plot_solution()
99
+ ```
100
+
101
+ ## API Reference
102
+
103
+ ### `Piece(coordinates)`
104
+
105
+ A polyomino piece defined by a set of `(x, y)` cell coordinates. Coordinates are automatically normalized so the minimum x and y are zero.
106
+
107
+ ### `Board(coordinates)`
108
+
109
+ A board defined by a set of `(x, y)` cell coordinates. Can be any shape — rectangles, L-shapes, boards with holes, split boards, etc.
110
+
111
+ ### `UbongoPuzzle(pieces, board)`
112
+
113
+ | Method / Property | Description |
114
+ |---|---|
115
+ | `solve(timeout=100)` | Solve the puzzle. Returns status string (`"OPTIMAL"`, `"FEASIBLE"`, `"INFEASIBLE"`, ...) |
116
+ | `solution` | `dict[tuple[int,int], int]` mapping each cell to its piece ID |
117
+ | `print_solution()` | Print an ASCII representation |
118
+ | `plot_solution()` | Display a matplotlib figure with colored pieces |
119
+ | `solution_as_fig` | Returns `(fig, ax)` without displaying |
120
+
121
+ ## How It Works
122
+
123
+ 1. **Piece expansion**: each piece is expanded into all distinct orientations (up to 8: 4 rotations × 2 flips).
124
+ 2. **Origin candidates**: for each orientation, all valid placements on the board are precomputed.
125
+ 3. **CP-SAT model**: boolean variables represent "piece *i* in orientation *o* is placed at origin *(x, y)*". Constraints enforce:
126
+ - Each piece is placed exactly once (one orientation + origin selected).
127
+ - Each board cell is covered by exactly one piece.
128
+ 4. **Solve**: OR-Tools CP-SAT finds a feasible assignment or proves infeasibility.
129
+
130
+ ## License
131
+
132
+ MIT
@@ -0,0 +1,9 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ ubongosolve.py
5
+ ubongosolve.egg-info/PKG-INFO
6
+ ubongosolve.egg-info/SOURCES.txt
7
+ ubongosolve.egg-info/dependency_links.txt
8
+ ubongosolve.egg-info/requires.txt
9
+ ubongosolve.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ matplotlib
2
+ ortools
@@ -0,0 +1 @@
1
+ ubongosolve
@@ -0,0 +1,485 @@
1
+ #%%
2
+ from __future__ import annotations
3
+ import itertools
4
+ import warnings
5
+ from typing import TypeAlias
6
+ import matplotlib.pyplot as plt
7
+ import matplotlib.patches as patches
8
+ from matplotlib.colors import to_rgba
9
+ from ortools.sat.python import cp_model
10
+
11
+ Coord: TypeAlias = tuple[int, int]
12
+ Coords: TypeAlias = set[Coord]
13
+
14
+ def _normalize_coordinates(coordinates: Coord) -> Coord:
15
+ # Make sure that all coodinates are non-negative, and
16
+ # the smallest is zero for both x and y axis.
17
+ # Note that this does not ensure that (0, 0) is in the coordinates.
18
+ # E.g., shapes like "_|"
19
+ # Return the new coordinates
20
+ min_x = min(c[0] for c in coordinates)
21
+ min_y = min(c[1] for c in coordinates)
22
+ out = set()
23
+ for x, y in coordinates:
24
+ out.add((x - min_x, y - min_y))
25
+ return out
26
+
27
+
28
+ def _coordindates_as_str(coordinates: Coords, char_positive: str="# ", char_negative: str=" ") -> str:
29
+ coordinates_normalized = _normalize_coordinates(coordinates)
30
+ #print(coordinates_normalized)
31
+ max_x = max(c[0] for c in coordinates_normalized)
32
+ max_y = max(c[1] for c in coordinates_normalized)
33
+
34
+ flag = []
35
+ for _ in range(max_y + 1):
36
+ flag.append([False] * (max_x + 1))
37
+ #print(flag)
38
+ for x, y in coordinates_normalized:
39
+ flag[y][x] = True
40
+
41
+ out = "\n".join("".join(char_positive if c else char_negative for c in row) for row in flag)
42
+ return out
43
+
44
+
45
+ def _rotate(coordinates: Coords, angle: int) -> Coords:
46
+ # Returns new coordinates such that
47
+ # the original is rotated counter-clockwise around the origin.
48
+ # Angle must be multiple of 90
49
+ angle = angle % 360
50
+ if angle % 90 != 0:
51
+ raise ValueError(f"rotation angle must be multiple of 90, but received: {angle}")
52
+ if angle == 90:
53
+ return set((y, -x) for x, y in coordinates)
54
+ elif angle == 180:
55
+ return set((-x, -y) for x, y in coordinates)
56
+ elif angle == 270:
57
+ return set((-y, x) for x, y in coordinates)
58
+ else:
59
+ return set(coordinates)
60
+
61
+
62
+ def _flip(coordinates: Coords) -> Coords:
63
+ # Returns new coordinates such that
64
+ # the original is flipped horizontally (over y-axis)
65
+ return set((-x, y) for x, y in coordinates)
66
+
67
+
68
+ class Piece:
69
+ def __init__(self, coordinates: Coords):
70
+ self.coordinates = _normalize_coordinates(coordinates)
71
+
72
+ def __str__(self) -> str:
73
+ return _coordindates_as_str(self.coordinates)
74
+
75
+ def rotate(self, angle: int) -> Piece:
76
+ return Piece(_rotate(self.coordinates, angle))
77
+
78
+ def flip(self) -> Piece:
79
+ return Piece(_flip(self.coordinates))
80
+
81
+
82
+ class Board:
83
+ def __init__(self, coordinates: Coords):
84
+ self.coordinates = _normalize_coordinates(coordinates)
85
+
86
+ def __str__(self) -> str:
87
+ return _coordindates_as_str(self.coordinates)
88
+
89
+
90
+ class UbongoPuzzle:
91
+ def __init__(self, pieces: list[Piece], board: Board):
92
+ self.pieces = pieces
93
+ self.board = board
94
+ self.model, self.pieces_ext, self.origin_flags = make_ubongo_problem(pieces, board)
95
+ self.solver = cp_model.CpSolver()
96
+ self.status = 0 # unknown
97
+
98
+ def solve(self, timeout: int=100) -> str:
99
+ self.solver.parameters.max_time_in_seconds = timeout
100
+ self.status = self.solver.Solve(self.model)
101
+ return self.solver.StatusName(self.status)
102
+
103
+ @property
104
+ def status_name(self) -> str:
105
+ return self.solver.StatusName(self.status)
106
+
107
+ @property
108
+ def solution(self) -> dict[tuple[int, int], int]:
109
+ if self.status_name not in ("OPTIMAL", "FEASIBLE"):
110
+ raise ValueError("Problem has not been solved yet")
111
+
112
+ return parse_ubongo_solution(self.solver, self.pieces_ext, self.origin_flags)
113
+
114
+ @property
115
+ def solution_as_str(self, **kwargs) -> str:
116
+ return _ubongo_solution_as_str(self.solution, **kwargs)
117
+
118
+ def print_solution(self):
119
+ print(self.solution_as_str)
120
+
121
+ @property
122
+ def solution_as_fig(self, **kwargs) -> tuple[plt.Figure, plt.Axes]:
123
+ return _ubongo_solution_as_fig(self.solution, **kwargs)
124
+
125
+ def plot_solution(self, **kwargs):
126
+ _ubongo_solution_as_fig(self.solution, **kwargs)
127
+ None
128
+
129
+
130
+ def make_ubongo_problem(pieces: list[Piece], board: Board):
131
+ # Generate a or-tools model that solves the "ubongo" problem
132
+ # i.e. put pieces on the board to fill without overwraps
133
+
134
+ model = cp_model.CpModel()
135
+
136
+ # Make sure the areas of pieces and board are equal
137
+ piece_area = sum(len(p.coordinates) for p in pieces)
138
+ board_area = len(board.coordinates)
139
+ if piece_area != board_area:
140
+ raise ValueError(f"Piece area != board area ({piece_area} vs {board_area})")
141
+
142
+ # Expand the list of pieces by flip and rotation
143
+ pieces_ext = {}
144
+ for id, piece in enumerate(pieces):
145
+ for flip, angle in itertools.product([0, 1], [0, 90, 180, 270]):
146
+ p = piece.rotate(angle) if flip == 0 else piece.flip().rotate(angle)
147
+ key = (id, flip, angle)
148
+ pieces_ext[key] = p
149
+
150
+ # For each (extended) piece, precompute the candidate origin positions
151
+ origin_candidates = {}
152
+ max_x = max(x for x, _ in board.coordinates)
153
+ max_y = max(y for _, y in board.coordinates)
154
+ for key, piece in pieces_ext.items():
155
+ candidates = set()
156
+ # Loop over all origin candidates.
157
+ # Due to the normalization, all piece contains at least one cell with x=0 and
158
+ # at least one cell with y=0.
159
+ # As a result, the origin coordinates must be within the range of the board;
160
+ # It if is, cell (0, y) or (x, 0) would be off-board.
161
+ # Note that the board is also normalized to have min_x = min_y = 0
162
+ for x, y in itertools.product(range(max_x + 1), range(max_y + 1)):
163
+ # Can we put the origin of the piece at (x, y)?
164
+ if all((x + a, y + b) in board.coordinates for a, b in piece.coordinates):
165
+ candidates.add((x, y))
166
+
167
+ if len(candidates) > 0:
168
+ origin_candidates[key] = candidates
169
+
170
+ # filter pieces_ext as per origin_candidates, so we omit orientation
171
+ # that has no position to locate on the board
172
+ pieces_ext = {k: v for k, v in pieces_ext.items() if k in origin_candidates}
173
+
174
+ # Detect edge case: if some piece has no orientation, then the problem is not solvable
175
+ covered_ids = set(id for (id, _, _) in origin_candidates)
176
+ for id, piece in enumerate(pieces):
177
+ if id not in covered_ids:
178
+ raise ValueError(f"Piece {id} cannot be placed anywhere on the board")
179
+
180
+ # Flags that indicate the location of the piece origin
181
+ origin_flags = {
182
+ (id, flip, angle, x, y): model.NewBoolVar(f"origin_{id}_{flip}_{angle}_{x}_{y}")
183
+ for (id, flip, angle) in pieces_ext
184
+ for x, y in origin_candidates[id, flip, angle]
185
+ }
186
+ # For each piece id, we must choose exactly one (orientation, origin) pair
187
+ for id in range(len(pieces)):
188
+ flags = [v for (i, _, _, _, _), v in origin_flags.items() if id == i]
189
+ model.AddExactlyOne(flags)
190
+
191
+ # Pieces cannot overwrap and board must be fully filled
192
+ # To implement this, we first collect, for each cell on the board,
193
+ # all "origin_flags" which would cover the cell when positive.
194
+ # Then, exactly one of the flags must be positive
195
+ # for the cell to be filled with no overwrap
196
+ cell_coverers = {(x, y): [] for x, y in board.coordinates}
197
+ for (id, flip, angle, x, y), flag in origin_flags.items():
198
+ for a, b in pieces_ext[id, flip, angle].coordinates:
199
+ cell_coverers[x+a, y+b].append(flag)
200
+ for flags in cell_coverers.values():
201
+ model.AddExactlyOne(flags)
202
+
203
+ return model, pieces_ext, origin_flags
204
+
205
+
206
+ def parse_ubongo_solution(
207
+ solver: cp_model.CpSolver,
208
+ pieces_ext: dict[tuple[int, int, int], Piece],
209
+ origin_flags: dict[tuple[int, int, int, int, int], cp_model.IntVar]
210
+ ) -> dict[tuple[int, int], int]:
211
+ out = {}
212
+ for (id, flip, angle, x, y), flag in origin_flags.items():
213
+ value = solver.Value(flag)
214
+ if value == 0:
215
+ continue
216
+ piece = pieces_ext[id, flip, angle]
217
+ for a, b in piece.coordinates:
218
+ out[x+a, y+b] = id
219
+ return out
220
+
221
+
222
+ def _ubongo_solution_as_str(
223
+ solution: dict[tuple[int, int], int],
224
+ chars: str="0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz*#@%="
225
+ ) -> str:
226
+ max_x = max(x for (x, _) in solution)
227
+ max_y = max(y for (_, y) in solution)
228
+
229
+ # Warning when id is out of prepared characters
230
+ maxid = max(solution.values())
231
+ if maxid > len(chars) - 1:
232
+ warnings.warn("We have more pieces than the predefined characters, so the same characters are used repeatedly")
233
+ while maxid > len(chars) - 1:
234
+ chars += chars
235
+
236
+ board = []
237
+ for _ in range(max_y + 1):
238
+ board.append([-1] * (max_x + 1))
239
+ for (x, y), id in solution.items():
240
+ board[y][x] = id
241
+
242
+ out = "\n".join("".join(chars[c] + " " if c >= 0 else " " for c in row) for row in board)
243
+ return out
244
+
245
+
246
+ def _ubongo_solution_as_fig(
247
+ solution: dict[tuple[int, int], int],
248
+ ax=None,
249
+ cmap_name: str = "tab20",
250
+ figsize: tuple[float, float]=(8, 4),
251
+ add_text: bool = True,
252
+ display: bool = True
253
+ ):
254
+ if ax is None:
255
+ fig, ax = plt.subplots(1, 1, figsize=figsize)
256
+ else:
257
+ fig = ax.figure
258
+
259
+ max_x = max(x for (x, _) in solution)
260
+ max_y = max(y for (_, y) in solution)
261
+ piece_ids = sorted(set(solution.values()))
262
+ n_pieces = len(piece_ids)
263
+
264
+ cmap = plt.get_cmap(cmap_name, max(n_pieces, 3))
265
+ color_map = {pid: cmap(i) for i, pid in enumerate(piece_ids)}
266
+
267
+ # Draw filled cells
268
+ for (x, y), pid in solution.items():
269
+ color = color_map[pid]
270
+ rect = patches.Rectangle((x, y), 1, 1, facecolor=color, edgecolor="white", linewidth=0.5)
271
+ ax.add_patch(rect)
272
+ if add_text:
273
+ ax.text(x + 0.5, y + 0.5, str(pid), ha="center", va="center",
274
+ fontsize=9, fontweight="bold", color="black")
275
+
276
+ # Draw thick borders between different pieces
277
+ for (x, y), pid in solution.items():
278
+ # Check each edge: right, top, left, bottom
279
+ for dx, dy, x0, y0, x1, y1 in [
280
+ (1, 0, x+1, y, x+1, y+1), # right
281
+ (0, 1, x, y+1, x+1, y+1), # top
282
+ (-1, 0, x, y, x, y+1), # left
283
+ (0, -1, x, y, x+1, y), # bottom
284
+ ]:
285
+ neighbor = (x + dx, y + dy)
286
+ if neighbor not in solution or solution[neighbor] != pid:
287
+ ax.plot([x0, x1], [y0, y1], color="black", linewidth=2)
288
+
289
+ ax.set_xlim(0, max_x + 1)
290
+ ax.set_ylim(0, max_y + 1)
291
+ ax.set_aspect("equal")
292
+ ax.invert_yaxis()
293
+ ax.axis("off")
294
+ fig.tight_layout()
295
+ if not display:
296
+ plt.close(fig) # Quick workaround not to display the graph
297
+
298
+ return fig, ax
299
+
300
+
301
+ class ubongo:
302
+ class pieces:
303
+ I2 = Piece([(0,0), (1,0)])
304
+
305
+ I3 = Piece([(0,0), (1,0), (2,0)])
306
+ L3 = Piece([(0,0), (1,0), (1,1)])
307
+
308
+ I4 = Piece([(0,0), (1,0), (2,0), (3,0)])
309
+ O4 = Piece([(0,0), (1,0), (0,1), (1,1)])
310
+ T4 = Piece([(0,0), (1,0), (2,0), (1,1)])
311
+ S4 = Piece([(1,0), (2,0), (0,1), (1,1)])
312
+ L4 = Piece([(0,0), (1,0), (2,0), (2,1)])
313
+
314
+ F5 = Piece([(0,0), (1,0), (2,0), (0,1), (1,1)])
315
+ L5 = Piece([(0,0), (1,0), (2,0), (3,0), (3,1)])
316
+ T5 = Piece([(0,0), (1,0), (2,0), (3,0), (1,1)])
317
+ Z5 = Piece([(0,0), (0,1), (1,1), (2,1), (2,2)])
318
+
319
+ _all = [I2, I3, L3, I4, O4, T4, S4, L4, F5, L5, T5, Z5]
320
+
321
+ sample_problems = [
322
+ {
323
+ "pieces": [pieces.T4, pieces.T5, pieces.S4],
324
+ "board": Board([
325
+ (1,0), (2,0),
326
+ (0,1), (1,1), (2,1), (3,1),
327
+ (1,2), (2,2), (3,2), (4,2),
328
+ (1,3), (2,3), (3,3)
329
+ ])
330
+ },
331
+ {
332
+ "pieces": [pieces.L3, pieces.I3, pieces.T5, pieces.L5],
333
+ "board": Board([
334
+ (1,0), (2,0),
335
+ (1,1), (2,1), (3,1),
336
+ (1,2), (2,2), (3,2),
337
+ (0,3), (1,3), (2,3), (3,3),
338
+ (0,4), (1,4),
339
+ (0,5), (1,5)
340
+ ])
341
+ },
342
+ ]
343
+
344
+
345
+ class whitechocolate:
346
+ # White Chocolate Puzzle
347
+ # https://store.hanayamatoys.co.jp/items/58611532
348
+ # https://www.amazon.co.jp/dp/B0BGSH79VJ
349
+ pieces = [
350
+ Piece([[0,0], [0,1], [0,2], [0,3], [0,4], [1,4]]),
351
+ Piece([[0,0], [0,1], [0,2], [0,3], [1,3], [2,3]]),
352
+ Piece([[0,0], [0,1], [0,2], [1,1], [1,2]]),
353
+ Piece([[0,0], [0,1], [0,2], [0,3], [1,3]]),
354
+ Piece([[0,0], [0,1], [0,2], [1,2]]),
355
+ Piece([[0,0], [0,1], [0,2], [0,3], [1,2], [1,3]]),
356
+ Piece([[0,0], [0,1], [1,1]]),
357
+ Piece([[0,0], [0,1], [0,2], [1,2], [2,2]]),
358
+ ]
359
+
360
+ board = Board([(x, y) for x, y in itertools.product(range(8), range(5))])
361
+
362
+ boards = {
363
+ # Basic board of 5 x 8 rectangle
364
+ "basic": Board([(x, y) for x, y in itertools.product(range(8), range(5))])
365
+
366
+ # Split boards
367
+ ,"split_1": Board([
368
+ [0,0], [1,0], [2,0], [3,0], [4,0], [5,0], [6,0], [7,0],
369
+ [0,1], [1,1], [2,1], [3,1], [4,1], [5,1], [6,2], [7,2],
370
+ [0,2], [1,2], [2,2], [3,2], [4,3], [5,3], [6,3], [7,3],
371
+ [0,3], [1,3], [2,4], [3,4], [4,4], [5,4], [6,4], [7,4],
372
+ [0,5], [1,5], [2,5], [3,5], [4,5], [5,5], [6,5], [7,5],
373
+ ])
374
+ ,"split_2": Board([
375
+ [0,0], [1,0], [2,0], [3,0], [4,0], [5,0], [6,0], [8,1],
376
+ [0,1], [1,1], [2,1], [3,1], [5,2], [6,2], [7,2], [8,2],
377
+ [0,2], [1,2], [2,2], [3,2], [5,3], [6,3], [7,3], [8,3],
378
+ [0,3], [1,3], [2,3], [3,3], [5,4], [6,4], [7,4], [8,4],
379
+ [0,4], [2,5], [3,5], [4,5], [5,5], [6,5], [7,5], [8,5],
380
+ ])
381
+
382
+ # Challenge boards
383
+ ,"challenge_1": Board([
384
+ [0,0], [1,0], [2,0], [3,0],
385
+ [0,1], [1,1], [2,1], [3,1],
386
+ [0,2], [1,2], [2,2], [3,2],
387
+ [0,3], [1,3], [2,3], [3,3], [4,3], [5,3], [6,3],
388
+ [0,4], [1,4], [2,4], [3,4], [4,4], [5,4], [6,4],
389
+ [0,5], [1,5], [2,5], [3,5], [4,5], [5,5], [6,5],
390
+ [0,6], [1,6], [2,6], [3,6], [4,6], [5,6], [6,6],
391
+ ])
392
+ ,"challenge_2": Board([
393
+ [3,0],
394
+ [1,1], [2,1], [3,1], [4,1], [5,1], [6,1],
395
+ [1,2], [2,2], [3,2], [4,2], [5,2], [6,2],
396
+ [1,3], [2,3], [3,3], [4,3], [5,3], [6,3], [7,3],
397
+ [0,4], [1,4], [2,4], [3,4], [4,4], [5,4], [6,4],
398
+ [1,5], [2,5], [3,5], [4,5], [5,5], [6,5],
399
+ [1,6], [2,6], [3,6], [4,6], [5,6], [6,6],
400
+ [4,7]
401
+ ])
402
+ ,"challenge_3": Board([
403
+ [4,0], [5,0],
404
+ [4,1], [5,1], [6,1], [7,1],
405
+ [0,2], [1,2], [2,2], [3,2], [6,2], [7,2],
406
+ [0,3], [1,3], [2,3], [3,3],
407
+ [0,4], [1,4], [2,4], [3,4], [4,4], [5,4], [6,4], [7,4],
408
+ [0,5], [1,5], [2,5], [3,5], [4,5], [5,5], [6,5], [7,5],
409
+ [4,6], [5,6], [6,6], [7,6],
410
+ [4,7], [5,7], [6,7], [7,7],
411
+ ])
412
+ ,"challenge_4": Board([
413
+ [2,0], [3,0],
414
+ [2,1], [3,1],
415
+ [0,2], [1,2], [2,2], [3,2], [4,2], [5,2], [6,2], [7,2],
416
+ [0,3], [1,3], [2,3], [3,3], [4,3], [5,3], [6,3], [7,3],
417
+ [0,5], [1,5], [2,5], [3,5], [4,5], [5,5], [6,5], [7,5],
418
+ [0,6], [1,6], [2,6], [3,6], [4,6], [5,6], [6,6], [7,6],
419
+ [2,7], [3,7],
420
+ [2,8], [3,8],
421
+ ])
422
+ ,"challenge_5": Board([
423
+ [2,0], [3,0],
424
+ [2,1], [3,1], [4,1], [5,1],
425
+ [1,2], [2,2], [3,2], [4,2], [5,2], [6,2], [7,2],
426
+ [1,3], [2,3], [3,3], [4,3], [5,3], [6,3], [7,3],
427
+ [0,4], [1,4], [2,4], [3,4], [4,4], [5,4], [6,4],
428
+ [0,5], [1,5], [2,5], [3,5], [4,5], [5,5], [6,5],
429
+ [2,6], [3,6], [4,6], [5,6],
430
+ [4,7], [5,7],
431
+ ])
432
+ ,"challenge_6": Board([
433
+ [4,0], [5,0], [6,0], [7,0],
434
+ [3,1], [4,1], [5,1], [6,1], [7,1], [8,1],
435
+ [2,2], [3,2], [4,2], [5,2], [6,2], [7,2], [8,2], [9,2],
436
+ [1,3], [2,3], [3,3], [4,3], [5,3], [6,3], [7,3], [8,3], [9,3], [10,3],
437
+ [0,4], [1,4], [2,4], [3,4], [4,4], [5,4], [6,4], [7,4], [8,4], [9,4], [10,4], [11,4],
438
+ ])
439
+ }
440
+
441
+ #%%
442
+ if __name__ == "__main__":
443
+ #%% Baic test
444
+ # Define pieces as sets of (x, y) coordinates
445
+ pieces = [
446
+ Piece([(0,0), (0,1), (0,2), (0,3), (1,3)]), # L-shape
447
+ Piece([(0,0), (1,0), (1,1), (2,1)]), # S-shape
448
+ Piece([(0,0), (0,1), (1,1)]), # mini L-shape
449
+ ]
450
+
451
+ # Define a board
452
+ board = Board([(x, y) for x in range(4) for y in range(3)])
453
+
454
+ # Solve
455
+ solver = UbongoPuzzle(pieces, board)
456
+ status = solver.solve()
457
+ print(status) # "OPTIMAL" or "FEASIBLE"
458
+
459
+ # View the solution
460
+ solver.print_solution() # text output
461
+ solver.plot_solution() # matplotlib figure
462
+
463
+ #%% Ubongo sample
464
+ for problem in ubongo.sample_problems:
465
+ p = UbongoPuzzle(problem["pieces"], problem["board"])
466
+ status = p.solve()
467
+ print(status)
468
+ #p.print_solution()
469
+ p.plot_solution()
470
+
471
+ #%% White Chocolate example
472
+ p = UbongoPuzzle(whitechocolate.pieces, whitechocolate.board)
473
+ status = p.solve()
474
+ print(status)
475
+ #p.print_solution()
476
+ p.plot_solution()
477
+
478
+ #%% White Chocolate example, more
479
+ for name, board in whitechocolate.boards.items():
480
+ p = UbongoPuzzle(whitechocolate.pieces, board)
481
+ status = p.solve()
482
+ print(f"{name}: {status}")
483
+ #p.print_solution()
484
+ p.plot_solution()
485
+ print()