Passed
Push — master ( 55dd63...b3e52b )
by Doug
10:40
created

VolumePacker::packLayer()   C

Complexity

Conditions 12
Paths 9

Size

Total Lines 61
Code Lines 45

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 45
CRAP Score 12

Importance

Changes 7
Bugs 1 Features 0
Metric Value
cc 12
eloc 45
c 7
b 1
f 0
nc 9
nop 4
dl 0
loc 61
ccs 45
cts 45
cp 1
crap 12
rs 6.9666

How to fix   Long Method    Complexity   

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