Passed
Push — repack_layers_for_new_height ( d25c8c...96fee3 )
by Doug
02:02
created

VolumePacker::tryAndStackItemsIntoSpace()   B

Complexity

Conditions 4
Paths 3

Size

Total Lines 32
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 4

Importance

Changes 0
Metric Value
dl 0
loc 32
ccs 18
cts 18
cp 1
rs 8.5806
c 0
b 0
f 0
cc 4
eloc 28
nc 3
nop 9
crap 4

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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