diskpack 0.2.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.2.0/src/diskpack.egg-info → diskpack-0.3.0}/PKG-INFO +1 -1
- {diskpack-0.2.0 → diskpack-0.3.0}/pyproject.toml +1 -1
- {diskpack-0.2.0 → diskpack-0.3.0}/src/diskpack/packer.py +161 -31
- {diskpack-0.2.0 → diskpack-0.3.0/src/diskpack.egg-info}/PKG-INFO +1 -1
- {diskpack-0.2.0 → diskpack-0.3.0}/LICENSE +0 -0
- {diskpack-0.2.0 → diskpack-0.3.0}/README.md +0 -0
- {diskpack-0.2.0 → diskpack-0.3.0}/setup.cfg +0 -0
- {diskpack-0.2.0 → diskpack-0.3.0}/src/diskpack/__init__.py +0 -0
- {diskpack-0.2.0 → diskpack-0.3.0}/src/diskpack.egg-info/SOURCES.txt +0 -0
- {diskpack-0.2.0 → diskpack-0.3.0}/src/diskpack.egg-info/dependency_links.txt +0 -0
- {diskpack-0.2.0 → diskpack-0.3.0}/src/diskpack.egg-info/requires.txt +0 -0
- {diskpack-0.2.0 → diskpack-0.3.0}/src/diskpack.egg-info/top_level.txt +0 -0
- {diskpack-0.2.0 → diskpack-0.3.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.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"
|
|
@@ -46,12 +46,28 @@ class PolygonGeometry:
|
|
|
46
46
|
self.polygons = [np.array(p, dtype=float) for p in polygons]
|
|
47
47
|
self.epsilon = epsilon
|
|
48
48
|
self._compute_bounds()
|
|
49
|
+
self._precompute_edges()
|
|
49
50
|
|
|
50
51
|
def _compute_bounds(self) -> None:
|
|
51
52
|
all_vertices = np.vstack(self.polygons)
|
|
52
53
|
self.min_coords = np.min(all_vertices, axis=0)
|
|
53
54
|
self.max_coords = np.max(all_vertices, axis=0)
|
|
54
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
|
+
|
|
55
71
|
def contains_points(self, points: np.ndarray) -> np.ndarray:
|
|
56
72
|
"""Even-Odd Rule for interior detection, supports holes."""
|
|
57
73
|
x, y = points[:, 0], points[:, 1]
|
|
@@ -69,22 +85,55 @@ class PolygonGeometry:
|
|
|
69
85
|
return inside
|
|
70
86
|
|
|
71
87
|
def distance_to_boundary(self, point: Point) -> float:
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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)
|
|
88
137
|
|
|
89
138
|
|
|
90
139
|
@dataclass
|
|
@@ -95,8 +144,16 @@ class SpatialIndex:
|
|
|
95
144
|
mega_threshold: float
|
|
96
145
|
grid: Dict[GridKey, List[int]] = field(default_factory=dict)
|
|
97
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))
|
|
98
151
|
|
|
99
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
|
+
|
|
100
157
|
if radius > self.cell_size * self.mega_threshold:
|
|
101
158
|
self.mega_circles.append(index)
|
|
102
159
|
else:
|
|
@@ -112,6 +169,43 @@ class SpatialIndex:
|
|
|
112
169
|
if neighbor_key in self.grid:
|
|
113
170
|
yield from self.grid[neighbor_key]
|
|
114
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
|
+
|
|
115
209
|
def _get_cell_key(self, point: Point) -> GridKey:
|
|
116
210
|
cell_coords = ((point - self.origin) // self.cell_size).astype(int)
|
|
117
211
|
return (int(cell_coords[0]), int(cell_coords[1]))
|
|
@@ -144,26 +238,62 @@ class CirclePacker:
|
|
|
144
238
|
return points[self.geometry.contains_points(points)]
|
|
145
239
|
|
|
146
240
|
def _compute_max_radius(self, point: Point) -> float:
|
|
241
|
+
"""Compute max radius for a single point."""
|
|
147
242
|
max_radius = self.geometry.distance_to_boundary(point)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
max_radius = min(max_radius, distance_to_circle)
|
|
243
|
+
circle_dist = self.spatial_index.distance_to_circles(point)
|
|
244
|
+
max_radius = min(max_radius, circle_dist)
|
|
151
245
|
return max_radius - self.config.padding
|
|
152
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
|
+
|
|
153
273
|
def _find_best_placement(self, candidates: np.ndarray) -> Optional[Tuple[Point, float]]:
|
|
154
|
-
|
|
274
|
+
if len(candidates) == 0:
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
# Batch compute all radii
|
|
278
|
+
radii = self._compute_max_radii_batch(candidates)
|
|
155
279
|
fixed = self.config.fixed_radius
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
radius
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
167
297
|
|
|
168
298
|
def _place_circle(self, center: Point, radius: float) -> None:
|
|
169
299
|
idx = len(self.centers)
|
|
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
|