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