Passed
Push — repack_layers_for_new_height ( d8e796...d25c8c )
by Doug
02:08
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 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