diskpack 0.7.0__tar.gz → 0.8.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.7.0/src/diskpack.egg-info → diskpack-0.8.0}/PKG-INFO +1 -1
- {diskpack-0.7.0 → diskpack-0.8.0}/pyproject.toml +1 -1
- diskpack-0.8.0/src/diskpack/packer.py +782 -0
- {diskpack-0.7.0 → diskpack-0.8.0/src/diskpack.egg-info}/PKG-INFO +1 -1
- diskpack-0.7.0/src/diskpack/packer.py +0 -430
- {diskpack-0.7.0 → diskpack-0.8.0}/LICENSE +0 -0
- {diskpack-0.7.0 → diskpack-0.8.0}/README.md +0 -0
- {diskpack-0.7.0 → diskpack-0.8.0}/setup.cfg +0 -0
- {diskpack-0.7.0 → diskpack-0.8.0}/src/diskpack/__init__.py +0 -0
- {diskpack-0.7.0 → diskpack-0.8.0}/src/diskpack.egg-info/SOURCES.txt +0 -0
- {diskpack-0.7.0 → diskpack-0.8.0}/src/diskpack.egg-info/dependency_links.txt +0 -0
- {diskpack-0.7.0 → diskpack-0.8.0}/src/diskpack.egg-info/requires.txt +0 -0
- {diskpack-0.7.0 → diskpack-0.8.0}/src/diskpack.egg-info/top_level.txt +0 -0
- {diskpack-0.7.0 → diskpack-0.8.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.8.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,782 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import heapq
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import List, Optional, Iterator, Tuple, Dict, Set
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
# Type aliases
|
|
8
|
+
Polygon = np.ndarray
|
|
9
|
+
Point = np.ndarray
|
|
10
|
+
GridKey = Tuple[int, int]
|
|
11
|
+
Circle = Tuple[float, float, float]
|
|
12
|
+
|
|
13
|
+
# Threshold for switching between vectorized and spatial index approaches
|
|
14
|
+
VECTORIZED_THRESHOLD = 750
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PackingMode(Enum):
|
|
18
|
+
"""Available packing strategies."""
|
|
19
|
+
RANDOM = "random" # Random sampling (original)
|
|
20
|
+
HEX_GRID = "hex_grid" # Hexagonal grid (fixed radius only)
|
|
21
|
+
FRONT = "front" # Front-based packing (highest density)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PackingConfig:
|
|
26
|
+
"""Configuration parameters for the circle packing algorithm."""
|
|
27
|
+
padding: float = 1.5
|
|
28
|
+
min_radius: float = 1.0
|
|
29
|
+
grid_resolution_divisor: float = 25
|
|
30
|
+
max_failed_attempts: int = 200
|
|
31
|
+
mega_circle_threshold: float = 0.5
|
|
32
|
+
ray_cast_epsilon: float = 1e-10
|
|
33
|
+
sample_batch_size: int = 50
|
|
34
|
+
fixed_radius: Optional[float] = None
|
|
35
|
+
use_hex_grid: bool = True
|
|
36
|
+
use_front_packing: bool = False # New option for front-based packing
|
|
37
|
+
verbose: bool = False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class PackingProgress:
|
|
42
|
+
"""Tracks the current state of the packing algorithm."""
|
|
43
|
+
circles_placed: int = 0
|
|
44
|
+
failed_attempts: int = 0
|
|
45
|
+
max_failed_attempts: int = 200
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def progress_ratio(self) -> float:
|
|
49
|
+
"""How close we are to stopping (0.0 = just started, 1.0 = about to stop)."""
|
|
50
|
+
return self.failed_attempts / self.max_failed_attempts
|
|
51
|
+
|
|
52
|
+
def __str__(self) -> str:
|
|
53
|
+
return f"Placed: {self.circles_placed} | Failed attempts: {self.failed_attempts}/{self.max_failed_attempts} ({self.progress_ratio:.0%})"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class PolygonGeometry:
|
|
57
|
+
"""Handles geometric calculations for polygon boundaries."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, polygons: List[Polygon], epsilon: float = 1e-10):
|
|
60
|
+
self.polygons = [np.array(p, dtype=float) for p in polygons]
|
|
61
|
+
self.epsilon = epsilon
|
|
62
|
+
self._compute_bounds()
|
|
63
|
+
self._precompute_edges()
|
|
64
|
+
|
|
65
|
+
def _compute_bounds(self) -> None:
|
|
66
|
+
all_vertices = np.vstack(self.polygons)
|
|
67
|
+
self.min_coords = np.min(all_vertices, axis=0)
|
|
68
|
+
self.max_coords = np.max(all_vertices, axis=0)
|
|
69
|
+
|
|
70
|
+
def _precompute_edges(self) -> None:
|
|
71
|
+
"""Precompute edge data for vectorized distance calculations."""
|
|
72
|
+
all_p1 = []
|
|
73
|
+
all_p2 = []
|
|
74
|
+
for poly in self.polygons:
|
|
75
|
+
n = len(poly)
|
|
76
|
+
for i in range(n):
|
|
77
|
+
all_p1.append(poly[i])
|
|
78
|
+
all_p2.append(poly[(i + 1) % n])
|
|
79
|
+
|
|
80
|
+
self.edge_starts = np.array(all_p1)
|
|
81
|
+
self.edge_ends = np.array(all_p2)
|
|
82
|
+
self.edge_vecs = self.edge_ends - self.edge_starts
|
|
83
|
+
self.edge_lengths_sq = np.sum(self.edge_vecs ** 2, axis=1)
|
|
84
|
+
self.edge_lengths = np.sqrt(self.edge_lengths_sq)
|
|
85
|
+
|
|
86
|
+
# Compute inward normals for each edge
|
|
87
|
+
self.edge_normals = np.zeros_like(self.edge_vecs)
|
|
88
|
+
for i, (start, vec) in enumerate(zip(self.edge_starts, self.edge_vecs)):
|
|
89
|
+
# Perpendicular (rotate 90 degrees)
|
|
90
|
+
normal = np.array([-vec[1], vec[0]])
|
|
91
|
+
if self.edge_lengths[i] > 0:
|
|
92
|
+
normal = normal / self.edge_lengths[i]
|
|
93
|
+
# Check if normal points inward (toward polygon center)
|
|
94
|
+
midpoint = start + vec / 2
|
|
95
|
+
test_point = midpoint + normal * 0.001
|
|
96
|
+
if not self._point_in_polygon_single(test_point):
|
|
97
|
+
normal = -normal
|
|
98
|
+
self.edge_normals[i] = normal
|
|
99
|
+
|
|
100
|
+
def _point_in_polygon_single(self, point: Point) -> bool:
|
|
101
|
+
"""Check if a single point is inside the polygon."""
|
|
102
|
+
x, y = point[0], point[1]
|
|
103
|
+
inside = False
|
|
104
|
+
for poly in self.polygons:
|
|
105
|
+
n = len(poly)
|
|
106
|
+
for i in range(n):
|
|
107
|
+
p1, p2 = poly[i], poly[(i + 1) % n]
|
|
108
|
+
if ((p1[1] > y) != (p2[1] > y)) and \
|
|
109
|
+
(x < (p2[0] - p1[0]) * (y - p1[1]) / (p2[1] - p1[1] + self.epsilon) + p1[0]):
|
|
110
|
+
inside = not inside
|
|
111
|
+
return inside
|
|
112
|
+
|
|
113
|
+
def contains_points(self, points: np.ndarray) -> np.ndarray:
|
|
114
|
+
"""Even-Odd Rule for interior detection, supports holes."""
|
|
115
|
+
x, y = points[:, 0], points[:, 1]
|
|
116
|
+
inside = np.zeros(len(points), dtype=bool)
|
|
117
|
+
|
|
118
|
+
for poly in self.polygons:
|
|
119
|
+
n = len(poly)
|
|
120
|
+
for i in range(n):
|
|
121
|
+
p1, p2 = poly[i], poly[(i + 1) % n]
|
|
122
|
+
crosses_edge = (p1[1] > y) != (p2[1] > y)
|
|
123
|
+
dy = p2[1] - p1[1] + self.epsilon
|
|
124
|
+
x_intercept = (p2[0] - p1[0]) * (y - p1[1]) / dy + p1[0]
|
|
125
|
+
inside ^= crosses_edge & (x < x_intercept)
|
|
126
|
+
|
|
127
|
+
return inside
|
|
128
|
+
|
|
129
|
+
def distance_to_boundary(self, point: Point) -> float:
|
|
130
|
+
"""Vectorized distance to nearest polygon edge."""
|
|
131
|
+
to_point = point - self.edge_starts
|
|
132
|
+
dots = np.sum(to_point * self.edge_vecs, axis=1)
|
|
133
|
+
|
|
134
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
135
|
+
t = np.clip(dots / self.edge_lengths_sq, 0, 1)
|
|
136
|
+
t = np.where(self.edge_lengths_sq == 0, 0, t)
|
|
137
|
+
|
|
138
|
+
projections = self.edge_starts + t[:, np.newaxis] * self.edge_vecs
|
|
139
|
+
distances = np.linalg.norm(point - projections, axis=1)
|
|
140
|
+
|
|
141
|
+
return float(np.min(distances))
|
|
142
|
+
|
|
143
|
+
def distances_to_boundary_batch(self, points: np.ndarray) -> np.ndarray:
|
|
144
|
+
"""Vectorized distance calculation for multiple points at once."""
|
|
145
|
+
to_point = points[:, np.newaxis, :] - self.edge_starts[np.newaxis, :, :]
|
|
146
|
+
dots = np.sum(to_point * self.edge_vecs[np.newaxis, :, :], axis=2)
|
|
147
|
+
|
|
148
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
149
|
+
t = np.clip(dots / self.edge_lengths_sq[np.newaxis, :], 0, 1)
|
|
150
|
+
t = np.where(self.edge_lengths_sq[np.newaxis, :] == 0, 0, t)
|
|
151
|
+
|
|
152
|
+
projections = (
|
|
153
|
+
self.edge_starts[np.newaxis, :, :] +
|
|
154
|
+
t[:, :, np.newaxis] * self.edge_vecs[np.newaxis, :, :]
|
|
155
|
+
)
|
|
156
|
+
distances = np.linalg.norm(points[:, np.newaxis, :] - projections, axis=2)
|
|
157
|
+
|
|
158
|
+
return np.min(distances, axis=1)
|
|
159
|
+
|
|
160
|
+
def closest_point_on_edge(self, point: Point, edge_idx: int) -> Tuple[Point, float]:
|
|
161
|
+
"""Find closest point on a specific edge and return it with distance."""
|
|
162
|
+
start = self.edge_starts[edge_idx]
|
|
163
|
+
vec = self.edge_vecs[edge_idx]
|
|
164
|
+
length_sq = self.edge_lengths_sq[edge_idx]
|
|
165
|
+
|
|
166
|
+
if length_sq == 0:
|
|
167
|
+
return start, np.linalg.norm(point - start)
|
|
168
|
+
|
|
169
|
+
t = np.clip(np.dot(point - start, vec) / length_sq, 0, 1)
|
|
170
|
+
closest = start + t * vec
|
|
171
|
+
return closest, np.linalg.norm(point - closest)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass
|
|
175
|
+
class SpatialIndex:
|
|
176
|
+
"""Grid-based spatial index for efficient collision detection."""
|
|
177
|
+
cell_size: float
|
|
178
|
+
origin: np.ndarray
|
|
179
|
+
mega_threshold: float
|
|
180
|
+
grid: Dict[GridKey, List[int]] = field(default_factory=dict)
|
|
181
|
+
mega_circles: List[int] = field(default_factory=list)
|
|
182
|
+
|
|
183
|
+
_centers: np.ndarray = field(default_factory=lambda: np.empty((0, 2)))
|
|
184
|
+
_radii: np.ndarray = field(default_factory=lambda: np.empty(0))
|
|
185
|
+
|
|
186
|
+
def add_circle(self, index: int, center: Point, radius: float) -> None:
|
|
187
|
+
self._centers = np.vstack([self._centers, center]) if len(self._centers) > 0 else center.reshape(1, 2)
|
|
188
|
+
self._radii = np.append(self._radii, radius)
|
|
189
|
+
|
|
190
|
+
if radius > self.cell_size * self.mega_threshold:
|
|
191
|
+
self.mega_circles.append(index)
|
|
192
|
+
else:
|
|
193
|
+
key = self._get_cell_key(center)
|
|
194
|
+
self.grid.setdefault(key, []).append(index)
|
|
195
|
+
|
|
196
|
+
def get_nearby_indices(self, point: Point) -> Iterator[int]:
|
|
197
|
+
yield from self.mega_circles
|
|
198
|
+
center_key = self._get_cell_key(point)
|
|
199
|
+
for dx in range(-1, 2):
|
|
200
|
+
for dy in range(-1, 2):
|
|
201
|
+
neighbor_key = (center_key[0] + dx, center_key[1] + dy)
|
|
202
|
+
if neighbor_key in self.grid:
|
|
203
|
+
yield from self.grid[neighbor_key]
|
|
204
|
+
|
|
205
|
+
def distance_to_circles(self, point: Point) -> float:
|
|
206
|
+
"""Get minimum distance from point to any existing circle's edge."""
|
|
207
|
+
if len(self._centers) == 0:
|
|
208
|
+
return float('inf')
|
|
209
|
+
|
|
210
|
+
indices = list(self.get_nearby_indices(point))
|
|
211
|
+
if not indices:
|
|
212
|
+
return float('inf')
|
|
213
|
+
|
|
214
|
+
centers = self._centers[indices]
|
|
215
|
+
radii = self._radii[indices]
|
|
216
|
+
|
|
217
|
+
distances = np.linalg.norm(centers - point, axis=1) - radii
|
|
218
|
+
return float(np.min(distances))
|
|
219
|
+
|
|
220
|
+
def _get_cell_key(self, point: Point) -> GridKey:
|
|
221
|
+
cell_coords = ((point - self.origin) // self.cell_size).astype(int)
|
|
222
|
+
return (int(cell_coords[0]), int(cell_coords[1]))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@dataclass(order=True)
|
|
226
|
+
class FrontCandidate:
|
|
227
|
+
"""A candidate position for placing a circle in front-based packing."""
|
|
228
|
+
priority: float # Negative radius for max-heap behavior with min-heap
|
|
229
|
+
center: Point = field(compare=False)
|
|
230
|
+
radius: float = field(compare=False)
|
|
231
|
+
source_type: str = field(compare=False) # 'edge', 'circle-circle', 'circle-edge'
|
|
232
|
+
source_ids: Tuple = field(compare=False) # IDs of circles/edges that generated this candidate
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class CirclePacker:
|
|
236
|
+
"""Packs circles within polygon boundaries using various strategies."""
|
|
237
|
+
|
|
238
|
+
def __init__(self, polygons: List[Polygon], config: Optional[PackingConfig] = None):
|
|
239
|
+
self.config = config or PackingConfig()
|
|
240
|
+
self.geometry = PolygonGeometry(polygons, self.config.ray_cast_epsilon)
|
|
241
|
+
self.centers: List[Point] = []
|
|
242
|
+
self.radii: List[float] = []
|
|
243
|
+
self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
|
|
244
|
+
|
|
245
|
+
extent = max(self.geometry.max_coords - self.geometry.min_coords)
|
|
246
|
+
cell_size = extent / self.config.grid_resolution_divisor
|
|
247
|
+
self.spatial_index = SpatialIndex(
|
|
248
|
+
cell_size=cell_size,
|
|
249
|
+
origin=self.geometry.min_coords,
|
|
250
|
+
mega_threshold=self.config.mega_circle_threshold
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Cache for numpy arrays
|
|
254
|
+
self._centers_arr: Optional[np.ndarray] = None
|
|
255
|
+
self._radii_arr: Optional[np.ndarray] = None
|
|
256
|
+
self._cache_valid = False
|
|
257
|
+
|
|
258
|
+
def _invalidate_cache(self) -> None:
|
|
259
|
+
self._cache_valid = False
|
|
260
|
+
|
|
261
|
+
def _get_arrays(self) -> Tuple[np.ndarray, np.ndarray]:
|
|
262
|
+
if not self._cache_valid or self._centers_arr is None:
|
|
263
|
+
if len(self.centers) > 0:
|
|
264
|
+
self._centers_arr = np.array(self.centers)
|
|
265
|
+
self._radii_arr = np.array(self.radii)
|
|
266
|
+
else:
|
|
267
|
+
self._centers_arr = np.empty((0, 2))
|
|
268
|
+
self._radii_arr = np.empty(0)
|
|
269
|
+
self._cache_valid = True
|
|
270
|
+
return self._centers_arr, self._radii_arr
|
|
271
|
+
|
|
272
|
+
def _sample_candidate_points(self, count: int) -> np.ndarray:
|
|
273
|
+
points = np.random.uniform(
|
|
274
|
+
self.geometry.min_coords,
|
|
275
|
+
self.geometry.max_coords,
|
|
276
|
+
size=(count, 2)
|
|
277
|
+
)
|
|
278
|
+
return points[self.geometry.contains_points(points)]
|
|
279
|
+
|
|
280
|
+
def _compute_max_radius(self, point: Point) -> float:
|
|
281
|
+
max_radius = self.geometry.distance_to_boundary(point)
|
|
282
|
+
circle_dist = self.spatial_index.distance_to_circles(point)
|
|
283
|
+
max_radius = min(max_radius, circle_dist)
|
|
284
|
+
return max_radius - self.config.padding
|
|
285
|
+
|
|
286
|
+
def _compute_max_radii_batch(self, points: np.ndarray) -> np.ndarray:
|
|
287
|
+
if len(points) == 0:
|
|
288
|
+
return np.array([])
|
|
289
|
+
|
|
290
|
+
max_radii = self.geometry.distances_to_boundary_batch(points)
|
|
291
|
+
|
|
292
|
+
if len(self.centers) == 0:
|
|
293
|
+
return max_radii - self.config.padding
|
|
294
|
+
|
|
295
|
+
centers_arr, radii_arr = self._get_arrays()
|
|
296
|
+
|
|
297
|
+
if len(self.centers) < VECTORIZED_THRESHOLD:
|
|
298
|
+
dists = np.linalg.norm(
|
|
299
|
+
points[:, np.newaxis, :] - centers_arr[np.newaxis, :, :],
|
|
300
|
+
axis=2
|
|
301
|
+
) - radii_arr
|
|
302
|
+
min_circle_dists = np.min(dists, axis=1)
|
|
303
|
+
max_radii = np.minimum(max_radii, min_circle_dists)
|
|
304
|
+
else:
|
|
305
|
+
for i, point in enumerate(points):
|
|
306
|
+
indices = list(self.spatial_index.get_nearby_indices(point))
|
|
307
|
+
if indices:
|
|
308
|
+
nearby_centers = centers_arr[indices]
|
|
309
|
+
nearby_radii = radii_arr[indices]
|
|
310
|
+
distances = np.linalg.norm(nearby_centers - point, axis=1) - nearby_radii
|
|
311
|
+
max_radii[i] = min(max_radii[i], np.min(distances))
|
|
312
|
+
|
|
313
|
+
return max_radii - self.config.padding
|
|
314
|
+
|
|
315
|
+
def _find_best_placement(self, candidates: np.ndarray) -> Optional[Tuple[Point, float]]:
|
|
316
|
+
if len(candidates) == 0:
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
radii = self._compute_max_radii_batch(candidates)
|
|
320
|
+
fixed = self.config.fixed_radius
|
|
321
|
+
|
|
322
|
+
if fixed is not None:
|
|
323
|
+
valid_mask = radii >= fixed
|
|
324
|
+
if not np.any(valid_mask):
|
|
325
|
+
return None
|
|
326
|
+
valid_indices = np.where(valid_mask)[0]
|
|
327
|
+
best_idx = valid_indices[0]
|
|
328
|
+
return candidates[best_idx], fixed
|
|
329
|
+
else:
|
|
330
|
+
best_idx = np.argmax(radii)
|
|
331
|
+
best_radius = radii[best_idx]
|
|
332
|
+
|
|
333
|
+
if best_radius >= self.config.min_radius:
|
|
334
|
+
return candidates[best_idx], best_radius
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
def _place_circle(self, center: Point, radius: float) -> None:
|
|
338
|
+
idx = len(self.centers)
|
|
339
|
+
self.centers.append(center)
|
|
340
|
+
self.radii.append(radius)
|
|
341
|
+
self.spatial_index.add_circle(idx, center, radius)
|
|
342
|
+
self._invalidate_cache()
|
|
343
|
+
|
|
344
|
+
def _is_valid_placement(self, center: Point, radius: float) -> bool:
|
|
345
|
+
"""Check if a circle placement is valid (inside polygon, no overlaps)."""
|
|
346
|
+
# Check if center is inside polygon
|
|
347
|
+
if not self.geometry._point_in_polygon_single(center):
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
# Check boundary distance
|
|
351
|
+
boundary_dist = self.geometry.distance_to_boundary(center)
|
|
352
|
+
if boundary_dist < radius + self.config.padding - 1e-9:
|
|
353
|
+
return False
|
|
354
|
+
|
|
355
|
+
# Check circle overlaps
|
|
356
|
+
if len(self.centers) > 0:
|
|
357
|
+
centers_arr, radii_arr = self._get_arrays()
|
|
358
|
+
distances = np.linalg.norm(centers_arr - center, axis=1)
|
|
359
|
+
min_allowed = radii_arr + radius + self.config.padding
|
|
360
|
+
if np.any(distances < min_allowed - 1e-9):
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
# =========================================================================
|
|
366
|
+
# Hex Grid Packing
|
|
367
|
+
# =========================================================================
|
|
368
|
+
|
|
369
|
+
def _generate_hex_grid(self, radius: float) -> np.ndarray:
|
|
370
|
+
spacing = (radius + self.config.padding) * 2
|
|
371
|
+
dy = spacing * np.sqrt(3) / 2
|
|
372
|
+
|
|
373
|
+
min_x, min_y = self.geometry.min_coords
|
|
374
|
+
max_x, max_y = self.geometry.max_coords
|
|
375
|
+
|
|
376
|
+
min_x -= spacing
|
|
377
|
+
min_y -= spacing
|
|
378
|
+
max_x += spacing
|
|
379
|
+
max_y += spacing
|
|
380
|
+
|
|
381
|
+
points = []
|
|
382
|
+
row = 0
|
|
383
|
+
y = min_y
|
|
384
|
+
|
|
385
|
+
while y <= max_y:
|
|
386
|
+
x_offset = (spacing / 2) if row % 2 else 0
|
|
387
|
+
x = min_x + x_offset
|
|
388
|
+
|
|
389
|
+
while x <= max_x:
|
|
390
|
+
points.append([x, y])
|
|
391
|
+
x += spacing
|
|
392
|
+
|
|
393
|
+
y += dy
|
|
394
|
+
row += 1
|
|
395
|
+
|
|
396
|
+
return np.array(points) if points else np.empty((0, 2))
|
|
397
|
+
|
|
398
|
+
def _pack_hex_grid(self) -> List[Circle]:
|
|
399
|
+
radius = self.config.fixed_radius
|
|
400
|
+
circles = []
|
|
401
|
+
|
|
402
|
+
grid_points = self._generate_hex_grid(radius)
|
|
403
|
+
|
|
404
|
+
if len(grid_points) == 0:
|
|
405
|
+
return circles
|
|
406
|
+
|
|
407
|
+
inside_mask = self.geometry.contains_points(grid_points)
|
|
408
|
+
interior_points = grid_points[inside_mask]
|
|
409
|
+
|
|
410
|
+
min_clearance = radius + self.config.padding
|
|
411
|
+
boundary_distances = self.geometry.distances_to_boundary_batch(interior_points)
|
|
412
|
+
valid_mask = boundary_distances >= min_clearance
|
|
413
|
+
|
|
414
|
+
valid_points = interior_points[valid_mask]
|
|
415
|
+
|
|
416
|
+
if self.config.verbose:
|
|
417
|
+
print(f"Hex grid: {len(grid_points)} total -> {len(interior_points)} inside -> {len(valid_points)} valid")
|
|
418
|
+
|
|
419
|
+
for point in valid_points:
|
|
420
|
+
self._place_circle(point, radius)
|
|
421
|
+
circles.append((float(point[0]), float(point[1]), float(radius)))
|
|
422
|
+
|
|
423
|
+
if self.config.verbose:
|
|
424
|
+
print(f"Done! Placed {len(circles)} circles")
|
|
425
|
+
|
|
426
|
+
return circles
|
|
427
|
+
|
|
428
|
+
# =========================================================================
|
|
429
|
+
# Front-Based Packing
|
|
430
|
+
# =========================================================================
|
|
431
|
+
|
|
432
|
+
def _find_tangent_circle_two_circles(
|
|
433
|
+
self, c1: Point, r1: float, c2: Point, r2: float, r: float
|
|
434
|
+
) -> List[Point]:
|
|
435
|
+
"""
|
|
436
|
+
Find positions where a circle of radius r is tangent to two existing circles.
|
|
437
|
+
Returns 0, 1, or 2 valid positions.
|
|
438
|
+
"""
|
|
439
|
+
d = np.linalg.norm(c2 - c1)
|
|
440
|
+
|
|
441
|
+
# Distance from each center to the new circle's center
|
|
442
|
+
d1 = r1 + r + self.config.padding
|
|
443
|
+
d2 = r2 + r + self.config.padding
|
|
444
|
+
|
|
445
|
+
# Check if solution exists (triangle inequality)
|
|
446
|
+
if d > d1 + d2 or d < abs(d1 - d2) or d < 1e-10:
|
|
447
|
+
return []
|
|
448
|
+
|
|
449
|
+
# Solve using law of cosines
|
|
450
|
+
# d1^2 = d^2 + d2^2 - 2*d*d2*cos(angle at c2)
|
|
451
|
+
# Actually easier: find intersection of two circles centered at c1, c2
|
|
452
|
+
|
|
453
|
+
# Using the formula for circle-circle intersection
|
|
454
|
+
a = (d1**2 - d2**2 + d**2) / (2 * d)
|
|
455
|
+
h_sq = d1**2 - a**2
|
|
456
|
+
|
|
457
|
+
if h_sq < 0:
|
|
458
|
+
return []
|
|
459
|
+
|
|
460
|
+
h = np.sqrt(h_sq)
|
|
461
|
+
|
|
462
|
+
# Unit vector from c1 to c2
|
|
463
|
+
u = (c2 - c1) / d
|
|
464
|
+
# Perpendicular
|
|
465
|
+
v = np.array([-u[1], u[0]])
|
|
466
|
+
|
|
467
|
+
# Midpoint along c1-c2 axis
|
|
468
|
+
p = c1 + a * u
|
|
469
|
+
|
|
470
|
+
# Two solutions
|
|
471
|
+
solutions = []
|
|
472
|
+
if h < 1e-10:
|
|
473
|
+
solutions.append(p)
|
|
474
|
+
else:
|
|
475
|
+
solutions.append(p + h * v)
|
|
476
|
+
solutions.append(p - h * v)
|
|
477
|
+
|
|
478
|
+
return solutions
|
|
479
|
+
|
|
480
|
+
def _find_tangent_circle_edge(
|
|
481
|
+
self, edge_idx: int, r: float
|
|
482
|
+
) -> List[Tuple[Point, float]]:
|
|
483
|
+
"""
|
|
484
|
+
Find positions along an edge where circles of radius r can be placed.
|
|
485
|
+
Returns list of (center, t) where t is position along edge [0, 1].
|
|
486
|
+
"""
|
|
487
|
+
start = self.geometry.edge_starts[edge_idx]
|
|
488
|
+
vec = self.geometry.edge_vecs[edge_idx]
|
|
489
|
+
normal = self.geometry.edge_normals[edge_idx]
|
|
490
|
+
length = self.geometry.edge_lengths[edge_idx]
|
|
491
|
+
|
|
492
|
+
if length < 1e-10:
|
|
493
|
+
return []
|
|
494
|
+
|
|
495
|
+
# Circle center is offset from edge by radius + padding
|
|
496
|
+
offset = r + self.config.padding
|
|
497
|
+
|
|
498
|
+
# Generate positions along the edge
|
|
499
|
+
positions = []
|
|
500
|
+
spacing = (r + self.config.padding) * 2
|
|
501
|
+
|
|
502
|
+
t = offset / length # Start offset from edge start
|
|
503
|
+
while t < 1 - offset / length:
|
|
504
|
+
point_on_edge = start + t * vec
|
|
505
|
+
center = point_on_edge + offset * normal
|
|
506
|
+
positions.append((center, t))
|
|
507
|
+
t += spacing / length
|
|
508
|
+
|
|
509
|
+
return positions
|
|
510
|
+
|
|
511
|
+
def _find_tangent_circle_circle_and_edge(
|
|
512
|
+
self, circle_idx: int, edge_idx: int, r: float
|
|
513
|
+
) -> List[Point]:
|
|
514
|
+
"""
|
|
515
|
+
Find positions where a circle of radius r is tangent to both
|
|
516
|
+
an existing circle and a polygon edge.
|
|
517
|
+
"""
|
|
518
|
+
c = self.centers[circle_idx]
|
|
519
|
+
rc = self.radii[circle_idx]
|
|
520
|
+
|
|
521
|
+
start = self.geometry.edge_starts[edge_idx]
|
|
522
|
+
vec = self.geometry.edge_vecs[edge_idx]
|
|
523
|
+
normal = self.geometry.edge_normals[edge_idx]
|
|
524
|
+
length = self.geometry.edge_lengths[edge_idx]
|
|
525
|
+
|
|
526
|
+
if length < 1e-10:
|
|
527
|
+
return []
|
|
528
|
+
|
|
529
|
+
# New circle must be:
|
|
530
|
+
# 1. At distance rc + r + padding from circle center
|
|
531
|
+
# 2. At distance r + padding from edge
|
|
532
|
+
|
|
533
|
+
edge_offset = r + self.config.padding
|
|
534
|
+
circle_dist = rc + r + self.config.padding
|
|
535
|
+
|
|
536
|
+
# The center lies on a line parallel to the edge, offset by edge_offset
|
|
537
|
+
# And on a circle around c with radius circle_dist
|
|
538
|
+
|
|
539
|
+
# Line: point = start + t * vec + edge_offset * normal
|
|
540
|
+
# Circle: |point - c| = circle_dist
|
|
541
|
+
|
|
542
|
+
# Substitute: |start + t * vec + edge_offset * normal - c| = circle_dist
|
|
543
|
+
# Let p0 = start + edge_offset * normal - c
|
|
544
|
+
# |p0 + t * vec| = circle_dist
|
|
545
|
+
# |p0|^2 + 2*t*(p0 . vec) + t^2*|vec|^2 = circle_dist^2
|
|
546
|
+
|
|
547
|
+
p0 = start + edge_offset * normal - c
|
|
548
|
+
a = np.dot(vec, vec) # |vec|^2
|
|
549
|
+
b = 2 * np.dot(p0, vec)
|
|
550
|
+
c_coef = np.dot(p0, p0) - circle_dist**2
|
|
551
|
+
|
|
552
|
+
discriminant = b**2 - 4 * a * c_coef
|
|
553
|
+
|
|
554
|
+
if discriminant < 0:
|
|
555
|
+
return []
|
|
556
|
+
|
|
557
|
+
solutions = []
|
|
558
|
+
sqrt_disc = np.sqrt(discriminant)
|
|
559
|
+
|
|
560
|
+
for t in [(-b + sqrt_disc) / (2 * a), (-b - sqrt_disc) / (2 * a)]:
|
|
561
|
+
if 0 <= t <= 1:
|
|
562
|
+
center = start + t * vec + edge_offset * normal
|
|
563
|
+
solutions.append(center)
|
|
564
|
+
|
|
565
|
+
return solutions
|
|
566
|
+
|
|
567
|
+
def _get_max_radius_at_point(self, center: Point) -> float:
|
|
568
|
+
"""Get the maximum radius that can fit at a given center point."""
|
|
569
|
+
# Distance to boundary
|
|
570
|
+
max_r = self.geometry.distance_to_boundary(center)
|
|
571
|
+
|
|
572
|
+
# Distance to existing circles
|
|
573
|
+
if len(self.centers) > 0:
|
|
574
|
+
centers_arr, radii_arr = self._get_arrays()
|
|
575
|
+
distances = np.linalg.norm(centers_arr - center, axis=1) - radii_arr
|
|
576
|
+
max_r = min(max_r, np.min(distances))
|
|
577
|
+
|
|
578
|
+
return max_r - self.config.padding
|
|
579
|
+
|
|
580
|
+
def _pack_front(self) -> List[Circle]:
|
|
581
|
+
"""
|
|
582
|
+
Pack circles using front-based algorithm.
|
|
583
|
+
Achieves higher density by systematically filling from edges inward.
|
|
584
|
+
"""
|
|
585
|
+
circles = []
|
|
586
|
+
min_r = self.config.min_radius
|
|
587
|
+
fixed_r = self.config.fixed_radius
|
|
588
|
+
|
|
589
|
+
# Priority queue: (negative_radius, center, radius, source_info)
|
|
590
|
+
# Using negative radius for max-heap behavior
|
|
591
|
+
candidates: List[Tuple[float, int, Point, float]] = []
|
|
592
|
+
candidate_id = 0
|
|
593
|
+
|
|
594
|
+
# Track which circle pairs we've already processed
|
|
595
|
+
processed_pairs: Set[Tuple[int, int]] = set()
|
|
596
|
+
processed_circle_edge: Set[Tuple[int, int]] = set()
|
|
597
|
+
|
|
598
|
+
def add_candidate(center: Point, radius: float):
|
|
599
|
+
nonlocal candidate_id
|
|
600
|
+
if radius >= min_r:
|
|
601
|
+
heapq.heappush(candidates, (-radius, candidate_id, center, radius))
|
|
602
|
+
candidate_id += 1
|
|
603
|
+
|
|
604
|
+
# Phase 1: Seed candidates along all edges
|
|
605
|
+
if self.config.verbose:
|
|
606
|
+
print("Phase 1: Seeding edge candidates...")
|
|
607
|
+
|
|
608
|
+
for edge_idx in range(len(self.geometry.edge_starts)):
|
|
609
|
+
if fixed_r is not None:
|
|
610
|
+
# Fixed radius mode: place along edges
|
|
611
|
+
edge_positions = self._find_tangent_circle_edge(edge_idx, fixed_r)
|
|
612
|
+
for center, t in edge_positions:
|
|
613
|
+
if self._is_valid_placement(center, fixed_r):
|
|
614
|
+
add_candidate(center, fixed_r)
|
|
615
|
+
else:
|
|
616
|
+
# Variable radius: sample points along edge and compute max radius
|
|
617
|
+
edge_positions = self._find_tangent_circle_edge(edge_idx, min_r)
|
|
618
|
+
for center, t in edge_positions:
|
|
619
|
+
if self.geometry._point_in_polygon_single(center):
|
|
620
|
+
max_r = self._get_max_radius_at_point(center)
|
|
621
|
+
if max_r >= min_r:
|
|
622
|
+
add_candidate(center, max_r)
|
|
623
|
+
|
|
624
|
+
if self.config.verbose:
|
|
625
|
+
print(f" Initial candidates: {len(candidates)}")
|
|
626
|
+
|
|
627
|
+
# Phase 2: Main loop - place circles and generate new candidates
|
|
628
|
+
if self.config.verbose:
|
|
629
|
+
print("Phase 2: Placing circles...")
|
|
630
|
+
|
|
631
|
+
iterations = 0
|
|
632
|
+
max_iterations = 100000 # Safety limit
|
|
633
|
+
|
|
634
|
+
while candidates and iterations < max_iterations:
|
|
635
|
+
iterations += 1
|
|
636
|
+
|
|
637
|
+
# Pop best candidate
|
|
638
|
+
neg_radius, _, center, radius = heapq.heappop(candidates)
|
|
639
|
+
|
|
640
|
+
# Recompute max radius (things may have changed)
|
|
641
|
+
if fixed_r is not None:
|
|
642
|
+
actual_radius = fixed_r
|
|
643
|
+
if not self._is_valid_placement(center, actual_radius):
|
|
644
|
+
continue
|
|
645
|
+
else:
|
|
646
|
+
actual_radius = self._get_max_radius_at_point(center)
|
|
647
|
+
if actual_radius < min_r:
|
|
648
|
+
continue
|
|
649
|
+
if not self._is_valid_placement(center, actual_radius):
|
|
650
|
+
continue
|
|
651
|
+
|
|
652
|
+
# Place the circle
|
|
653
|
+
self._place_circle(center, actual_radius)
|
|
654
|
+
circles.append((float(center[0]), float(center[1]), float(actual_radius)))
|
|
655
|
+
|
|
656
|
+
if self.config.verbose and len(circles) % 50 == 0:
|
|
657
|
+
print(f" Placed {len(circles)} circles, {len(candidates)} candidates remaining")
|
|
658
|
+
|
|
659
|
+
new_circle_idx = len(self.centers) - 1
|
|
660
|
+
|
|
661
|
+
# Generate new candidates from circle-circle tangencies
|
|
662
|
+
for other_idx in range(new_circle_idx):
|
|
663
|
+
pair = (min(other_idx, new_circle_idx), max(other_idx, new_circle_idx))
|
|
664
|
+
if pair in processed_pairs:
|
|
665
|
+
continue
|
|
666
|
+
processed_pairs.add(pair)
|
|
667
|
+
|
|
668
|
+
other_center = self.centers[other_idx]
|
|
669
|
+
other_radius = self.radii[other_idx]
|
|
670
|
+
|
|
671
|
+
# Try to find tangent circles
|
|
672
|
+
if fixed_r is not None:
|
|
673
|
+
tangent_centers = self._find_tangent_circle_two_circles(
|
|
674
|
+
center, actual_radius, other_center, other_radius, fixed_r
|
|
675
|
+
)
|
|
676
|
+
for tc in tangent_centers:
|
|
677
|
+
if self._is_valid_placement(tc, fixed_r):
|
|
678
|
+
add_candidate(tc, fixed_r)
|
|
679
|
+
else:
|
|
680
|
+
# For variable radius, try a few different radii
|
|
681
|
+
for test_r in [min_r, min_r * 2, min_r * 4]:
|
|
682
|
+
tangent_centers = self._find_tangent_circle_two_circles(
|
|
683
|
+
center, actual_radius, other_center, other_radius, test_r
|
|
684
|
+
)
|
|
685
|
+
for tc in tangent_centers:
|
|
686
|
+
if self.geometry._point_in_polygon_single(tc):
|
|
687
|
+
max_r = self._get_max_radius_at_point(tc)
|
|
688
|
+
if max_r >= min_r:
|
|
689
|
+
add_candidate(tc, max_r)
|
|
690
|
+
|
|
691
|
+
# Generate new candidates from circle-edge tangencies
|
|
692
|
+
for edge_idx in range(len(self.geometry.edge_starts)):
|
|
693
|
+
ce_pair = (new_circle_idx, edge_idx)
|
|
694
|
+
if ce_pair in processed_circle_edge:
|
|
695
|
+
continue
|
|
696
|
+
processed_circle_edge.add(ce_pair)
|
|
697
|
+
|
|
698
|
+
if fixed_r is not None:
|
|
699
|
+
tangent_centers = self._find_tangent_circle_circle_and_edge(
|
|
700
|
+
new_circle_idx, edge_idx, fixed_r
|
|
701
|
+
)
|
|
702
|
+
for tc in tangent_centers:
|
|
703
|
+
if self._is_valid_placement(tc, fixed_r):
|
|
704
|
+
add_candidate(tc, fixed_r)
|
|
705
|
+
else:
|
|
706
|
+
for test_r in [min_r, min_r * 2]:
|
|
707
|
+
tangent_centers = self._find_tangent_circle_circle_and_edge(
|
|
708
|
+
new_circle_idx, edge_idx, test_r
|
|
709
|
+
)
|
|
710
|
+
for tc in tangent_centers:
|
|
711
|
+
if self.geometry._point_in_polygon_single(tc):
|
|
712
|
+
max_r = self._get_max_radius_at_point(tc)
|
|
713
|
+
if max_r >= min_r:
|
|
714
|
+
add_candidate(tc, max_r)
|
|
715
|
+
|
|
716
|
+
if self.config.verbose:
|
|
717
|
+
print(f"Done! Placed {len(circles)} circles in {iterations} iterations")
|
|
718
|
+
|
|
719
|
+
return circles
|
|
720
|
+
|
|
721
|
+
# =========================================================================
|
|
722
|
+
# Random Sampling Packing
|
|
723
|
+
# =========================================================================
|
|
724
|
+
|
|
725
|
+
def _pack_random(self) -> Iterator[Circle]:
|
|
726
|
+
self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
|
|
727
|
+
|
|
728
|
+
while self.progress.failed_attempts < self.config.max_failed_attempts:
|
|
729
|
+
candidates = self._sample_candidate_points(self.config.sample_batch_size)
|
|
730
|
+
result = self._find_best_placement(candidates)
|
|
731
|
+
|
|
732
|
+
if result is not None:
|
|
733
|
+
center, radius = result
|
|
734
|
+
self._place_circle(center, radius)
|
|
735
|
+
self.progress.circles_placed += 1
|
|
736
|
+
self.progress.failed_attempts = 0
|
|
737
|
+
|
|
738
|
+
if self.config.verbose and self.progress.circles_placed % 25 == 0:
|
|
739
|
+
print(self.progress)
|
|
740
|
+
|
|
741
|
+
yield (float(center[0]), float(center[1]), float(radius))
|
|
742
|
+
else:
|
|
743
|
+
self.progress.failed_attempts += 1
|
|
744
|
+
|
|
745
|
+
if self.config.verbose and self.progress.failed_attempts % 50 == 0:
|
|
746
|
+
print(self.progress)
|
|
747
|
+
|
|
748
|
+
if self.config.verbose:
|
|
749
|
+
print(f"Done! {self.progress}")
|
|
750
|
+
|
|
751
|
+
# =========================================================================
|
|
752
|
+
# Main Entry Points
|
|
753
|
+
# =========================================================================
|
|
754
|
+
|
|
755
|
+
def generate(self) -> Iterator[Circle]:
|
|
756
|
+
"""
|
|
757
|
+
Generate circles until no more can be placed.
|
|
758
|
+
|
|
759
|
+
Strategy selection:
|
|
760
|
+
1. If use_front_packing=True: use front-based algorithm (highest density)
|
|
761
|
+
2. Else if fixed_radius and use_hex_grid=True: use hex grid (fastest for fixed)
|
|
762
|
+
3. Else: use random sampling
|
|
763
|
+
|
|
764
|
+
Yields:
|
|
765
|
+
Tuples of (x, y, radius) for each placed circle.
|
|
766
|
+
"""
|
|
767
|
+
# Front-based packing (highest density)
|
|
768
|
+
if self.config.use_front_packing:
|
|
769
|
+
yield from self._pack_front()
|
|
770
|
+
return
|
|
771
|
+
|
|
772
|
+
# Hex grid for fixed radius (unless disabled)
|
|
773
|
+
if self.config.fixed_radius is not None and self.config.use_hex_grid:
|
|
774
|
+
yield from self._pack_hex_grid()
|
|
775
|
+
return
|
|
776
|
+
|
|
777
|
+
# Random sampling (original method)
|
|
778
|
+
yield from self._pack_random()
|
|
779
|
+
|
|
780
|
+
def pack(self) -> List[Circle]:
|
|
781
|
+
"""Pack circles and return them as a list."""
|
|
782
|
+
return list(self.generate())
|
|
@@ -1,430 +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
|
-
# Threshold for switching between vectorized and spatial index approaches
|
|
12
|
-
VECTORIZED_THRESHOLD = 750
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@dataclass
|
|
16
|
-
class PackingConfig:
|
|
17
|
-
"""Configuration parameters for the circle packing algorithm."""
|
|
18
|
-
padding: float = 1.5
|
|
19
|
-
min_radius: float = 1.0
|
|
20
|
-
grid_resolution_divisor: float = 25
|
|
21
|
-
max_failed_attempts: int = 200
|
|
22
|
-
mega_circle_threshold: float = 0.5
|
|
23
|
-
ray_cast_epsilon: float = 1e-10
|
|
24
|
-
sample_batch_size: int = 50
|
|
25
|
-
fixed_radius: Optional[float] = None
|
|
26
|
-
use_hex_grid: bool = True
|
|
27
|
-
verbose: bool = False
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@dataclass
|
|
31
|
-
class PackingProgress:
|
|
32
|
-
"""Tracks the current state of the packing algorithm."""
|
|
33
|
-
circles_placed: int = 0
|
|
34
|
-
failed_attempts: int = 0
|
|
35
|
-
max_failed_attempts: int = 200
|
|
36
|
-
|
|
37
|
-
@property
|
|
38
|
-
def progress_ratio(self) -> float:
|
|
39
|
-
"""How close we are to stopping (0.0 = just started, 1.0 = about to stop)."""
|
|
40
|
-
return self.failed_attempts / self.max_failed_attempts
|
|
41
|
-
|
|
42
|
-
def __str__(self) -> str:
|
|
43
|
-
return f"Placed: {self.circles_placed} | Failed attempts: {self.failed_attempts}/{self.max_failed_attempts} ({self.progress_ratio:.0%})"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
class PolygonGeometry:
|
|
47
|
-
"""Handles geometric calculations for polygon boundaries."""
|
|
48
|
-
|
|
49
|
-
def __init__(self, polygons: List[Polygon], epsilon: float = 1e-10):
|
|
50
|
-
self.polygons = [np.array(p, dtype=float) for p in polygons]
|
|
51
|
-
self.epsilon = epsilon
|
|
52
|
-
self._compute_bounds()
|
|
53
|
-
self._precompute_edges()
|
|
54
|
-
|
|
55
|
-
def _compute_bounds(self) -> None:
|
|
56
|
-
all_vertices = np.vstack(self.polygons)
|
|
57
|
-
self.min_coords = np.min(all_vertices, axis=0)
|
|
58
|
-
self.max_coords = np.max(all_vertices, axis=0)
|
|
59
|
-
|
|
60
|
-
def _precompute_edges(self) -> None:
|
|
61
|
-
"""Precompute edge data for vectorized distance calculations."""
|
|
62
|
-
all_p1 = []
|
|
63
|
-
all_p2 = []
|
|
64
|
-
for poly in self.polygons:
|
|
65
|
-
n = len(poly)
|
|
66
|
-
for i in range(n):
|
|
67
|
-
all_p1.append(poly[i])
|
|
68
|
-
all_p2.append(poly[(i + 1) % n])
|
|
69
|
-
|
|
70
|
-
self.edge_starts = np.array(all_p1)
|
|
71
|
-
self.edge_ends = np.array(all_p2)
|
|
72
|
-
self.edge_vecs = self.edge_ends - self.edge_starts
|
|
73
|
-
self.edge_lengths_sq = np.sum(self.edge_vecs ** 2, axis=1)
|
|
74
|
-
|
|
75
|
-
def contains_points(self, points: np.ndarray) -> np.ndarray:
|
|
76
|
-
"""Even-Odd Rule for interior detection, supports holes."""
|
|
77
|
-
x, y = points[:, 0], points[:, 1]
|
|
78
|
-
inside = np.zeros(len(points), dtype=bool)
|
|
79
|
-
|
|
80
|
-
for poly in self.polygons:
|
|
81
|
-
n = len(poly)
|
|
82
|
-
for i in range(n):
|
|
83
|
-
p1, p2 = poly[i], poly[(i + 1) % n]
|
|
84
|
-
crosses_edge = (p1[1] > y) != (p2[1] > y)
|
|
85
|
-
dy = p2[1] - p1[1] + self.epsilon
|
|
86
|
-
x_intercept = (p2[0] - p1[0]) * (y - p1[1]) / dy + p1[0]
|
|
87
|
-
inside ^= crosses_edge & (x < x_intercept)
|
|
88
|
-
|
|
89
|
-
return inside
|
|
90
|
-
|
|
91
|
-
def distance_to_boundary(self, point: Point) -> float:
|
|
92
|
-
"""Vectorized distance to nearest polygon edge."""
|
|
93
|
-
to_point = point - self.edge_starts
|
|
94
|
-
|
|
95
|
-
dots = np.sum(to_point * self.edge_vecs, axis=1)
|
|
96
|
-
|
|
97
|
-
with np.errstate(divide='ignore', invalid='ignore'):
|
|
98
|
-
t = np.clip(dots / self.edge_lengths_sq, 0, 1)
|
|
99
|
-
t = np.where(self.edge_lengths_sq == 0, 0, t)
|
|
100
|
-
|
|
101
|
-
projections = self.edge_starts + t[:, np.newaxis] * self.edge_vecs
|
|
102
|
-
distances = np.linalg.norm(point - projections, axis=1)
|
|
103
|
-
|
|
104
|
-
return float(np.min(distances))
|
|
105
|
-
|
|
106
|
-
def distances_to_boundary_batch(self, points: np.ndarray) -> np.ndarray:
|
|
107
|
-
"""
|
|
108
|
-
Vectorized distance calculation for multiple points at once.
|
|
109
|
-
"""
|
|
110
|
-
n_points = len(points)
|
|
111
|
-
n_edges = len(self.edge_starts)
|
|
112
|
-
|
|
113
|
-
to_point = points[:, np.newaxis, :] - self.edge_starts[np.newaxis, :, :]
|
|
114
|
-
dots = np.sum(to_point * self.edge_vecs[np.newaxis, :, :], axis=2)
|
|
115
|
-
|
|
116
|
-
with np.errstate(divide='ignore', invalid='ignore'):
|
|
117
|
-
t = np.clip(dots / self.edge_lengths_sq[np.newaxis, :], 0, 1)
|
|
118
|
-
t = np.where(self.edge_lengths_sq[np.newaxis, :] == 0, 0, t)
|
|
119
|
-
|
|
120
|
-
projections = (
|
|
121
|
-
self.edge_starts[np.newaxis, :, :] +
|
|
122
|
-
t[:, :, np.newaxis] * self.edge_vecs[np.newaxis, :, :]
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
distances = np.linalg.norm(points[:, np.newaxis, :] - projections, axis=2)
|
|
126
|
-
|
|
127
|
-
return np.min(distances, axis=1)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
@dataclass
|
|
131
|
-
class SpatialIndex:
|
|
132
|
-
"""Grid-based spatial index for efficient collision detection."""
|
|
133
|
-
cell_size: float
|
|
134
|
-
origin: np.ndarray
|
|
135
|
-
mega_threshold: float
|
|
136
|
-
grid: Dict[GridKey, List[int]] = field(default_factory=dict)
|
|
137
|
-
mega_circles: List[int] = field(default_factory=list)
|
|
138
|
-
|
|
139
|
-
_centers: np.ndarray = field(default_factory=lambda: np.empty((0, 2)))
|
|
140
|
-
_radii: np.ndarray = field(default_factory=lambda: np.empty(0))
|
|
141
|
-
|
|
142
|
-
def add_circle(self, index: int, center: Point, radius: float) -> None:
|
|
143
|
-
self._centers = np.vstack([self._centers, center]) if len(self._centers) > 0 else center.reshape(1, 2)
|
|
144
|
-
self._radii = np.append(self._radii, radius)
|
|
145
|
-
|
|
146
|
-
if radius > self.cell_size * self.mega_threshold:
|
|
147
|
-
self.mega_circles.append(index)
|
|
148
|
-
else:
|
|
149
|
-
key = self._get_cell_key(center)
|
|
150
|
-
self.grid.setdefault(key, []).append(index)
|
|
151
|
-
|
|
152
|
-
def get_nearby_indices(self, point: Point) -> Iterator[int]:
|
|
153
|
-
yield from self.mega_circles
|
|
154
|
-
center_key = self._get_cell_key(point)
|
|
155
|
-
for dx in range(-1, 2):
|
|
156
|
-
for dy in range(-1, 2):
|
|
157
|
-
neighbor_key = (center_key[0] + dx, center_key[1] + dy)
|
|
158
|
-
if neighbor_key in self.grid:
|
|
159
|
-
yield from self.grid[neighbor_key]
|
|
160
|
-
|
|
161
|
-
def distance_to_circles(self, point: Point) -> float:
|
|
162
|
-
"""Get minimum distance from point to any existing circle's edge."""
|
|
163
|
-
if len(self._centers) == 0:
|
|
164
|
-
return float('inf')
|
|
165
|
-
|
|
166
|
-
indices = list(self.get_nearby_indices(point))
|
|
167
|
-
if not indices:
|
|
168
|
-
return float('inf')
|
|
169
|
-
|
|
170
|
-
centers = self._centers[indices]
|
|
171
|
-
radii = self._radii[indices]
|
|
172
|
-
|
|
173
|
-
distances = np.linalg.norm(centers - point, axis=1) - radii
|
|
174
|
-
return float(np.min(distances))
|
|
175
|
-
|
|
176
|
-
def _get_cell_key(self, point: Point) -> GridKey:
|
|
177
|
-
cell_coords = ((point - self.origin) // self.cell_size).astype(int)
|
|
178
|
-
return (int(cell_coords[0]), int(cell_coords[1]))
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
class CirclePacker:
|
|
182
|
-
"""Packs circles within polygon boundaries using random sampling."""
|
|
183
|
-
|
|
184
|
-
def __init__(self, polygons: List[Polygon], config: Optional[PackingConfig] = None):
|
|
185
|
-
self.config = config or PackingConfig()
|
|
186
|
-
self.geometry = PolygonGeometry(polygons, self.config.ray_cast_epsilon)
|
|
187
|
-
self.centers: List[Point] = []
|
|
188
|
-
self.radii: List[float] = []
|
|
189
|
-
self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
|
|
190
|
-
|
|
191
|
-
extent = max(self.geometry.max_coords - self.geometry.min_coords)
|
|
192
|
-
cell_size = extent / self.config.grid_resolution_divisor
|
|
193
|
-
self.spatial_index = SpatialIndex(
|
|
194
|
-
cell_size=cell_size,
|
|
195
|
-
origin=self.geometry.min_coords,
|
|
196
|
-
mega_threshold=self.config.mega_circle_threshold
|
|
197
|
-
)
|
|
198
|
-
|
|
199
|
-
# Cache for numpy arrays (avoid repeated conversion)
|
|
200
|
-
self._centers_arr: Optional[np.ndarray] = None
|
|
201
|
-
self._radii_arr: Optional[np.ndarray] = None
|
|
202
|
-
self._cache_valid = False
|
|
203
|
-
|
|
204
|
-
def _invalidate_cache(self) -> None:
|
|
205
|
-
"""Mark the numpy array cache as needing refresh."""
|
|
206
|
-
self._cache_valid = False
|
|
207
|
-
|
|
208
|
-
def _get_arrays(self) -> Tuple[np.ndarray, np.ndarray]:
|
|
209
|
-
"""Get cached numpy arrays of centers and radii."""
|
|
210
|
-
if not self._cache_valid or self._centers_arr is None:
|
|
211
|
-
if len(self.centers) > 0:
|
|
212
|
-
self._centers_arr = np.array(self.centers)
|
|
213
|
-
self._radii_arr = np.array(self.radii)
|
|
214
|
-
else:
|
|
215
|
-
self._centers_arr = np.empty((0, 2))
|
|
216
|
-
self._radii_arr = np.empty(0)
|
|
217
|
-
self._cache_valid = True
|
|
218
|
-
return self._centers_arr, self._radii_arr
|
|
219
|
-
|
|
220
|
-
def _sample_candidate_points(self, count: int) -> np.ndarray:
|
|
221
|
-
points = np.random.uniform(
|
|
222
|
-
self.geometry.min_coords,
|
|
223
|
-
self.geometry.max_coords,
|
|
224
|
-
size=(count, 2)
|
|
225
|
-
)
|
|
226
|
-
return points[self.geometry.contains_points(points)]
|
|
227
|
-
|
|
228
|
-
def _compute_max_radius(self, point: Point) -> float:
|
|
229
|
-
"""Compute max radius for a single point."""
|
|
230
|
-
max_radius = self.geometry.distance_to_boundary(point)
|
|
231
|
-
circle_dist = self.spatial_index.distance_to_circles(point)
|
|
232
|
-
max_radius = min(max_radius, circle_dist)
|
|
233
|
-
return max_radius - self.config.padding
|
|
234
|
-
|
|
235
|
-
def _compute_max_radii_batch(self, points: np.ndarray) -> np.ndarray:
|
|
236
|
-
"""
|
|
237
|
-
Vectorized max radius computation for multiple points.
|
|
238
|
-
|
|
239
|
-
Uses hybrid approach:
|
|
240
|
-
- Fully vectorized numpy for small circle counts (faster due to no Python loop)
|
|
241
|
-
- Spatial index for large circle counts (faster due to fewer distance calculations)
|
|
242
|
-
"""
|
|
243
|
-
if len(points) == 0:
|
|
244
|
-
return np.array([])
|
|
245
|
-
|
|
246
|
-
# Boundary distances (fully vectorized)
|
|
247
|
-
max_radii = self.geometry.distances_to_boundary_batch(points)
|
|
248
|
-
|
|
249
|
-
if len(self.centers) == 0:
|
|
250
|
-
return max_radii - self.config.padding
|
|
251
|
-
|
|
252
|
-
centers_arr, radii_arr = self._get_arrays()
|
|
253
|
-
|
|
254
|
-
if len(self.centers) < VECTORIZED_THRESHOLD:
|
|
255
|
-
# Fully vectorized approach for small circle counts
|
|
256
|
-
# Compute distance from each point to each circle: (n_points, n_circles)
|
|
257
|
-
dists = np.linalg.norm(
|
|
258
|
-
points[:, np.newaxis, :] - centers_arr[np.newaxis, :, :],
|
|
259
|
-
axis=2
|
|
260
|
-
) - radii_arr
|
|
261
|
-
|
|
262
|
-
# Min distance to any circle for each point
|
|
263
|
-
min_circle_dists = np.min(dists, axis=1)
|
|
264
|
-
max_radii = np.minimum(max_radii, min_circle_dists)
|
|
265
|
-
else:
|
|
266
|
-
# Spatial index approach for large circle counts
|
|
267
|
-
for i, point in enumerate(points):
|
|
268
|
-
indices = list(self.spatial_index.get_nearby_indices(point))
|
|
269
|
-
if indices:
|
|
270
|
-
nearby_centers = centers_arr[indices]
|
|
271
|
-
nearby_radii = radii_arr[indices]
|
|
272
|
-
distances = np.linalg.norm(nearby_centers - point, axis=1) - nearby_radii
|
|
273
|
-
max_radii[i] = min(max_radii[i], np.min(distances))
|
|
274
|
-
|
|
275
|
-
return max_radii - self.config.padding
|
|
276
|
-
|
|
277
|
-
def _find_best_placement(self, candidates: np.ndarray) -> Optional[Tuple[Point, float]]:
|
|
278
|
-
if len(candidates) == 0:
|
|
279
|
-
return None
|
|
280
|
-
|
|
281
|
-
radii = self._compute_max_radii_batch(candidates)
|
|
282
|
-
fixed = self.config.fixed_radius
|
|
283
|
-
|
|
284
|
-
if fixed is not None:
|
|
285
|
-
valid_mask = radii >= fixed
|
|
286
|
-
if not np.any(valid_mask):
|
|
287
|
-
return None
|
|
288
|
-
valid_indices = np.where(valid_mask)[0]
|
|
289
|
-
best_idx = valid_indices[0]
|
|
290
|
-
return candidates[best_idx], fixed
|
|
291
|
-
else:
|
|
292
|
-
best_idx = np.argmax(radii)
|
|
293
|
-
best_radius = radii[best_idx]
|
|
294
|
-
|
|
295
|
-
if best_radius >= self.config.min_radius:
|
|
296
|
-
return candidates[best_idx], best_radius
|
|
297
|
-
return None
|
|
298
|
-
|
|
299
|
-
def _place_circle(self, center: Point, radius: float) -> None:
|
|
300
|
-
idx = len(self.centers)
|
|
301
|
-
self.centers.append(center)
|
|
302
|
-
self.radii.append(radius)
|
|
303
|
-
self.spatial_index.add_circle(idx, center, radius)
|
|
304
|
-
self._invalidate_cache()
|
|
305
|
-
|
|
306
|
-
def _generate_hex_grid(self, radius: float) -> np.ndarray:
|
|
307
|
-
"""
|
|
308
|
-
Generate a hexagonal grid of points within the bounding box.
|
|
309
|
-
Hex grid is the optimal packing arrangement for equal circles.
|
|
310
|
-
"""
|
|
311
|
-
spacing = (radius + self.config.padding) * 2
|
|
312
|
-
dy = spacing * np.sqrt(3) / 2
|
|
313
|
-
|
|
314
|
-
min_x, min_y = self.geometry.min_coords
|
|
315
|
-
max_x, max_y = self.geometry.max_coords
|
|
316
|
-
|
|
317
|
-
# Add margin to ensure coverage
|
|
318
|
-
min_x -= spacing
|
|
319
|
-
min_y -= spacing
|
|
320
|
-
max_x += spacing
|
|
321
|
-
max_y += spacing
|
|
322
|
-
|
|
323
|
-
points = []
|
|
324
|
-
row = 0
|
|
325
|
-
y = min_y
|
|
326
|
-
|
|
327
|
-
while y <= max_y:
|
|
328
|
-
# Offset every other row by half spacing
|
|
329
|
-
x_offset = (spacing / 2) if row % 2 else 0
|
|
330
|
-
x = min_x + x_offset
|
|
331
|
-
|
|
332
|
-
while x <= max_x:
|
|
333
|
-
points.append([x, y])
|
|
334
|
-
x += spacing
|
|
335
|
-
|
|
336
|
-
y += dy
|
|
337
|
-
row += 1
|
|
338
|
-
|
|
339
|
-
return np.array(points) if points else np.empty((0, 2))
|
|
340
|
-
|
|
341
|
-
def _pack_hex_grid(self) -> List[Circle]:
|
|
342
|
-
"""
|
|
343
|
-
Pack circles using hexagonal grid placement.
|
|
344
|
-
Much faster and denser than random sampling for fixed radius.
|
|
345
|
-
"""
|
|
346
|
-
radius = self.config.fixed_radius
|
|
347
|
-
circles = []
|
|
348
|
-
|
|
349
|
-
# Generate hex grid
|
|
350
|
-
grid_points = self._generate_hex_grid(radius)
|
|
351
|
-
|
|
352
|
-
if len(grid_points) == 0:
|
|
353
|
-
return circles
|
|
354
|
-
|
|
355
|
-
# Filter to points inside polygon
|
|
356
|
-
inside_mask = self.geometry.contains_points(grid_points)
|
|
357
|
-
interior_points = grid_points[inside_mask]
|
|
358
|
-
|
|
359
|
-
# Filter to points with enough clearance from boundary
|
|
360
|
-
min_clearance = radius + self.config.padding
|
|
361
|
-
boundary_distances = self.geometry.distances_to_boundary_batch(interior_points)
|
|
362
|
-
valid_mask = boundary_distances >= min_clearance
|
|
363
|
-
|
|
364
|
-
valid_points = interior_points[valid_mask]
|
|
365
|
-
|
|
366
|
-
if self.config.verbose:
|
|
367
|
-
print(f"Hex grid: {len(grid_points)} total -> {len(interior_points)} inside -> {len(valid_points)} valid")
|
|
368
|
-
|
|
369
|
-
# All valid points become circles (no collision check needed - hex grid guarantees no overlap)
|
|
370
|
-
for point in valid_points:
|
|
371
|
-
self._place_circle(point, radius)
|
|
372
|
-
circles.append((float(point[0]), float(point[1]), float(radius)))
|
|
373
|
-
|
|
374
|
-
if self.config.verbose:
|
|
375
|
-
print(f"Done! Placed {len(circles)} circles")
|
|
376
|
-
|
|
377
|
-
return circles
|
|
378
|
-
|
|
379
|
-
def _pack_random(self) -> Iterator[Circle]:
|
|
380
|
-
"""
|
|
381
|
-
Pack circles using random sampling.
|
|
382
|
-
Used for variable radius mode or when organic placement is desired.
|
|
383
|
-
"""
|
|
384
|
-
self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
|
|
385
|
-
|
|
386
|
-
while self.progress.failed_attempts < self.config.max_failed_attempts:
|
|
387
|
-
candidates = self._sample_candidate_points(self.config.sample_batch_size)
|
|
388
|
-
result = self._find_best_placement(candidates)
|
|
389
|
-
|
|
390
|
-
if result is not None:
|
|
391
|
-
center, radius = result
|
|
392
|
-
self._place_circle(center, radius)
|
|
393
|
-
self.progress.circles_placed += 1
|
|
394
|
-
self.progress.failed_attempts = 0
|
|
395
|
-
|
|
396
|
-
if self.config.verbose and self.progress.circles_placed % 25 == 0:
|
|
397
|
-
print(self.progress)
|
|
398
|
-
|
|
399
|
-
yield (float(center[0]), float(center[1]), float(radius))
|
|
400
|
-
else:
|
|
401
|
-
self.progress.failed_attempts += 1
|
|
402
|
-
|
|
403
|
-
if self.config.verbose and self.progress.failed_attempts % 50 == 0:
|
|
404
|
-
print(self.progress)
|
|
405
|
-
|
|
406
|
-
if self.config.verbose:
|
|
407
|
-
print(f"Done! {self.progress}")
|
|
408
|
-
|
|
409
|
-
def generate(self) -> Iterator[Circle]:
|
|
410
|
-
"""
|
|
411
|
-
Generate circles until no more can be placed.
|
|
412
|
-
|
|
413
|
-
For fixed_radius mode with use_hex_grid=True (default), uses optimized hex grid.
|
|
414
|
-
For fixed_radius mode with use_hex_grid=False, uses random sampling for organic look.
|
|
415
|
-
For variable radius mode, uses random sampling with best-fit selection.
|
|
416
|
-
|
|
417
|
-
Yields:
|
|
418
|
-
Tuples of (x, y, radius) for each placed circle.
|
|
419
|
-
"""
|
|
420
|
-
# Use hex grid for fixed radius (unless disabled)
|
|
421
|
-
if self.config.fixed_radius is not None and self.config.use_hex_grid:
|
|
422
|
-
yield from self._pack_hex_grid()
|
|
423
|
-
return
|
|
424
|
-
|
|
425
|
-
# Random sampling mode (variable radius OR fixed radius with organic placement)
|
|
426
|
-
yield from self._pack_random()
|
|
427
|
-
|
|
428
|
-
def pack(self) -> List[Circle]:
|
|
429
|
-
"""Pack circles and return them as a list."""
|
|
430
|
-
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
|