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