diskpack 0.1.0__tar.gz → 0.3.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.1.0/src/diskpack.egg-info → diskpack-0.3.0}/PKG-INFO +1 -1
- {diskpack-0.1.0 → diskpack-0.3.0}/pyproject.toml +1 -1
- diskpack-0.3.0/src/diskpack/packer.py +338 -0
- {diskpack-0.1.0 → diskpack-0.3.0/src/diskpack.egg-info}/PKG-INFO +1 -1
- {diskpack-0.1.0 → diskpack-0.3.0}/tests/tests.py +6 -13
- diskpack-0.1.0/src/diskpack/packer.py +0 -208
- {diskpack-0.1.0 → diskpack-0.3.0}/LICENSE +0 -0
- {diskpack-0.1.0 → diskpack-0.3.0}/README.md +0 -0
- {diskpack-0.1.0 → diskpack-0.3.0}/setup.cfg +0 -0
- {diskpack-0.1.0 → diskpack-0.3.0}/src/diskpack/__init__.py +0 -0
- {diskpack-0.1.0 → diskpack-0.3.0}/src/diskpack.egg-info/SOURCES.txt +0 -0
- {diskpack-0.1.0 → diskpack-0.3.0}/src/diskpack.egg-info/dependency_links.txt +0 -0
- {diskpack-0.1.0 → diskpack-0.3.0}/src/diskpack.egg-info/requires.txt +0 -0
- {diskpack-0.1.0 → diskpack-0.3.0}/src/diskpack.egg-info/top_level.txt +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.3.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,338 @@
|
|
|
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
|
+
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
|
+
"""
|
|
112
|
+
n_points = len(points)
|
|
113
|
+
n_edges = len(self.edge_starts)
|
|
114
|
+
|
|
115
|
+
# Reshape for broadcasting: (n_points, 1, 2) - (n_edges, 2) -> (n_points, n_edges, 2)
|
|
116
|
+
to_point = points[:, np.newaxis, :] - self.edge_starts[np.newaxis, :, :]
|
|
117
|
+
|
|
118
|
+
# Dot products: (n_points, n_edges)
|
|
119
|
+
dots = np.sum(to_point * self.edge_vecs[np.newaxis, :, :], axis=2)
|
|
120
|
+
|
|
121
|
+
# Project onto edges
|
|
122
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
123
|
+
t = np.clip(dots / self.edge_lengths_sq[np.newaxis, :], 0, 1)
|
|
124
|
+
t = np.where(self.edge_lengths_sq[np.newaxis, :] == 0, 0, t)
|
|
125
|
+
|
|
126
|
+
# Closest points on edges: (n_points, n_edges, 2)
|
|
127
|
+
projections = (
|
|
128
|
+
self.edge_starts[np.newaxis, :, :] +
|
|
129
|
+
t[:, :, np.newaxis] * self.edge_vecs[np.newaxis, :, :]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Distances: (n_points, n_edges)
|
|
133
|
+
distances = np.linalg.norm(points[:, np.newaxis, :] - projections, axis=2)
|
|
134
|
+
|
|
135
|
+
# Min distance per point
|
|
136
|
+
return np.min(distances, axis=1)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass
|
|
140
|
+
class SpatialIndex:
|
|
141
|
+
"""Grid-based spatial index for efficient collision detection."""
|
|
142
|
+
cell_size: float
|
|
143
|
+
origin: np.ndarray
|
|
144
|
+
mega_threshold: float
|
|
145
|
+
grid: Dict[GridKey, List[int]] = field(default_factory=dict)
|
|
146
|
+
mega_circles: List[int] = field(default_factory=list)
|
|
147
|
+
|
|
148
|
+
# Store centers/radii arrays for vectorized lookup
|
|
149
|
+
_centers: np.ndarray = field(default_factory=lambda: np.empty((0, 2)))
|
|
150
|
+
_radii: np.ndarray = field(default_factory=lambda: np.empty(0))
|
|
151
|
+
|
|
152
|
+
def add_circle(self, index: int, center: Point, radius: float) -> None:
|
|
153
|
+
# Update arrays
|
|
154
|
+
self._centers = np.vstack([self._centers, center]) if len(self._centers) > 0 else center.reshape(1, 2)
|
|
155
|
+
self._radii = np.append(self._radii, radius)
|
|
156
|
+
|
|
157
|
+
if radius > self.cell_size * self.mega_threshold:
|
|
158
|
+
self.mega_circles.append(index)
|
|
159
|
+
else:
|
|
160
|
+
key = self._get_cell_key(center)
|
|
161
|
+
self.grid.setdefault(key, []).append(index)
|
|
162
|
+
|
|
163
|
+
def get_nearby_indices(self, point: Point) -> Iterator[int]:
|
|
164
|
+
yield from self.mega_circles
|
|
165
|
+
center_key = self._get_cell_key(point)
|
|
166
|
+
for dx in range(-1, 2):
|
|
167
|
+
for dy in range(-1, 2):
|
|
168
|
+
neighbor_key = (center_key[0] + dx, center_key[1] + dy)
|
|
169
|
+
if neighbor_key in self.grid:
|
|
170
|
+
yield from self.grid[neighbor_key]
|
|
171
|
+
|
|
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
|
+
def distance_to_circles(self, point: Point) -> float:
|
|
195
|
+
"""Get minimum distance from point to any existing circle's edge."""
|
|
196
|
+
if len(self._centers) == 0:
|
|
197
|
+
return float('inf')
|
|
198
|
+
|
|
199
|
+
indices = list(self.get_nearby_indices(point))
|
|
200
|
+
if not indices:
|
|
201
|
+
return float('inf')
|
|
202
|
+
|
|
203
|
+
centers = self._centers[indices]
|
|
204
|
+
radii = self._radii[indices]
|
|
205
|
+
|
|
206
|
+
distances = np.linalg.norm(centers - point, axis=1) - radii
|
|
207
|
+
return float(np.min(distances))
|
|
208
|
+
|
|
209
|
+
def _get_cell_key(self, point: Point) -> GridKey:
|
|
210
|
+
cell_coords = ((point - self.origin) // self.cell_size).astype(int)
|
|
211
|
+
return (int(cell_coords[0]), int(cell_coords[1]))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class CirclePacker:
|
|
215
|
+
"""Packs circles within polygon boundaries using random sampling."""
|
|
216
|
+
|
|
217
|
+
def __init__(self, polygons: List[Polygon], config: Optional[PackingConfig] = None):
|
|
218
|
+
self.config = config or PackingConfig()
|
|
219
|
+
self.geometry = PolygonGeometry(polygons, self.config.ray_cast_epsilon)
|
|
220
|
+
self.centers: List[Point] = []
|
|
221
|
+
self.radii: List[float] = []
|
|
222
|
+
self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
|
|
223
|
+
|
|
224
|
+
extent = max(self.geometry.max_coords - self.geometry.min_coords)
|
|
225
|
+
cell_size = extent / self.config.grid_resolution_divisor
|
|
226
|
+
self.spatial_index = SpatialIndex(
|
|
227
|
+
cell_size=cell_size,
|
|
228
|
+
origin=self.geometry.min_coords,
|
|
229
|
+
mega_threshold=self.config.mega_circle_threshold
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _sample_candidate_points(self, count: int) -> np.ndarray:
|
|
233
|
+
points = np.random.uniform(
|
|
234
|
+
self.geometry.min_coords,
|
|
235
|
+
self.geometry.max_coords,
|
|
236
|
+
size=(count, 2)
|
|
237
|
+
)
|
|
238
|
+
return points[self.geometry.contains_points(points)]
|
|
239
|
+
|
|
240
|
+
def _compute_max_radius(self, point: Point) -> float:
|
|
241
|
+
"""Compute max radius for a single point."""
|
|
242
|
+
max_radius = self.geometry.distance_to_boundary(point)
|
|
243
|
+
circle_dist = self.spatial_index.distance_to_circles(point)
|
|
244
|
+
max_radius = min(max_radius, circle_dist)
|
|
245
|
+
return max_radius - self.config.padding
|
|
246
|
+
|
|
247
|
+
def _compute_max_radii_batch(self, points: np.ndarray) -> np.ndarray:
|
|
248
|
+
"""
|
|
249
|
+
Vectorized max radius computation for multiple points.
|
|
250
|
+
"""
|
|
251
|
+
if len(points) == 0:
|
|
252
|
+
return np.array([])
|
|
253
|
+
|
|
254
|
+
# Boundary distances (fully vectorized)
|
|
255
|
+
max_radii = self.geometry.distances_to_boundary_batch(points)
|
|
256
|
+
|
|
257
|
+
# Circle collision distances (per-point, but with vectorized distance calc)
|
|
258
|
+
if len(self.centers) > 0:
|
|
259
|
+
centers_arr = np.array(self.centers)
|
|
260
|
+
radii_arr = np.array(self.radii)
|
|
261
|
+
|
|
262
|
+
for i, point in enumerate(points):
|
|
263
|
+
# Get nearby indices
|
|
264
|
+
indices = list(self.spatial_index.get_nearby_indices(point))
|
|
265
|
+
if indices:
|
|
266
|
+
nearby_centers = centers_arr[indices]
|
|
267
|
+
nearby_radii = radii_arr[indices]
|
|
268
|
+
distances = np.linalg.norm(nearby_centers - point, axis=1) - nearby_radii
|
|
269
|
+
max_radii[i] = min(max_radii[i], np.min(distances))
|
|
270
|
+
|
|
271
|
+
return max_radii - self.config.padding
|
|
272
|
+
|
|
273
|
+
def _find_best_placement(self, candidates: np.ndarray) -> Optional[Tuple[Point, float]]:
|
|
274
|
+
if len(candidates) == 0:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
# Batch compute all radii
|
|
278
|
+
radii = self._compute_max_radii_batch(candidates)
|
|
279
|
+
fixed = self.config.fixed_radius
|
|
280
|
+
|
|
281
|
+
if fixed is not None:
|
|
282
|
+
# For fixed radius, filter to valid positions and pick randomly
|
|
283
|
+
valid_mask = radii >= fixed
|
|
284
|
+
if not np.any(valid_mask):
|
|
285
|
+
return None
|
|
286
|
+
valid_indices = np.where(valid_mask)[0]
|
|
287
|
+
best_idx = valid_indices[0] # Take first valid (or could randomize)
|
|
288
|
+
return candidates[best_idx], fixed
|
|
289
|
+
else:
|
|
290
|
+
# Variable radius: pick the largest
|
|
291
|
+
best_idx = np.argmax(radii)
|
|
292
|
+
best_radius = radii[best_idx]
|
|
293
|
+
|
|
294
|
+
if best_radius >= self.config.min_radius:
|
|
295
|
+
return candidates[best_idx], best_radius
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
def _place_circle(self, center: Point, radius: float) -> None:
|
|
299
|
+
idx = len(self.centers)
|
|
300
|
+
self.centers.append(center)
|
|
301
|
+
self.radii.append(radius)
|
|
302
|
+
self.spatial_index.add_circle(idx, center, radius)
|
|
303
|
+
|
|
304
|
+
def generate(self) -> Iterator[Circle]:
|
|
305
|
+
"""
|
|
306
|
+
Generate circles until no more can be placed.
|
|
307
|
+
|
|
308
|
+
Yields:
|
|
309
|
+
Tuples of (x, y, radius) for each placed circle.
|
|
310
|
+
"""
|
|
311
|
+
self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
|
|
312
|
+
|
|
313
|
+
while self.progress.failed_attempts < self.config.max_failed_attempts:
|
|
314
|
+
candidates = self._sample_candidate_points(self.config.sample_batch_size)
|
|
315
|
+
result = self._find_best_placement(candidates)
|
|
316
|
+
|
|
317
|
+
if result is not None:
|
|
318
|
+
center, radius = result
|
|
319
|
+
self._place_circle(center, radius)
|
|
320
|
+
self.progress.circles_placed += 1
|
|
321
|
+
self.progress.failed_attempts = 0
|
|
322
|
+
|
|
323
|
+
if self.config.verbose and self.progress.circles_placed % 25 == 0:
|
|
324
|
+
print(self.progress)
|
|
325
|
+
|
|
326
|
+
yield (float(center[0]), float(center[1]), float(radius))
|
|
327
|
+
else:
|
|
328
|
+
self.progress.failed_attempts += 1
|
|
329
|
+
|
|
330
|
+
if self.config.verbose and self.progress.failed_attempts % 50 == 0:
|
|
331
|
+
print(self.progress)
|
|
332
|
+
|
|
333
|
+
if self.config.verbose:
|
|
334
|
+
print(f"Done! {self.progress}")
|
|
335
|
+
|
|
336
|
+
def pack(self) -> List[Circle]:
|
|
337
|
+
"""Pack circles and return them as a list."""
|
|
338
|
+
return list(self.generate())
|
|
@@ -3,14 +3,13 @@ import numpy as np
|
|
|
3
3
|
from diskpack.packer import CirclePacker, PackingConfig, PolygonGeometry
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
|
|
7
6
|
class TestCirclePacker(unittest.TestCase):
|
|
8
7
|
def setUp(self):
|
|
9
8
|
"""Set up a simple 10x10 square for testing."""
|
|
10
9
|
self.square_verts = np.array([[0, 0], [10, 0], [10, 10], [0, 10]])
|
|
11
10
|
self.square = [self.square_verts]
|
|
12
11
|
# Use small padding/min_radius to allow for dense filling
|
|
13
|
-
self.config = PackingConfig(padding=0.1, min_radius=0.5,
|
|
12
|
+
self.config = PackingConfig(padding=0.1, min_radius=0.5, max_failed_attempts=100)
|
|
14
13
|
|
|
15
14
|
def _calculate_poly_area(self, segments: list) -> float:
|
|
16
15
|
"""Calculates area of polygons (including holes) using the Shoelace Formula."""
|
|
@@ -18,20 +17,15 @@ class TestCirclePacker(unittest.TestCase):
|
|
|
18
17
|
for poly in segments:
|
|
19
18
|
x = poly[:, 0]
|
|
20
19
|
y = poly[:, 1]
|
|
21
|
-
# Area = 0.5 * |sum(x_i * y_{i+1} - x_{i+1} * y_i)|
|
|
22
20
|
area = 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
|
|
23
|
-
# If it's a hole (clockwise), it naturally subtracts if we don't use abs here,
|
|
24
|
-
# but since our parser handles holes via Even-Odd, we treat all as positive
|
|
25
|
-
# and subtract holes manually if needed. For this test, we assume shells.
|
|
26
21
|
total_area += area
|
|
27
22
|
return total_area
|
|
28
23
|
|
|
29
24
|
def test_filling_density(self):
|
|
30
25
|
"""Verify that the packer fills a minimum percentage of the polygon area."""
|
|
31
26
|
packer = CirclePacker(self.square, self.config)
|
|
32
|
-
circles =
|
|
27
|
+
circles = packer.pack()
|
|
33
28
|
|
|
34
|
-
# Calculate areas
|
|
35
29
|
poly_area = self._calculate_poly_area(self.square)
|
|
36
30
|
circle_area = sum(np.pi * (r**2) for _, _, r in circles)
|
|
37
31
|
|
|
@@ -41,8 +35,6 @@ class TestCirclePacker(unittest.TestCase):
|
|
|
41
35
|
print(f"Total Area: {poly_area:.2f} | Circle Area: {circle_area:.2f}")
|
|
42
36
|
print(f"Packing Density: {fill_percentage:.2f}%")
|
|
43
37
|
|
|
44
|
-
# Assert a minimum density threshold.
|
|
45
|
-
# For a random packer, 30-50% is a reasonable 'success' floor depending on radius.
|
|
46
38
|
self.assertGreater(fill_percentage, 25.0, f"Packing density too low: {fill_percentage:.2f}%")
|
|
47
39
|
|
|
48
40
|
def test_geometry_containment(self):
|
|
@@ -61,15 +53,16 @@ class TestCirclePacker(unittest.TestCase):
|
|
|
61
53
|
def test_no_overlap_integrity(self):
|
|
62
54
|
"""Mathematically verify no circles overlap including padding."""
|
|
63
55
|
packer = CirclePacker(self.square, self.config)
|
|
64
|
-
circles =
|
|
56
|
+
circles = packer.pack()
|
|
65
57
|
|
|
66
58
|
for i, (x1, y1, r1) in enumerate(circles):
|
|
67
59
|
for j, (x2, y2, r2) in enumerate(circles):
|
|
68
|
-
if i == j:
|
|
60
|
+
if i == j:
|
|
61
|
+
continue
|
|
69
62
|
dist = np.sqrt((x1 - x2)**2 + (y1 - y2)**2)
|
|
70
|
-
# Distance must be >= sum of radii + padding
|
|
71
63
|
min_sep = r1 + r2 + self.config.padding
|
|
72
64
|
self.assertGreaterEqual(dist, min_sep - 1e-9)
|
|
73
65
|
|
|
66
|
+
|
|
74
67
|
if __name__ == '__main__':
|
|
75
68
|
unittest.main()
|
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
@dataclass
|
|
25
|
-
class PackingProgress:
|
|
26
|
-
"""Tracks the current state of the packing algorithm."""
|
|
27
|
-
circles_placed: int = 0
|
|
28
|
-
failed_attempts: int = 0
|
|
29
|
-
max_failed_attempts: int = 200
|
|
30
|
-
|
|
31
|
-
@property
|
|
32
|
-
def progress_ratio(self) -> float:
|
|
33
|
-
"""How close we are to stopping (0.0 = just started, 1.0 = about to stop)."""
|
|
34
|
-
return self.failed_attempts / self.max_failed_attempts
|
|
35
|
-
|
|
36
|
-
def __str__(self) -> str:
|
|
37
|
-
return f"Placed: {self.circles_placed} | Failed attempts: {self.failed_attempts}/{self.max_failed_attempts} ({self.progress_ratio:.0%})"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class PolygonGeometry:
|
|
41
|
-
"""Handles geometric calculations for polygon boundaries."""
|
|
42
|
-
|
|
43
|
-
def __init__(self, polygons: List[Polygon], epsilon: float = 1e-10):
|
|
44
|
-
self.polygons = [np.array(p, dtype=float) for p in polygons]
|
|
45
|
-
self.epsilon = epsilon
|
|
46
|
-
self._compute_bounds()
|
|
47
|
-
|
|
48
|
-
def _compute_bounds(self) -> None:
|
|
49
|
-
all_vertices = np.vstack(self.polygons)
|
|
50
|
-
self.min_coords = np.min(all_vertices, axis=0)
|
|
51
|
-
self.max_coords = np.max(all_vertices, axis=0)
|
|
52
|
-
|
|
53
|
-
def contains_points(self, points: np.ndarray) -> np.ndarray:
|
|
54
|
-
"""Even-Odd Rule for interior detection, supports holes."""
|
|
55
|
-
x, y = points[:, 0], points[:, 1]
|
|
56
|
-
inside = np.zeros(len(points), dtype=bool)
|
|
57
|
-
|
|
58
|
-
for poly in self.polygons:
|
|
59
|
-
n = len(poly)
|
|
60
|
-
for i in range(n):
|
|
61
|
-
p1, p2 = poly[i], poly[(i + 1) % n]
|
|
62
|
-
crosses_edge = (p1[1] > y) != (p2[1] > y)
|
|
63
|
-
dy = p2[1] - p1[1] + self.epsilon
|
|
64
|
-
x_intercept = (p2[0] - p1[0]) * (y - p1[1]) / dy + p1[0]
|
|
65
|
-
inside ^= crosses_edge & (x < x_intercept)
|
|
66
|
-
|
|
67
|
-
return inside
|
|
68
|
-
|
|
69
|
-
def distance_to_boundary(self, point: Point) -> float:
|
|
70
|
-
min_distance = float('inf')
|
|
71
|
-
for poly in self.polygons:
|
|
72
|
-
for i in range(len(poly)):
|
|
73
|
-
p1, p2 = poly[i], poly[(i + 1) % len(poly)]
|
|
74
|
-
min_distance = min(min_distance, self._point_to_segment_distance(point, p1, p2))
|
|
75
|
-
return min_distance
|
|
76
|
-
|
|
77
|
-
@staticmethod
|
|
78
|
-
def _point_to_segment_distance(point: Point, seg_start: Point, seg_end: Point) -> float:
|
|
79
|
-
segment_vec = seg_end - seg_start
|
|
80
|
-
segment_length_sq = np.sum(segment_vec ** 2)
|
|
81
|
-
if segment_length_sq == 0:
|
|
82
|
-
return np.linalg.norm(point - seg_start)
|
|
83
|
-
t = np.clip(np.dot(point - seg_start, segment_vec) / segment_length_sq, 0, 1)
|
|
84
|
-
projection = seg_start + t * segment_vec
|
|
85
|
-
return np.linalg.norm(point - projection)
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
@dataclass
|
|
89
|
-
class SpatialIndex:
|
|
90
|
-
"""Grid-based spatial index for efficient collision detection."""
|
|
91
|
-
cell_size: float
|
|
92
|
-
origin: np.ndarray
|
|
93
|
-
mega_threshold: float
|
|
94
|
-
grid: Dict[GridKey, List[int]] = field(default_factory=dict)
|
|
95
|
-
mega_circles: List[int] = field(default_factory=list)
|
|
96
|
-
|
|
97
|
-
def add_circle(self, index: int, center: Point, radius: float) -> None:
|
|
98
|
-
if radius > self.cell_size * self.mega_threshold:
|
|
99
|
-
self.mega_circles.append(index)
|
|
100
|
-
else:
|
|
101
|
-
key = self._get_cell_key(center)
|
|
102
|
-
self.grid.setdefault(key, []).append(index)
|
|
103
|
-
|
|
104
|
-
def get_nearby_indices(self, point: Point) -> Iterator[int]:
|
|
105
|
-
yield from self.mega_circles
|
|
106
|
-
center_key = self._get_cell_key(point)
|
|
107
|
-
for dx in range(-1, 2):
|
|
108
|
-
for dy in range(-1, 2):
|
|
109
|
-
neighbor_key = (center_key[0] + dx, center_key[1] + dy)
|
|
110
|
-
if neighbor_key in self.grid:
|
|
111
|
-
yield from self.grid[neighbor_key]
|
|
112
|
-
|
|
113
|
-
def _get_cell_key(self, point: Point) -> GridKey:
|
|
114
|
-
cell_coords = ((point - self.origin) // self.cell_size).astype(int)
|
|
115
|
-
return (int(cell_coords[0]), int(cell_coords[1]))
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
class CirclePacker:
|
|
119
|
-
"""Packs circles within polygon boundaries using random sampling."""
|
|
120
|
-
|
|
121
|
-
def __init__(self, polygons: List[Polygon], config: Optional[PackingConfig] = None):
|
|
122
|
-
self.config = config or PackingConfig()
|
|
123
|
-
self.geometry = PolygonGeometry(polygons, self.config.ray_cast_epsilon)
|
|
124
|
-
self.centers: List[Point] = []
|
|
125
|
-
self.radii: List[float] = []
|
|
126
|
-
self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
|
|
127
|
-
|
|
128
|
-
extent = max(self.geometry.max_coords - self.geometry.min_coords)
|
|
129
|
-
cell_size = extent / self.config.grid_resolution_divisor
|
|
130
|
-
self.spatial_index = SpatialIndex(
|
|
131
|
-
cell_size=cell_size,
|
|
132
|
-
origin=self.geometry.min_coords,
|
|
133
|
-
mega_threshold=self.config.mega_circle_threshold
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
def _sample_candidate_points(self, count: int) -> np.ndarray:
|
|
137
|
-
points = np.random.uniform(
|
|
138
|
-
self.geometry.min_coords,
|
|
139
|
-
self.geometry.max_coords,
|
|
140
|
-
size=(count, 2)
|
|
141
|
-
)
|
|
142
|
-
return points[self.geometry.contains_points(points)]
|
|
143
|
-
|
|
144
|
-
def _compute_max_radius(self, point: Point) -> float:
|
|
145
|
-
max_radius = self.geometry.distance_to_boundary(point)
|
|
146
|
-
for idx in self.spatial_index.get_nearby_indices(point):
|
|
147
|
-
distance_to_circle = np.linalg.norm(self.centers[idx] - point) - self.radii[idx]
|
|
148
|
-
max_radius = min(max_radius, distance_to_circle)
|
|
149
|
-
return max_radius - self.config.padding
|
|
150
|
-
|
|
151
|
-
def _find_best_placement(self, candidates: np.ndarray, fixed_radius: Optional[float]) -> Optional[Tuple[Point, float]]:
|
|
152
|
-
best_point, best_radius = None, 0
|
|
153
|
-
for point in candidates:
|
|
154
|
-
radius = self._compute_max_radius(point)
|
|
155
|
-
if fixed_radius is not None:
|
|
156
|
-
radius = fixed_radius if radius >= fixed_radius else -1
|
|
157
|
-
if radius > best_radius:
|
|
158
|
-
best_point, best_radius = point, radius
|
|
159
|
-
|
|
160
|
-
if best_point is not None and best_radius >= self.config.min_radius:
|
|
161
|
-
return best_point, best_radius
|
|
162
|
-
return None
|
|
163
|
-
|
|
164
|
-
def _place_circle(self, center: Point, radius: float) -> None:
|
|
165
|
-
idx = len(self.centers)
|
|
166
|
-
self.centers.append(center)
|
|
167
|
-
self.radii.append(radius)
|
|
168
|
-
self.spatial_index.add_circle(idx, center, radius)
|
|
169
|
-
|
|
170
|
-
def generate(self, fixed_radius: Optional[float] = None, verbose: bool = False) -> Iterator[Circle]:
|
|
171
|
-
"""
|
|
172
|
-
Generate circles until no more can be placed.
|
|
173
|
-
|
|
174
|
-
Args:
|
|
175
|
-
fixed_radius: If provided, all circles will have this exact radius.
|
|
176
|
-
verbose: If True, print progress updates periodically.
|
|
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, fixed_radius)
|
|
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 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 verbose and self.progress.failed_attempts % 50 == 0:
|
|
201
|
-
print(self.progress)
|
|
202
|
-
|
|
203
|
-
if verbose:
|
|
204
|
-
print(f"Done! {self.progress}")
|
|
205
|
-
|
|
206
|
-
def pack(self, fixed_radius: Optional[float] = None, verbose: bool = False) -> List[Circle]:
|
|
207
|
-
"""Pack circles and return them as a list."""
|
|
208
|
-
return list(self.generate(fixed_radius, verbose=verbose))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|