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