Completed
Push — master ( 324696...ae9d26 )
by Doug
02:42
created

Packer   B

Complexity

Total Complexity 53

Size/Duplication

Total Lines 326
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 10

Test Coverage

Coverage 98.92%

Importance

Changes 77
Bugs 6 Features 13
Metric Value
c 77
b 6
f 13
dl 0
loc 326
wmc 53
lcom 1
cbo 10
ccs 183
cts 185
cp 0.9892
rs 7.4757

15 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 7 1
A setBoxes() 0 4 1
A addBox() 0 5 1
A addItem() 0 7 2
A setItems() 0 11 3
A pack() 0 13 2
C doVolumePacking() 0 51 10
C packIntoBox() 0 78 14
A isItemTooLargeForBox() 0 3 2
A fitsSameGap() 0 3 1
A fitsRotatedGap() 0 3 1
B fitsBetterRotated() 0 9 6
A fitsGap() 0 4 2
A canStackItemInLayer() 0 7 4
A isLayerStarted() 0 3 3

How to fix   Complexity   

Complex Class

Complex classes like Packer often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Packer, and based on these observations, apply Extract Interface, too.

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 14
    {
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
        $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 4
    public function setItems($items)
64
    {
65 2
        if ($items instanceof ItemList) {
66 4
            $this->items = clone $items;
67 2
        } else {
68 2
            $this->items = new ItemList();
69 2
            foreach ($items as $item) {
70 2
                $this->items->insert($item);
71 2
            }
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 2
    {
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
            $redistributor = new WeightRedistributor($this->boxes);
107 3
            $packedBoxes = $redistributor->redistributeWeight($packedBoxes);
108
        }
109 12
110 12
        $this->logger->log(LogLevel::INFO, "packing completed, {$packedBoxes->count()} boxes");
111
        return $packedBoxes;
112
    }
113
114
    /**
115
     * Pack items into boxes using the principle of largest volume item first
116
     *
117
     * @throws \RuntimeException
118
     * @return PackedBoxList
119 15
     */
120
    public function doVolumePacking()
121
    {
122 15
123
        $packedBoxes = new PackedBoxList;
124
125 15
        //Keep going until everything packed
126 15
        while ($this->items->count()) {
127 15
            $boxesToEvaluate = clone $this->boxes;
128
            $packedBoxesIteration = new PackedBoxList;
129
130 15
            //Loop through boxes starting with smallest, see what happens
131 14
            while (!$boxesToEvaluate->isEmpty()) {
132 14
                $box = $boxesToEvaluate->extract();
133 14
                $packedBox = $this->packIntoBox($box, clone $this->items);
134 14
                if ($packedBox->getItems()->count()) {
135
                    $packedBoxesIteration->insert($packedBox);
136
137 14
                    //Have we found a single box that contains everything?
138 13
                    if ($packedBox->getItems()->count() === $this->items->count()) {
139
                        break;
140 6
                    }
141 7
                }
142
            }
143
144 15
            //Check iteration was productive
145 2
            if ($packedBoxesIteration->isEmpty()) {
146
                throw new \RuntimeException('Item ' . $this->items->top()->getDescription() . ' is too large to fit into any box');
147
            }
148
149 14
            //Find best box of iteration, and remove packed items from unpacked list
150 14
            $bestBox = $packedBoxesIteration->top();
151 14
            $unPackedItems = $this->items->asArray();
152 14
            foreach (clone $bestBox->getItems() as $packedItem) {
153 14
                foreach ($unPackedItems as $unpackedKey => $unpackedItem) {
154 14
                    if ($packedItem === $unpackedItem) {
155 14
                        unset($unPackedItems[$unpackedKey]);
156
                        break;
157 14
                    }
158 14
                }
159 14
            }
160 14
            $unpackedItemList = new ItemList();
161 4
            foreach ($unPackedItems as $unpackedItem) {
162 14
                $unpackedItemList->insert($unpackedItem);
163 14
            }
164 14
            $this->items = $unpackedItemList;
165
            $packedBoxes->insert($bestBox);
166 14
167
        }
168 13
169
        return $packedBoxes;
170
    }
171
172
    /**
173
     * Pack as many items as possible into specific given box
174
     * @param Box      $box
175
     * @param ItemList $items
176
     * @return PackedBox packed box
177 4
     */
178
    public function packIntoBox(Box $box, ItemList $items)
179
    {
180 4
        $this->logger->log(LogLevel::DEBUG, "[EVALUATING BOX] {$box->getReference()}");
181 4
182
        $packedItems = new ItemList;
183 4
        $remainingDepth = $box->getInnerDepth();
184
        $remainingWeight = $box->getMaxWeight() - $box->getEmptyWeight();
185 4
        $remainingWidth = $box->getInnerWidth();
186 4
        $remainingLength = $box->getInnerLength();
187 4
188 4
        $layerWidth = $layerLength = $layerDepth = 0;
189 4
        while (!$items->isEmpty()) {
190 3
191 4
            $itemToPack = $items->top();
192 3
193 3
            //skip items that are simply too large
194 1
            if ($this->isItemTooLargeForBox($itemToPack, $remainingDepth, $remainingWeight)) {
195
                $items->extract();
196 4
                continue;
197
            }
198
199 4
            $this->logger->log(LogLevel::DEBUG, "evaluating item {$itemToPack->getDescription()}");
200 4
            $this->logger->log(LogLevel::DEBUG, "remaining width: {$remainingWidth}, length: {$remainingLength}, depth: {$remainingDepth}");
201
            $this->logger->log(LogLevel::DEBUG, "layerWidth: {$layerWidth}, layerLength: {$layerLength}, layerDepth: {$layerDepth}");
202 4
203 3
            $itemWidth = $itemToPack->getWidth();
204 3
            $itemLength = $itemToPack->getLength();
205 3
206 3
            if ($this->fitsGap($itemToPack, $remainingWidth, $remainingLength)) {
207
208
                $packedItems->insert($items->extract());
209 3
                $remainingWeight -= $itemToPack->getWeight();
210 3
211 3
                $nextItem = !$items->isEmpty() ? $items->top() : null;
212 3
                if ($this->fitsBetterRotated($itemToPack, $nextItem, $remainingWidth, $remainingLength)) {
213 3
                    $this->logger->log(LogLevel::DEBUG, "fits (better) unrotated");
214
                    $remainingLength -= $itemLength;
215
                    $layerLength += $itemLength;
216 2
                    $layerWidth = max($itemWidth, $layerWidth);
217 2
                } else {
218
                    $this->logger->log(LogLevel::DEBUG, "fits (better) rotated");
219 2
                    $remainingLength -= $itemWidth;
220 2
                    $layerLength += $itemWidth;
221 2
                    $layerWidth = max($itemLength, $layerWidth);
222 2
                }
223 2
                $layerDepth = max($layerDepth, $itemToPack->getDepth()); //greater than 0, items will always be less deep
224
225 2
                //allow items to be stacked in place within the same footprint up to current layerdepth
226 2
                $maxStackDepth = $layerDepth - $itemToPack->getDepth();
227 2
                while (!$items->isEmpty() && $this->canStackItemInLayer($itemToPack, $items->top(), $maxStackDepth, $remainingWeight)) {
228
                    $remainingWeight -= $items->top()->getWeight();
229 2
                    $maxStackDepth -= $items->top()->getDepth();
230 2
                    $packedItems->insert($items->extract());
231 2
                }
232
            } else {
233 2
                if ($remainingWidth >= min($itemWidth, $itemLength) && $this->isLayerStarted($layerWidth, $layerLength, $layerDepth)) {
234 2
                    $this->logger->log(LogLevel::DEBUG, "No more fit in lengthwise, resetting for new row");
235 2
                    $remainingLength += $layerLength;
236
                    $remainingWidth -= $layerWidth;
237
                    $layerWidth = $layerLength = 0;
238
                    continue;
239
                } elseif ($remainingLength < min($itemWidth, $itemLength) || $layerDepth == 0) {
240 2
                    $this->logger->log(LogLevel::DEBUG, "doesn't fit on layer even when empty");
241 2
                    $items->extract();
242
                    continue;
243 2
                }
244 2
245 2
                $remainingWidth = $layerWidth ? min(floor($layerWidth * 1.1), $box->getInnerWidth()) : $box->getInnerWidth();
246 2
                $remainingLength = $layerLength ? min(floor($layerLength * 1.1), $box->getInnerLength()) : $box->getInnerLength();
247
                $remainingDepth -= $layerDepth;
248 3
249 3
                $layerWidth = $layerLength = $layerDepth = 0;
250 4
                $this->logger->log(LogLevel::DEBUG, "doesn't fit, so starting next vertical layer");
251 4
            }
252
        }
253
        $this->logger->log(LogLevel::DEBUG, "done with this box");
254 4
        return new PackedBox($box, $packedItems, $remainingWidth, $remainingLength, $remainingDepth, $remainingWeight);
255 4
    }
256
257 4
    /**
258
     * @param Item $item
259
     * @param int $remainingDepth
260
     * @param int $remainingWeight
261
     * @return bool
262
     */
263
    protected function isItemTooLargeForBox(Item $item, $remainingDepth, $remainingWeight) {
264
        return $item->getDepth() > $remainingDepth || $item->getWeight() > $remainingWeight;
265
    }
266
267 23
    /**
268
     * Figure out space left for next item if we pack this one in it's regular orientation
269 23
     * @param Item $item
270
     * @param int $remainingWidth
271 23
     * @param int $remainingLength
272 23
     * @return int
273 23
     */
274 23
    protected function fitsSameGap(Item $item, $remainingWidth, $remainingLength) {
275 23
        return min($remainingWidth - $item->getWidth(), $remainingLength - $item->getLength());
276
    }
277 23
278 23
    /**
279
     * Figure out space left for next item if we pack this one rotated by 90deg
280 23
     * @param Item $item
281
     * @param int $remainingWidth
282
     * @param int $remainingLength
283 23
     * @return int
284 8
     */
285 8
    protected function fitsRotatedGap(Item $item, $remainingWidth, $remainingLength) {
286
        return min($remainingWidth - $item->getLength(), $remainingLength - $item->getWidth());
287
    }
288 23
289 23
    /**
290 23
     * @param Item $item
291
     * @param Item|null $nextItem
292 23
     * @param $remainingWidth
293 23
     * @param $remainingLength
294
     * @return bool
295 23
     */
296
    protected function fitsBetterRotated(Item $item, Item $nextItem = null, $remainingWidth, $remainingLength) {
297 23
298 23
        $fitsSameGap = $this->fitsSameGap($item, $remainingWidth, $remainingLength);
299
        $fitsRotatedGap = $this->fitsRotatedGap($item, $remainingWidth, $remainingLength);
300 23
301 23
        return !!($fitsRotatedGap < 0 ||
302 22
        ($fitsSameGap >= 0 && $fitsSameGap <= $fitsRotatedGap) ||
303 22
        ($item->getWidth() <= $remainingWidth && $nextItem == $item && $remainingLength >= 2 * $item->getLength()));
304 22
    }
305 22
306 22
    /**
307 8
     * Does item fit in specified gap
308 8
     * @param Item $item
309 8
     * @param $remainingWidth
310 8
     * @param $remainingLength
311
     * @return bool
312 23
     */
313
    protected function fitsGap(Item $item, $remainingWidth, $remainingLength) {
314
        return $this->fitsSameGap($item, $remainingWidth, $remainingLength) >= 0 ||
315 23
               $this->fitsRotatedGap($item, $remainingWidth, $remainingLength) >= 0;
316 23
    }
317 1
318 1
    /**
319 1
     * Figure out if we can stack the next item vertically on top of this rather than side by side
320 1
     * Used when we've packed a tall item, and have just put a shorter one next to it
321 23
     * @param Item $item
322 19
     * @param Item $nextItem
323 19
     * @param $maxStackDepth
324 19
     * @param $remainingWeight
325 19
     * @return bool
326 19
     */
327 19
    protected function canStackItemInLayer(Item $item, Item $nextItem, $maxStackDepth, $remainingWeight)
328 17
    {
329 2
        return $nextItem->getDepth() <= $maxStackDepth &&
330 2
               $nextItem->getWeight() <= $remainingWeight &&
331 2
               $nextItem->getWidth() <= $item->getWidth() &&
332
               $nextItem->getLength() <= $item->getLength();
333
    }
334 17
335 17
    /**
336 17
     * @param $layerWidth
337
     * @param $layerLength
338 17
     * @param $layerDepth
339 17
     * @return bool
340
     */
341 23
    protected function isLayerStarted($layerWidth, $layerLength, $layerDepth) {
342 23
        return $layerWidth > 0 && $layerLength > 0 && $layerDepth > 0;
343 23
    }
344
}
345