Passed
Push — master ( 794d07...64825a )
by Doug
02:07
created

Packer::addItem()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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