Passed
Push — repack_layers_for_new_height ( d8e796...d25c8c )
by Doug
02:08
created

VolumePacker   B

Complexity

Total Complexity 41

Size/Duplication

Total Lines 405
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 14

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 41
lcom 1
cbo 14
dl 0
loc 405
ccs 145
cts 145
cp 1
rs 8.2769
c 0
b 0
f 0

14 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 16 3
A pack() 0 22 3
C packLayer() 0 74 10
A stabiliseLayers() 0 5 1
B getOrientationForItem() 0 29 2
B tryAndStackItemsIntoSpace() 0 32 4
A checkConstraints() 0 6 2
A checkNonDimensionalConstraints() 0 10 3
A checkDimensionalConstraints() 0 7 1
A rebuildItemList() 0 11 3
A rotateLayersNinetyDegrees() 0 11 3
A hasItemsLeftToPack() 0 4 1
A getNextItem() 0 4 2
A getPackedItemList() 0 11 3

How to fix   Complexity   

Complex Class

Complex classes like VolumePacker 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 VolumePacker, and based on these observations, apply Extract Interface, too.

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