diskpack 0.8.0__tar.gz → 0.9.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.8.0/src/diskpack.egg-info → diskpack-0.9.0}/PKG-INFO +1 -1
- {diskpack-0.8.0 → diskpack-0.9.0}/pyproject.toml +1 -1
- diskpack-0.9.0/src/diskpack/__init__.py +44 -0
- diskpack-0.9.0/src/diskpack/config.py +91 -0
- diskpack-0.9.0/src/diskpack/geometry.py +194 -0
- diskpack-0.9.0/src/diskpack/packer.py +688 -0
- {diskpack-0.8.0 → diskpack-0.9.0/src/diskpack.egg-info}/PKG-INFO +1 -1
- {diskpack-0.8.0 → diskpack-0.9.0}/src/diskpack.egg-info/SOURCES.txt +2 -0
- diskpack-0.8.0/src/diskpack/__init__.py +0 -3
- diskpack-0.8.0/src/diskpack/packer.py +0 -782
- {diskpack-0.8.0 → diskpack-0.9.0}/LICENSE +0 -0
- {diskpack-0.8.0 → diskpack-0.9.0}/README.md +0 -0
- {diskpack-0.8.0 → diskpack-0.9.0}/setup.cfg +0 -0
- {diskpack-0.8.0 → diskpack-0.9.0}/src/diskpack.egg-info/dependency_links.txt +0 -0
- {diskpack-0.8.0 → diskpack-0.9.0}/src/diskpack.egg-info/requires.txt +0 -0
- {diskpack-0.8.0 → diskpack-0.9.0}/src/diskpack.egg-info/top_level.txt +0 -0
- {diskpack-0.8.0 → diskpack-0.9.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.9.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,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
diskpack - State-of-the-art circle packing for arbitrary polygons.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from diskpack import CirclePacker, PackingConfig
|
|
6
|
+
|
|
7
|
+
# Basic usage
|
|
8
|
+
packer = CirclePacker([polygon_vertices])
|
|
9
|
+
circles = packer.pack()
|
|
10
|
+
|
|
11
|
+
# With configuration
|
|
12
|
+
config = PackingConfig(use_hybrid_packing=True, verbose=True)
|
|
13
|
+
packer = CirclePacker([polygon_vertices], config)
|
|
14
|
+
circles = packer.pack()
|
|
15
|
+
|
|
16
|
+
# Fixed radius (uses optimal hex grid)
|
|
17
|
+
config = PackingConfig(fixed_radius=5.0)
|
|
18
|
+
packer = CirclePacker([polygon_vertices], config)
|
|
19
|
+
circles = packer.pack()
|
|
20
|
+
|
|
21
|
+
Packing modes:
|
|
22
|
+
- Random sampling: Original greedy algorithm
|
|
23
|
+
- Hex grid: Optimal for fixed radius, blazing fast
|
|
24
|
+
- Front-based: Fills corners well, more circles
|
|
25
|
+
- Hybrid: State-of-the-art, combines all approaches for best density
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .config import PackingConfig, PackingProgress, PackingMode, Circle, Point, Polygon
|
|
29
|
+
from .packer import CirclePacker
|
|
30
|
+
from .geometry import PolygonGeometry, SpatialIndex
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"CirclePacker",
|
|
34
|
+
"PackingConfig",
|
|
35
|
+
"PackingProgress",
|
|
36
|
+
"PackingMode",
|
|
37
|
+
"PolygonGeometry",
|
|
38
|
+
"SpatialIndex",
|
|
39
|
+
"Circle",
|
|
40
|
+
"Point",
|
|
41
|
+
"Polygon",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration and type definitions for circle packing.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional, Tuple
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
# Type aliases
|
|
11
|
+
Polygon = np.ndarray
|
|
12
|
+
Point = np.ndarray
|
|
13
|
+
GridKey = Tuple[int, int]
|
|
14
|
+
Circle = Tuple[float, float, float] # (x, y, radius)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PackingMode(Enum):
|
|
18
|
+
"""Available packing strategies."""
|
|
19
|
+
RANDOM = "random"
|
|
20
|
+
HEX_GRID = "hex_grid"
|
|
21
|
+
FRONT = "front"
|
|
22
|
+
HYBRID = "hybrid"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class PackingConfig:
|
|
27
|
+
"""
|
|
28
|
+
Configuration parameters for the circle packing algorithm.
|
|
29
|
+
|
|
30
|
+
Basic parameters:
|
|
31
|
+
padding: Minimum gap between circles and between circles and edges
|
|
32
|
+
min_radius: Smallest circle that will be placed
|
|
33
|
+
fixed_radius: If set, all circles will have this exact radius
|
|
34
|
+
|
|
35
|
+
Algorithm selection:
|
|
36
|
+
use_hex_grid: Use hexagonal grid for fixed radius (fastest)
|
|
37
|
+
use_front_packing: Use front-based algorithm
|
|
38
|
+
use_hybrid_packing: Use state-of-the-art multi-phase algorithm
|
|
39
|
+
|
|
40
|
+
Performance tuning:
|
|
41
|
+
max_failed_attempts: Stop after this many consecutive failures
|
|
42
|
+
sample_batch_size: Points sampled per iteration (random mode)
|
|
43
|
+
grid_resolution_divisor: Controls spatial index granularity
|
|
44
|
+
|
|
45
|
+
Hybrid mode parameters:
|
|
46
|
+
hybrid_large_threshold: Phase 1 minimum (fraction of max radius)
|
|
47
|
+
hybrid_medium_threshold: Phase 2 minimum (fraction of max radius)
|
|
48
|
+
hybrid_micro_grid_min_gap: Minimum gap size for micro hex fill
|
|
49
|
+
"""
|
|
50
|
+
# Basic parameters
|
|
51
|
+
padding: float = 1.5
|
|
52
|
+
min_radius: float = 1.0
|
|
53
|
+
fixed_radius: Optional[float] = None
|
|
54
|
+
|
|
55
|
+
# Algorithm selection
|
|
56
|
+
use_hex_grid: bool = True
|
|
57
|
+
use_front_packing: bool = False
|
|
58
|
+
use_hybrid_packing: bool = False
|
|
59
|
+
|
|
60
|
+
# Performance tuning
|
|
61
|
+
max_failed_attempts: int = 200
|
|
62
|
+
sample_batch_size: int = 50
|
|
63
|
+
grid_resolution_divisor: float = 25
|
|
64
|
+
mega_circle_threshold: float = 0.5
|
|
65
|
+
ray_cast_epsilon: float = 1e-10
|
|
66
|
+
|
|
67
|
+
# Hybrid mode parameters
|
|
68
|
+
hybrid_large_threshold: float = 0.5
|
|
69
|
+
hybrid_medium_threshold: float = 0.25
|
|
70
|
+
hybrid_micro_grid_min_gap: float = 5.0
|
|
71
|
+
|
|
72
|
+
# Output
|
|
73
|
+
verbose: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class PackingProgress:
|
|
78
|
+
"""Tracks the current state of the packing algorithm."""
|
|
79
|
+
circles_placed: int = 0
|
|
80
|
+
failed_attempts: int = 0
|
|
81
|
+
max_failed_attempts: int = 200
|
|
82
|
+
phase: str = ""
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def progress_ratio(self) -> float:
|
|
86
|
+
"""How close to stopping (0.0 = just started, 1.0 = done)."""
|
|
87
|
+
return self.failed_attempts / self.max_failed_attempts if self.max_failed_attempts > 0 else 0
|
|
88
|
+
|
|
89
|
+
def __str__(self) -> str:
|
|
90
|
+
phase_str = f"[{self.phase}] " if self.phase else ""
|
|
91
|
+
return f"{phase_str}Placed: {self.circles_placed} | Failed: {self.failed_attempts}/{self.max_failed_attempts} ({self.progress_ratio:.0%})"
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geometry utilities for circle packing.
|
|
3
|
+
|
|
4
|
+
Contains:
|
|
5
|
+
- PolygonGeometry: boundary calculations, point-in-polygon tests
|
|
6
|
+
- SpatialIndex: grid-based spatial indexing for collision detection
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import List, Iterator, Tuple, Dict
|
|
12
|
+
|
|
13
|
+
# Type aliases
|
|
14
|
+
Polygon = np.ndarray
|
|
15
|
+
Point = np.ndarray
|
|
16
|
+
GridKey = Tuple[int, int]
|
|
17
|
+
|
|
18
|
+
# Threshold for switching between vectorized and spatial index approaches
|
|
19
|
+
VECTORIZED_THRESHOLD = 750
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PolygonGeometry:
|
|
23
|
+
"""Handles geometric calculations for polygon boundaries."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, polygons: List[Polygon], epsilon: float = 1e-10):
|
|
26
|
+
self.polygons = [np.array(p, dtype=float) for p in polygons]
|
|
27
|
+
self.epsilon = epsilon
|
|
28
|
+
self._compute_bounds()
|
|
29
|
+
self._precompute_edges()
|
|
30
|
+
|
|
31
|
+
def _compute_bounds(self) -> None:
|
|
32
|
+
all_vertices = np.vstack(self.polygons)
|
|
33
|
+
self.min_coords = np.min(all_vertices, axis=0)
|
|
34
|
+
self.max_coords = np.max(all_vertices, axis=0)
|
|
35
|
+
self.extent = max(self.max_coords - self.min_coords)
|
|
36
|
+
|
|
37
|
+
def _precompute_edges(self) -> None:
|
|
38
|
+
"""Precompute edge data for vectorized distance calculations."""
|
|
39
|
+
all_p1 = []
|
|
40
|
+
all_p2 = []
|
|
41
|
+
for poly in self.polygons:
|
|
42
|
+
n = len(poly)
|
|
43
|
+
for i in range(n):
|
|
44
|
+
all_p1.append(poly[i])
|
|
45
|
+
all_p2.append(poly[(i + 1) % n])
|
|
46
|
+
|
|
47
|
+
self.edge_starts = np.array(all_p1)
|
|
48
|
+
self.edge_ends = np.array(all_p2)
|
|
49
|
+
self.edge_vecs = self.edge_ends - self.edge_starts
|
|
50
|
+
self.edge_lengths_sq = np.sum(self.edge_vecs ** 2, axis=1)
|
|
51
|
+
self.edge_lengths = np.sqrt(self.edge_lengths_sq)
|
|
52
|
+
|
|
53
|
+
# Compute inward normals
|
|
54
|
+
self.edge_normals = np.zeros_like(self.edge_vecs)
|
|
55
|
+
for i, (start, vec) in enumerate(zip(self.edge_starts, self.edge_vecs)):
|
|
56
|
+
normal = np.array([-vec[1], vec[0]])
|
|
57
|
+
if self.edge_lengths[i] > 0:
|
|
58
|
+
normal = normal / self.edge_lengths[i]
|
|
59
|
+
midpoint = start + vec / 2
|
|
60
|
+
test_point = midpoint + normal * 0.001
|
|
61
|
+
if not self.contains_point(test_point):
|
|
62
|
+
normal = -normal
|
|
63
|
+
self.edge_normals[i] = normal
|
|
64
|
+
|
|
65
|
+
def contains_point(self, point: Point) -> bool:
|
|
66
|
+
"""Check if a single point is inside the polygon (even-odd rule)."""
|
|
67
|
+
x, y = point[0], point[1]
|
|
68
|
+
inside = False
|
|
69
|
+
for poly in self.polygons:
|
|
70
|
+
n = len(poly)
|
|
71
|
+
for i in range(n):
|
|
72
|
+
p1, p2 = poly[i], poly[(i + 1) % n]
|
|
73
|
+
if ((p1[1] > y) != (p2[1] > y)) and \
|
|
74
|
+
(x < (p2[0] - p1[0]) * (y - p1[1]) / (p2[1] - p1[1] + self.epsilon) + p1[0]):
|
|
75
|
+
inside = not inside
|
|
76
|
+
return inside
|
|
77
|
+
|
|
78
|
+
def contains_points(self, points: np.ndarray) -> np.ndarray:
|
|
79
|
+
"""Vectorized even-odd rule for multiple points."""
|
|
80
|
+
x, y = points[:, 0], points[:, 1]
|
|
81
|
+
inside = np.zeros(len(points), dtype=bool)
|
|
82
|
+
|
|
83
|
+
for poly in self.polygons:
|
|
84
|
+
n = len(poly)
|
|
85
|
+
for i in range(n):
|
|
86
|
+
p1, p2 = poly[i], poly[(i + 1) % n]
|
|
87
|
+
crosses_edge = (p1[1] > y) != (p2[1] > y)
|
|
88
|
+
dy = p2[1] - p1[1] + self.epsilon
|
|
89
|
+
x_intercept = (p2[0] - p1[0]) * (y - p1[1]) / dy + p1[0]
|
|
90
|
+
inside ^= crosses_edge & (x < x_intercept)
|
|
91
|
+
|
|
92
|
+
return inside
|
|
93
|
+
|
|
94
|
+
def distance_to_boundary(self, point: Point) -> float:
|
|
95
|
+
"""Distance from a point to the nearest polygon edge."""
|
|
96
|
+
to_point = point - self.edge_starts
|
|
97
|
+
dots = np.sum(to_point * self.edge_vecs, axis=1)
|
|
98
|
+
|
|
99
|
+
with np.errstate(divide='ignore', invalid='ignore'):
|
|
100
|
+
t = np.clip(dots / self.edge_lengths_sq, 0, 1)
|
|
101
|
+
t = np.where(self.edge_lengths_sq == 0, 0, t)
|
|
102
|
+
|
|
103
|
+
projections = self.edge_starts + t[:, np.newaxis] * self.edge_vecs
|
|
104
|
+
distances = np.linalg.norm(point - projections, axis=1)
|
|
105
|
+
|
|
106
|
+
return float(np.min(distances))
|
|
107
|
+
|
|
108
|
+
def distances_to_boundary_batch(self, points: np.ndarray) -> np.ndarray:
|
|
109
|
+
"""Vectorized distance calculation for multiple points."""
|
|
110
|
+
if len(points) == 0:
|
|
111
|
+
return np.array([])
|
|
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
|
+
distances = np.linalg.norm(points[:, np.newaxis, :] - projections, axis=2)
|
|
125
|
+
|
|
126
|
+
return np.min(distances, axis=1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class SpatialIndex:
|
|
131
|
+
"""Grid-based spatial index for efficient collision detection."""
|
|
132
|
+
cell_size: float
|
|
133
|
+
origin: np.ndarray
|
|
134
|
+
mega_threshold: float
|
|
135
|
+
grid: Dict[GridKey, List[int]] = field(default_factory=dict)
|
|
136
|
+
mega_circles: List[int] = field(default_factory=list)
|
|
137
|
+
|
|
138
|
+
_centers: np.ndarray = field(default_factory=lambda: np.empty((0, 2)))
|
|
139
|
+
_radii: np.ndarray = field(default_factory=lambda: np.empty(0))
|
|
140
|
+
|
|
141
|
+
def add_circle(self, index: int, center: Point, radius: float) -> None:
|
|
142
|
+
"""Add a circle to the spatial index."""
|
|
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 indices of circles that might be near a point."""
|
|
154
|
+
yield from self.mega_circles
|
|
155
|
+
center_key = self._get_cell_key(point)
|
|
156
|
+
for dx in range(-1, 2):
|
|
157
|
+
for dy in range(-1, 2):
|
|
158
|
+
neighbor_key = (center_key[0] + dx, center_key[1] + dy)
|
|
159
|
+
if neighbor_key in self.grid:
|
|
160
|
+
yield from self.grid[neighbor_key]
|
|
161
|
+
|
|
162
|
+
def get_circles_in_region(self, min_pt: Point, max_pt: Point) -> List[int]:
|
|
163
|
+
"""Get all circle indices that might intersect a rectangular region."""
|
|
164
|
+
indices = set(self.mega_circles)
|
|
165
|
+
|
|
166
|
+
min_key = self._get_cell_key(min_pt)
|
|
167
|
+
max_key = self._get_cell_key(max_pt)
|
|
168
|
+
|
|
169
|
+
for gx in range(min_key[0] - 1, max_key[0] + 2):
|
|
170
|
+
for gy in range(min_key[1] - 1, max_key[1] + 2):
|
|
171
|
+
if (gx, gy) in self.grid:
|
|
172
|
+
indices.update(self.grid[(gx, gy)])
|
|
173
|
+
|
|
174
|
+
return list(indices)
|
|
175
|
+
|
|
176
|
+
def distance_to_circles(self, point: Point) -> float:
|
|
177
|
+
"""Get minimum distance from point to any existing circle's edge."""
|
|
178
|
+
if len(self._centers) == 0:
|
|
179
|
+
return float('inf')
|
|
180
|
+
|
|
181
|
+
indices = list(self.get_nearby_indices(point))
|
|
182
|
+
if not indices:
|
|
183
|
+
return float('inf')
|
|
184
|
+
|
|
185
|
+
centers = self._centers[indices]
|
|
186
|
+
radii = self._radii[indices]
|
|
187
|
+
|
|
188
|
+
distances = np.linalg.norm(centers - point, axis=1) - radii
|
|
189
|
+
return float(np.min(distances))
|
|
190
|
+
|
|
191
|
+
def _get_cell_key(self, point: Point) -> GridKey:
|
|
192
|
+
"""Convert a point to its grid cell coordinates."""
|
|
193
|
+
cell_coords = ((point - self.origin) // self.cell_size).astype(int)
|
|
194
|
+
return (int(cell_coords[0]), int(cell_coords[1]))
|