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.
- {diskpack-0.3.0/src/diskpack.egg-info → diskpack-0.5.0}/PKG-INFO +1 -1
- {diskpack-0.3.0 → diskpack-0.5.0}/pyproject.toml +1 -1
- {diskpack-0.3.0 → diskpack-0.5.0}/src/diskpack/packer.py +105 -59
- {diskpack-0.3.0 → diskpack-0.5.0/src/diskpack.egg-info}/PKG-INFO +1 -1
- {diskpack-0.3.0 → diskpack-0.5.0}/LICENSE +0 -0
- {diskpack-0.3.0 → diskpack-0.5.0}/README.md +0 -0
- {diskpack-0.3.0 → diskpack-0.5.0}/setup.cfg +0 -0
- {diskpack-0.3.0 → diskpack-0.5.0}/src/diskpack/__init__.py +0 -0
- {diskpack-0.3.0 → diskpack-0.5.0}/src/diskpack.egg-info/SOURCES.txt +0 -0
- {diskpack-0.3.0 → diskpack-0.5.0}/src/diskpack.egg-info/dependency_links.txt +0 -0
- {diskpack-0.3.0 → diskpack-0.5.0}/src/diskpack.egg-info/requires.txt +0 -0
- {diskpack-0.3.0 → diskpack-0.5.0}/src/diskpack.egg-info/top_level.txt +0 -0
- {diskpack-0.3.0 → diskpack-0.5.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.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]
|
|
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
|
|
260
|
+
def _generate_hex_grid(self, radius: float) -> np.ndarray:
|
|
305
261
|
"""
|
|
306
|
-
Generate
|
|
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
|
-
|
|
309
|
-
|
|
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())
|
|
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
|