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.
- {diskpack-0.3.0/src/diskpack.egg-info → diskpack-0.4.0}/PKG-INFO +1 -1
- {diskpack-0.3.0 → diskpack-0.4.0}/pyproject.toml +1 -1
- {diskpack-0.3.0 → diskpack-0.4.0}/src/diskpack/packer.py +92 -55
- {diskpack-0.3.0 → diskpack-0.4.0/src/diskpack.egg-info}/PKG-INFO +1 -1
- {diskpack-0.3.0 → diskpack-0.4.0}/LICENSE +0 -0
- {diskpack-0.3.0 → diskpack-0.4.0}/README.md +0 -0
- {diskpack-0.3.0 → diskpack-0.4.0}/setup.cfg +0 -0
- {diskpack-0.3.0 → diskpack-0.4.0}/src/diskpack/__init__.py +0 -0
- {diskpack-0.3.0 → diskpack-0.4.0}/src/diskpack.egg-info/SOURCES.txt +0 -0
- {diskpack-0.3.0 → diskpack-0.4.0}/src/diskpack.egg-info/dependency_links.txt +0 -0
- {diskpack-0.3.0 → diskpack-0.4.0}/src/diskpack.egg-info/requires.txt +0 -0
- {diskpack-0.3.0 → diskpack-0.4.0}/src/diskpack.egg-info/top_level.txt +0 -0
- {diskpack-0.3.0 → diskpack-0.4.0}/tests/tests.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "diskpack"
|
|
7
|
-
version = "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]
|
|
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:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|