Completed
Branch master (c1e62b)
by Doug
02:44
created

Packer::fitsGap()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 2

Importance

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