diskpack 0.3.0__tar.gz → 0.4.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.3.0
3
+ Version: 0.4.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.3.0"
7
+ version = "0.4.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"
@@ -102,37 +102,24 @@ class PolygonGeometry:
102
102
  def distances_to_boundary_batch(self, points: np.ndarray) -> np.ndarray:
103
103
  """
104
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
105
  """
112
106
  n_points = len(points)
113
107
  n_edges = len(self.edge_starts)
114
108
 
115
- # Reshape for broadcasting: (n_points, 1, 2) - (n_edges, 2) -> (n_points, n_edges, 2)
116
109
  to_point = points[:, np.newaxis, :] - self.edge_starts[np.newaxis, :, :]
117
-
118
- # Dot products: (n_points, n_edges)
119
110
  dots = np.sum(to_point * self.edge_vecs[np.newaxis, :, :], axis=2)
120
111
 
121
- # Project onto edges
122
112
  with np.errstate(divide='ignore', invalid='ignore'):
123
113
  t = np.clip(dots / self.edge_lengths_sq[np.newaxis, :], 0, 1)
124
114
  t = np.where(self.edge_lengths_sq[np.newaxis, :] == 0, 0, t)
125
115
 
126
- # Closest points on edges: (n_points, n_edges, 2)
127
116
  projections = (
128
- self.edge_starts[np.newaxis, :, :] +
117
+ self.edge_starts[np.newaxis, :, :] +
129
118
  t[:, :, np.newaxis] * self.edge_vecs[np.newaxis, :, :]
130
119
  )
131
120
 
132
- # Distances: (n_points, n_edges)
133
121
  distances = np.linalg.norm(points[:, np.newaxis, :] - projections, axis=2)
134
122
 
135
- # Min distance per point
136
123
  return np.min(distances, axis=1)
137
124
 
138
125
 
@@ -144,16 +131,14 @@ class SpatialIndex:
144
131
  mega_threshold: float
145
132
  grid: Dict[GridKey, List[int]] = field(default_factory=dict)
146
133
  mega_circles: List[int] = field(default_factory=list)
147
-
148
- # Store centers/radii arrays for vectorized lookup
134
+
149
135
  _centers: np.ndarray = field(default_factory=lambda: np.empty((0, 2)))
150
136
  _radii: np.ndarray = field(default_factory=lambda: np.empty(0))
151
137
 
152
138
  def add_circle(self, index: int, center: Point, radius: float) -> None:
153
- # Update arrays
154
139
  self._centers = np.vstack([self._centers, center]) if len(self._centers) > 0 else center.reshape(1, 2)
155
140
  self._radii = np.append(self._radii, radius)
156
-
141
+
157
142
  if radius > self.cell_size * self.mega_threshold:
158
143
  self.mega_circles.append(index)
159
144
  else:
@@ -169,40 +154,18 @@ class SpatialIndex:
169
154
  if neighbor_key in self.grid:
170
155
  yield from self.grid[neighbor_key]
171
156
 
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
157
  def distance_to_circles(self, point: Point) -> float:
195
158
  """Get minimum distance from point to any existing circle's edge."""
196
159
  if len(self._centers) == 0:
197
160
  return float('inf')
198
-
161
+
199
162
  indices = list(self.get_nearby_indices(point))
200
163
  if not indices:
201
164
  return float('inf')
202
-
165
+
203
166
  centers = self._centers[indices]
204
167
  radii = self._radii[indices]
205
-
168
+
206
169
  distances = np.linalg.norm(centers - point, axis=1) - radii
207
170
  return float(np.min(distances))
208
171
 
@@ -245,22 +208,17 @@ class CirclePacker:
245
208
  return max_radius - self.config.padding
246
209
 
247
210
  def _compute_max_radii_batch(self, points: np.ndarray) -> np.ndarray:
248
- """
249
- Vectorized max radius computation for multiple points.
250
- """
211
+ """Vectorized max radius computation for multiple points."""
251
212
  if len(points) == 0:
252
213
  return np.array([])
253
214
 
254
- # Boundary distances (fully vectorized)
255
215
  max_radii = self.geometry.distances_to_boundary_batch(points)
256
216
 
257
- # Circle collision distances (per-point, but with vectorized distance calc)
258
217
  if len(self.centers) > 0:
259
218
  centers_arr = np.array(self.centers)
260
219
  radii_arr = np.array(self.radii)
261
-
220
+
262
221
  for i, point in enumerate(points):
263
- # Get nearby indices
264
222
  indices = list(self.spatial_index.get_nearby_indices(point))
265
223
  if indices:
266
224
  nearby_centers = centers_arr[indices]
@@ -274,23 +232,20 @@ class CirclePacker:
274
232
  if len(candidates) == 0:
275
233
  return None
276
234
 
277
- # Batch compute all radii
278
235
  radii = self._compute_max_radii_batch(candidates)
279
236
  fixed = self.config.fixed_radius
280
237
 
281
238
  if fixed is not None:
282
- # For fixed radius, filter to valid positions and pick randomly
283
239
  valid_mask = radii >= fixed
284
240
  if not np.any(valid_mask):
285
241
  return None
286
242
  valid_indices = np.where(valid_mask)[0]
287
- best_idx = valid_indices[0] # Take first valid (or could randomize)
243
+ best_idx = valid_indices[0]
288
244
  return candidates[best_idx], fixed
289
245
  else:
290
- # Variable radius: pick the largest
291
246
  best_idx = np.argmax(radii)
292
247
  best_radius = radii[best_idx]
293
-
248
+
294
249
  if best_radius >= self.config.min_radius:
295
250
  return candidates[best_idx], best_radius
296
251
  return None
@@ -301,13 +256,95 @@ class CirclePacker:
301
256
  self.radii.append(radius)
302
257
  self.spatial_index.add_circle(idx, center, radius)
303
258
 
259
+ def _generate_hex_grid(self, radius: float) -> np.ndarray:
260
+ """
261
+ Generate a hexagonal grid of points within the bounding box.
262
+ Hex grid is the optimal packing arrangement for equal circles.
263
+ """
264
+ spacing = (radius + self.config.padding) * 2
265
+ dy = spacing * np.sqrt(3) / 2
266
+
267
+ min_x, min_y = self.geometry.min_coords
268
+ max_x, max_y = self.geometry.max_coords
269
+
270
+ # Add margin to ensure coverage
271
+ min_x -= spacing
272
+ min_y -= spacing
273
+ max_x += spacing
274
+ max_y += spacing
275
+
276
+ points = []
277
+ row = 0
278
+ y = min_y
279
+
280
+ while y <= max_y:
281
+ # Offset every other row by half spacing
282
+ x_offset = (spacing / 2) if row % 2 else 0
283
+ x = min_x + x_offset
284
+
285
+ while x <= max_x:
286
+ points.append([x, y])
287
+ x += spacing
288
+
289
+ y += dy
290
+ row += 1
291
+
292
+ return np.array(points) if points else np.empty((0, 2))
293
+
294
+ def _pack_hex_grid(self) -> List[Circle]:
295
+ """
296
+ Pack circles using hexagonal grid placement.
297
+ Much faster and denser than random sampling for fixed radius.
298
+ """
299
+ radius = self.config.fixed_radius
300
+ circles = []
301
+
302
+ # Generate hex grid
303
+ grid_points = self._generate_hex_grid(radius)
304
+
305
+ if len(grid_points) == 0:
306
+ return circles
307
+
308
+ # Filter to points inside polygon
309
+ inside_mask = self.geometry.contains_points(grid_points)
310
+ interior_points = grid_points[inside_mask]
311
+
312
+ # Filter to points with enough clearance from boundary
313
+ min_clearance = radius + self.config.padding
314
+ boundary_distances = self.geometry.distances_to_boundary_batch(interior_points)
315
+ valid_mask = boundary_distances >= min_clearance
316
+
317
+ valid_points = interior_points[valid_mask]
318
+
319
+ if self.config.verbose:
320
+ print(f"Hex grid: {len(grid_points)} total -> {len(interior_points)} inside -> {len(valid_points)} valid")
321
+
322
+ # All valid points become circles (no collision check needed - hex grid guarantees no overlap)
323
+ for point in valid_points:
324
+ self._place_circle(point, radius)
325
+ circles.append((float(point[0]), float(point[1]), float(radius)))
326
+
327
+ if self.config.verbose:
328
+ print(f"Done! Placed {len(circles)} circles")
329
+
330
+ return circles
331
+
304
332
  def generate(self) -> Iterator[Circle]:
305
333
  """
306
334
  Generate circles until no more can be placed.
307
335
 
336
+ For fixed_radius mode, uses optimized hex grid placement.
337
+ For variable radius mode, uses random sampling with best-fit selection.
338
+
308
339
  Yields:
309
340
  Tuples of (x, y, radius) for each placed circle.
310
341
  """
342
+ # Use hex grid for fixed radius - much faster and denser
343
+ if self.config.fixed_radius is not None:
344
+ yield from self._pack_hex_grid()
345
+ return
346
+
347
+ # Variable radius mode - use random sampling
311
348
  self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
312
349
 
313
350
  while self.progress.failed_attempts < self.config.max_failed_attempts:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: diskpack
3
- Version: 0.3.0
3
+ Version: 0.4.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
File without changes
File without changes
File without changes
File without changes