diskpack 0.1.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: diskpack
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: A high-performance vectorized circle packer with spatial hashing.
5
5
  Author-email: James Kelly <mrkellyjam@gmail.com>
6
6
  Requires-Python: >=3.8
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "diskpack"
7
- version = "0.1.0"
7
+ version = "0.3.0"
8
8
  authors = [{ name="James Kelly", email="mrkellyjam@gmail.com" }]
9
9
  description = "A high-performance vectorized circle packer with spatial hashing."
10
10
  readme = "README.md"
@@ -0,0 +1,338 @@
1
+ import numpy as np
2
+ from dataclasses import dataclass, field
3
+ from typing import List, Optional, Iterator, Tuple, Dict
4
+
5
+ # Type aliases
6
+ Polygon = np.ndarray
7
+ Point = np.ndarray
8
+ GridKey = Tuple[int, int]
9
+ Circle = Tuple[float, float, float]
10
+
11
+
12
+ @dataclass
13
+ class PackingConfig:
14
+ """Configuration parameters for the circle packing algorithm."""
15
+ padding: float = 1.5
16
+ min_radius: float = 1.0
17
+ grid_resolution_divisor: float = 25
18
+ max_failed_attempts: int = 200
19
+ mega_circle_threshold: float = 0.5
20
+ ray_cast_epsilon: float = 1e-10
21
+ sample_batch_size: int = 50
22
+ fixed_radius: Optional[float] = None
23
+ verbose: bool = False
24
+
25
+
26
+ @dataclass
27
+ class PackingProgress:
28
+ """Tracks the current state of the packing algorithm."""
29
+ circles_placed: int = 0
30
+ failed_attempts: int = 0
31
+ max_failed_attempts: int = 200
32
+
33
+ @property
34
+ def progress_ratio(self) -> float:
35
+ """How close we are to stopping (0.0 = just started, 1.0 = about to stop)."""
36
+ return self.failed_attempts / self.max_failed_attempts
37
+
38
+ def __str__(self) -> str:
39
+ return f"Placed: {self.circles_placed} | Failed attempts: {self.failed_attempts}/{self.max_failed_attempts} ({self.progress_ratio:.0%})"
40
+
41
+
42
+ class PolygonGeometry:
43
+ """Handles geometric calculations for polygon boundaries."""
44
+
45
+ def __init__(self, polygons: List[Polygon], epsilon: float = 1e-10):
46
+ self.polygons = [np.array(p, dtype=float) for p in polygons]
47
+ self.epsilon = epsilon
48
+ self._compute_bounds()
49
+ self._precompute_edges()
50
+
51
+ def _compute_bounds(self) -> None:
52
+ all_vertices = np.vstack(self.polygons)
53
+ self.min_coords = np.min(all_vertices, axis=0)
54
+ self.max_coords = np.max(all_vertices, axis=0)
55
+
56
+ def _precompute_edges(self) -> None:
57
+ """Precompute edge data for vectorized distance calculations."""
58
+ all_p1 = []
59
+ all_p2 = []
60
+ for poly in self.polygons:
61
+ n = len(poly)
62
+ for i in range(n):
63
+ all_p1.append(poly[i])
64
+ all_p2.append(poly[(i + 1) % n])
65
+
66
+ self.edge_starts = np.array(all_p1)
67
+ self.edge_ends = np.array(all_p2)
68
+ self.edge_vecs = self.edge_ends - self.edge_starts
69
+ self.edge_lengths_sq = np.sum(self.edge_vecs ** 2, axis=1)
70
+
71
+ def contains_points(self, points: np.ndarray) -> np.ndarray:
72
+ """Even-Odd Rule for interior detection, supports holes."""
73
+ x, y = points[:, 0], points[:, 1]
74
+ inside = np.zeros(len(points), dtype=bool)
75
+
76
+ for poly in self.polygons:
77
+ n = len(poly)
78
+ for i in range(n):
79
+ p1, p2 = poly[i], poly[(i + 1) % n]
80
+ crosses_edge = (p1[1] > y) != (p2[1] > y)
81
+ dy = p2[1] - p1[1] + self.epsilon
82
+ x_intercept = (p2[0] - p1[0]) * (y - p1[1]) / dy + p1[0]
83
+ inside ^= crosses_edge & (x < x_intercept)
84
+
85
+ return inside
86
+
87
+ def distance_to_boundary(self, point: Point) -> float:
88
+ """Vectorized distance to nearest polygon edge."""
89
+ to_point = point - self.edge_starts
90
+
91
+ dots = np.sum(to_point * self.edge_vecs, axis=1)
92
+
93
+ with np.errstate(divide='ignore', invalid='ignore'):
94
+ t = np.clip(dots / self.edge_lengths_sq, 0, 1)
95
+ t = np.where(self.edge_lengths_sq == 0, 0, t)
96
+
97
+ projections = self.edge_starts + t[:, np.newaxis] * self.edge_vecs
98
+ distances = np.linalg.norm(point - projections, axis=1)
99
+
100
+ return float(np.min(distances))
101
+
102
+ def distances_to_boundary_batch(self, points: np.ndarray) -> np.ndarray:
103
+ """
104
+ Vectorized distance calculation for multiple points at once.
105
+
106
+ Args:
107
+ points: Array of shape (n_points, 2)
108
+
109
+ Returns:
110
+ Array of shape (n_points,) with distance to nearest edge for each point
111
+ """
112
+ n_points = len(points)
113
+ n_edges = len(self.edge_starts)
114
+
115
+ # Reshape for broadcasting: (n_points, 1, 2) - (n_edges, 2) -> (n_points, n_edges, 2)
116
+ to_point = points[:, np.newaxis, :] - self.edge_starts[np.newaxis, :, :]
117
+
118
+ # Dot products: (n_points, n_edges)
119
+ dots = np.sum(to_point * self.edge_vecs[np.newaxis, :, :], axis=2)
120
+
121
+ # Project onto edges
122
+ with np.errstate(divide='ignore', invalid='ignore'):
123
+ t = np.clip(dots / self.edge_lengths_sq[np.newaxis, :], 0, 1)
124
+ t = np.where(self.edge_lengths_sq[np.newaxis, :] == 0, 0, t)
125
+
126
+ # Closest points on edges: (n_points, n_edges, 2)
127
+ projections = (
128
+ self.edge_starts[np.newaxis, :, :] +
129
+ t[:, :, np.newaxis] * self.edge_vecs[np.newaxis, :, :]
130
+ )
131
+
132
+ # Distances: (n_points, n_edges)
133
+ distances = np.linalg.norm(points[:, np.newaxis, :] - projections, axis=2)
134
+
135
+ # Min distance per point
136
+ return np.min(distances, axis=1)
137
+
138
+
139
+ @dataclass
140
+ class SpatialIndex:
141
+ """Grid-based spatial index for efficient collision detection."""
142
+ cell_size: float
143
+ origin: np.ndarray
144
+ mega_threshold: float
145
+ grid: Dict[GridKey, List[int]] = field(default_factory=dict)
146
+ mega_circles: List[int] = field(default_factory=list)
147
+
148
+ # Store centers/radii arrays for vectorized lookup
149
+ _centers: np.ndarray = field(default_factory=lambda: np.empty((0, 2)))
150
+ _radii: np.ndarray = field(default_factory=lambda: np.empty(0))
151
+
152
+ def add_circle(self, index: int, center: Point, radius: float) -> None:
153
+ # Update arrays
154
+ self._centers = np.vstack([self._centers, center]) if len(self._centers) > 0 else center.reshape(1, 2)
155
+ self._radii = np.append(self._radii, radius)
156
+
157
+ if radius > self.cell_size * self.mega_threshold:
158
+ self.mega_circles.append(index)
159
+ else:
160
+ key = self._get_cell_key(center)
161
+ self.grid.setdefault(key, []).append(index)
162
+
163
+ def get_nearby_indices(self, point: Point) -> Iterator[int]:
164
+ yield from self.mega_circles
165
+ center_key = self._get_cell_key(point)
166
+ for dx in range(-1, 2):
167
+ for dy in range(-1, 2):
168
+ neighbor_key = (center_key[0] + dx, center_key[1] + dy)
169
+ if neighbor_key in self.grid:
170
+ yield from self.grid[neighbor_key]
171
+
172
+ def get_nearby_indices_batch(self, points: np.ndarray) -> List[np.ndarray]:
173
+ """
174
+ Get nearby circle indices for multiple points.
175
+ Returns list of index arrays, one per point.
176
+ """
177
+ results = []
178
+ mega_set = set(self.mega_circles)
179
+
180
+ for point in points:
181
+ indices = list(self.mega_circles)
182
+ center_key = self._get_cell_key(point)
183
+ for dx in range(-1, 2):
184
+ for dy in range(-1, 2):
185
+ neighbor_key = (center_key[0] + dx, center_key[1] + dy)
186
+ if neighbor_key in self.grid:
187
+ for idx in self.grid[neighbor_key]:
188
+ if idx not in mega_set:
189
+ indices.append(idx)
190
+ results.append(np.array(indices, dtype=int))
191
+
192
+ return results
193
+
194
+ def distance_to_circles(self, point: Point) -> float:
195
+ """Get minimum distance from point to any existing circle's edge."""
196
+ if len(self._centers) == 0:
197
+ return float('inf')
198
+
199
+ indices = list(self.get_nearby_indices(point))
200
+ if not indices:
201
+ return float('inf')
202
+
203
+ centers = self._centers[indices]
204
+ radii = self._radii[indices]
205
+
206
+ distances = np.linalg.norm(centers - point, axis=1) - radii
207
+ return float(np.min(distances))
208
+
209
+ def _get_cell_key(self, point: Point) -> GridKey:
210
+ cell_coords = ((point - self.origin) // self.cell_size).astype(int)
211
+ return (int(cell_coords[0]), int(cell_coords[1]))
212
+
213
+
214
+ class CirclePacker:
215
+ """Packs circles within polygon boundaries using random sampling."""
216
+
217
+ def __init__(self, polygons: List[Polygon], config: Optional[PackingConfig] = None):
218
+ self.config = config or PackingConfig()
219
+ self.geometry = PolygonGeometry(polygons, self.config.ray_cast_epsilon)
220
+ self.centers: List[Point] = []
221
+ self.radii: List[float] = []
222
+ self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
223
+
224
+ extent = max(self.geometry.max_coords - self.geometry.min_coords)
225
+ cell_size = extent / self.config.grid_resolution_divisor
226
+ self.spatial_index = SpatialIndex(
227
+ cell_size=cell_size,
228
+ origin=self.geometry.min_coords,
229
+ mega_threshold=self.config.mega_circle_threshold
230
+ )
231
+
232
+ def _sample_candidate_points(self, count: int) -> np.ndarray:
233
+ points = np.random.uniform(
234
+ self.geometry.min_coords,
235
+ self.geometry.max_coords,
236
+ size=(count, 2)
237
+ )
238
+ return points[self.geometry.contains_points(points)]
239
+
240
+ def _compute_max_radius(self, point: Point) -> float:
241
+ """Compute max radius for a single point."""
242
+ max_radius = self.geometry.distance_to_boundary(point)
243
+ circle_dist = self.spatial_index.distance_to_circles(point)
244
+ max_radius = min(max_radius, circle_dist)
245
+ return max_radius - self.config.padding
246
+
247
+ def _compute_max_radii_batch(self, points: np.ndarray) -> np.ndarray:
248
+ """
249
+ Vectorized max radius computation for multiple points.
250
+ """
251
+ if len(points) == 0:
252
+ return np.array([])
253
+
254
+ # Boundary distances (fully vectorized)
255
+ max_radii = self.geometry.distances_to_boundary_batch(points)
256
+
257
+ # Circle collision distances (per-point, but with vectorized distance calc)
258
+ if len(self.centers) > 0:
259
+ centers_arr = np.array(self.centers)
260
+ radii_arr = np.array(self.radii)
261
+
262
+ for i, point in enumerate(points):
263
+ # Get nearby indices
264
+ indices = list(self.spatial_index.get_nearby_indices(point))
265
+ if indices:
266
+ nearby_centers = centers_arr[indices]
267
+ nearby_radii = radii_arr[indices]
268
+ distances = np.linalg.norm(nearby_centers - point, axis=1) - nearby_radii
269
+ max_radii[i] = min(max_radii[i], np.min(distances))
270
+
271
+ return max_radii - self.config.padding
272
+
273
+ def _find_best_placement(self, candidates: np.ndarray) -> Optional[Tuple[Point, float]]:
274
+ if len(candidates) == 0:
275
+ return None
276
+
277
+ # Batch compute all radii
278
+ radii = self._compute_max_radii_batch(candidates)
279
+ fixed = self.config.fixed_radius
280
+
281
+ if fixed is not None:
282
+ # For fixed radius, filter to valid positions and pick randomly
283
+ valid_mask = radii >= fixed
284
+ if not np.any(valid_mask):
285
+ return None
286
+ valid_indices = np.where(valid_mask)[0]
287
+ best_idx = valid_indices[0] # Take first valid (or could randomize)
288
+ return candidates[best_idx], fixed
289
+ else:
290
+ # Variable radius: pick the largest
291
+ best_idx = np.argmax(radii)
292
+ best_radius = radii[best_idx]
293
+
294
+ if best_radius >= self.config.min_radius:
295
+ return candidates[best_idx], best_radius
296
+ return None
297
+
298
+ def _place_circle(self, center: Point, radius: float) -> None:
299
+ idx = len(self.centers)
300
+ self.centers.append(center)
301
+ self.radii.append(radius)
302
+ self.spatial_index.add_circle(idx, center, radius)
303
+
304
+ def generate(self) -> Iterator[Circle]:
305
+ """
306
+ Generate circles until no more can be placed.
307
+
308
+ Yields:
309
+ Tuples of (x, y, radius) for each placed circle.
310
+ """
311
+ self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
312
+
313
+ while self.progress.failed_attempts < self.config.max_failed_attempts:
314
+ candidates = self._sample_candidate_points(self.config.sample_batch_size)
315
+ result = self._find_best_placement(candidates)
316
+
317
+ if result is not None:
318
+ center, radius = result
319
+ self._place_circle(center, radius)
320
+ self.progress.circles_placed += 1
321
+ self.progress.failed_attempts = 0
322
+
323
+ if self.config.verbose and self.progress.circles_placed % 25 == 0:
324
+ print(self.progress)
325
+
326
+ yield (float(center[0]), float(center[1]), float(radius))
327
+ else:
328
+ self.progress.failed_attempts += 1
329
+
330
+ if self.config.verbose and self.progress.failed_attempts % 50 == 0:
331
+ print(self.progress)
332
+
333
+ if self.config.verbose:
334
+ print(f"Done! {self.progress}")
335
+
336
+ def pack(self) -> List[Circle]:
337
+ """Pack circles and return them as a list."""
338
+ return list(self.generate())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: diskpack
3
- Version: 0.1.0
3
+ Version: 0.3.0
4
4
  Summary: A high-performance vectorized circle packer with spatial hashing.
5
5
  Author-email: James Kelly <mrkellyjam@gmail.com>
6
6
  Requires-Python: >=3.8
@@ -3,14 +3,13 @@ import numpy as np
3
3
  from diskpack.packer import CirclePacker, PackingConfig, PolygonGeometry
4
4
 
5
5
 
6
-
7
6
  class TestCirclePacker(unittest.TestCase):
8
7
  def setUp(self):
9
8
  """Set up a simple 10x10 square for testing."""
10
9
  self.square_verts = np.array([[0, 0], [10, 0], [10, 10], [0, 10]])
11
10
  self.square = [self.square_verts]
12
11
  # Use small padding/min_radius to allow for dense filling
13
- self.config = PackingConfig(padding=0.1, min_radius=0.5, patience_before_stop=100)
12
+ self.config = PackingConfig(padding=0.1, min_radius=0.5, max_failed_attempts=100)
14
13
 
15
14
  def _calculate_poly_area(self, segments: list) -> float:
16
15
  """Calculates area of polygons (including holes) using the Shoelace Formula."""
@@ -18,20 +17,15 @@ class TestCirclePacker(unittest.TestCase):
18
17
  for poly in segments:
19
18
  x = poly[:, 0]
20
19
  y = poly[:, 1]
21
- # Area = 0.5 * |sum(x_i * y_{i+1} - x_{i+1} * y_i)|
22
20
  area = 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
23
- # If it's a hole (clockwise), it naturally subtracts if we don't use abs here,
24
- # but since our parser handles holes via Even-Odd, we treat all as positive
25
- # and subtract holes manually if needed. For this test, we assume shells.
26
21
  total_area += area
27
22
  return total_area
28
23
 
29
24
  def test_filling_density(self):
30
25
  """Verify that the packer fills a minimum percentage of the polygon area."""
31
26
  packer = CirclePacker(self.square, self.config)
32
- circles = list(packer.generate())
27
+ circles = packer.pack()
33
28
 
34
- # Calculate areas
35
29
  poly_area = self._calculate_poly_area(self.square)
36
30
  circle_area = sum(np.pi * (r**2) for _, _, r in circles)
37
31
 
@@ -41,8 +35,6 @@ class TestCirclePacker(unittest.TestCase):
41
35
  print(f"Total Area: {poly_area:.2f} | Circle Area: {circle_area:.2f}")
42
36
  print(f"Packing Density: {fill_percentage:.2f}%")
43
37
 
44
- # Assert a minimum density threshold.
45
- # For a random packer, 30-50% is a reasonable 'success' floor depending on radius.
46
38
  self.assertGreater(fill_percentage, 25.0, f"Packing density too low: {fill_percentage:.2f}%")
47
39
 
48
40
  def test_geometry_containment(self):
@@ -61,15 +53,16 @@ class TestCirclePacker(unittest.TestCase):
61
53
  def test_no_overlap_integrity(self):
62
54
  """Mathematically verify no circles overlap including padding."""
63
55
  packer = CirclePacker(self.square, self.config)
64
- circles = list(packer.generate())
56
+ circles = packer.pack()
65
57
 
66
58
  for i, (x1, y1, r1) in enumerate(circles):
67
59
  for j, (x2, y2, r2) in enumerate(circles):
68
- if i == j: continue
60
+ if i == j:
61
+ continue
69
62
  dist = np.sqrt((x1 - x2)**2 + (y1 - y2)**2)
70
- # Distance must be >= sum of radii + padding
71
63
  min_sep = r1 + r2 + self.config.padding
72
64
  self.assertGreaterEqual(dist, min_sep - 1e-9)
73
65
 
66
+
74
67
  if __name__ == '__main__':
75
68
  unittest.main()
@@ -1,208 +0,0 @@
1
- import numpy as np
2
- from dataclasses import dataclass, field
3
- from typing import List, Optional, Iterator, Tuple, Dict
4
-
5
- # Type aliases
6
- Polygon = np.ndarray
7
- Point = np.ndarray
8
- GridKey = Tuple[int, int]
9
- Circle = Tuple[float, float, float]
10
-
11
-
12
- @dataclass
13
- class PackingConfig:
14
- """Configuration parameters for the circle packing algorithm."""
15
- padding: float = 1.5
16
- min_radius: float = 1.0
17
- grid_resolution_divisor: float = 25
18
- max_failed_attempts: int = 200
19
- mega_circle_threshold: float = 0.5
20
- ray_cast_epsilon: float = 1e-10
21
- sample_batch_size: int = 50
22
-
23
-
24
- @dataclass
25
- class PackingProgress:
26
- """Tracks the current state of the packing algorithm."""
27
- circles_placed: int = 0
28
- failed_attempts: int = 0
29
- max_failed_attempts: int = 200
30
-
31
- @property
32
- def progress_ratio(self) -> float:
33
- """How close we are to stopping (0.0 = just started, 1.0 = about to stop)."""
34
- return self.failed_attempts / self.max_failed_attempts
35
-
36
- def __str__(self) -> str:
37
- return f"Placed: {self.circles_placed} | Failed attempts: {self.failed_attempts}/{self.max_failed_attempts} ({self.progress_ratio:.0%})"
38
-
39
-
40
- class PolygonGeometry:
41
- """Handles geometric calculations for polygon boundaries."""
42
-
43
- def __init__(self, polygons: List[Polygon], epsilon: float = 1e-10):
44
- self.polygons = [np.array(p, dtype=float) for p in polygons]
45
- self.epsilon = epsilon
46
- self._compute_bounds()
47
-
48
- def _compute_bounds(self) -> None:
49
- all_vertices = np.vstack(self.polygons)
50
- self.min_coords = np.min(all_vertices, axis=0)
51
- self.max_coords = np.max(all_vertices, axis=0)
52
-
53
- def contains_points(self, points: np.ndarray) -> np.ndarray:
54
- """Even-Odd Rule for interior detection, supports holes."""
55
- x, y = points[:, 0], points[:, 1]
56
- inside = np.zeros(len(points), dtype=bool)
57
-
58
- for poly in self.polygons:
59
- n = len(poly)
60
- for i in range(n):
61
- p1, p2 = poly[i], poly[(i + 1) % n]
62
- crosses_edge = (p1[1] > y) != (p2[1] > y)
63
- dy = p2[1] - p1[1] + self.epsilon
64
- x_intercept = (p2[0] - p1[0]) * (y - p1[1]) / dy + p1[0]
65
- inside ^= crosses_edge & (x < x_intercept)
66
-
67
- return inside
68
-
69
- def distance_to_boundary(self, point: Point) -> float:
70
- min_distance = float('inf')
71
- for poly in self.polygons:
72
- for i in range(len(poly)):
73
- p1, p2 = poly[i], poly[(i + 1) % len(poly)]
74
- min_distance = min(min_distance, self._point_to_segment_distance(point, p1, p2))
75
- return min_distance
76
-
77
- @staticmethod
78
- def _point_to_segment_distance(point: Point, seg_start: Point, seg_end: Point) -> float:
79
- segment_vec = seg_end - seg_start
80
- segment_length_sq = np.sum(segment_vec ** 2)
81
- if segment_length_sq == 0:
82
- return np.linalg.norm(point - seg_start)
83
- t = np.clip(np.dot(point - seg_start, segment_vec) / segment_length_sq, 0, 1)
84
- projection = seg_start + t * segment_vec
85
- return np.linalg.norm(point - projection)
86
-
87
-
88
- @dataclass
89
- class SpatialIndex:
90
- """Grid-based spatial index for efficient collision detection."""
91
- cell_size: float
92
- origin: np.ndarray
93
- mega_threshold: float
94
- grid: Dict[GridKey, List[int]] = field(default_factory=dict)
95
- mega_circles: List[int] = field(default_factory=list)
96
-
97
- def add_circle(self, index: int, center: Point, radius: float) -> None:
98
- if radius > self.cell_size * self.mega_threshold:
99
- self.mega_circles.append(index)
100
- else:
101
- key = self._get_cell_key(center)
102
- self.grid.setdefault(key, []).append(index)
103
-
104
- def get_nearby_indices(self, point: Point) -> Iterator[int]:
105
- yield from self.mega_circles
106
- center_key = self._get_cell_key(point)
107
- for dx in range(-1, 2):
108
- for dy in range(-1, 2):
109
- neighbor_key = (center_key[0] + dx, center_key[1] + dy)
110
- if neighbor_key in self.grid:
111
- yield from self.grid[neighbor_key]
112
-
113
- def _get_cell_key(self, point: Point) -> GridKey:
114
- cell_coords = ((point - self.origin) // self.cell_size).astype(int)
115
- return (int(cell_coords[0]), int(cell_coords[1]))
116
-
117
-
118
- class CirclePacker:
119
- """Packs circles within polygon boundaries using random sampling."""
120
-
121
- def __init__(self, polygons: List[Polygon], config: Optional[PackingConfig] = None):
122
- self.config = config or PackingConfig()
123
- self.geometry = PolygonGeometry(polygons, self.config.ray_cast_epsilon)
124
- self.centers: List[Point] = []
125
- self.radii: List[float] = []
126
- self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
127
-
128
- extent = max(self.geometry.max_coords - self.geometry.min_coords)
129
- cell_size = extent / self.config.grid_resolution_divisor
130
- self.spatial_index = SpatialIndex(
131
- cell_size=cell_size,
132
- origin=self.geometry.min_coords,
133
- mega_threshold=self.config.mega_circle_threshold
134
- )
135
-
136
- def _sample_candidate_points(self, count: int) -> np.ndarray:
137
- points = np.random.uniform(
138
- self.geometry.min_coords,
139
- self.geometry.max_coords,
140
- size=(count, 2)
141
- )
142
- return points[self.geometry.contains_points(points)]
143
-
144
- def _compute_max_radius(self, point: Point) -> float:
145
- max_radius = self.geometry.distance_to_boundary(point)
146
- for idx in self.spatial_index.get_nearby_indices(point):
147
- distance_to_circle = np.linalg.norm(self.centers[idx] - point) - self.radii[idx]
148
- max_radius = min(max_radius, distance_to_circle)
149
- return max_radius - self.config.padding
150
-
151
- def _find_best_placement(self, candidates: np.ndarray, fixed_radius: Optional[float]) -> Optional[Tuple[Point, float]]:
152
- best_point, best_radius = None, 0
153
- for point in candidates:
154
- radius = self._compute_max_radius(point)
155
- if fixed_radius is not None:
156
- radius = fixed_radius if radius >= fixed_radius else -1
157
- if radius > best_radius:
158
- best_point, best_radius = point, radius
159
-
160
- if best_point is not None and best_radius >= self.config.min_radius:
161
- return best_point, best_radius
162
- return None
163
-
164
- def _place_circle(self, center: Point, radius: float) -> None:
165
- idx = len(self.centers)
166
- self.centers.append(center)
167
- self.radii.append(radius)
168
- self.spatial_index.add_circle(idx, center, radius)
169
-
170
- def generate(self, fixed_radius: Optional[float] = None, verbose: bool = False) -> Iterator[Circle]:
171
- """
172
- Generate circles until no more can be placed.
173
-
174
- Args:
175
- fixed_radius: If provided, all circles will have this exact radius.
176
- verbose: If True, print progress updates periodically.
177
-
178
- Yields:
179
- Tuples of (x, y, radius) for each placed circle.
180
- """
181
- self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
182
-
183
- while self.progress.failed_attempts < self.config.max_failed_attempts:
184
- candidates = self._sample_candidate_points(self.config.sample_batch_size)
185
- result = self._find_best_placement(candidates, fixed_radius)
186
-
187
- if result is not None:
188
- center, radius = result
189
- self._place_circle(center, radius)
190
- self.progress.circles_placed += 1
191
- self.progress.failed_attempts = 0
192
-
193
- if verbose and self.progress.circles_placed % 25 == 0:
194
- print(self.progress)
195
-
196
- yield (float(center[0]), float(center[1]), float(radius))
197
- else:
198
- self.progress.failed_attempts += 1
199
-
200
- if verbose and self.progress.failed_attempts % 50 == 0:
201
- print(self.progress)
202
-
203
- if verbose:
204
- print(f"Done! {self.progress}")
205
-
206
- def pack(self, fixed_radius: Optional[float] = None, verbose: bool = False) -> List[Circle]:
207
- """Pack circles and return them as a list."""
208
- return list(self.generate(fixed_radius, verbose=verbose))
File without changes
File without changes
File without changes