Passed
Push — master ( 020179...2cb551 )
by Doug
11:37
created

Packer::setTimeoutChecker()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 3
rs 10
ccs 0
cts 1
cp 0
crap 2
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);
0 ignored issues
show
Bug introduced by
$box of type array is incompatible with the type DVDoug\BoxPacker\Box expected by parameter $box of DVDoug\BoxPacker\VolumePacker::__construct(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

199
                $volumePacker = new VolumePacker(/** @scrutinizer ignore-type */ $box, $this->items);
Loading history...
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
Bug Best Practice introduced by
The expression $wipPermutations of type array<integer,array<stri...xPacker\PackedBoxList>> is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

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.

Loading history...
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