Completed
Push — remove_logging ( 2ce4b7 )
by Doug
01:52
created

Packer::pack()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

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