Passed
Pull Request — master (#198)
by
unknown
05:44
created

Packer::addItem()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

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