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