Passed
Push — master ( 8de8ac...4e9f9e )
by Doug
05:17 queued 02:42
created

Packer   B

Complexity

Total Complexity 48

Size/Duplication

Total Lines 295
Duplicated Lines 0 %

Test Coverage

Coverage 98.43%

Importance

Changes 12
Bugs 0 Features 0
Metric Value
eloc 121
c 12
b 0
f 0
dl 0
loc 295
ccs 125
cts 127
cp 0.9843
rs 8.5599
wmc 48

17 Methods

Rating   Name   Duplication   Size   Complexity  
A getMaxBoxesToBalanceWeight() 0 3 1
A setItems() 0 8 3
A setBoxes() 0 5 3
A setMaxBoxesToBalanceWeight() 0 3 1
B doBasicPacking() 0 43 7
A beStrictAboutItemOrdering() 0 3 1
A getUnpackedItems() 0 3 1
A __construct() 0 8 1
A setBoxQuantity() 0 3 1
A throwOnUnpackableItem() 0 3 1
A setLogger() 0 3 1
A addItem() 0 4 1
A addBox() 0 5 2
C packAllPermutations() 0 60 13
A pack() 0 16 4
A setPackedBoxSorter() 0 3 1
A getBoxList() 0 24 6

How to fix   Complexity   

Complex Class

Complex classes like Packer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Packer, and based on these observations, apply Extract Interface, too.

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