1 | <?php |
||
2 | |||
3 | /** |
||
4 | * Box packing (3D bin packing, knapsack problem). |
||
5 | * |
||
6 | * @author Doug Wright |
||
7 | */ |
||
8 | declare(strict_types=1); |
||
9 | |||
10 | namespace DVDoug\BoxPacker; |
||
11 | |||
12 | use DVDoug\BoxPacker\Exception\NoBoxesAvailableException; |
||
13 | use Psr\Log\LoggerAwareInterface; |
||
14 | use Psr\Log\LoggerInterface; |
||
15 | use Psr\Log\LogLevel; |
||
16 | use Psr\Log\NullLogger; |
||
17 | use WeakMap; |
||
18 | |||
19 | use function array_pop; |
||
20 | use function count; |
||
21 | use function usort; |
||
22 | |||
23 | use const PHP_INT_MAX; |
||
24 | |||
25 | /** |
||
26 | * Actual packer. |
||
27 | */ |
||
28 | class Packer implements LoggerAwareInterface |
||
29 | { |
||
30 | private LoggerInterface $logger; |
||
31 | |||
32 | protected int $maxBoxesToBalanceWeight = 12; |
||
33 | |||
34 | protected ItemList $items; |
||
35 | |||
36 | protected BoxList $boxes; |
||
37 | |||
38 | /** |
||
39 | * @var WeakMap<Box, int> |
||
40 | */ |
||
41 | protected WeakMap $boxQuantitiesAvailable; |
||
42 | |||
43 | protected PackedBoxSorter $packedBoxSorter; |
||
44 | |||
45 | protected bool $throwOnUnpackableItem = true; |
||
46 | |||
47 | private bool $beStrictAboutItemOrdering = false; |
||
48 | 51 | ||
49 | protected ?TimeoutChecker $timeoutChecker = null; |
||
50 | 51 | ||
51 | 51 | public function __construct() |
|
52 | 51 | { |
|
53 | 51 | $this->items = new ItemList(); |
|
54 | $this->boxes = new BoxList(); |
||
55 | 51 | $this->packedBoxSorter = new DefaultPackedBoxSorter(); |
|
56 | $this->boxQuantitiesAvailable = new WeakMap(); |
||
57 | |||
58 | 6 | $this->logger = new NullLogger(); |
|
59 | } |
||
60 | 6 | ||
61 | public function setLogger(LoggerInterface $logger): void |
||
62 | { |
||
63 | $this->logger = $logger; |
||
64 | } |
||
65 | |||
66 | 36 | /** |
|
67 | * Add item to be packed. |
||
68 | 36 | */ |
|
69 | 36 | public function addItem(Item $item, int $qty = 1): void |
|
70 | { |
||
71 | $this->items->insert($item, $qty); |
||
72 | $this->logger->log(LogLevel::INFO, "added {$qty} x {$item->getDescription()}", ['item' => $item]); |
||
73 | } |
||
74 | |||
75 | /** |
||
76 | 19 | * Set a list of items all at once. |
|
77 | * @param iterable<Item>|ItemList $items |
||
78 | 19 | */ |
|
79 | 14 | public function setItems(iterable|ItemList $items): void |
|
80 | { |
||
81 | 6 | if ($items instanceof ItemList) { |
|
82 | 6 | $this->items = clone $items; |
|
83 | 6 | } else { |
|
84 | $this->items = new ItemList(); |
||
85 | foreach ($items as $item) { |
||
86 | $this->items->insert($item); |
||
87 | } |
||
88 | } |
||
89 | } |
||
90 | |||
91 | 37 | /** |
|
92 | * Add box size. |
||
93 | 37 | */ |
|
94 | 37 | public function addBox(Box $box): void |
|
95 | 37 | { |
|
96 | $this->boxes->insert($box); |
||
97 | $this->setBoxQuantity($box, $box instanceof LimitedSupplyBox ? $box->getQuantityAvailable() : PHP_INT_MAX); |
||
98 | $this->logger->log(LogLevel::INFO, "added box {$box->getReference()}", ['box' => $box]); |
||
99 | } |
||
100 | |||
101 | 18 | /** |
|
102 | * Add a pre-prepared set of boxes all at once. |
||
103 | 18 | */ |
|
104 | 18 | public function setBoxes(BoxList $boxList): void |
|
105 | 18 | { |
|
106 | $this->boxes = $boxList; |
||
107 | foreach ($this->boxes as $box) { |
||
108 | $this->setBoxQuantity($box, $box instanceof LimitedSupplyBox ? $box->getQuantityAvailable() : PHP_INT_MAX); |
||
109 | } |
||
110 | } |
||
111 | |||
112 | 49 | /** |
|
113 | * Set the quantity of this box type available. |
||
114 | 49 | */ |
|
115 | public function setBoxQuantity(Box $box, int $qty): void |
||
116 | { |
||
117 | $this->boxQuantitiesAvailable[$box] = $qty; |
||
118 | } |
||
119 | |||
120 | 1 | /** |
|
121 | * Number of boxes at which balancing weight is deemed not worth the extra computation time. |
||
122 | 1 | */ |
|
123 | public function getMaxBoxesToBalanceWeight(): int |
||
124 | { |
||
125 | return $this->maxBoxesToBalanceWeight; |
||
126 | } |
||
127 | |||
128 | 6 | /** |
|
129 | * Number of boxes at which balancing weight is deemed not worth the extra computation time. |
||
130 | 6 | */ |
|
131 | public function setMaxBoxesToBalanceWeight(int $maxBoxesToBalanceWeight): void |
||
132 | { |
||
133 | 1 | $this->maxBoxesToBalanceWeight = $maxBoxesToBalanceWeight; |
|
134 | } |
||
135 | 1 | ||
136 | public function setPackedBoxSorter(PackedBoxSorter $packedBoxSorter): void |
||
137 | { |
||
138 | 14 | $this->packedBoxSorter = $packedBoxSorter; |
|
139 | } |
||
140 | 14 | ||
141 | public function setTimeoutChecker(TimeoutChecker $timeoutChecker): void |
||
142 | { |
||
143 | $this->timeoutChecker = $timeoutChecker; |
||
144 | } |
||
145 | |||
146 | public function throwOnUnpackableItem(bool $throwOnUnpackableItem): void |
||
147 | { |
||
148 | $this->throwOnUnpackableItem = $throwOnUnpackableItem; |
||
149 | } |
||
150 | |||
151 | 7 | public function beStrictAboutItemOrdering(bool $beStrict): void |
|
152 | { |
||
153 | 7 | $this->beStrictAboutItemOrdering = $beStrict; |
|
154 | } |
||
155 | |||
156 | /** |
||
157 | * Return the items that haven't been packed. |
||
158 | */ |
||
159 | 43 | public function getUnpackedItems(): ItemList |
|
160 | { |
||
161 | 43 | return $this->items; |
|
162 | } |
||
163 | 43 | ||
164 | /** |
||
165 | * Pack items into boxes using built-in heuristics for the best solution. |
||
166 | 40 | */ |
|
167 | 13 | public function pack(): PackedBoxList |
|
168 | 13 | { |
|
169 | 13 | $this->logger->log(LogLevel::INFO, '[PACKING STARTED]'); |
|
170 | $this->timeoutChecker?->start(); |
||
171 | $packedBoxes = $this->doBasicPacking(); |
||
172 | 40 | ||
173 | // If we have multiple boxes, try and optimise/even-out weight distribution |
||
174 | 40 | if (!$this->beStrictAboutItemOrdering && $packedBoxes->count() > 1 && $packedBoxes->count() <= $this->maxBoxesToBalanceWeight) { |
|
175 | $redistributor = new WeightRedistributor($this->boxes, $this->packedBoxSorter, $this->boxQuantitiesAvailable, $this->timeoutChecker); |
||
176 | $redistributor->setLogger($this->logger); |
||
177 | $packedBoxes = $redistributor->redistributeWeight($packedBoxes); |
||
178 | } |
||
179 | |||
180 | 43 | $this->logger->log(LogLevel::INFO, "[PACKING COMPLETED], {$packedBoxes->count()} boxes"); |
|
181 | |||
182 | 43 | return $packedBoxes; |
|
183 | } |
||
184 | |||
185 | 43 | /** |
|
186 | 43 | * @internal |
|
187 | */ |
||
188 | public function doBasicPacking(bool $enforceSingleBox = false): PackedBoxList |
||
189 | 43 | { |
|
190 | 42 | $packedBoxes = new PackedBoxList($this->packedBoxSorter); |
|
191 | 42 | ||
192 | 42 | // Keep going until everything packed |
|
193 | 42 | while ($this->items->count()) { |
|
194 | 42 | $packedBoxesIteration = []; |
|
195 | 40 | ||
196 | // Loop through boxes starting with smallest, see what happens |
||
197 | foreach ($this->getBoxList($enforceSingleBox) as $box) { |
||
198 | 40 | $this->timeoutChecker?->throwOnTimeout(); |
|
199 | 35 | $volumePacker = new VolumePacker($box, $this->items); |
|
200 | 35 | $volumePacker->setLogger($this->logger); |
|
201 | $volumePacker->beStrictAboutItemOrdering($this->beStrictAboutItemOrdering); |
||
202 | $packedBox = $volumePacker->pack(); |
||
203 | if ($packedBox->items->count()) { |
||
204 | $packedBoxesIteration[] = $packedBox; |
||
205 | 43 | ||
206 | // Have we found a single box that contains everything? |
||
207 | 40 | if ($packedBox->items->count() === $this->items->count()) { |
|
208 | 40 | $this->logger->log(LogLevel::DEBUG, "Single box found for remaining {$this->items->count()} items"); |
|
209 | break; |
||
210 | 40 | } |
|
211 | } |
||
212 | 40 | } |
|
213 | 40 | ||
214 | 10 | if (count($packedBoxesIteration) > 0) { |
|
215 | 3 | // Find best box of iteration, and remove packed items from unpacked list |
|
216 | usort($packedBoxesIteration, $this->packedBoxSorter->compare(...)); |
||
217 | 7 | $bestBox = $packedBoxesIteration[0]; |
|
218 | 7 | ||
219 | $this->items->removePackedItems($bestBox->items); |
||
220 | |||
221 | $packedBoxes->insert($bestBox); |
||
222 | 40 | --$this->boxQuantitiesAvailable[$bestBox->box]; |
|
223 | } elseif ($this->throwOnUnpackableItem) { |
||
224 | throw new NoBoxesAvailableException("No boxes could be found for item '{$this->items->top()->getDescription()}'", $this->items); |
||
225 | } else { |
||
226 | $this->logger->log(LogLevel::INFO, "{$this->items->count()} unpackable items found"); |
||
227 | break; |
||
228 | } |
||
229 | } |
||
230 | |||
231 | 7 | return $packedBoxes; |
|
232 | } |
||
233 | 7 | ||
234 | /** |
||
235 | 7 | * Pack items into boxes returning "all" possible box combination permutations. |
|
236 | * Use with caution (will be slow) with a large number of box types! |
||
237 | 7 | * |
|
238 | 7 | * @return PackedBoxList[] |
|
239 | */ |
||
240 | public function packAllPermutations(): array |
||
241 | 7 | { |
|
242 | 7 | $this->logger->log(LogLevel::INFO, '[PACKING STARTED (all permutations)]'); |
|
243 | 7 | $this->timeoutChecker?->start(); |
|
244 | 7 | ||
245 | 6 | $boxQuantitiesAvailable = clone $this->boxQuantitiesAvailable; |
|
246 | |||
247 | 7 | $wipPermutations = [['permutation' => new PackedBoxList($this->packedBoxSorter), 'itemsLeft' => $this->items]]; |
|
248 | 3 | $completedPermutations = []; |
|
249 | 3 | ||
250 | // Keep going until everything packed |
||
251 | while ($wipPermutations) { |
||
0 ignored issues
–
show
|
|||
252 | 7 | $wipPermutation = array_pop($wipPermutations); |
|
253 | 7 | $remainingBoxQuantities = clone $boxQuantitiesAvailable; |
|
254 | 7 | foreach ($wipPermutation['permutation'] as $packedBox) { |
|
255 | 7 | --$remainingBoxQuantities[$packedBox->box]; |
|
256 | 7 | } |
|
257 | 7 | if ($wipPermutation['itemsLeft']->count() === 0) { |
|
258 | 7 | $completedPermutations[] = $wipPermutation['permutation']; |
|
259 | 6 | continue; |
|
260 | } |
||
261 | |||
262 | $additionalPermutationsForThisPermutation = []; |
||
263 | foreach ($this->boxes as $box) { |
||
264 | 7 | $this->timeoutChecker?->throwOnTimeout(); |
|
265 | 6 | if ($remainingBoxQuantities[$box] > 0) { |
|
266 | 6 | $volumePacker = new VolumePacker($box, $wipPermutation['itemsLeft']); |
|
267 | 6 | $volumePacker->setLogger($this->logger); |
|
268 | 6 | $packedBox = $volumePacker->pack(); |
|
269 | 6 | if ($packedBox->items->count()) { |
|
270 | 6 | $additionalPermutationsForThisPermutation[] = $packedBox; |
|
271 | } |
||
272 | 4 | } |
|
273 | 1 | } |
|
274 | |||
275 | 3 | if (count($additionalPermutationsForThisPermutation) > 0) { |
|
276 | 3 | foreach ($additionalPermutationsForThisPermutation as $additionalPermutationForThisPermutation) { |
|
277 | 2 | $newPermutation = clone $wipPermutation['permutation']; |
|
278 | $newPermutation->insert($additionalPermutationForThisPermutation); |
||
279 | $itemsRemainingOnPermutation = clone $wipPermutation['itemsLeft']; |
||
280 | $itemsRemainingOnPermutation->removePackedItems($additionalPermutationForThisPermutation->items); |
||
281 | $wipPermutations[] = ['permutation' => $newPermutation, 'itemsLeft' => $itemsRemainingOnPermutation]; |
||
282 | 6 | } |
|
283 | } elseif ($this->throwOnUnpackableItem) { |
||
284 | 6 | throw new NoBoxesAvailableException("No boxes could be found for item '{$wipPermutation['itemsLeft']->top()->getDescription()}'", $wipPermutation['itemsLeft']); |
|
285 | 5 | } else { |
|
286 | 5 | $this->logger->log(LogLevel::INFO, "{$this->items->count()} unpackable items found"); |
|
287 | if ($wipPermutation['permutation']->count() > 0) { // don't treat initial empty permutation as completed |
||
288 | $completedPermutations[] = $wipPermutation['permutation']; |
||
289 | } |
||
290 | 6 | } |
|
291 | } |
||
292 | |||
293 | $this->logger->log(LogLevel::INFO, '[PACKING COMPLETED], ' . count($completedPermutations) . ' permutations'); |
||
294 | |||
295 | foreach ($completedPermutations as $completedPermutation) { |
||
296 | foreach ($completedPermutation as $packedBox) { |
||
297 | $this->items->removePackedItems($packedBox->items); |
||
298 | } |
||
299 | } |
||
300 | 43 | ||
301 | return $completedPermutations; |
||
302 | 43 | } |
|
303 | 43 | ||
304 | 43 | /** |
|
305 | 43 | * Get a "smart" ordering of the boxes to try packing items into. The initial BoxList is already sorted in order |
|
306 | * so that the smallest boxes are evaluated first, but this means that time is spent on boxes that cannot possibly |
||
307 | 43 | * hold the entire set of items due to volume limitations. These should be evaluated first. |
|
308 | * |
||
309 | 43 | * @return iterable<Box> |
|
310 | 43 | */ |
|
311 | 43 | protected function getBoxList(bool $enforceSingleBox = false): iterable |
|
312 | 42 | { |
|
313 | 42 | $this->logger->log(LogLevel::INFO, 'Determining box search pattern', ['enforceSingleBox' => $enforceSingleBox]); |
|
314 | 37 | $itemVolume = 0; |
|
315 | 21 | foreach ($this->items as $item) { |
|
316 | 21 | $itemVolume += $item->getWidth() * $item->getLength() * $item->getDepth(); |
|
317 | } |
||
318 | $this->logger->log(LogLevel::DEBUG, 'Item volume', ['itemVolume' => $itemVolume]); |
||
319 | |||
320 | $preferredBoxes = []; |
||
321 | 43 | $otherBoxes = []; |
|
322 | foreach ($this->boxes as $box) { |
||
323 | 43 | if ($this->boxQuantitiesAvailable[$box] > 0) { |
|
324 | if ($box->getInnerWidth() * $box->getInnerLength() * $box->getInnerDepth() >= $itemVolume) { |
||
325 | $preferredBoxes[] = $box; |
||
326 | } elseif (!$enforceSingleBox) { |
||
327 | $otherBoxes[] = $box; |
||
328 | } |
||
329 | } |
||
330 | } |
||
331 | |||
332 | $this->logger->log(LogLevel::INFO, 'Box search pattern complete', ['preferredBoxCount' => count($preferredBoxes), 'otherBoxCount' => count($otherBoxes)]); |
||
333 | |||
334 | return [...$preferredBoxes, ...$otherBoxes]; |
||
335 | } |
||
336 | } |
||
337 |
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.