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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: diskpack
3
- Version: 0.8.0
3
+ Version: 0.9.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.8.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]))