Test Failed
Push — 2.x-dev ( fa2a34...794087 )
by Doug
01:31
created

VolumePacker::packLayer()   B

Complexity

Conditions 9
Paths 7

Size

Total Lines 54
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 39
CRAP Score 9.0094

Importance

Changes 0
Metric Value
cc 9
eloc 41
nc 7
nop 4
dl 0
loc 54
ccs 39
cts 41
cp 0.9512
crap 9.0094
rs 7.7084
c 0
b 0
f 0

How to fix   Long Method   

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
 *
5
 * @author Doug Wright
6
 */
7
8
namespace DVDoug\BoxPacker;
9
10
use Psr\Log\LoggerAwareInterface;
11
use Psr\Log\LoggerAwareTrait;
12
use Psr\Log\NullLogger;
13
14
/**
15
 * Actual packer.
16
 *
17
 * @author Doug Wright
18
 */
19
class VolumePacker implements LoggerAwareInterface
20
{
21
    use LoggerAwareTrait;
22
23
    /**
24
     * Box to pack items into.
25
     *
26
     * @var Box
27
     */
28
    protected $box;
29
30
    /**
31
     * @var int
32
     */
33
    protected $boxWidth;
34
35
    /**
36
     * @var int
37
     */
38
    protected $boxLength;
39
40
    /**
41
     * List of items to be packed.
42
     *
43
     * @var ItemList
44
     */
45
    protected $items;
46
47
    /**
48
     * List of items temporarily skipped to be packed.
49
     *
50
     * @var ItemList
51
     */
52
    protected $skippedItems;
53
54
    /**
55
     * Remaining weight capacity of the box.
56
     *
57
     * @var int
58
     */
59
    protected $remainingWeight;
60
61
    /**
62
     * Whether the box was rotated for packing.
63
     *
64
     * @var bool
65
     */
66
    protected $boxRotated = false;
67
68
    /**
69
     * @var PackedLayer[]
70
     */
71
    protected $layers = [];
72
73
    /**
74
     * Constructor.
75
     *
76
     * @param Box      $box
77
     * @param ItemList $items
78
     */
79 20
    public function __construct(Box $box, ItemList $items)
80
    {
81 20
        $this->box = $box;
82 20
        $this->items = $items;
83
84 20
        $this->boxWidth = max($this->box->getInnerWidth(), $this->box->getInnerLength());
85 20
        $this->boxLength = min($this->box->getInnerWidth(), $this->box->getInnerLength());
86 20
        $this->remainingWeight = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
87 20
        $this->skippedItems = new ItemList();
88 20
        $this->logger = new NullLogger();
89
90
        // we may have just rotated the box for packing purposes, record if we did
91 20
        if ($this->box->getInnerWidth() !== $this->boxWidth || $this->box->getInnerLength() !== $this->boxLength) {
92 3
            $this->boxRotated = true;
93
        }
94 20
    }
95
96
    /**
97
     * Pack as many items as possible into specific given box.
98
     *
99
     * @return PackedBox packed box
100
     */
101 20
    public function pack()
102
    {
103 20
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}");
104
105 20
        while (count($this->items) > 0) {
106 20
            $layerStartDepth = $this->getCurrentPackedDepth();
107 20
            $this->packLayer($layerStartDepth, $this->boxWidth, $this->boxLength, $this->box->getInnerDepth() - $layerStartDepth);
108
        }
109
110 20
        if ($this->boxRotated) {
111 3
            $this->rotateLayersNinetyDegrees();
112
        }
113
114 20
        $this->stabiliseLayers();
115
116 20
        $this->logger->debug('done with this box');
117
118 20
        return PackedBox::fromPackedItemList($this->box, $this->getPackedItemList());
119
    }
120
121
    /**
122
     * Pack items into an individual vertical layer.
123
     *
124
     * @param int $startDepth
125
     * @param int $widthLeft
126
     * @param int $lengthLeft
127
     * @param int $depthLeft
128
     */
129 20
    protected function packLayer($startDepth, $widthLeft, $lengthLeft, $depthLeft)
130
    {
131 20
        $this->layers[] = $layer = new PackedLayer();
132 20
        $prevItem = null;
133 20
        $x = $y = $rowWidth = $rowLength = $layerDepth = 0;
134
135 20
        while (count($this->items) > 0) {
136 20
            $itemToPack = $this->items->extract();
137
138
            //skip items that are simply too heavy or too large
139 20
            if (!$this->checkConstraints($itemToPack)) {
140 5
                $this->rebuildItemList();
141 5
                continue;
142
            }
143
144 19
            $orientatedItem = $this->getOrientationForItem($itemToPack, $prevItem, $this->items, $this->hasItemsLeftToPack(), $widthLeft, $lengthLeft, $depthLeft);
145
146 19
            if ($orientatedItem instanceof OrientatedItem) {
147 19
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $startDepth);
148 19
                $layer->insert($packedItem);
149 19
                $this->remainingWeight -= $orientatedItem->getItem()->getWeight();
150 19
                $widthLeft -= $orientatedItem->getWidth();
151
152 19
                $rowWidth += $orientatedItem->getWidth();
153 19
                $rowLength = max($rowLength, $orientatedItem->getLength());
154 19
                $layerDepth = max($layerDepth, $orientatedItem->getDepth());
155
156
                //allow items to be stacked in place within the same footprint up to current layer depth
157 19
                $stackableDepth = $layerDepth - $orientatedItem->getDepth();
158 19
                $this->tryAndStackItemsIntoSpace($layer, $prevItem, $this->items, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $x, $y, $startDepth + $orientatedItem->getDepth());
159 19
                $x += $orientatedItem->getWidth();
160
161 19
                $prevItem = $packedItem;
162 19
                $this->rebuildItemList();
163 12
            } elseif (count($layer->getItems()) === 0) { // zero items on layer
164
                $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
165
                continue;
166 12
            } elseif ($widthLeft > 0 && count($this->items) > 0) { // skip for now, move on to the next item
167 10
                $this->logger->debug("doesn't fit, skipping for now");
168 10
                $this->skippedItems->insert($itemToPack);
169 12
            } elseif ($x > 0 && $lengthLeft >= min($itemToPack->getWidth(), $itemToPack->getLength(), $itemToPack->getDepth())) {
170 12
                $this->logger->debug('No more fit in width wise, resetting for new row');
171 12
                $widthLeft += $rowWidth;
172 12
                $lengthLeft -= $rowLength;
173 12
                $y += $rowLength;
174 12
                $x = $rowWidth = $rowLength = 0;
175 12
                $this->rebuildItemList($itemToPack);
176 12
                $prevItem = null;
177 12
                continue;
178
            } else {
179 9
                $this->logger->debug('no items fit, so starting next vertical layer');
180 9
                $this->rebuildItemList($itemToPack);
181
182 9
                return;
183
            }
184
        }
185 20
    }
186
187
    /**
188
     * During packing, it is quite possible that layers have been created that aren't physically stable
189
     * i.e. they overhang the ones below.
190
     *
191
     * This function reorders them so that the ones with the greatest surface area are placed at the bottom
192
     */
193 20
    public function stabiliseLayers()
194
    {
195 20
        $stabiliser = new LayerStabiliser();
196 20
        $this->layers = $stabiliser->stabilise($this->layers);
197 20
    }
198
199
    /**
200
     * @param Item            $itemToPack
201
     * @param PackedItem|null $prevItem
202
     * @param ItemList        $nextItems
203
     * @param bool            $isLastItem
204
     * @param int             $maxWidth
205
     * @param int             $maxLength
206
     * @param int             $maxDepth
207
     *
208
     * @return OrientatedItem|null
209
     */
210 19
    protected function getOrientationForItem(
211
        Item $itemToPack,
212
        PackedItem $prevItem = null,
213
        ItemList $nextItems,
214
        $isLastItem,
215
        $maxWidth,
216
        $maxLength,
217
        $maxDepth
218
    ) {
219 19
        $this->logger->debug(
220 19
            "evaluating item {$itemToPack->getDescription()} for fit",
221
            [
222 19
                'item' => $itemToPack,
223
                'space' => [
224 19
                    'maxWidth' => $maxWidth,
225 19
                    'maxLength' => $maxLength,
226 19
                    'maxDepth' => $maxDepth,
227
                ],
228
            ]
229
        );
230
231 19
        $prevOrientatedItem = $prevItem ? $prevItem->toOrientatedItem() : null;
232
233 19
        $orientatedItemFactory = new OrientatedItemFactory($this->box);
234 19
        $orientatedItemFactory->setLogger($this->logger);
235 19
        $orientatedItemDecision = $orientatedItemFactory->getBestOrientation($itemToPack, $prevOrientatedItem, $nextItems, $isLastItem, $maxWidth, $maxLength, $maxDepth);
236
237 19
        return $orientatedItemDecision;
238
    }
239
240
    /**
241
     * Figure out if we can stack the next item vertically on top of this rather than side by side
242
     * Used when we've packed a tall item, and have just put a shorter one next to it.
243
     *
244
     * @param PackedLayer     $layer
245
     * @param PackedItem|null $prevItem
246
     * @param ItemList        $nextItems
247
     * @param int             $maxWidth
248
     * @param int             $maxLength
249
     * @param int             $maxDepth
250
     * @param int             $x
251
     * @param int             $y
252
     * @param int             $z
253
     */
254 19
    protected function tryAndStackItemsIntoSpace(
255
        PackedLayer $layer,
256
        PackedItem $prevItem = null,
257
        ItemList $nextItems,
258
        $maxWidth,
259
        $maxLength,
260
        $maxDepth,
261
        $x,
262
        $y,
263
        $z
264
    ) {
265 19
        while (count($this->items) > 0 && $this->checkNonDimensionalConstraints($this->items->top())) {
266 16
            $stackedItem = $this->getOrientationForItem(
267 16
                $this->items->top(),
268 16
                $prevItem,
269 16
                $nextItems,
270 16
                $this->items->count() === 1,
271 16
                $maxWidth,
272 16
                $maxLength,
273 16
                $maxDepth
274
            );
275 16
            if ($stackedItem) {
276 2
                $this->remainingWeight -= $this->items->top()->getWeight();
277 2
                $layer->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $z));
278 2
                $this->items->extract();
279 2
                $maxDepth -= $stackedItem->getDepth();
280 2
                $z += $stackedItem->getDepth();
281
            } else {
282 16
                break;
283
            }
284
        }
285 19
    }
286
287
    /**
288
     * Check item generally fits into box.
289
     *
290
     * @param Item $itemToPack
291
     *
292
     * @return bool
293
     */
294 20
    protected function checkConstraints(
295
        Item $itemToPack
296
    ) {
297 20
        return $this->checkNonDimensionalConstraints($itemToPack) &&
298 20
               $this->checkDimensionalConstraints($itemToPack);
299
    }
300
301
    /**
302
     * As well as purely dimensional constraints, there are other constraints that need to be met
303
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box).
304
     *
305
     * @param Item $itemToPack
306
     *
307
     * @return bool
308
     */
309 20
    protected function checkNonDimensionalConstraints(Item $itemToPack)
310
    {
311 20
        $weightOK = $itemToPack->getWeight() <= $this->remainingWeight;
312
313 20
        if ($itemToPack instanceof ConstrainedItem) {
314
            return $weightOK && $itemToPack->canBePackedInBox($this->getPackedItemList()->asItemList(), $this->box);
315
        }
316
317 20
        return $weightOK;
318
    }
319
320
    /**
321
     * Check the item physically fits in the box (at all).
322
     *
323
     * @param Item $itemToPack
324
     *
325
     * @return bool
326
     */
327 19
    protected function checkDimensionalConstraints(Item $itemToPack)
328
    {
329 19
        $orientatedItemFactory = new OrientatedItemFactory($this->box);
330 19
        $orientatedItemFactory->setLogger($this->logger);
331
332 19
        return (bool) $orientatedItemFactory->getPossibleOrientationsInEmptyBox($itemToPack);
333
    }
334
335
    /**
336
     * Reintegrate skipped items into main list.
337
     *
338
     * @param Item|null $currentItem item from current iteration
339
     */
340 20
    protected function rebuildItemList(Item $currentItem = null)
341
    {
342 20
        if (count($this->items) === 0) {
343 20
            $this->items = $this->skippedItems;
344 20
            $this->skippedItems = new ItemList();
345
        }
346
347 20
        if ($currentItem instanceof Item) {
348 12
            $this->items->insert($currentItem);
349
        }
350 20
    }
351
352
    /**
353
     * Swap back width/length of the packed items to match orientation of the box if needed.
354
     */
355 3
    protected function rotateLayersNinetyDegrees()
356
    {
357 3
        foreach ($this->layers as $i => $originalLayer) {
358 3
            $newLayer = new PackedLayer();
359 3
            foreach ($originalLayer->getItems() as $item) {
360 3
                $packedItem = new PackedItem($item->getItem(), $item->getY(), $item->getX(), $item->getZ(), $item->getLength(), $item->getWidth(), $item->getDepth());
361 3
                $newLayer->insert($packedItem);
362
            }
363 3
            $this->layers[$i] = $newLayer;
364
        }
365 3
    }
366
367
    /**
368
     * Are there items left to pack?
369
     *
370
     * @return bool
371
     */
372 19
    protected function hasItemsLeftToPack()
373
    {
374 19
        return count($this->skippedItems) + count($this->items) === 0;
375
    }
376
377
    /**
378
     * Generate a single list of items packed.
379
     *
380
     * @return PackedItemList
381
     */
382 20
    protected function getPackedItemList()
383
    {
384 20
        $packedItemList = new PackedItemList();
385 20
        foreach ($this->layers as $layer) {
386 20
            foreach ($layer->getItems() as $packedItem) {
387 20
                $packedItemList->insert($packedItem);
388
            }
389
        }
390
391 20
        return $packedItemList;
392
    }
393
394
    /**
395
     * Return the current packed depth.
396
     *
397
     * @return int
398
     */
399 20
    protected function getCurrentPackedDepth()
400
    {
401 20
        $depth = 0;
402 20
        foreach ($this->layers as $layer) {
403 9
            $depth += $layer->getDepth();
404
        }
405
406 20
        return $depth;
407
    }
408
}
409