diskpack 0.6.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: diskpack
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: A high-performance vectorized circle packer with spatial hashing.
5
5
  Author-email: James Kelly <mrkellyjam@gmail.com>
6
6
  Requires-Python: >=3.8
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "diskpack"
7
- version = "0.6.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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: diskpack
3
- Version: 0.6.0
3
+ Version: 0.8.0
4
4
  Summary: A high-performance vectorized circle packer with spatial hashing.
5
5
  Author-email: James Kelly <mrkellyjam@gmail.com>
6
6
  Requires-Python: >=3.8
@@ -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 = 300
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