Completed
Push — master ( d45521...cece1c )
by Doug
21:03
created

Packer::packIntoBox()   D

Complexity

Conditions 17
Paths 16

Size

Total Lines 81
Code Lines 58

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 59
CRAP Score 17

Importance

Changes 10
Bugs 2 Features 0
Metric Value
c 10
b 2
f 0
dl 0
loc 81
ccs 59
cts 59
cp 1
rs 4.984
cc 17
eloc 58
nc 16
nop 2
crap 17

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
/**
3
 * Box packing (3D bin packing, knapsack problem)
4
 * @package BoxPacker
5
 * @author Doug Wright
6
 */
7
namespace DVDoug\BoxPacker;
8
9
use Psr\Log\LoggerAwareInterface;
10
use Psr\Log\LoggerAwareTrait;
11
use Psr\Log\LogLevel;
12
use Psr\Log\NullLogger;
13
14
/**
15
 * Actual packer
16
 * @author Doug Wright
17
 * @package BoxPacker
18
 */
19
class Packer implements LoggerAwareInterface
20
{
21
    use LoggerAwareTrait;
22
23
    /**
24
     * List of items to be packed
25
     * @var ItemList
26
     */
27
    protected $items;
28
29
    /**
30
     * List of box sizes available to pack items into
31
     * @var BoxList
32
     */
33
    protected $boxes;
34
35
    /**
36
     * Constructor
37 4582
     */
38 4582
    public function __construct()
39 4582
    {
40
        $this->items = new ItemList();
41 4582
        $this->boxes = new BoxList();
42 4582
43
        $this->logger = new NullLogger();
44
    }
45
46
    /**
47
     * Add item to be packed
48
     * @param Item $item
49 4572
     * @param int  $qty
50 4572
     */
51 4572
    public function addItem(Item $item, $qty = 1)
52 4572
    {
53 4572
        for ($i = 0; $i < $qty; $i++) {
54 4572
            $this->items->insert($item);
55
        }
56
        $this->logger->log(LogLevel::INFO, "added {$qty} x {$item->getDescription()}");
57
    }
58
59
    /**
60 1323
     * Set a list of items all at once
61 1233
     * @param \Traversable $items
62 1233
     */
63 1233
    public function setItems($items)
64 1209
    {
65 1209
        if ($items instanceof ItemList) {
66 1209
            $this->items = clone $items;
67 1209
        } elseif (is_array($items)) {
68 1323
            $this->items = new ItemList();
69 1209
            foreach ($items as $item) {
70
                $this->items->insert($item);
71
            }
72
        } else {
73 1233
            throw new \RuntimeException('Not a valid list of items');
74
        }
75
    }
76
77
    /**
78
     * Add box size
79 4572
     * @param Box $box
80 4572
     */
81 4572
    public function addBox(Box $box)
82 4572
    {
83
        $this->boxes->insert($box);
84
        $this->logger->log(LogLevel::INFO, "added box {$box->getReference()}");
85
    }
86
87
    /**
88 1233
     * Add a pre-prepared set of boxes all at once
89 1233
     * @param BoxList $boxList
90 1233
     */
91
    public function setBoxes(BoxList $boxList)
92
    {
93
        $this->boxes = clone $boxList;
94
    }
95
96
    /**
97
     * Pack items into boxes
98 4572
     *
99 4572
     * @throws \RuntimeException
100
     * @return PackedBoxList
101
     */
102 4570
    public function pack()
103 1322
    {
104 1322
        $packedBoxes = $this->doVolumePacking();
105
106 4570
        //If we have multiple boxes, try and optimise/even-out weight distribution
107 4570
        if ($packedBoxes->count() > 1) {
108
            $packedBoxes = $this->redistributeWeight($packedBoxes);
109
        }
110
111
        $this->logger->log(LogLevel::INFO, "packing completed, {$packedBoxes->count()} boxes");
112
        return $packedBoxes;
113
    }
114
115
    /**
116 4573
     * Pack items into boxes using the principle of largest volume item first
117
     *
118 4573
     * @throws \RuntimeException
119
     * @return PackedBoxList
120
     */
121 4573
    public function doVolumePacking()
122 4573
    {
123 4573
124
        $packedBoxes = new PackedBoxList;
125
126 4573
        //Keep going until everything packed
127 4572
        while ($this->items->count()) {
128 4572
            $boxesToEvaluate = clone $this->boxes;
129 4572
            $packedBoxesIteration = new PackedBoxList;
130 4572
131
            //Loop through boxes starting with smallest, see what happens
132
            while (!$boxesToEvaluate->isEmpty()) {
133 4572
                $box = $boxesToEvaluate->extract();
134 4571
                $packedBox = $this->packIntoBox($box, clone $this->items);
135
                if ($packedBox->getItems()->count()) {
136 3397
                    $packedBoxesIteration->insert($packedBox);
137 4556
138
                    //Have we found a single box that contains everything?
139
                    if ($packedBox->getItems()->count() === $this->items->count()) {
140 4573
                        break;
141 2
                    }
142
                }
143
            }
144
145 4572
            //Check iteration was productive
146 4572
            if ($packedBoxesIteration->isEmpty()) {
147 4572
                throw new \RuntimeException('Item ' . $this->items->top()->getDescription() . ' is too large to fit into any box');
148 4572
            }
149 4572
150 4572
            //Find best box of iteration, and remove packed items from unpacked list
151 4572
            $bestBox = $packedBoxesIteration->top();
152
            $unPackedItems = $this->items->asArray();
153 4572
            foreach (clone $bestBox->getItems() as $packedItem) {
154 4572
                foreach ($unPackedItems as $unpackedKey => $unpackedItem) {
155 4572
                    if ($packedItem === $unpackedItem) {
156 4572
                        unset($unPackedItems[$unpackedKey]);
157 1323
                        break;
158 4572
                    }
159 4572
                }
160 4572
            }
161
            $unpackedItemList = new ItemList();
162 4572
            foreach ($unPackedItems as $unpackedItem) {
163
                $unpackedItemList->insert($unpackedItem);
164 4571
            }
165
            $this->items = $unpackedItemList;
166
            $packedBoxes->insert($bestBox);
167
168
        }
169
170
        return $packedBoxes;
171
    }
172
173 1323
    /**
174
     * Given a solution set of packed boxes, repack them to achieve optimum weight distribution
175 1323
     *
176 1323
     * @param PackedBoxList $originalBoxes
177
     * @return PackedBoxList
178 1323
     */
179
    public function redistributeWeight(PackedBoxList $originalBoxes)
180 1323
    {
181 1323
182 1323
        $targetWeight = $originalBoxes->getMeanWeight();
183 1323
        $this->logger->log(LogLevel::DEBUG, "repacking for weight distribution, weight variance {$originalBoxes->getWeightVariance()}, target weight {$targetWeight}");
184 1323
185 1312
        $packedBoxes = new PackedBoxList;
186 1312
187 1323
        $overWeightBoxes = [];
188 1312
        $underWeightBoxes = [];
189 1312
        foreach (clone $originalBoxes as $packedBox) {
190
            $boxWeight = $packedBox->getWeight();
191 11
            if ($boxWeight > $targetWeight) {
192
                $overWeightBoxes[] = $packedBox;
193 1323
            } elseif ($boxWeight < $targetWeight) {
194
                $underWeightBoxes[] = $packedBox;
195
            } else {
196 1323
                $packedBoxes->insert($packedBox); //target weight, so we'll keep these
197 1323
            }
198
        }
199 1323
200 1312
        do { //Keep moving items from most overweight box to most underweight box
201 1312
            $tryRepack = false;
202 1312
            $this->logger->log(LogLevel::DEBUG, 'boxes under/over target: ' . count($underWeightBoxes) . '/' . count($overWeightBoxes));
203 1312
204
            foreach ($underWeightBoxes as $u => $underWeightBox) {
205
                $this->logger->log(LogLevel::DEBUG, 'Underweight Box ' . $u);
206 1312
                foreach ($overWeightBoxes as $o => $overWeightBox) {
207 1312
                    $this->logger->log(LogLevel::DEBUG, 'Overweight Box ' . $o);
208 1312
                    $overWeightBoxItems = $overWeightBox->getItems()->asArray();
209 1130
210 1130
                    //For each item in the heavier box, try and move it to the lighter one
211
                    foreach ($overWeightBoxItems as $oi => $overWeightBoxItem) {
212
                        $this->logger->log(LogLevel::DEBUG, 'Overweight Item ' . $oi);
213 1233
                        if ($underWeightBox->getWeight() + $overWeightBoxItem->getWeight() > $targetWeight) {
214 1233
                            $this->logger->log(LogLevel::DEBUG, 'Skipping item for hindering weight distribution');
215
                            continue; //skip if moving this item would hinder rather than help weight distribution
216 1233
                        }
217 1233
218 1233
                        $newItemsForLighterBox = clone $underWeightBox->getItems();
219 1233
                        $newItemsForLighterBox->insert($overWeightBoxItem);
220 1233
221
                        $newLighterBoxPacker = new Packer(); //we may need a bigger box
222 1233
                        $newLighterBoxPacker->setBoxes($this->boxes);
223 1209
                        $newLighterBoxPacker->setItems($newItemsForLighterBox);
224 1209
                        $this->logger->log(LogLevel::INFO, "[ATTEMPTING TO PACK LIGHTER BOX]");
225
                        $newLighterBox = $newLighterBoxPacker->doVolumePacking()->extract();
226 1209
227 1209
                        if ($newLighterBox->getItems()->count() === $newItemsForLighterBox->count()) { //new item fits
228 1209
                            $this->logger->log(LogLevel::DEBUG, 'New item fits');
229
                            unset($overWeightBoxItems[$oi]); //now packed in different box
230 1209
231 1209
                            $newHeavierBoxPacker = new Packer(); //we may be able to use a smaller box
232 1209
                            $newHeavierBoxPacker->setBoxes($this->boxes);
233 4
                            $newHeavierBoxPacker->setItems($overWeightBoxItems);
234 4
235
                            $this->logger->log(LogLevel::INFO, "[ATTEMPTING TO PACK HEAVIER BOX]");
236
                            $newHeavierBoxes = $newHeavierBoxPacker->doVolumePacking();
237 1207
                            if (count($newHeavierBoxes) > 1) { //found an edge case in packing algorithm that *increased* box count
238 1207
                                $this->logger->log(LogLevel::INFO, "[REDISTRIBUTING WEIGHT] Abandoning redistribution, because new packing is less efficient than original");
239
                                return $originalBoxes;
240 1207
                            }
241 1207
242 1207
                            $overWeightBoxes[$o] = $newHeavierBoxes->extract();
243 1207
                            $underWeightBoxes[$u] = $newLighterBox;
244
245 1308
                            $tryRepack = true; //we did some work, so see if we can do even better
246 1308
                            usort($overWeightBoxes, [$packedBoxes, 'reverseCompare']);
247 1321
                            usort($underWeightBoxes, [$packedBoxes, 'reverseCompare']);
248 1321
                            break 3;
249
                        }
250
                    }
251 1319
                }
252 1319
            }
253
        } while ($tryRepack);
254 1319
255
        //Combine back into a single list
256
        $packedBoxes->insertFromArray($overWeightBoxes);
257
        $packedBoxes->insertFromArray($underWeightBoxes);
258
259
        return $packedBoxes;
260
    }
261
262
263
    /**
264 4581
     * Pack as many items as possible into specific given box
265 4581
     * @param Box      $box
266
     * @param ItemList $items
267 4581
     * @return PackedBox packed box
268 4581
     */
269 4581
    public function packIntoBox(Box $box, ItemList $items)
270 4581
    {
271 4581
        $this->logger->log(LogLevel::DEBUG, "[EVALUATING BOX] {$box->getReference()}");
272
273 4581
        $packedItems = new ItemList;
274 4581
        $remainingDepth = $box->getInnerDepth();
275
        $remainingWeight = $box->getMaxWeight() - $box->getEmptyWeight();
276 4581
        $remainingWidth = $box->getInnerWidth();
277
        $remainingLength = $box->getInnerLength();
278 4581
279 3473
        $layerWidth = $layerLength = $layerDepth = 0;
280 3473
        while (!$items->isEmpty()) {
281
282
            $itemToPack = $items->top();
283 4581
284 4581
            //skip items that are simply too large
285 4581
            if ($this->isItemTooLargeForBox($itemToPack, $remainingDepth, $remainingWeight)) {
286
                $items->extract();
287 4581
                continue;
288 4581
            }
289
290 4581
            $this->logger->log(LogLevel::DEBUG, "evaluating item {$itemToPack->getDescription()}");
291 4581
            $this->logger->log(LogLevel::DEBUG, "remaining width: {$remainingWidth}, length: {$remainingLength}, depth: {$remainingDepth}");
292
            $this->logger->log(LogLevel::DEBUG, "layerWidth: {$layerWidth}, layerLength: {$layerLength}, layerDepth: {$layerDepth}");
293 4581
294
            $itemWidth = $itemToPack->getWidth();
295 4581
            $itemLength = $itemToPack->getLength();
296 4581
297
            $fitsSameGap = $this->fitsSameGap($itemToPack, $remainingWidth, $remainingLength);
298 4581
            $fitsRotatedGap = $this->fitsRotatedGap($itemToPack, $remainingWidth, $remainingLength);
299 4572
300 4581
            if ($fitsSameGap >= 0 || $fitsRotatedGap >= 0) {
301 3158
302 3158
                $packedItems->insert($items->extract());
303 3158
                $remainingWeight -= $itemToPack->getWeight();
304 3158
305 3158
                $nextItem = !$items->isEmpty() ? $items->top() : null;
306
                if ($this->fitsBetterRotated($itemToPack, $nextItem, $remainingWidth, $remainingLength)) {
307 4536
                    $this->logger->log(LogLevel::DEBUG, "fits (better) unrotated");
308 4536
                    $remainingLength -= $itemLength;
309 4536
                    $layerLength += $itemLength;
310 4536
                    $layerWidth = max($itemWidth, $layerWidth);
311
                } else {
312 4581
                    $this->logger->log(LogLevel::DEBUG, "fits (better) rotated");
313
                    $remainingLength -= $itemWidth;
314
                    $layerLength += $itemWidth;
315 4581
                    $layerWidth = max($itemLength, $layerWidth);
316 4581
                }
317 4550
                $layerDepth = max($layerDepth, $itemToPack->getDepth()); //greater than 0, items will always be less deep
318 4550
319 4550
                //allow items to be stacked in place within the same footprint up to current layerdepth
320 4550
                $maxStackDepth = $layerDepth - $itemToPack->getDepth();
321 4550
                while (!$items->isEmpty() && $this->canStackItemInLayer($itemToPack, $items->top(), $maxStackDepth, $remainingWeight)) {
322 285
                    $remainingWeight -= $items->top()->getWeight();
323 285
                    $maxStackDepth -= $items->top()->getDepth();
324 285
                    $packedItems->insert($items->extract());
325 285
                }
326
            } else {
327 4550
                if ($remainingWidth >= min($itemWidth, $itemLength) && $layerDepth > 0 && $layerWidth > 0 && $layerLength > 0) {
328
                    $this->logger->log(LogLevel::DEBUG, "No more fit in lengthwise, resetting for new row");
329 285
                    $remainingLength += $layerLength;
330 4581
                    $remainingWidth -= $layerWidth;
331
                    $layerWidth = $layerLength = 0;
332 4562
                    continue;
333 4502
                } elseif ($remainingLength < min($itemWidth, $itemLength) || $layerDepth == 0) {
334 4502
                    $this->logger->log(LogLevel::DEBUG, "doesn't fit on layer even when empty");
335 4502
                    $items->extract();
336 4502
                    continue;
337 4502
                }
338
339
                $remainingWidth = $layerWidth ? min(floor($layerWidth * 1.1), $box->getInnerWidth()) : $box->getInnerWidth();
340 4549
                $remainingLength = $layerLength ? min(floor($layerLength * 1.1), $box->getInnerLength()) : $box->getInnerLength();
341 4153
                $remainingDepth -= $layerDepth;
342 4153
343 4153
                $layerWidth = $layerLength = $layerDepth = 0;
344
                $this->logger->log(LogLevel::DEBUG, "doesn't fit, so starting next vertical layer");
345
            }
346 4487
        }
347 4487
        $this->logger->log(LogLevel::DEBUG, "done with this box");
348 4487
        return new PackedBox($box, $packedItems, $remainingWidth, $remainingLength, $remainingDepth, $remainingWeight);
349
    }
350 4487
351 4487
    /**
352
     * @param Item $item
353 4581
     * @param int $remainingDepth
354 4581
     * @param int $remainingWeight
355 4581
     * @return bool
356
     */
357
    protected function isItemTooLargeForBox(Item $item, $remainingDepth, $remainingWeight) {
358
        return $item->getDepth() > $remainingDepth || $item->getWeight() > $remainingWeight;
359
    }
360
361
    /**
362
     * Figure out space left for next item if we pack this one in it's regular orientation
363
     * @param Item $item
364
     * @param int $remainingWidth
365
     * @param int $remainingLength
366
     * @return int
367
     */
368
    protected function fitsSameGap(Item $item, $remainingWidth, $remainingLength) {
369
        return min($remainingWidth - $item->getWidth(), $remainingLength - $item->getLength());
370
    }
371
372
    /**
373
     * Figure out space left for next item if we pack this one rotated by 90deg
374
     * @param Item $item
375
     * @param int $remainingWidth
376
     * @param int $remainingLength
377
     * @return int
378
     */
379
    protected function fitsRotatedGap(Item $item, $remainingWidth, $remainingLength) {
380
        return min($remainingWidth - $item->getLength(), $remainingLength - $item->getWidth());
381
    }
382
383
    /**
384
     * @param Item $item
385
     * @param Item|null $nextItem
386
     * @param $remainingWidth
387
     * @param $remainingLength
388
     * @return bool
389
     */
390
    protected function fitsBetterRotated(Item $item, Item $nextItem = null, $remainingWidth, $remainingLength) {
391
392
        $fitsSameGap = $this->fitsSameGap($item, $remainingWidth, $remainingLength);
393
        $fitsRotatedGap = $this->fitsRotatedGap($item, $remainingWidth, $remainingLength);
394
395
        return !!($fitsRotatedGap < 0 ||
396
        ($fitsSameGap >= 0 && $fitsSameGap <= $fitsRotatedGap) ||
397
        ($item->getWidth() <= $remainingWidth && $nextItem == $item && $remainingLength >= 2 * $item->getLength()));
398
    }
399
400
    /**
401
     * Figure out if we can stack the next item vertically on top of this rather than side by side
402
     * Used when we've packed a tall item, and have just put a shorter one next to it
403
     * @param Item $item
404
     * @param Item $nextItem
405
     * @param $maxStackDepth
406
     * @param $remainingWeight
407
     * @return bool
408
     */
409
    protected function canStackItemInLayer(Item $item, Item $nextItem, $maxStackDepth, $remainingWeight)
410
    {
411
        return $nextItem->getDepth() <= $maxStackDepth &&
412
               $nextItem->getWeight() <= $remainingWeight &&
413
               $nextItem->getWidth() <= $item->getWidth() &&
414
               $nextItem->getLength() <= $item->getLength();
415
    }
416
417
    /**
418
     * Pack as many items as possible into specific given box
419
     * @deprecated
420
     * @param Box      $box
421
     * @param ItemList $items
422
     * @return ItemList items packed into box
423
     */
424
    public function packBox(Box $box, ItemList $items)
425
    {
426
        $packedBox = $this->packIntoBox($box, $items);
427
        return $packedBox->getItems();
428
    }
429
}
430