multi-puzzle-solver 0.9.12__py3-none-any.whl → 0.9.14__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,212 +1,212 @@
1
- """
2
- This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/inertia.html and converts them to a json file.
3
- Look at the ./input_output/ directory for examples of input images and output json files.
4
- The output json is used in the test_solve.py file to test the solver.
5
- """
6
- from pathlib import Path
7
- import numpy as np
8
- cv = None
9
- Image = None
10
-
11
-
12
- def extract_lines(bw):
13
- # Create the images that will use to extract the horizontal and vertical lines
14
- horizontal = np.copy(bw)
15
- vertical = np.copy(bw)
16
-
17
- cols = horizontal.shape[1]
18
- horizontal_size = cols // 5
19
- # Create structure element for extracting horizontal lines through morphology operations
20
- horizontalStructure = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
21
- horizontal = cv.erode(horizontal, horizontalStructure)
22
- horizontal = cv.dilate(horizontal, horizontalStructure)
23
- horizontal_means = np.mean(horizontal, axis=1)
24
- horizontal_cutoff = np.percentile(horizontal_means, 50)
25
- # location where the horizontal lines are
26
- horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
27
- # print(f"horizontal_idx: {horizontal_idx}")
28
- height = len(horizontal_idx)
29
- # show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
30
-
31
- rows = vertical.shape[0]
32
- verticalsize = rows // 5
33
- # Create structure element for extracting vertical lines through morphology operations
34
- verticalStructure = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
35
- vertical = cv.erode(vertical, verticalStructure)
36
- vertical = cv.dilate(vertical, verticalStructure)
37
- vertical_means = np.mean(vertical, axis=0)
38
- vertical_cutoff = np.percentile(vertical_means, 50)
39
- vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
40
- # print(f"vertical_idx: {vertical_idx}")
41
- width = len(vertical_idx)
42
- # print(f"height: {height}, width: {width}")
43
- # print(f"vertical_means: {vertical_means}")
44
- # show_wait_destroy("vertical", vertical) # this has the vertical lines
45
-
46
- vertical = cv.bitwise_not(vertical)
47
- # show_wait_destroy("vertical_bit", vertical)
48
-
49
- return horizontal_idx, vertical_idx
50
-
51
- def show_wait_destroy(winname, img):
52
- cv.imshow(winname, img)
53
- cv.moveWindow(winname, 500, 0)
54
- cv.waitKey(0)
55
- cv.destroyWindow(winname)
56
-
57
-
58
- def mean_consecutives(arr: np.ndarray) -> np.ndarray:
59
- """if a sequence of values is consecutive, then average the values"""
60
- sums = []
61
- counts = []
62
- for i in range(len(arr)):
63
- if i == 0:
64
- sums.append(arr[i])
65
- counts.append(1)
66
- elif arr[i] == arr[i-1] + 1:
67
- sums[-1] += arr[i]
68
- counts[-1] += 1
69
- else:
70
- sums.append(arr[i])
71
- counts.append(1)
72
- return np.array(sums) // np.array(counts)
73
-
74
- def dfs(x, y, out, output, current_num):
75
- if x < 0 or x >= out.shape[1] or y < 0 or y >= out.shape[0]:
76
- return
77
- if out[y, x] != ' ':
78
- return
79
- out[y, x] = current_num
80
- if output['top'][y, x] == 0:
81
- dfs(x, y-1, out, output, current_num)
82
- if output['left'][y, x] == 0:
83
- dfs(x-1, y, out, output, current_num)
84
- if output['right'][y, x] == 0:
85
- dfs(x+1, y, out, output, current_num)
86
- if output['bottom'][y, x] == 0:
87
- dfs(x, y+1, out, output, current_num)
88
-
89
- def main(image):
90
- global Image
91
- global cv
92
- import matplotlib.pyplot as plt
93
- from PIL import Image as Image_module
94
- import cv2 as cv_module
95
- Image = Image_module
96
- cv = cv_module
97
-
98
-
99
- image_path = Path(image)
100
- output_path = image_path.parent / (image_path.stem + '.json')
101
- src = cv.imread(image, cv.IMREAD_COLOR)
102
- assert src is not None, f'Error opening image: {image}'
103
- if len(src.shape) != 2:
104
- gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
105
- else:
106
- gray = src
107
- # now the image is in grayscale
108
-
109
- # Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
110
- gray = cv.bitwise_not(gray)
111
- bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
112
- cv.THRESH_BINARY, 15, -2)
113
- # show_wait_destroy("binary", bw)
114
-
115
- # show_wait_destroy("src", src)
116
- horizontal_idx, vertical_idx = extract_lines(bw)
117
- horizontal_idx = mean_consecutives(horizontal_idx)
118
- vertical_idx = mean_consecutives(vertical_idx)
119
- height = len(horizontal_idx)
120
- width = len(vertical_idx)
121
- print(f"height: {height}, width: {width}")
122
- print(f"horizontal_idx: {horizontal_idx}")
123
- print(f"vertical_idx: {vertical_idx}")
124
- arr = np.zeros((height - 1, width - 1), dtype=object)
125
- output = {'top': arr.copy(), 'left': arr.copy(), 'right': arr.copy(), 'bottom': arr.copy()}
126
- target = 200_000
127
- hists = {'top': {}, 'left': {}, 'right': {}, 'bottom': {}}
128
- for j in range(height - 1):
129
- for i in range(width - 1):
130
- hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
131
- vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
132
- hidx1 = max(0, hidx1 - 2)
133
- hidx2 = min(src.shape[0], hidx2 + 4)
134
- vidx1 = max(0, vidx1 - 2)
135
- vidx2 = min(src.shape[1], vidx2 + 4)
136
- cell = src[hidx1:hidx2, vidx1:vidx2]
137
- mid_x = cell.shape[1] // 2
138
- mid_y = cell.shape[0] // 2
139
- # show_wait_destroy(f"cell_{i}_{j}", cell)
140
- cell = cv.bitwise_not(cell) # invert colors
141
- top = cell[0:10, mid_y-5:mid_y+5]
142
- hists['top'][j, i] = np.sum(top)
143
- left = cell[mid_x-5:mid_x+5, 0:10]
144
- hists['left'][j, i] = np.sum(left)
145
- right = cell[mid_x-5:mid_x+5, -10:]
146
- hists['right'][j, i] = np.sum(right)
147
- bottom = cell[-10:, mid_y-5:mid_y+5]
148
- hists['bottom'][j, i] = np.sum(bottom)
149
-
150
- fig, axs = plt.subplots(2, 2)
151
- axs[0, 0].hist(list(hists['top'].values()), bins=100)
152
- axs[0, 0].set_title('Top')
153
- axs[0, 1].hist(list(hists['left'].values()), bins=100)
154
- axs[0, 1].set_title('Left')
155
- axs[1, 0].hist(list(hists['right'].values()), bins=100)
156
- axs[1, 0].set_title('Right')
157
- axs[1, 1].hist(list(hists['bottom'].values()), bins=100)
158
- axs[1, 1].set_title('Bottom')
159
- target_top = np.mean(list(hists['top'].values()))
160
- target_left = np.mean(list(hists['left'].values()))
161
- target_right = np.mean(list(hists['right'].values()))
162
- target_bottom = np.mean(list(hists['bottom'].values()))
163
- axs[0, 0].axvline(target_top, color='red')
164
- axs[0, 1].axvline(target_left, color='red')
165
- axs[1, 0].axvline(target_right, color='red')
166
- axs[1, 1].axvline(target_bottom, color='red')
167
- # plt.show()
168
- # 1/0
169
- print(f"target_top: {target_top}, target_left: {target_left}, target_right: {target_right}, target_bottom: {target_bottom}")
170
- for j in range(height - 1):
171
- for i in range(width - 1):
172
- if hists['top'][j, i] > target_top:
173
- output['top'][j, i] = 1
174
- if hists['left'][j, i] > target_left:
175
- output['left'][j, i] = 1
176
- if hists['right'][j, i] > target_right:
177
- output['right'][j, i] = 1
178
- if hists['bottom'][j, i] > target_bottom:
179
- output['bottom'][j, i] = 1
180
- print(f"cell_{j}_{i}", end=': ')
181
- print('T' if output['top'][j, i] else '', end='')
182
- print('L' if output['left'][j, i] else '', end='')
183
- print('R' if output['right'][j, i] else '', end='')
184
- print('B' if output['bottom'][j, i] else '', end='')
185
- print(' Sums: ', hists['top'][j, i], hists['left'][j, i], hists['right'][j, i], hists['bottom'][j, i])
186
-
187
- current_count = 0
188
- out = np.full_like(output['top'], ' ', dtype='U2')
189
- for j in range(out.shape[0]):
190
- for i in range(out.shape[1]):
191
- if out[j, i] == ' ':
192
- dfs(i, j, out, output, str(current_count).zfill(2))
193
- current_count += 1
194
-
195
- with open(output_path, 'w') as f:
196
- f.write('[\n')
197
- for i, row in enumerate(out):
198
- f.write(' ' + str(row.tolist()).replace("'", '"'))
199
- if i != len(out) - 1:
200
- f.write(',')
201
- f.write('\n')
202
- f.write(']')
203
- print('output json: ', output_path)
204
-
205
- if __name__ == '__main__':
206
- # to run this script and visualize the output, in the root run:
207
- # python .\src\puzzle_solver\puzzles\stitches\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
208
- # main(Path(__file__).parent / 'input_output' / 'MTM6OSw4MjEsNDAx.png')
209
- # main(Path(__file__).parent / 'input_output' / 'weekly_oct_3rd_2025.png')
210
- # main(Path(__file__).parent / 'input_output' / 'star_battle_67f73ff90cd8cdb4b3e30f56f5261f4968f5dac940bc6.png')
211
- # main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
212
- main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
1
+ """
2
+ This file is a simple helper that parses the images from https://www.chiark.greenend.org.uk/~sgtatham/puzzles/js/inertia.html and converts them to a json file.
3
+ Look at the ./input_output/ directory for examples of input images and output json files.
4
+ The output json is used in the test_solve.py file to test the solver.
5
+ """
6
+ from pathlib import Path
7
+ import numpy as np
8
+ cv = None
9
+ Image = None
10
+
11
+
12
+ def extract_lines(bw):
13
+ # Create the images that will use to extract the horizontal and vertical lines
14
+ horizontal = np.copy(bw)
15
+ vertical = np.copy(bw)
16
+
17
+ cols = horizontal.shape[1]
18
+ horizontal_size = cols // 5
19
+ # Create structure element for extracting horizontal lines through morphology operations
20
+ horizontalStructure = cv.getStructuringElement(cv.MORPH_RECT, (horizontal_size, 1))
21
+ horizontal = cv.erode(horizontal, horizontalStructure)
22
+ horizontal = cv.dilate(horizontal, horizontalStructure)
23
+ horizontal_means = np.mean(horizontal, axis=1)
24
+ horizontal_cutoff = np.percentile(horizontal_means, 50)
25
+ # location where the horizontal lines are
26
+ horizontal_idx = np.where(horizontal_means > horizontal_cutoff)[0]
27
+ # print(f"horizontal_idx: {horizontal_idx}")
28
+ height = len(horizontal_idx)
29
+ # show_wait_destroy("horizontal", horizontal) # this has the horizontal lines
30
+
31
+ rows = vertical.shape[0]
32
+ verticalsize = rows // 5
33
+ # Create structure element for extracting vertical lines through morphology operations
34
+ verticalStructure = cv.getStructuringElement(cv.MORPH_RECT, (1, verticalsize))
35
+ vertical = cv.erode(vertical, verticalStructure)
36
+ vertical = cv.dilate(vertical, verticalStructure)
37
+ vertical_means = np.mean(vertical, axis=0)
38
+ vertical_cutoff = np.percentile(vertical_means, 50)
39
+ vertical_idx = np.where(vertical_means > vertical_cutoff)[0]
40
+ # print(f"vertical_idx: {vertical_idx}")
41
+ width = len(vertical_idx)
42
+ # print(f"height: {height}, width: {width}")
43
+ # print(f"vertical_means: {vertical_means}")
44
+ # show_wait_destroy("vertical", vertical) # this has the vertical lines
45
+
46
+ vertical = cv.bitwise_not(vertical)
47
+ # show_wait_destroy("vertical_bit", vertical)
48
+
49
+ return horizontal_idx, vertical_idx
50
+
51
+ def show_wait_destroy(winname, img):
52
+ cv.imshow(winname, img)
53
+ cv.moveWindow(winname, 500, 0)
54
+ cv.waitKey(0)
55
+ cv.destroyWindow(winname)
56
+
57
+
58
+ def mean_consecutives(arr: np.ndarray) -> np.ndarray:
59
+ """if a sequence of values is consecutive, then average the values"""
60
+ sums = []
61
+ counts = []
62
+ for i in range(len(arr)):
63
+ if i == 0:
64
+ sums.append(arr[i])
65
+ counts.append(1)
66
+ elif arr[i] == arr[i-1] + 1:
67
+ sums[-1] += arr[i]
68
+ counts[-1] += 1
69
+ else:
70
+ sums.append(arr[i])
71
+ counts.append(1)
72
+ return np.array(sums) // np.array(counts)
73
+
74
+ def dfs(x, y, out, output, current_num):
75
+ if x < 0 or x >= out.shape[1] or y < 0 or y >= out.shape[0]:
76
+ return
77
+ if out[y, x] != ' ':
78
+ return
79
+ out[y, x] = current_num
80
+ if output['top'][y, x] == 0:
81
+ dfs(x, y-1, out, output, current_num)
82
+ if output['left'][y, x] == 0:
83
+ dfs(x-1, y, out, output, current_num)
84
+ if output['right'][y, x] == 0:
85
+ dfs(x+1, y, out, output, current_num)
86
+ if output['bottom'][y, x] == 0:
87
+ dfs(x, y+1, out, output, current_num)
88
+
89
+ def main(image):
90
+ global Image
91
+ global cv
92
+ import matplotlib.pyplot as plt
93
+ from PIL import Image as Image_module
94
+ import cv2 as cv_module
95
+ Image = Image_module
96
+ cv = cv_module
97
+
98
+
99
+ image_path = Path(image)
100
+ output_path = image_path.parent / (image_path.stem + '.json')
101
+ src = cv.imread(image, cv.IMREAD_COLOR)
102
+ assert src is not None, f'Error opening image: {image}'
103
+ if len(src.shape) != 2:
104
+ gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY)
105
+ else:
106
+ gray = src
107
+ # now the image is in grayscale
108
+
109
+ # Apply adaptiveThreshold at the bitwise_not of gray, notice the ~ symbol
110
+ gray = cv.bitwise_not(gray)
111
+ bw = cv.adaptiveThreshold(gray.copy(), 255, cv.ADAPTIVE_THRESH_MEAN_C, \
112
+ cv.THRESH_BINARY, 15, -2)
113
+ # show_wait_destroy("binary", bw)
114
+
115
+ # show_wait_destroy("src", src)
116
+ horizontal_idx, vertical_idx = extract_lines(bw)
117
+ horizontal_idx = mean_consecutives(horizontal_idx)
118
+ vertical_idx = mean_consecutives(vertical_idx)
119
+ height = len(horizontal_idx)
120
+ width = len(vertical_idx)
121
+ print(f"height: {height}, width: {width}")
122
+ print(f"horizontal_idx: {horizontal_idx}")
123
+ print(f"vertical_idx: {vertical_idx}")
124
+ arr = np.zeros((height - 1, width - 1), dtype=object)
125
+ output = {'top': arr.copy(), 'left': arr.copy(), 'right': arr.copy(), 'bottom': arr.copy()}
126
+ target = 200_000
127
+ hists = {'top': {}, 'left': {}, 'right': {}, 'bottom': {}}
128
+ for j in range(height - 1):
129
+ for i in range(width - 1):
130
+ hidx1, hidx2 = horizontal_idx[j], horizontal_idx[j+1]
131
+ vidx1, vidx2 = vertical_idx[i], vertical_idx[i+1]
132
+ hidx1 = max(0, hidx1 - 2)
133
+ hidx2 = min(src.shape[0], hidx2 + 4)
134
+ vidx1 = max(0, vidx1 - 2)
135
+ vidx2 = min(src.shape[1], vidx2 + 4)
136
+ cell = src[hidx1:hidx2, vidx1:vidx2]
137
+ mid_x = cell.shape[1] // 2
138
+ mid_y = cell.shape[0] // 2
139
+ # show_wait_destroy(f"cell_{i}_{j}", cell)
140
+ cell = cv.bitwise_not(cell) # invert colors
141
+ top = cell[0:10, mid_y-5:mid_y+5]
142
+ hists['top'][j, i] = np.sum(top)
143
+ left = cell[mid_x-5:mid_x+5, 0:10]
144
+ hists['left'][j, i] = np.sum(left)
145
+ right = cell[mid_x-5:mid_x+5, -10:]
146
+ hists['right'][j, i] = np.sum(right)
147
+ bottom = cell[-10:, mid_y-5:mid_y+5]
148
+ hists['bottom'][j, i] = np.sum(bottom)
149
+
150
+ fig, axs = plt.subplots(2, 2)
151
+ axs[0, 0].hist(list(hists['top'].values()), bins=100)
152
+ axs[0, 0].set_title('Top')
153
+ axs[0, 1].hist(list(hists['left'].values()), bins=100)
154
+ axs[0, 1].set_title('Left')
155
+ axs[1, 0].hist(list(hists['right'].values()), bins=100)
156
+ axs[1, 0].set_title('Right')
157
+ axs[1, 1].hist(list(hists['bottom'].values()), bins=100)
158
+ axs[1, 1].set_title('Bottom')
159
+ target_top = np.mean(list(hists['top'].values()))
160
+ target_left = np.mean(list(hists['left'].values()))
161
+ target_right = np.mean(list(hists['right'].values()))
162
+ target_bottom = np.mean(list(hists['bottom'].values()))
163
+ axs[0, 0].axvline(target_top, color='red')
164
+ axs[0, 1].axvline(target_left, color='red')
165
+ axs[1, 0].axvline(target_right, color='red')
166
+ axs[1, 1].axvline(target_bottom, color='red')
167
+ # plt.show()
168
+ # 1/0
169
+ print(f"target_top: {target_top}, target_left: {target_left}, target_right: {target_right}, target_bottom: {target_bottom}")
170
+ for j in range(height - 1):
171
+ for i in range(width - 1):
172
+ if hists['top'][j, i] > target_top:
173
+ output['top'][j, i] = 1
174
+ if hists['left'][j, i] > target_left:
175
+ output['left'][j, i] = 1
176
+ if hists['right'][j, i] > target_right:
177
+ output['right'][j, i] = 1
178
+ if hists['bottom'][j, i] > target_bottom:
179
+ output['bottom'][j, i] = 1
180
+ print(f"cell_{j}_{i}", end=': ')
181
+ print('T' if output['top'][j, i] else '', end='')
182
+ print('L' if output['left'][j, i] else '', end='')
183
+ print('R' if output['right'][j, i] else '', end='')
184
+ print('B' if output['bottom'][j, i] else '', end='')
185
+ print(' Sums: ', hists['top'][j, i], hists['left'][j, i], hists['right'][j, i], hists['bottom'][j, i])
186
+
187
+ current_count = 0
188
+ out = np.full_like(output['top'], ' ', dtype='U2')
189
+ for j in range(out.shape[0]):
190
+ for i in range(out.shape[1]):
191
+ if out[j, i] == ' ':
192
+ dfs(i, j, out, output, str(current_count).zfill(2))
193
+ current_count += 1
194
+
195
+ with open(output_path, 'w') as f:
196
+ f.write('[\n')
197
+ for i, row in enumerate(out):
198
+ f.write(' ' + str(row.tolist()).replace("'", '"'))
199
+ if i != len(out) - 1:
200
+ f.write(',')
201
+ f.write('\n')
202
+ f.write(']')
203
+ print('output json: ', output_path)
204
+
205
+ if __name__ == '__main__':
206
+ # to run this script and visualize the output, in the root run:
207
+ # python .\src\puzzle_solver\puzzles\stitches\parse_map\parse_map.py | python .\src\puzzle_solver\utils\visualizer.py --read_stdin
208
+ # main(Path(__file__).parent / 'input_output' / 'MTM6OSw4MjEsNDAx.png')
209
+ # main(Path(__file__).parent / 'input_output' / 'weekly_oct_3rd_2025.png')
210
+ # main(Path(__file__).parent / 'input_output' / 'star_battle_67f73ff90cd8cdb4b3e30f56f5261f4968f5dac940bc6.png')
211
+ # main(Path(__file__).parent / 'input_output' / 'LITS_MDoxNzksNzY3.png')
212
+ main(Path(__file__).parent / 'input_output' / 'lits_OTo3LDMwNiwwMTU=.png')
@@ -3,7 +3,7 @@ import numpy as np
3
3
  from ortools.sat.python import cp_model
4
4
 
5
5
  from puzzle_solver.core.utils import Pos, get_all_pos, set_char, get_char, Direction, in_bounds, get_next_pos, get_row_pos, get_col_pos, get_opposite_direction
6
- from puzzle_solver.core.utils_ortools import generic_solve_all, SingleSolution, and_constraint, or_constraint
6
+ from puzzle_solver.core.utils_ortools import force_connected_component, generic_solve_all, SingleSolution
7
7
 
8
8
 
9
9
  class Board:
@@ -25,7 +25,6 @@ class Board:
25
25
  self.model = cp_model.CpModel()
26
26
  self.cell_active: dict[Pos, cp_model.IntVar] = {}
27
27
  self.cell_direction: dict[tuple[Pos, Direction], cp_model.IntVar] = {}
28
- self.reach_layers: list[dict[Pos, cp_model.IntVar]] = [] # R_t[p] booleans, t = 0..T
29
28
 
30
29
  self.create_vars()
31
30
  self.add_all_constraints()
@@ -35,19 +34,13 @@ class Board:
35
34
  self.cell_active[pos] = self.model.NewBoolVar(f'{pos}')
36
35
  for direction in Direction:
37
36
  self.cell_direction[(pos, direction)] = self.model.NewBoolVar(f'{pos}:{direction}')
38
- # Percolation layers R_t (monotone flood fill)
39
- for t in range(self.V * self.H + 1):
40
- Rt: dict[Pos, cp_model.IntVar] = {}
41
- for pos in get_all_pos(self.V, self.H):
42
- Rt[pos] = self.model.NewBoolVar(f"R[{t}][{pos}]")
43
- self.reach_layers.append(Rt)
44
37
 
45
38
  def add_all_constraints(self):
46
39
  self.force_hints()
47
40
  self.force_sides()
48
41
  self.force_0_or_2_active()
49
42
  self.force_direction_constraints()
50
- self.force_percolation()
43
+ self.force_connected_component()
51
44
 
52
45
 
53
46
  def force_hints(self):
@@ -108,38 +101,16 @@ class Board:
108
101
  for pos in get_row_pos(0, self.H):
109
102
  self.model.Add(self.cell_direction[(pos, Direction.UP)] == 0)
110
103
 
111
- def force_percolation(self):
112
- """
113
- Layered percolation:
114
- - root is exactly the first cell in the first column
115
- - R_t is monotone nondecreasing in t (R_t+1 >= R_t)
116
- - A cell can 'turn on' at layer t+1 iff it's active and has a neighbor on AND pointing to it at layer t
117
- - Final layer is equal to the active mask: R_T[p] == active[p] => all active cells are connected to the unique root
118
- """
119
- # only the start position is a root
120
- self.model.Add(self.reach_layers[0][self.first_col_start_pos] == 1)
121
- for pos in get_all_pos(self.V, self.H):
122
- if pos != self.first_col_start_pos:
123
- self.model.Add(self.reach_layers[0][pos] == 0)
124
-
125
- for t in range(1, len(self.reach_layers)):
126
- Rt_prev = self.reach_layers[t - 1]
127
- Rt = self.reach_layers[t]
128
- for p in get_all_pos(self.V, self.H):
129
- # Rt[p] = Rt_prev[p] | (active[p] & Rt_prev[neighbour #1]) | (active[p] & Rt_prev[neighbour #2]) | ...
130
- # Create helper (active[p] & Rt_prev[neighbour #X]) for each neighbor q
131
- neigh_helpers: list[cp_model.IntVar] = []
132
- for direction in Direction:
133
- q = get_next_pos(p, direction)
134
- if not in_bounds(q, self.V, self.H):
135
- continue
136
- a = self.model.NewBoolVar(f"A[{t}][{p}]<-({q})")
137
- and_constraint(self.model, target=a, cs=[self.cell_active[p], Rt_prev[q], self.cell_direction[(q, get_opposite_direction(direction))]])
138
- neigh_helpers.append(a)
139
- or_constraint(self.model, target=Rt[p], cs=[Rt_prev[p]] + neigh_helpers)
140
- # every avtive track must be reachible -> single connected component
141
- for pos in get_all_pos(self.V, self.H):
142
- self.model.Add(self.reach_layers[-1][pos] == 1).OnlyEnforceIf(self.cell_active[pos])
104
+ def force_connected_component(self):
105
+ def is_neighbor(pd1: tuple[Pos, Direction], pd2: tuple[Pos, Direction]) -> bool:
106
+ p1, d1 = pd1
107
+ p2, d2 = pd2
108
+ if p1 == p2: # same position, different direction, is neighbor
109
+ return True
110
+ if get_next_pos(p1, d1) == p2 and d2 == get_opposite_direction(d1):
111
+ return True
112
+ return False
113
+ force_connected_component(self.model, self.cell_direction, is_neighbor=is_neighbor)
143
114
 
144
115
 
145
116