diskpack 0.1.0__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {diskpack-0.1.0/src/diskpack.egg-info → diskpack-0.2.0}/PKG-INFO +1 -1
- {diskpack-0.1.0 → diskpack-0.2.0}/pyproject.toml +1 -1
- {diskpack-0.1.0 → diskpack-0.2.0}/src/diskpack/packer.py +22 -22
- {diskpack-0.1.0 → diskpack-0.2.0/src/diskpack.egg-info}/PKG-INFO +1 -1
- {diskpack-0.1.0 → diskpack-0.2.0}/tests/tests.py +6 -13
- {diskpack-0.1.0 → diskpack-0.2.0}/LICENSE +0 -0
- {diskpack-0.1.0 → diskpack-0.2.0}/README.md +0 -0
- {diskpack-0.1.0 → diskpack-0.2.0}/setup.cfg +0 -0
- {diskpack-0.1.0 → diskpack-0.2.0}/src/diskpack/__init__.py +0 -0
- {diskpack-0.1.0 → diskpack-0.2.0}/src/diskpack.egg-info/SOURCES.txt +0 -0
- {diskpack-0.1.0 → diskpack-0.2.0}/src/diskpack.egg-info/dependency_links.txt +0 -0
- {diskpack-0.1.0 → diskpack-0.2.0}/src/diskpack.egg-info/requires.txt +0 -0
- {diskpack-0.1.0 → diskpack-0.2.0}/src/diskpack.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "diskpack"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.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"
|
|
@@ -19,6 +19,8 @@ class PackingConfig:
|
|
|
19
19
|
mega_circle_threshold: float = 0.5
|
|
20
20
|
ray_cast_epsilon: float = 1e-10
|
|
21
21
|
sample_batch_size: int = 50
|
|
22
|
+
fixed_radius: Optional[float] = None
|
|
23
|
+
verbose: bool = False
|
|
22
24
|
|
|
23
25
|
|
|
24
26
|
@dataclass
|
|
@@ -27,12 +29,12 @@ class PackingProgress:
|
|
|
27
29
|
circles_placed: int = 0
|
|
28
30
|
failed_attempts: int = 0
|
|
29
31
|
max_failed_attempts: int = 200
|
|
30
|
-
|
|
32
|
+
|
|
31
33
|
@property
|
|
32
34
|
def progress_ratio(self) -> float:
|
|
33
35
|
"""How close we are to stopping (0.0 = just started, 1.0 = about to stop)."""
|
|
34
36
|
return self.failed_attempts / self.max_failed_attempts
|
|
35
|
-
|
|
37
|
+
|
|
36
38
|
def __str__(self) -> str:
|
|
37
39
|
return f"Placed: {self.circles_placed} | Failed attempts: {self.failed_attempts}/{self.max_failed_attempts} ({self.progress_ratio:.0%})"
|
|
38
40
|
|
|
@@ -148,12 +150,14 @@ class CirclePacker:
|
|
|
148
150
|
max_radius = min(max_radius, distance_to_circle)
|
|
149
151
|
return max_radius - self.config.padding
|
|
150
152
|
|
|
151
|
-
def _find_best_placement(self, candidates: np.ndarray
|
|
153
|
+
def _find_best_placement(self, candidates: np.ndarray) -> Optional[Tuple[Point, float]]:
|
|
152
154
|
best_point, best_radius = None, 0
|
|
155
|
+
fixed = self.config.fixed_radius
|
|
156
|
+
|
|
153
157
|
for point in candidates:
|
|
154
158
|
radius = self._compute_max_radius(point)
|
|
155
|
-
if
|
|
156
|
-
radius =
|
|
159
|
+
if fixed is not None:
|
|
160
|
+
radius = fixed if radius >= fixed else -1
|
|
157
161
|
if radius > best_radius:
|
|
158
162
|
best_point, best_radius = point, radius
|
|
159
163
|
|
|
@@ -167,42 +171,38 @@ class CirclePacker:
|
|
|
167
171
|
self.radii.append(radius)
|
|
168
172
|
self.spatial_index.add_circle(idx, center, radius)
|
|
169
173
|
|
|
170
|
-
def generate(self
|
|
174
|
+
def generate(self) -> Iterator[Circle]:
|
|
171
175
|
"""
|
|
172
176
|
Generate circles until no more can be placed.
|
|
173
|
-
|
|
174
|
-
Args:
|
|
175
|
-
fixed_radius: If provided, all circles will have this exact radius.
|
|
176
|
-
verbose: If True, print progress updates periodically.
|
|
177
|
-
|
|
177
|
+
|
|
178
178
|
Yields:
|
|
179
179
|
Tuples of (x, y, radius) for each placed circle.
|
|
180
180
|
"""
|
|
181
181
|
self.progress = PackingProgress(max_failed_attempts=self.config.max_failed_attempts)
|
|
182
|
-
|
|
182
|
+
|
|
183
183
|
while self.progress.failed_attempts < self.config.max_failed_attempts:
|
|
184
184
|
candidates = self._sample_candidate_points(self.config.sample_batch_size)
|
|
185
|
-
result = self._find_best_placement(candidates
|
|
186
|
-
|
|
185
|
+
result = self._find_best_placement(candidates)
|
|
186
|
+
|
|
187
187
|
if result is not None:
|
|
188
188
|
center, radius = result
|
|
189
189
|
self._place_circle(center, radius)
|
|
190
190
|
self.progress.circles_placed += 1
|
|
191
191
|
self.progress.failed_attempts = 0
|
|
192
|
-
|
|
193
|
-
if verbose and self.progress.circles_placed % 25 == 0:
|
|
192
|
+
|
|
193
|
+
if self.config.verbose and self.progress.circles_placed % 25 == 0:
|
|
194
194
|
print(self.progress)
|
|
195
|
-
|
|
195
|
+
|
|
196
196
|
yield (float(center[0]), float(center[1]), float(radius))
|
|
197
197
|
else:
|
|
198
198
|
self.progress.failed_attempts += 1
|
|
199
|
-
|
|
200
|
-
if verbose and self.progress.failed_attempts % 50 == 0:
|
|
199
|
+
|
|
200
|
+
if self.config.verbose and self.progress.failed_attempts % 50 == 0:
|
|
201
201
|
print(self.progress)
|
|
202
202
|
|
|
203
|
-
if verbose:
|
|
203
|
+
if self.config.verbose:
|
|
204
204
|
print(f"Done! {self.progress}")
|
|
205
205
|
|
|
206
|
-
def pack(self
|
|
206
|
+
def pack(self) -> List[Circle]:
|
|
207
207
|
"""Pack circles and return them as a list."""
|
|
208
|
-
return list(self.generate(
|
|
208
|
+
return list(self.generate())
|
|
@@ -3,14 +3,13 @@ import numpy as np
|
|
|
3
3
|
from diskpack.packer import CirclePacker, PackingConfig, PolygonGeometry
|
|
4
4
|
|
|
5
5
|
|
|
6
|
-
|
|
7
6
|
class TestCirclePacker(unittest.TestCase):
|
|
8
7
|
def setUp(self):
|
|
9
8
|
"""Set up a simple 10x10 square for testing."""
|
|
10
9
|
self.square_verts = np.array([[0, 0], [10, 0], [10, 10], [0, 10]])
|
|
11
10
|
self.square = [self.square_verts]
|
|
12
11
|
# Use small padding/min_radius to allow for dense filling
|
|
13
|
-
self.config = PackingConfig(padding=0.1, min_radius=0.5,
|
|
12
|
+
self.config = PackingConfig(padding=0.1, min_radius=0.5, max_failed_attempts=100)
|
|
14
13
|
|
|
15
14
|
def _calculate_poly_area(self, segments: list) -> float:
|
|
16
15
|
"""Calculates area of polygons (including holes) using the Shoelace Formula."""
|
|
@@ -18,20 +17,15 @@ class TestCirclePacker(unittest.TestCase):
|
|
|
18
17
|
for poly in segments:
|
|
19
18
|
x = poly[:, 0]
|
|
20
19
|
y = poly[:, 1]
|
|
21
|
-
# Area = 0.5 * |sum(x_i * y_{i+1} - x_{i+1} * y_i)|
|
|
22
20
|
area = 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
|
|
23
|
-
# If it's a hole (clockwise), it naturally subtracts if we don't use abs here,
|
|
24
|
-
# but since our parser handles holes via Even-Odd, we treat all as positive
|
|
25
|
-
# and subtract holes manually if needed. For this test, we assume shells.
|
|
26
21
|
total_area += area
|
|
27
22
|
return total_area
|
|
28
23
|
|
|
29
24
|
def test_filling_density(self):
|
|
30
25
|
"""Verify that the packer fills a minimum percentage of the polygon area."""
|
|
31
26
|
packer = CirclePacker(self.square, self.config)
|
|
32
|
-
circles =
|
|
27
|
+
circles = packer.pack()
|
|
33
28
|
|
|
34
|
-
# Calculate areas
|
|
35
29
|
poly_area = self._calculate_poly_area(self.square)
|
|
36
30
|
circle_area = sum(np.pi * (r**2) for _, _, r in circles)
|
|
37
31
|
|
|
@@ -41,8 +35,6 @@ class TestCirclePacker(unittest.TestCase):
|
|
|
41
35
|
print(f"Total Area: {poly_area:.2f} | Circle Area: {circle_area:.2f}")
|
|
42
36
|
print(f"Packing Density: {fill_percentage:.2f}%")
|
|
43
37
|
|
|
44
|
-
# Assert a minimum density threshold.
|
|
45
|
-
# For a random packer, 30-50% is a reasonable 'success' floor depending on radius.
|
|
46
38
|
self.assertGreater(fill_percentage, 25.0, f"Packing density too low: {fill_percentage:.2f}%")
|
|
47
39
|
|
|
48
40
|
def test_geometry_containment(self):
|
|
@@ -61,15 +53,16 @@ class TestCirclePacker(unittest.TestCase):
|
|
|
61
53
|
def test_no_overlap_integrity(self):
|
|
62
54
|
"""Mathematically verify no circles overlap including padding."""
|
|
63
55
|
packer = CirclePacker(self.square, self.config)
|
|
64
|
-
circles =
|
|
56
|
+
circles = packer.pack()
|
|
65
57
|
|
|
66
58
|
for i, (x1, y1, r1) in enumerate(circles):
|
|
67
59
|
for j, (x2, y2, r2) in enumerate(circles):
|
|
68
|
-
if i == j:
|
|
60
|
+
if i == j:
|
|
61
|
+
continue
|
|
69
62
|
dist = np.sqrt((x1 - x2)**2 + (y1 - y2)**2)
|
|
70
|
-
# Distance must be >= sum of radii + padding
|
|
71
63
|
min_sep = r1 + r2 + self.config.padding
|
|
72
64
|
self.assertGreaterEqual(dist, min_sep - 1e-9)
|
|
73
65
|
|
|
66
|
+
|
|
74
67
|
if __name__ == '__main__':
|
|
75
68
|
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|