diskpack 0.2.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.2.0/src/diskpack.egg-info → diskpack-0.4.0}/PKG-INFO +1 -1
- {diskpack-0.2.0 → diskpack-0.4.0}/pyproject.toml +1 -1
- diskpack-0.4.0/src/diskpack/packer.py +375 -0
- {diskpack-0.2.0 → diskpack-0.4.0/src/diskpack.egg-info}/PKG-INFO +1 -1
- diskpack-0.2.0/src/diskpack/packer.py +0 -208
- {diskpack-0.2.0 → diskpack-0.4.0}/LICENSE +0 -0
- {diskpack-0.2.0 → diskpack-0.4.0}/README.md +0 -0
- {diskpack-0.2.0 → diskpack-0.4.0}/setup.cfg +0 -0
- {diskpack-0.2.0 → diskpack-0.4.0}/src/diskpack/__init__.py +0 -0
- {diskpack-0.2.0 → diskpack-0.4.0}/src/diskpack.egg-info/SOURCES.txt +0 -0
- {diskpack-0.2.0 → diskpack-0.4.0}/src/diskpack.egg-info/dependency_links.txt +0 -0
- {diskpack-0.2.0 → diskpack-0.4.0}/src/diskpack.egg-info/requires.txt +0 -0
- {diskpack-0.2.0 → diskpack-0.4.0}/src/diskpack.egg-info/top_level.txt +0 -0
- {diskpack-0.2.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"
|
|
@@ -0,0 +1,375 @@
|
|
|
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
|
+
n_points = len(points)
|
|
107
|
+
n_edges = len(self.edge_starts)
|
|
108
|
+
|
|
109
|
+
to_point = points[:, np.newaxis, :] - self.edge_starts[np.newaxis, :, :]
|
|
110
|
+
dots = np.sum(to_point * self.edge_vecs[np.newaxis, :, :], axis=2)
|
|
111
|
+
|
|
112
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
113
|
+
t = np.clip(dots / self.edge_lengths_sq[np.newaxis, :], 0, 1)
|
|
114
|
+
t = np.where(self.edge_lengths_sq[np.newaxis, :] == 0, 0, t)
|
|
115
|
+
|
|
116
|
+
projections = (
|
|
117
|
+
self.edge_starts[np.newaxis, :, :] +
|
|
118
|
+
t[:, :, np.newaxis] * self.edge_vecs[np.newaxis, :, :]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
distances = np.linalg.norm(points[:, np.newaxis, :] - projections, axis=2)
|
|
122
|
+
|
|
123
|
+
return np.min(distances, axis=1)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class SpatialIndex:
|
|
128
|
+
"""Grid-based spatial index for efficient collision detection."""
|
|
129
|
+
cell_size: float
|
|
130
|
+
origin: np.ndarray
|
|
131
|
+
mega_threshold: float
|
|
132
|
+
grid: Dict[GridKey, List[int]] = field(default_factory=dict)
|
|
133
|
+
mega_circles: List[int] = field(default_factory=list)
|
|
134
|
+
|
|
135
|
+
_centers: np.ndarray = field(default_factory=lambda: np.empty((0, 2)))
|
|
136
|
+
_radii: np.ndarray = field(default_factory=lambda: np.empty(0))
|
|
137
|
+
|
|
138
|
+
def add_circle(self, index: int, center: Point, radius: float) -> None:
|
|
139
|
+
self._centers = np.vstack([self._centers, center]) if len(self._centers) > 0 else center.reshape(1, 2)
|
|
140
|
+
self._radii = np.append(self._radii, radius)
|
|
141
|
+
|
|
142
|
+
if radius > self.cell_size * self.mega_threshold:
|
|
143
|
+
self.mega_circles.append(index)
|
|
144
|
+
else:
|
|
145
|
+
key = self._get_cell_key(center)
|
|
146
|
+
self.grid.setdefault(key, []).append(index)
|
|
147
|
+
|
|
148
|
+
def get_nearby_indices(self, point: Point) -> Iterator[int]:
|
|
149
|
+
yield from self.mega_circles
|
|
150
|
+
center_key = self._get_cell_key(point)
|
|
151
|
+
for dx in range(-1, 2):
|
|
152
|
+
for dy in range(-1, 2):
|
|
153
|
+
neighbor_key = (center_key[0] + dx, center_key[1] + dy)
|
|
154
|
+
if neighbor_key in self.grid:
|
|
155
|
+
yield from self.grid[neighbor_key]
|
|
156
|
+
|
|
157
|
+
def distance_to_circles(self, point: Point) -> float:
|
|
158
|
+
"""Get minimum distance from point to any existing circle's edge."""
|
|
159
|
+
if len(self._centers) == 0:
|
|
160
|
+
return float('inf')
|
|
161
|
+
|
|
162
|
+
indices = list(self.get_nearby_indices(point))
|
|
163
|
+
if not indices:
|
|
164
|
+
return float('inf')
|
|
165
|
+
|
|
166
|
+
centers = self._centers[indices]
|
|
167
|
+
radii = self._radii[indices]
|
|
168
|
+
|
|
169
|
+
distances = np.linalg.norm(centers - point, axis=1) - radii
|
|
170
|
+
return float(np.min(distances))
|
|
171
|
+
|
|
172
|
+
def _get_cell_key(self, point: Point) -> GridKey:
|
|
173
|
+
cell_coords = ((point - self.origin) // self.cell_size).astype(int)
|
|
174
|
+
return (int(cell_coords[0]), int(cell_coords[1]))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CirclePacker:
|
|
178
|
+
"""Packs circles within polygon boundaries using random sampling."""
|
|
179
|
+
|
|
180
|
+
def __init__(self, polygons: List[Polygon], config: Optional[PackingConfig] = None):
|
|
181
|
+
self.config = config or PackingConfig()
|
|
182
|
+
self.geometry = PolygonGeometry(polygons, self.config.ray_cast_epsilon)
|
|
183
|
+
self.centers: List[Point] = []
|
|
184
|
+
self.radii: List[float] = []
|
|
185
|
+
self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
|
|
186
|
+
|
|
187
|
+
extent = max(self.geometry.max_coords - self.geometry.min_coords)
|
|
188
|
+
cell_size = extent / self.config.grid_resolution_divisor
|
|
189
|
+
self.spatial_index = SpatialIndex(
|
|
190
|
+
cell_size=cell_size,
|
|
191
|
+
origin=self.geometry.min_coords,
|
|
192
|
+
mega_threshold=self.config.mega_circle_threshold
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _sample_candidate_points(self, count: int) -> np.ndarray:
|
|
196
|
+
points = np.random.uniform(
|
|
197
|
+
self.geometry.min_coords,
|
|
198
|
+
self.geometry.max_coords,
|
|
199
|
+
size=(count, 2)
|
|
200
|
+
)
|
|
201
|
+
return points[self.geometry.contains_points(points)]
|
|
202
|
+
|
|
203
|
+
def _compute_max_radius(self, point: Point) -> float:
|
|
204
|
+
"""Compute max radius for a single point."""
|
|
205
|
+
max_radius = self.geometry.distance_to_boundary(point)
|
|
206
|
+
circle_dist = self.spatial_index.distance_to_circles(point)
|
|
207
|
+
max_radius = min(max_radius, circle_dist)
|
|
208
|
+
return max_radius - self.config.padding
|
|
209
|
+
|
|
210
|
+
def _compute_max_radii_batch(self, points: np.ndarray) -> np.ndarray:
|
|
211
|
+
"""Vectorized max radius computation for multiple points."""
|
|
212
|
+
if len(points) == 0:
|
|
213
|
+
return np.array([])
|
|
214
|
+
|
|
215
|
+
max_radii = self.geometry.distances_to_boundary_batch(points)
|
|
216
|
+
|
|
217
|
+
if len(self.centers) > 0:
|
|
218
|
+
centers_arr = np.array(self.centers)
|
|
219
|
+
radii_arr = np.array(self.radii)
|
|
220
|
+
|
|
221
|
+
for i, point in enumerate(points):
|
|
222
|
+
indices = list(self.spatial_index.get_nearby_indices(point))
|
|
223
|
+
if indices:
|
|
224
|
+
nearby_centers = centers_arr[indices]
|
|
225
|
+
nearby_radii = radii_arr[indices]
|
|
226
|
+
distances = np.linalg.norm(nearby_centers - point, axis=1) - nearby_radii
|
|
227
|
+
max_radii[i] = min(max_radii[i], np.min(distances))
|
|
228
|
+
|
|
229
|
+
return max_radii - self.config.padding
|
|
230
|
+
|
|
231
|
+
def _find_best_placement(self, candidates: np.ndarray) -> Optional[Tuple[Point, float]]:
|
|
232
|
+
if len(candidates) == 0:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
radii = self._compute_max_radii_batch(candidates)
|
|
236
|
+
fixed = self.config.fixed_radius
|
|
237
|
+
|
|
238
|
+
if fixed is not None:
|
|
239
|
+
valid_mask = radii >= fixed
|
|
240
|
+
if not np.any(valid_mask):
|
|
241
|
+
return None
|
|
242
|
+
valid_indices = np.where(valid_mask)[0]
|
|
243
|
+
best_idx = valid_indices[0]
|
|
244
|
+
return candidates[best_idx], fixed
|
|
245
|
+
else:
|
|
246
|
+
best_idx = np.argmax(radii)
|
|
247
|
+
best_radius = radii[best_idx]
|
|
248
|
+
|
|
249
|
+
if best_radius >= self.config.min_radius:
|
|
250
|
+
return candidates[best_idx], best_radius
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
def _place_circle(self, center: Point, radius: float) -> None:
|
|
254
|
+
idx = len(self.centers)
|
|
255
|
+
self.centers.append(center)
|
|
256
|
+
self.radii.append(radius)
|
|
257
|
+
self.spatial_index.add_circle(idx, center, radius)
|
|
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
|
+
|
|
332
|
+
def generate(self) -> Iterator[Circle]:
|
|
333
|
+
"""
|
|
334
|
+
Generate circles until no more can be placed.
|
|
335
|
+
|
|
336
|
+
For fixed_radius mode, uses optimized hex grid placement.
|
|
337
|
+
For variable radius mode, uses random sampling with best-fit selection.
|
|
338
|
+
|
|
339
|
+
Yields:
|
|
340
|
+
Tuples of (x, y, radius) for each placed circle.
|
|
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
|
|
348
|
+
self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
|
|
349
|
+
|
|
350
|
+
while self.progress.failed_attempts < self.config.max_failed_attempts:
|
|
351
|
+
candidates = self._sample_candidate_points(self.config.sample_batch_size)
|
|
352
|
+
result = self._find_best_placement(candidates)
|
|
353
|
+
|
|
354
|
+
if result is not None:
|
|
355
|
+
center, radius = result
|
|
356
|
+
self._place_circle(center, radius)
|
|
357
|
+
self.progress.circles_placed += 1
|
|
358
|
+
self.progress.failed_attempts = 0
|
|
359
|
+
|
|
360
|
+
if self.config.verbose and self.progress.circles_placed % 25 == 0:
|
|
361
|
+
print(self.progress)
|
|
362
|
+
|
|
363
|
+
yield (float(center[0]), float(center[1]), float(radius))
|
|
364
|
+
else:
|
|
365
|
+
self.progress.failed_attempts += 1
|
|
366
|
+
|
|
367
|
+
if self.config.verbose and self.progress.failed_attempts % 50 == 0:
|
|
368
|
+
print(self.progress)
|
|
369
|
+
|
|
370
|
+
if self.config.verbose:
|
|
371
|
+
print(f"Done! {self.progress}")
|
|
372
|
+
|
|
373
|
+
def pack(self) -> List[Circle]:
|
|
374
|
+
"""Pack circles and return them as a list."""
|
|
375
|
+
return list(self.generate())
|
|
@@ -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
|
-
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
|
-
|
|
50
|
-
def _compute_bounds(self) -> None:
|
|
51
|
-
all_vertices = np.vstack(self.polygons)
|
|
52
|
-
self.min_coords = np.min(all_vertices, axis=0)
|
|
53
|
-
self.max_coords = np.max(all_vertices, axis=0)
|
|
54
|
-
|
|
55
|
-
def contains_points(self, points: np.ndarray) -> np.ndarray:
|
|
56
|
-
"""Even-Odd Rule for interior detection, supports holes."""
|
|
57
|
-
x, y = points[:, 0], points[:, 1]
|
|
58
|
-
inside = np.zeros(len(points), dtype=bool)
|
|
59
|
-
|
|
60
|
-
for poly in self.polygons:
|
|
61
|
-
n = len(poly)
|
|
62
|
-
for i in range(n):
|
|
63
|
-
p1, p2 = poly[i], poly[(i + 1) % n]
|
|
64
|
-
crosses_edge = (p1[1] > y) != (p2[1] > y)
|
|
65
|
-
dy = p2[1] - p1[1] + self.epsilon
|
|
66
|
-
x_intercept = (p2[0] - p1[0]) * (y - p1[1]) / dy + p1[0]
|
|
67
|
-
inside ^= crosses_edge & (x < x_intercept)
|
|
68
|
-
|
|
69
|
-
return inside
|
|
70
|
-
|
|
71
|
-
def distance_to_boundary(self, point: Point) -> float:
|
|
72
|
-
min_distance = float('inf')
|
|
73
|
-
for poly in self.polygons:
|
|
74
|
-
for i in range(len(poly)):
|
|
75
|
-
p1, p2 = poly[i], poly[(i + 1) % len(poly)]
|
|
76
|
-
min_distance = min(min_distance, self._point_to_segment_distance(point, p1, p2))
|
|
77
|
-
return min_distance
|
|
78
|
-
|
|
79
|
-
@staticmethod
|
|
80
|
-
def _point_to_segment_distance(point: Point, seg_start: Point, seg_end: Point) -> float:
|
|
81
|
-
segment_vec = seg_end - seg_start
|
|
82
|
-
segment_length_sq = np.sum(segment_vec ** 2)
|
|
83
|
-
if segment_length_sq == 0:
|
|
84
|
-
return np.linalg.norm(point - seg_start)
|
|
85
|
-
t = np.clip(np.dot(point - seg_start, segment_vec) / segment_length_sq, 0, 1)
|
|
86
|
-
projection = seg_start + t * segment_vec
|
|
87
|
-
return np.linalg.norm(point - projection)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
@dataclass
|
|
91
|
-
class SpatialIndex:
|
|
92
|
-
"""Grid-based spatial index for efficient collision detection."""
|
|
93
|
-
cell_size: float
|
|
94
|
-
origin: np.ndarray
|
|
95
|
-
mega_threshold: float
|
|
96
|
-
grid: Dict[GridKey, List[int]] = field(default_factory=dict)
|
|
97
|
-
mega_circles: List[int] = field(default_factory=list)
|
|
98
|
-
|
|
99
|
-
def add_circle(self, index: int, center: Point, radius: float) -> None:
|
|
100
|
-
if radius > self.cell_size * self.mega_threshold:
|
|
101
|
-
self.mega_circles.append(index)
|
|
102
|
-
else:
|
|
103
|
-
key = self._get_cell_key(center)
|
|
104
|
-
self.grid.setdefault(key, []).append(index)
|
|
105
|
-
|
|
106
|
-
def get_nearby_indices(self, point: Point) -> Iterator[int]:
|
|
107
|
-
yield from self.mega_circles
|
|
108
|
-
center_key = self._get_cell_key(point)
|
|
109
|
-
for dx in range(-1, 2):
|
|
110
|
-
for dy in range(-1, 2):
|
|
111
|
-
neighbor_key = (center_key[0] + dx, center_key[1] + dy)
|
|
112
|
-
if neighbor_key in self.grid:
|
|
113
|
-
yield from self.grid[neighbor_key]
|
|
114
|
-
|
|
115
|
-
def _get_cell_key(self, point: Point) -> GridKey:
|
|
116
|
-
cell_coords = ((point - self.origin) // self.cell_size).astype(int)
|
|
117
|
-
return (int(cell_coords[0]), int(cell_coords[1]))
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
class CirclePacker:
|
|
121
|
-
"""Packs circles within polygon boundaries using random sampling."""
|
|
122
|
-
|
|
123
|
-
def __init__(self, polygons: List[Polygon], config: Optional[PackingConfig] = None):
|
|
124
|
-
self.config = config or PackingConfig()
|
|
125
|
-
self.geometry = PolygonGeometry(polygons, self.config.ray_cast_epsilon)
|
|
126
|
-
self.centers: List[Point] = []
|
|
127
|
-
self.radii: List[float] = []
|
|
128
|
-
self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
|
|
129
|
-
|
|
130
|
-
extent = max(self.geometry.max_coords - self.geometry.min_coords)
|
|
131
|
-
cell_size = extent / self.config.grid_resolution_divisor
|
|
132
|
-
self.spatial_index = SpatialIndex(
|
|
133
|
-
cell_size=cell_size,
|
|
134
|
-
origin=self.geometry.min_coords,
|
|
135
|
-
mega_threshold=self.config.mega_circle_threshold
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
def _sample_candidate_points(self, count: int) -> np.ndarray:
|
|
139
|
-
points = np.random.uniform(
|
|
140
|
-
self.geometry.min_coords,
|
|
141
|
-
self.geometry.max_coords,
|
|
142
|
-
size=(count, 2)
|
|
143
|
-
)
|
|
144
|
-
return points[self.geometry.contains_points(points)]
|
|
145
|
-
|
|
146
|
-
def _compute_max_radius(self, point: Point) -> float:
|
|
147
|
-
max_radius = self.geometry.distance_to_boundary(point)
|
|
148
|
-
for idx in self.spatial_index.get_nearby_indices(point):
|
|
149
|
-
distance_to_circle = np.linalg.norm(self.centers[idx] - point) - self.radii[idx]
|
|
150
|
-
max_radius = min(max_radius, distance_to_circle)
|
|
151
|
-
return max_radius - self.config.padding
|
|
152
|
-
|
|
153
|
-
def _find_best_placement(self, candidates: np.ndarray) -> Optional[Tuple[Point, float]]:
|
|
154
|
-
best_point, best_radius = None, 0
|
|
155
|
-
fixed = self.config.fixed_radius
|
|
156
|
-
|
|
157
|
-
for point in candidates:
|
|
158
|
-
radius = self._compute_max_radius(point)
|
|
159
|
-
if fixed is not None:
|
|
160
|
-
radius = fixed if radius >= fixed else -1
|
|
161
|
-
if radius > best_radius:
|
|
162
|
-
best_point, best_radius = point, radius
|
|
163
|
-
|
|
164
|
-
if best_point is not None and best_radius >= self.config.min_radius:
|
|
165
|
-
return best_point, best_radius
|
|
166
|
-
return None
|
|
167
|
-
|
|
168
|
-
def _place_circle(self, center: Point, radius: float) -> None:
|
|
169
|
-
idx = len(self.centers)
|
|
170
|
-
self.centers.append(center)
|
|
171
|
-
self.radii.append(radius)
|
|
172
|
-
self.spatial_index.add_circle(idx, center, radius)
|
|
173
|
-
|
|
174
|
-
def generate(self) -> Iterator[Circle]:
|
|
175
|
-
"""
|
|
176
|
-
Generate circles until no more can be placed.
|
|
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)
|
|
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 self.config.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 self.config.verbose and self.progress.failed_attempts % 50 == 0:
|
|
201
|
-
print(self.progress)
|
|
202
|
-
|
|
203
|
-
if self.config.verbose:
|
|
204
|
-
print(f"Done! {self.progress}")
|
|
205
|
-
|
|
206
|
-
def pack(self) -> List[Circle]:
|
|
207
|
-
"""Pack circles and return them as a list."""
|
|
208
|
-
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
|