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

@@ -188,6 +188,7 @@ def polyominoes(N):
188
188
  shapes = {frozenset(Pos(x, y) for x, y in s) for s in shapes} # regular class, not the dirty-fast one
189
189
  return shapes
190
190
 
191
+
191
192
  def polyominoes_with_shape_id(N):
192
193
  """Refer to polyominoes() for more details. This function returns a set of all polyominoes of size N (rotated and reflected up to D4 symmetry) along with a unique ID for each polyomino that is unique up to D4 symmetry.
193
194
  Args:
@@ -226,3 +227,137 @@ def polyominoes_with_shape_id(N):
226
227
  result = {(s, canon_to_id[shape_to_canon[s]]) for s in shapes}
227
228
  result = {(frozenset(Pos(x, y) for x, y in s), _id) for s, _id in result}
228
229
  return result
230
+
231
+
232
+ def render_grid(cell_flags: np.ndarray,
233
+ center_char: Union[np.ndarray, str, None] = None,
234
+ show_axes: bool = True,
235
+ scale_x: int = 2) -> str:
236
+ """
237
+ most of this function was AI generated then modified by me, I don't currently care about the details of rendering to the terminal this looked good enough during my testing.
238
+ cell_flags: np.ndarray of shape (N, N) with characters 'U', 'D', 'L', 'R' to represent the edges of the cells.
239
+ center_char: np.ndarray of shape (N, N) with the center of the cells, or a string to use for all cells, or None to not show centers.
240
+ scale_x: horizontal stretch factor (>=1). Try 2 or 3 for squarer cells.
241
+ """
242
+ if cell_flags is not None:
243
+ N = cell_flags.shape[0]
244
+ H = np.zeros((N+1, N), dtype=bool)
245
+ V = np.zeros((N, N+1), dtype=bool)
246
+ for r in range(N):
247
+ for c in range(N):
248
+ s = cell_flags[r, c]
249
+ if 'U' in s: H[r, c] = True # edge between (r,c) and (r, c+1) above the cell
250
+ if 'D' in s: H[r+1, c] = True # edge below the cell
251
+ if 'L' in s: V[r, c] = True # edge left of the cell
252
+ if 'R' in s: V[r, c+1] = True # edge right of the cell
253
+ assert H is not None and V is not None, 'H and V must be provided'
254
+ # Bitmask for corner connections
255
+ U, R, D, L = 1, 2, 4, 8
256
+ JUNCTION = {
257
+ 0: ' ',
258
+ U: '│', D: '│', U|D: '│',
259
+ L: '─', R: '─', L|R: '─',
260
+ U|R: '└', R|D: '┌', D|L: '┐', L|U: '┘',
261
+ U|D|L: '┤', U|D|R: '├', L|R|U: '┴', L|R|D: '┬',
262
+ U|R|D|L: '┼',
263
+ }
264
+
265
+ assert scale_x >= 1
266
+ N = V.shape[0]
267
+ assert H.shape == (N+1, N) and V.shape == (N, N+1)
268
+
269
+ rows = 2*N + 1
270
+ cols = 2*N*scale_x + 1 # stretched width
271
+ canvas = [[' ']*cols for _ in range(rows)]
272
+
273
+ def x_corner(c): # x of corner column c
274
+ return (2*c) * scale_x
275
+ def x_between(c,k): # kth in-between column (1..scale_x) between c and c+1 corners
276
+ return (2*c) * scale_x + k
277
+
278
+ # horizontal edges: fill the stretched band between corners with '─'
279
+ for r in range(N+1):
280
+ rr = 2*r
281
+ for c in range(N):
282
+ if H[r, c]:
283
+ # previously: for k in range(1, scale_x*2, 2):
284
+ for k in range(1, scale_x*2): # 1..(2*scale_x-1), no gaps
285
+ canvas[rr][x_between(c, k)] = '─'
286
+
287
+ # vertical edges: draw at the corner columns (no horizontal stretching needed)
288
+ for r in range(N):
289
+ rr = 2*r + 1
290
+ for c in range(N+1):
291
+ if V[r, c]:
292
+ canvas[rr][x_corner(c)] = '│'
293
+
294
+ # junctions at corners
295
+ for r in range(N+1):
296
+ rr = 2*r
297
+ for c in range(N+1):
298
+ m = 0
299
+ if r > 0 and V[r-1, c]: m |= U
300
+ if c < N and H[r, c]: m |= R
301
+ if r < N and V[r, c]: m |= D
302
+ if c > 0 and H[r, c-1]: m |= L
303
+ canvas[rr][x_corner(c)] = JUNCTION[m]
304
+
305
+ # centers
306
+ # ── Centers (now safe for multi-character strings) ──────────────────────
307
+ # We render center text within the interior span (between corner columns),
308
+ # centered if it fits; otherwise we truncate to the span width.
309
+ def put_center_text(rr: int, c: int, text: str):
310
+ # interior span (exclusive of the corner columns)
311
+ left = x_corner(c) + 1
312
+ right = x_corner(c+1) - 1
313
+ if right < left:
314
+ return # no interior space (shouldn’t happen when scale_x>=1)
315
+ span_width = right - left + 1
316
+
317
+ s = str(text)
318
+ if len(s) > span_width:
319
+ s = s[:span_width] # hard truncate if it doesn't fit
320
+ # center within the span
321
+ start = left + (span_width - len(s)) // 2
322
+ for i, ch in enumerate(s):
323
+ canvas[rr][start + i] = ch
324
+
325
+ if center_char is not None:
326
+ for r in range(N):
327
+ rr = 2*r + 1
328
+ for c in range(N):
329
+ val = center_char if isinstance(center_char, str) else center_char[r, c]
330
+ put_center_text(rr, c, '' if val is None else str(val))
331
+
332
+ # turn canvas rows into strings
333
+ art_rows = [''.join(row) for row in canvas]
334
+
335
+ if not show_axes:
336
+ return '\n'.join(art_rows)
337
+
338
+ # ── Axes ────────────────────────────────────────────────────────────────
339
+ gut = max(2, len(str(N-1))) # left gutter width
340
+ gutter = ' ' * gut
341
+ top_tens = list(gutter + ' ' * cols)
342
+ top_ones = list(gutter + ' ' * cols)
343
+
344
+ for c in range(N):
345
+ xc_center = x_corner(c) + scale_x
346
+ if N >= 10:
347
+ top_tens[gut + xc_center] = str((c // 10) % 10)
348
+ top_ones[gut + xc_center] = str(c % 10)
349
+
350
+ # tiny corner labels
351
+ if gut >= 2:
352
+ top_tens[gut-2:gut] = list(' ')
353
+ top_ones[gut-2:gut] = list(' ')
354
+
355
+ labeled = []
356
+ for r, line in enumerate(art_rows):
357
+ if r % 2 == 1: # cell-center row
358
+ label = str(r//2).rjust(gut)
359
+ else:
360
+ label = ' ' * gut
361
+ labeled.append(label + line)
362
+
363
+ return ''.join(top_tens) + '\n' + ''.join(top_ones) + '\n' + '\n'.join(labeled)
@@ -0,0 +1,98 @@
1
+ import numpy as np
2
+ from ortools.sat.python import cp_model
3
+ from ortools.sat.python.cp_model import LinearExpr as lxp
4
+
5
+ from puzzle_solver.core.utils import Direction, Pos, get_all_pos, get_next_pos, in_bounds, set_char, get_char, get_neighbors8, get_row_pos, get_col_pos
6
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
7
+
8
+
9
+ class Board:
10
+ def __init__(self, board: np.array):
11
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
12
+ assert all(c.item() in [' ', 'B', 'W'] for c in np.nditer(board)), 'board must contain only space or B'
13
+ self.board = board
14
+ self.V, self.H = board.shape
15
+ self.model = cp_model.CpModel()
16
+ self.model_vars: dict[Pos, cp_model.IntVar] = {}
17
+
18
+ self.create_vars()
19
+ self.add_all_constraints()
20
+
21
+ def create_vars(self):
22
+ for pos in get_all_pos(self.V, self.H):
23
+ self.model_vars[pos] = self.model.NewBoolVar(f'{pos}')
24
+
25
+ def add_all_constraints(self):
26
+ for pos in get_all_pos(self.V, self.H): # force clues
27
+ c = get_char(self.board, pos)
28
+ if c == 'B':
29
+ self.model.Add(self.model_vars[pos] == 1)
30
+ elif c == 'W':
31
+ self.model.Add(self.model_vars[pos] == 0)
32
+ # 1. Each row and each column must contain an equal number of white and black circles.
33
+ for row in range(self.V):
34
+ row_vars = [self.model_vars[pos] for pos in get_row_pos(row, self.H)]
35
+ self.model.Add(lxp.sum(row_vars) == len(row_vars) // 2)
36
+ for col in range(self.H):
37
+ col_vars = [self.model_vars[pos] for pos in get_col_pos(col, self.V)]
38
+ self.model.Add(lxp.sum(col_vars) == len(col_vars) // 2)
39
+ # 2. More than two circles of the same color can't be adjacent.
40
+ for pos in get_all_pos(self.V, self.H):
41
+ self.disallow_three_in_a_row(pos, Direction.RIGHT)
42
+ self.disallow_three_in_a_row(pos, Direction.DOWN)
43
+ # 3. Each row and column is unique.
44
+ # a list per row
45
+ self.force_unique([[self.model_vars[pos] for pos in get_row_pos(row, self.H)] for row in range(self.V)])
46
+ # a list per column
47
+ self.force_unique([[self.model_vars[pos] for pos in get_col_pos(col, self.V)] for col in range(self.H)])
48
+
49
+ def disallow_three_in_a_row(self, p1: Pos, direction: Direction):
50
+ p2 = get_next_pos(p1, direction)
51
+ p3 = get_next_pos(p2, direction)
52
+ if any(not in_bounds(p, self.V, self.H) for p in [p1, p2, p3]):
53
+ return
54
+ self.model.AddBoolOr([
55
+ self.model_vars[p1],
56
+ self.model_vars[p2],
57
+ self.model_vars[p3],
58
+ ])
59
+ self.model.AddBoolOr([
60
+ self.model_vars[p1].Not(),
61
+ self.model_vars[p2].Not(),
62
+ self.model_vars[p3].Not(),
63
+ ])
64
+
65
+ def force_unique(self, model_vars: list[list[cp_model.IntVar]]):
66
+ if not model_vars or len(model_vars) < 2:
67
+ return
68
+ m = len(model_vars[0])
69
+ assert m <= 61, f"Too many cells for binary encoding in int64: m={m}, model_vars={model_vars}"
70
+
71
+ codes = []
72
+ pow2 = [1 << k for k in range(m)] # weights for bit positions (LSB at index 0)
73
+ for i, l in enumerate(model_vars):
74
+ code = self.model.NewIntVar(0, (1 << m) - 1, f"code_{i}")
75
+ # Sum 2^k * r[k] == code
76
+ self.model.Add(code == sum(pow2[k] * l[k] for k in range(m)))
77
+ codes.append(code)
78
+
79
+ self.model.AddAllDifferent(codes)
80
+
81
+ def solve_and_print(self, verbose: bool = True):
82
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
83
+ assignment: dict[Pos, int] = {}
84
+ for pos, var in board.model_vars.items():
85
+ assignment[pos] = solver.Value(var)
86
+ return SingleSolution(assignment=assignment)
87
+ def callback(single_res: SingleSolution):
88
+ print("Solution found")
89
+ res = np.full((self.V, self.H), ' ', dtype=object)
90
+ for pos in get_all_pos(self.V, self.H):
91
+ c = get_char(self.board, pos)
92
+ c = 'B' if single_res.assignment[pos] == 1 else 'W'
93
+ set_char(res, pos, c)
94
+ print('[')
95
+ for row in res:
96
+ print(" [ '" + "', '".join(row.tolist()) + "' ],")
97
+ print(']')
98
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -0,0 +1,130 @@
1
+ from dataclasses import dataclass
2
+
3
+ import numpy as np
4
+
5
+ from ortools.sat.python import cp_model
6
+ from ortools.sat.python.cp_model import LinearExpr as lxp
7
+
8
+ from puzzle_solver.core.utils import Pos, get_all_pos, in_bounds, set_char, get_char, get_neighbors8, Direction, get_next_pos, render_grid
9
+ from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution
10
+
11
+
12
+ def factor_pairs(N: int, upper_limit_i: int, upper_limit_j: int):
13
+ """Return all unique pairs (a, b) such that a * b == N, with a, b <= upper_limit."""
14
+ if N <= 0 or upper_limit_i <= 0 or upper_limit_j <= 0:
15
+ return []
16
+
17
+ pairs = []
18
+ i = 1
19
+ while i * i <= N:
20
+ if N % i == 0:
21
+ j = N // i
22
+ if i <= upper_limit_i and j <= upper_limit_j:
23
+ pairs.append((i, j))
24
+ if i != j and j <= upper_limit_i and i <= upper_limit_j:
25
+ pairs.append((j, i))
26
+ i += 1
27
+ return pairs
28
+
29
+
30
+ @dataclass
31
+ class Rectangle:
32
+ active: cp_model.IntVar
33
+ N: int
34
+ clue_id: int
35
+ width: int
36
+ height: int
37
+ body: set[Pos]
38
+
39
+
40
+ class Board:
41
+ def __init__(self, board: np.array):
42
+ assert board.ndim == 2, f'board must be 2d, got {board.ndim}'
43
+ assert all((c.item() == ' ') or str(c.item()).isdecimal() for c in np.nditer(board)), 'board must contain only space or digits'
44
+ self.board = board
45
+ self.V, self.H = board.shape
46
+ self.clue_pos: list[Pos] = [pos for pos in get_all_pos(self.V, self.H) if str(get_char(self.board, pos)).isdecimal()]
47
+ self.clue_pos_to_id: dict[Pos, int] = {pos: i for i, pos in enumerate(self.clue_pos)}
48
+ self.clue_pos_to_value: dict[Pos, int] = {pos: int(get_char(self.board, pos)) for pos in self.clue_pos}
49
+
50
+ self.model = cp_model.CpModel()
51
+ self.model_vars: dict[tuple[Pos, Pos], cp_model.IntVar] = {}
52
+ self.rectangles: list[Rectangle] = []
53
+
54
+ self.create_vars()
55
+ self.add_all_constraints()
56
+
57
+ def create_vars(self):
58
+ self.init_rectangles()
59
+ # for each position it belongs to exactly 1 clue
60
+ # instead of iterating over all clues, we only look at the clues that are possible for this position (by looking at the rectangles that contain this position)
61
+ for pos in get_all_pos(self.V, self.H):
62
+ possible_clue_here = {rectangle.clue_id for rectangle in self.rectangles if pos in rectangle.body} # get the clue position for any rectangle that contains this position
63
+ for possible_clue in possible_clue_here:
64
+ self.model_vars[(pos, possible_clue)] = self.model.NewBoolVar(f'{pos}:{possible_clue}')
65
+
66
+ def init_rectangles(self) -> list[Rectangle]:
67
+ self.fixed_pos: set[Pos] = set(self.clue_pos)
68
+ for pos in self.clue_pos: # for each clue on the board
69
+ clue_id = self.clue_pos_to_id[pos]
70
+ clue_num = self.clue_pos_to_value[pos]
71
+ other_fixed_pos = self.fixed_pos - {pos}
72
+ for width, height in factor_pairs(clue_num, self.V, self.H): # for each possible width x height rectangle that can fit the clue
73
+ # if the digit is at pos and we have a width x height rectangle then we can translate the rectangle "0 to width" to the left and "0 to height" to the top
74
+ for dx in range(width):
75
+ for dy in range(height):
76
+ body = {Pos(x=pos.x - dx + i, y=pos.y - dy + j) for i in range(width) for j in range(height)}
77
+ if any(not in_bounds(p, self.V, self.H) for p in body): # a rectangle cannot be out of bounds
78
+ continue
79
+ if any(p in other_fixed_pos for p in body): # a rectangle cannot contain a different clue; each clue is 1 rectangle only
80
+ continue
81
+ rectangle = Rectangle(active=self.model.NewBoolVar(f'{clue_id}'), N=clue_num, clue_id=clue_id, width=width, height=height, body=body)
82
+ self.rectangles.append(rectangle)
83
+
84
+ def add_all_constraints(self):
85
+ # each pos has only 1 rectangle active
86
+ for pos in get_all_pos(self.V, self.H):
87
+ self.model.AddExactlyOne(rectangle.active for rectangle in self.rectangles if pos in rectangle.body)
88
+ # each pos has only 1 clue active
89
+ for pos in get_all_pos(self.V, self.H):
90
+ self.model.AddExactlyOne(self.model_vars[(pos, clue_id)] for clue_id in self.clue_pos_to_id.values() if (pos, clue_id) in self.model_vars)
91
+ # a rectangle being active means all its body ponts to the clue
92
+ for rectangle in self.rectangles:
93
+ is_active = rectangle.active
94
+ for pos in rectangle.body:
95
+ self.model.Add(self.model_vars[(pos, rectangle.clue_id)] == 1).OnlyEnforceIf(is_active)
96
+
97
+ def solve_and_print(self, verbose: bool = True):
98
+ def board_to_solution(board: Board, solver: cp_model.CpSolverSolutionCallback) -> SingleSolution:
99
+ assignment: dict[Pos, int] = {}
100
+ for (i, rectangle) in enumerate(self.rectangles):
101
+ if solver.Value(rectangle.active) == 1:
102
+ for pos in rectangle.body:
103
+ assignment[pos] = f'id{rectangle.clue_id}:N={rectangle.N}:{rectangle.height}x{rectangle.width}'
104
+ return SingleSolution(assignment=assignment)
105
+ def callback(single_res: SingleSolution):
106
+ print("Solution found")
107
+ res = np.full((self.V, self.H), '', dtype=object)
108
+ id_board = np.full((self.V, self.H), '', dtype=object)
109
+ for pos in get_all_pos(self.V, self.H):
110
+ cur = single_res.assignment[pos]
111
+ set_char(id_board, pos, cur)
112
+ left_pos = get_next_pos(pos, Direction.LEFT)
113
+ right_pos = get_next_pos(pos, Direction.RIGHT)
114
+ top_pos = get_next_pos(pos, Direction.UP)
115
+ bottom_pos = get_next_pos(pos, Direction.DOWN)
116
+ if left_pos not in single_res.assignment or single_res.assignment[left_pos] != cur:
117
+ set_char(res, pos, get_char(res, pos) + 'L')
118
+ if right_pos not in single_res.assignment or single_res.assignment[right_pos] != cur:
119
+ set_char(res, pos, get_char(res, pos) + 'R')
120
+ if top_pos not in single_res.assignment or single_res.assignment[top_pos] != cur:
121
+ set_char(res, pos, get_char(res, pos) + 'U')
122
+ if bottom_pos not in single_res.assignment or single_res.assignment[bottom_pos] != cur:
123
+ set_char(res, pos, get_char(res, pos) + 'D')
124
+ # print('[')
125
+ # for row in id_board:
126
+ # print(' ', row.tolist(), end=',\n')
127
+ # print(' ])')
128
+ print(render_grid(res, center_char=self.board))
129
+
130
+ return generic_solve_all(self, board_to_solution, callback=callback if verbose else None, verbose=verbose)
@@ -2,7 +2,7 @@ import numpy as np
2
2
  from collections import defaultdict
3
3
  from ortools.sat.python import cp_model
4
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
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, render_grid
6
6
  from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, force_connected_component
7
7
 
8
8
 
@@ -118,131 +118,7 @@ class Board:
118
118
  continue
119
119
  c = ''.join(sorted(single_res.assignment[pos]))
120
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 '·'))
121
+ # replace " " with "·"
122
+ board = np.where(self.board == ' ', '·', self.board)
123
+ print(render_grid(cell_flags=res, center_char=board))
122
124
  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
-