diskpack 0.9.0__tar.gz → 0.10.2__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.
@@ -0,0 +1,206 @@
1
+ Metadata-Version: 2.4
2
+ Name: diskpack
3
+ Version: 0.10.2
4
+ Summary: A high-performance vectorized circle packer with spatial hashing.
5
+ Author-email: James Kelly <mrkellyjam@gmail.com>
6
+ Requires-Python: >=3.8
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: numpy>=1.20
10
+ Dynamic: license-file
11
+
12
+ # diskpack
13
+
14
+ State-of-the-art circle packing for arbitrary polygons.
15
+
16
+ ![packed star](star\_packed.png)
17
+
18
+
19
+
20
+
21
+ ```bash
22
+ pip install diskpack
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```python
28
+ from diskpack import CirclePacker, PackingConfig
29
+ import numpy as np
30
+
31
+ # Define a polygon (list of vertices)
32
+ square = [(0, 0), (100, 0), (100, 100), (0, 100)]
33
+
34
+ # Pack circles
35
+ packer = CirclePacker([np.array(square)])
36
+ circles = packer.pack()
37
+
38
+ # Each circle is (x, y, radius)
39
+ for x, y, r in circles:
40
+ print(f"Circle at ({x:.1f}, {y:.1f}) with radius {r:.1f}")
41
+ ```
42
+
43
+ ## Choosing the Right Algorithm
44
+
45
+ diskpack offers multiple packing strategies optimized for different use cases:
46
+
47
+ | Shape Type | Best Algorithm | Config |
48
+ |------------|----------------|--------|
49
+ | Simple convex (square, rectangle) | Random sampling | `PackingConfig()` |
50
+ | Complex/concave (star, L-shape, letters) | Hybrid | `PackingConfig(use_hybrid_packing=True)` |
51
+ | Fixed radius, need speed | Hex grid | `PackingConfig(fixed_radius=5.0)` |
52
+ | Fixed radius, need density | Hybrid | `PackingConfig(fixed_radius=5.0, use_hybrid_packing=True)` |
53
+ | Artistic/organic look | Random | `PackingConfig(use_hex_grid=False)` |
54
+
55
+ ### Simple Convex Shapes
56
+
57
+ For squares, rectangles, and other simple convex polygons, the default random sampling achieves the best density:
58
+
59
+ ```python
60
+ config = PackingConfig(
61
+ padding=0.5,
62
+ min_radius=1.0,
63
+ )
64
+ packer = CirclePacker([np.array(square)], config)
65
+ circles = packer.pack() # ~86% density
66
+ ```
67
+
68
+ ### Complex/Concave Shapes
69
+
70
+ For stars, L-shapes, letters, and other complex polygons, hybrid mode fills corners better:
71
+
72
+ ```python
73
+ star = [
74
+ (50, 0), (61, 35), (98, 35), (68, 57), (79, 91),
75
+ (50, 70), (21, 91), (32, 57), (2, 35), (39, 35)
76
+ ]
77
+
78
+ config = PackingConfig(
79
+ use_hybrid_packing=True,
80
+ verbose=True, # See progress
81
+ )
82
+ packer = CirclePacker([np.array(star)], config)
83
+ circles = packer.pack() # ~69% density (vs ~64% for random)
84
+ ```
85
+
86
+ ### Fixed Radius Packing
87
+
88
+ When all circles must have the same radius:
89
+
90
+ ```python
91
+ # Fastest (hex grid pattern)
92
+ config = PackingConfig(fixed_radius=3.0)
93
+
94
+ # Densest (fills corners)
95
+ config = PackingConfig(fixed_radius=3.0, use_hybrid_packing=True)
96
+
97
+ # Organic look (random placement)
98
+ config = PackingConfig(fixed_radius=3.0, use_hex_grid=False)
99
+ ```
100
+
101
+ ## Tuning Hybrid Mode
102
+
103
+ Hybrid mode works in three phases:
104
+
105
+ 1. **Phase 1 (Large)**: Place circles ≥ 50% of max possible radius
106
+ 2. **Phase 2 (Medium)**: Place circles ≥ 25% of max possible radius
107
+ 3. **Phase 3 (Small)**: Fill remaining gaps with random sampling
108
+
109
+ You can tune the thresholds:
110
+
111
+ ```python
112
+ # For complex shapes with tight corners (default)
113
+ config = PackingConfig(
114
+ use_hybrid_packing=True,
115
+ hybrid_large_threshold=0.5, # Phase 1: >= 50% of max
116
+ hybrid_medium_threshold=0.25, # Phase 2: >= 25% of max
117
+ )
118
+
119
+ # For simpler shapes (more circles in Phases 1-2)
120
+ config = PackingConfig(
121
+ use_hybrid_packing=True,
122
+ hybrid_large_threshold=0.3, # Phase 1: >= 30% of max
123
+ hybrid_medium_threshold=0.1, # Phase 2: >= 10% of max
124
+ )
125
+ ```
126
+
127
+ ## Performance Tuning
128
+
129
+ ```python
130
+ config = PackingConfig(
131
+ # Stop after N consecutive failed attempts (higher = more circles, slower)
132
+ max_failed_attempts=200,
133
+
134
+ # Points sampled per iteration (higher = better placements, more memory)
135
+ sample_batch_size=50,
136
+
137
+ # Minimum gap between circles
138
+ padding=1.5,
139
+
140
+ # Smallest circle to place
141
+ min_radius=1.0,
142
+ )
143
+ ```
144
+
145
+ ## API Reference
146
+
147
+ ### CirclePacker
148
+
149
+ ```python
150
+ CirclePacker(polygons: List[np.ndarray], config: PackingConfig = None)
151
+ ```
152
+
153
+ - `polygons`: List of polygon vertices. Each polygon is an Nx2 numpy array.
154
+ - `config`: Optional configuration. Uses defaults if not provided.
155
+
156
+ **Methods:**
157
+
158
+ - `pack() -> List[Tuple[float, float, float]]`: Pack circles and return as list
159
+ - `generate() -> Iterator[Tuple[float, float, float]]`: Generate circles lazily
160
+
161
+ ### PackingConfig
162
+
163
+ See the docstring for full parameter documentation:
164
+
165
+ ```python
166
+ from diskpack import PackingConfig
167
+ help(PackingConfig)
168
+ ```
169
+
170
+ ## Benchmark Results
171
+
172
+ Tested on 100×100 unit shapes:
173
+
174
+ | Shape | Algorithm | Time | Circles | Density |
175
+ |-------|-----------|------|---------|---------|
176
+ | Square | Random | 0.31s | 49 | **86.4%** |
177
+ | Square | Hybrid | **0.15s** | 55 | 85.8% |
178
+ | L-Shape | Random | 0.68s | 50 | 76.9% |
179
+ | L-Shape | Hybrid | **0.30s** | 36 | **79.3%** |
180
+ | Star | Random | 0.32s | 39 | 64.4% |
181
+ | Star | Hybrid | **0.25s** | 27 | **68.8%** |
182
+
183
+ Fixed radius (r=3.0) on Star shape:
184
+
185
+ | Algorithm | Time | Circles | Density |
186
+ |-----------|------|---------|---------|
187
+ | Hex Grid | **0.007s** | 40 | 40.0% |
188
+ | Hybrid | 0.08s | **51** | **51.0%** |
189
+
190
+ ## Comparison with Shapely
191
+
192
+ diskpack achieves higher density and faster packing compared to the Python Shapely library. Where Shapely creates Python objects for each point-in-polygon and distance check, diskpack uses vectorized NumPy operations with precomputed edge geometry and batched candidate evaluation — sampling many points per iteration and greedily placing the largest valid circle. A grid-based spatial index keeps collision detection O(1) as circle count grows. The batched Shapely method can approach similar density by also picking the best of many candidates, but at ~30x the runtime due to per-point object overhead.
193
+
194
+ ![diskpack vs shapely comparison](diskpack_shapely_comp.png)
195
+
196
+ | Method | Circles | Density | Time |
197
+ |--------|---------|---------|------|
198
+ | **diskpack** | 47 | **86.7%** | **0.679s** |
199
+ | shapely (naive) | 109 | 73.4% | 1.424s |
200
+ | shapely (batched) | 60 | 85.3% | 19.569s |
201
+
202
+ See [`demo/diskpack_comparisons.ipynb`](demo/diskpack_comparisons.ipynb) for the full benchmark notebook.
203
+
204
+ ## License
205
+
206
+ MIT
@@ -0,0 +1,195 @@
1
+ # diskpack
2
+
3
+ State-of-the-art circle packing for arbitrary polygons.
4
+
5
+ ![packed star](star\_packed.png)
6
+
7
+
8
+
9
+
10
+ ```bash
11
+ pip install diskpack
12
+ ```
13
+
14
+ ## Quick Start
15
+
16
+ ```python
17
+ from diskpack import CirclePacker, PackingConfig
18
+ import numpy as np
19
+
20
+ # Define a polygon (list of vertices)
21
+ square = [(0, 0), (100, 0), (100, 100), (0, 100)]
22
+
23
+ # Pack circles
24
+ packer = CirclePacker([np.array(square)])
25
+ circles = packer.pack()
26
+
27
+ # Each circle is (x, y, radius)
28
+ for x, y, r in circles:
29
+ print(f"Circle at ({x:.1f}, {y:.1f}) with radius {r:.1f}")
30
+ ```
31
+
32
+ ## Choosing the Right Algorithm
33
+
34
+ diskpack offers multiple packing strategies optimized for different use cases:
35
+
36
+ | Shape Type | Best Algorithm | Config |
37
+ |------------|----------------|--------|
38
+ | Simple convex (square, rectangle) | Random sampling | `PackingConfig()` |
39
+ | Complex/concave (star, L-shape, letters) | Hybrid | `PackingConfig(use_hybrid_packing=True)` |
40
+ | Fixed radius, need speed | Hex grid | `PackingConfig(fixed_radius=5.0)` |
41
+ | Fixed radius, need density | Hybrid | `PackingConfig(fixed_radius=5.0, use_hybrid_packing=True)` |
42
+ | Artistic/organic look | Random | `PackingConfig(use_hex_grid=False)` |
43
+
44
+ ### Simple Convex Shapes
45
+
46
+ For squares, rectangles, and other simple convex polygons, the default random sampling achieves the best density:
47
+
48
+ ```python
49
+ config = PackingConfig(
50
+ padding=0.5,
51
+ min_radius=1.0,
52
+ )
53
+ packer = CirclePacker([np.array(square)], config)
54
+ circles = packer.pack() # ~86% density
55
+ ```
56
+
57
+ ### Complex/Concave Shapes
58
+
59
+ For stars, L-shapes, letters, and other complex polygons, hybrid mode fills corners better:
60
+
61
+ ```python
62
+ star = [
63
+ (50, 0), (61, 35), (98, 35), (68, 57), (79, 91),
64
+ (50, 70), (21, 91), (32, 57), (2, 35), (39, 35)
65
+ ]
66
+
67
+ config = PackingConfig(
68
+ use_hybrid_packing=True,
69
+ verbose=True, # See progress
70
+ )
71
+ packer = CirclePacker([np.array(star)], config)
72
+ circles = packer.pack() # ~69% density (vs ~64% for random)
73
+ ```
74
+
75
+ ### Fixed Radius Packing
76
+
77
+ When all circles must have the same radius:
78
+
79
+ ```python
80
+ # Fastest (hex grid pattern)
81
+ config = PackingConfig(fixed_radius=3.0)
82
+
83
+ # Densest (fills corners)
84
+ config = PackingConfig(fixed_radius=3.0, use_hybrid_packing=True)
85
+
86
+ # Organic look (random placement)
87
+ config = PackingConfig(fixed_radius=3.0, use_hex_grid=False)
88
+ ```
89
+
90
+ ## Tuning Hybrid Mode
91
+
92
+ Hybrid mode works in three phases:
93
+
94
+ 1. **Phase 1 (Large)**: Place circles ≥ 50% of max possible radius
95
+ 2. **Phase 2 (Medium)**: Place circles ≥ 25% of max possible radius
96
+ 3. **Phase 3 (Small)**: Fill remaining gaps with random sampling
97
+
98
+ You can tune the thresholds:
99
+
100
+ ```python
101
+ # For complex shapes with tight corners (default)
102
+ config = PackingConfig(
103
+ use_hybrid_packing=True,
104
+ hybrid_large_threshold=0.5, # Phase 1: >= 50% of max
105
+ hybrid_medium_threshold=0.25, # Phase 2: >= 25% of max
106
+ )
107
+
108
+ # For simpler shapes (more circles in Phases 1-2)
109
+ config = PackingConfig(
110
+ use_hybrid_packing=True,
111
+ hybrid_large_threshold=0.3, # Phase 1: >= 30% of max
112
+ hybrid_medium_threshold=0.1, # Phase 2: >= 10% of max
113
+ )
114
+ ```
115
+
116
+ ## Performance Tuning
117
+
118
+ ```python
119
+ config = PackingConfig(
120
+ # Stop after N consecutive failed attempts (higher = more circles, slower)
121
+ max_failed_attempts=200,
122
+
123
+ # Points sampled per iteration (higher = better placements, more memory)
124
+ sample_batch_size=50,
125
+
126
+ # Minimum gap between circles
127
+ padding=1.5,
128
+
129
+ # Smallest circle to place
130
+ min_radius=1.0,
131
+ )
132
+ ```
133
+
134
+ ## API Reference
135
+
136
+ ### CirclePacker
137
+
138
+ ```python
139
+ CirclePacker(polygons: List[np.ndarray], config: PackingConfig = None)
140
+ ```
141
+
142
+ - `polygons`: List of polygon vertices. Each polygon is an Nx2 numpy array.
143
+ - `config`: Optional configuration. Uses defaults if not provided.
144
+
145
+ **Methods:**
146
+
147
+ - `pack() -> List[Tuple[float, float, float]]`: Pack circles and return as list
148
+ - `generate() -> Iterator[Tuple[float, float, float]]`: Generate circles lazily
149
+
150
+ ### PackingConfig
151
+
152
+ See the docstring for full parameter documentation:
153
+
154
+ ```python
155
+ from diskpack import PackingConfig
156
+ help(PackingConfig)
157
+ ```
158
+
159
+ ## Benchmark Results
160
+
161
+ Tested on 100×100 unit shapes:
162
+
163
+ | Shape | Algorithm | Time | Circles | Density |
164
+ |-------|-----------|------|---------|---------|
165
+ | Square | Random | 0.31s | 49 | **86.4%** |
166
+ | Square | Hybrid | **0.15s** | 55 | 85.8% |
167
+ | L-Shape | Random | 0.68s | 50 | 76.9% |
168
+ | L-Shape | Hybrid | **0.30s** | 36 | **79.3%** |
169
+ | Star | Random | 0.32s | 39 | 64.4% |
170
+ | Star | Hybrid | **0.25s** | 27 | **68.8%** |
171
+
172
+ Fixed radius (r=3.0) on Star shape:
173
+
174
+ | Algorithm | Time | Circles | Density |
175
+ |-----------|------|---------|---------|
176
+ | Hex Grid | **0.007s** | 40 | 40.0% |
177
+ | Hybrid | 0.08s | **51** | **51.0%** |
178
+
179
+ ## Comparison with Shapely
180
+
181
+ diskpack achieves higher density and faster packing compared to the Python Shapely library. Where Shapely creates Python objects for each point-in-polygon and distance check, diskpack uses vectorized NumPy operations with precomputed edge geometry and batched candidate evaluation — sampling many points per iteration and greedily placing the largest valid circle. A grid-based spatial index keeps collision detection O(1) as circle count grows. The batched Shapely method can approach similar density by also picking the best of many candidates, but at ~30x the runtime due to per-point object overhead.
182
+
183
+ ![diskpack vs shapely comparison](diskpack_shapely_comp.png)
184
+
185
+ | Method | Circles | Density | Time |
186
+ |--------|---------|---------|------|
187
+ | **diskpack** | 47 | **86.7%** | **0.679s** |
188
+ | shapely (naive) | 109 | 73.4% | 1.424s |
189
+ | shapely (batched) | 60 | 85.3% | 19.569s |
190
+
191
+ See [`demo/diskpack_comparisons.ipynb`](demo/diskpack_comparisons.ipynb) for the full benchmark notebook.
192
+
193
+ ## License
194
+
195
+ MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "diskpack"
7
- version = "0.9.0"
7
+ version = "0.10.2"
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,155 @@
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
+ Quick Start
31
+ -----------
32
+ # Best density for simple convex shapes (squares, rectangles, circles):
33
+ config = PackingConfig() # Uses random sampling (default)
34
+
35
+ # Best density for complex/concave shapes (stars, L-shapes, letters):
36
+ config = PackingConfig(use_hybrid_packing=True)
37
+
38
+ # Fixed radius circles - fastest:
39
+ config = PackingConfig(fixed_radius=5.0)
40
+
41
+ # Fixed radius circles - best density on complex shapes:
42
+ config = PackingConfig(fixed_radius=5.0, use_hybrid_packing=True)
43
+
44
+
45
+ Algorithm Selection Guide
46
+ -------------------------
47
+ | Shape Type | Best Algorithm | Config |
48
+ |---------------------|-----------------------------|---------------------------------|
49
+ | Simple convex | Random sampling (default) | PackingConfig() |
50
+ | Complex/concave | Hybrid | use_hybrid_packing=True |
51
+ | Fixed radius, speed | Hex grid (default for fixed)| fixed_radius=X |
52
+ | Fixed radius, dense | Hybrid | fixed_radius=X, use_hybrid_packing=True |
53
+ | Artistic/organic | Random sampling | use_hex_grid=False |
54
+
55
+
56
+ Tuning Hybrid Mode
57
+ ------------------
58
+ Hybrid mode works in phases:
59
+ Phase 1: Place large circles (>= hybrid_large_threshold * max_radius)
60
+ Phase 2: Place medium circles (>= hybrid_medium_threshold * max_radius)
61
+ Phase 3: Fill remaining space with random sampling
62
+
63
+ For complex shapes with tight corners (stars, letters):
64
+ - Use higher thresholds: hybrid_large_threshold=0.5, hybrid_medium_threshold=0.25
65
+ - This prioritizes filling corners with appropriately-sized circles
66
+
67
+ For simpler shapes where you want hybrid's speed:
68
+ - Use lower thresholds: hybrid_large_threshold=0.3, hybrid_medium_threshold=0.1
69
+ - This lets the front-based algorithm place more circles before random cleanup
70
+
71
+
72
+ Parameters
73
+ ----------
74
+ padding : float, default=1.5
75
+ Minimum gap between circles and between circles and polygon edges.
76
+
77
+ min_radius : float, default=1.0
78
+ Smallest circle that will be placed. Circles below this size are skipped.
79
+
80
+ fixed_radius : float, optional
81
+ If set, all circles will have exactly this radius. Enables hex grid mode
82
+ by default (override with use_hex_grid=False for organic placement).
83
+
84
+ use_hex_grid : bool, default=True
85
+ Use hexagonal grid for fixed radius packing. Fastest option but creates
86
+ a regular pattern. Set to False for organic/random placement.
87
+
88
+ use_front_packing : bool, default=False
89
+ Use front-based algorithm. Good for filling corners but may produce
90
+ many small circles. Usually better to use use_hybrid_packing instead.
91
+
92
+ use_hybrid_packing : bool, default=False
93
+ Use state-of-the-art multi-phase algorithm. Best for complex shapes.
94
+ Combines front-based (for corners) with random sampling (for gaps).
95
+
96
+ max_failed_attempts : int, default=200
97
+ Stop after this many consecutive failed placement attempts.
98
+ Higher values may find more circles but take longer.
99
+
100
+ sample_batch_size : int, default=50
101
+ Number of random points sampled per iteration. Higher values find
102
+ better placements per iteration but use more memory.
103
+
104
+ hybrid_large_threshold : float, default=0.5
105
+ Phase 1 minimum radius as fraction of max possible radius.
106
+ Only circles >= this fraction are placed in Phase 1.
107
+
108
+ hybrid_medium_threshold : float, default=0.25
109
+ Phase 2 minimum radius as fraction of max possible radius.
110
+ Only circles >= this fraction are placed in Phase 2.
111
+
112
+ verbose : bool, default=False
113
+ Print progress information during packing.
114
+ """
115
+ # Basic parameters
116
+ padding: float = 1.5
117
+ min_radius: float = 1.0
118
+ fixed_radius: Optional[float] = None
119
+
120
+ # Algorithm selection
121
+ use_hex_grid: bool = True
122
+ use_front_packing: bool = False
123
+ use_hybrid_packing: bool = False
124
+
125
+ # Performance tuning
126
+ max_failed_attempts: int = 200
127
+ sample_batch_size: int = 50
128
+ grid_resolution_divisor: float = 25
129
+ mega_circle_threshold: float = 0.5
130
+ ray_cast_epsilon: float = 1e-10
131
+
132
+ # Hybrid mode parameters
133
+ hybrid_large_threshold: float = 0.5
134
+ hybrid_medium_threshold: float = 0.25
135
+
136
+ # Output
137
+ verbose: bool = False
138
+
139
+
140
+ @dataclass
141
+ class PackingProgress:
142
+ """Tracks the current state of the packing algorithm."""
143
+ circles_placed: int = 0
144
+ failed_attempts: int = 0
145
+ max_failed_attempts: int = 200
146
+ phase: str = ""
147
+
148
+ @property
149
+ def progress_ratio(self) -> float:
150
+ """How close to stopping (0.0 = just started, 1.0 = done)."""
151
+ return self.failed_attempts / self.max_failed_attempts if self.max_failed_attempts > 0 else 0
152
+
153
+ def __str__(self) -> str:
154
+ phase_str = f"[{self.phase}] " if self.phase else ""
155
+ return f"{phase_str}Placed: {self.circles_placed} | Failed: {self.failed_attempts}/{self.max_failed_attempts} ({self.progress_ratio:.0%})"