Completed
Push — master ( 9fb66e...efb060 )
by Doug
13:22 queued 11:47
created

VolumePacker::tryAndStackItemsIntoSpace()   B

Complexity

Conditions 4
Paths 3

Size

Total Lines 32
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 4

Importance

Changes 0
Metric Value
c 0
b 0
f 0
dl 0
loc 32
ccs 16
cts 16
cp 1
rs 8.5806
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 37
    public function __construct(Box $box, ItemList $items)
76
    {
77 37
        $this->box = $box;
78 37
        $this->items = $items;
79
80 37
        $this->boxWidth = max($this->box->getInnerWidth(), $this->box->getInnerLength());
81 37
        $this->boxLength = min($this->box->getInnerWidth(), $this->box->getInnerLength());
82 37
        $this->remainingWeight = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
83 37
        $this->skippedItems = new ItemList();
84 37
        $this->logger = new NullLogger();
85
86
        // we may have just rotated the box for packing purposes, record if we did
87 37
        if ($this->box->getInnerWidth() != $this->boxWidth || $this->box->getInnerLength() != $this->boxLength) {
88 9
            $this->boxRotated = true;
89
        }
90
    }
91
92
    /**
93
     * Pack as many items as possible into specific given box.
94
     *
95
     * @return PackedBox packed box
96
     */
97 37
    public function pack(): PackedBox
98
    {
99 37
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}");
100
101 37
        $packedItems = new PackedItemList();
102 37
        $prevItem = null;
103
104 37
        $x = $y = $z = $rowWidth = $rowLength = $layerWidth = $layerLength = $layerDepth = 0;
105
106 37
        $packingWidthLeft = $this->boxWidth;
107 37
        $packingLengthLeft = $this->boxLength;
108 37
        $packingDepthLeft = $this->box->getInnerDepth();
109
110 37
        while (count($this->items) > 0) {
111 37
            $itemToPack = $this->items->extract();
112 37
            $nextItem = $this->getNextItem();
113
114
            //skip items that are simply too heavy or too large
115 37
            if (!$this->checkConstraints($itemToPack, $packedItems)) {
116 10
                $this->rebuildItemList();
117 10
                continue;
118
            }
119
120 37
            $orientatedItem = $this->getOrientationForItem($itemToPack, $prevItem, $nextItem, $this->hasItemsLeftToPack(), $packingWidthLeft, $packingLengthLeft, $packingDepthLeft);
121
122 37
            if ($orientatedItem instanceof OrientatedItem) {
123 37
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $z);
124 37
                $packedItems->insert($packedItem);
125 37
                $this->remainingWeight -= $orientatedItem->getItem()->getWeight();
126 37
                $packingWidthLeft -= $orientatedItem->getWidth();
127
128 37
                $rowWidth += $orientatedItem->getWidth();
129 37
                $rowLength = max($rowLength, $orientatedItem->getLength());
130 37
                $layerDepth = max($layerDepth, $orientatedItem->getDepth());
131
132
                //allow items to be stacked in place within the same footprint up to current layer depth
133 37
                $stackableDepth = $layerDepth - $orientatedItem->getDepth();
134 37
                $this->tryAndStackItemsIntoSpace($packedItems, $prevItem, $nextItem, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $x, $y, $z + $orientatedItem->getDepth());
135 37
                $x += $orientatedItem->getWidth();
136
137 37
                $prevItem = $packedItem;
138
139 37
                $this->rebuildItemList();
140
            } else {
141 25
                if ($layerWidth == 0 && $layerDepth == 0) { // zero items on layer
142 4
                    $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
143 4
                    $prevItem = null;
144 4
                    continue;
145 25
                } elseif (count($this->items) > 0) { // skip for now, move on to the next item
146 22
                    $this->logger->debug("doesn't fit, skipping for now");
147 22
                    $this->skippedItems->insert($itemToPack);
148 25
                } elseif ($x > 0 && $packingLengthLeft >= min($itemToPack->getWidth(), $itemToPack->getLength())) {
149 25
                    $this->logger->debug('No more fit in width wise, resetting for new row');
150 25
                    $layerWidth = max($layerWidth, $rowWidth);
151 25
                    $layerLength += $rowLength;
152 25
                    $packingWidthLeft += $rowWidth;
153 25
                    $packingLengthLeft -= $rowLength;
154 25
                    $y += $rowLength;
155 25
                    $x = $rowWidth = $rowLength = 0;
156 25
                    $this->rebuildItemList($itemToPack);
157 25
                    $prevItem = null;
158 25
                    continue;
159
                } else {
160 19
                    $this->logger->debug('no items fit, so starting next vertical layer');
161
162 19
                    $layerWidth = max($layerWidth, $rowWidth);
163 19
                    $layerLength += $rowLength;
164 19
                    $packingWidthLeft = $rowWidth ? min(intval($layerWidth * 1.1), $this->boxWidth) : $this->boxWidth;
165 19
                    $packingLengthLeft = $rowLength ? min(intval($layerLength * 1.1), $this->boxLength) : $this->boxLength;
166 19
                    $packingDepthLeft -= $layerDepth;
167
168 19
                    $z += $layerDepth;
169 19
                    $x = $y = $rowWidth = $rowLength = $layerWidth = $layerLength = $layerDepth = 0;
170
171 19
                    $this->rebuildItemList($itemToPack);
172 19
                    $prevItem = null;
173
                }
174
            }
175
        }
176 37
        $this->logger->debug('done with this box');
177
178 37
        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 37
    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 37
        $this->logger->debug(
202 37
            "evaluating item {$itemToPack->getDescription()} for fit",
203
            [
204 37
                'item'  => $itemToPack,
205
                'space' => [
206 37
                    'maxWidth'    => $maxWidth,
207 37
                    'maxLength'   => $maxLength,
208 37
                    'maxDepth'    => $maxDepth,
209
                ],
210
            ]
211
        );
212
213 37
        $prevOrientatedItem = $prevItem ? $prevItem->toOrientatedItem() : null;
214
215 37
        $orientatedItemFactory = new OrientatedItemFactory();
216 37
        $orientatedItemFactory->setLogger($this->logger);
217 37
        $orientatedItem = $orientatedItemFactory->getBestOrientation($this->box, $itemToPack, $prevOrientatedItem, $nextItem, $isLastItem, $maxWidth, $maxLength, $maxDepth);
218
219 37
        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 37
    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 37
        while (count($this->items) > 0 && $this->checkNonDimensionalConstraints($this->items->top(), $packedItems)) {
248 33
            $stackedItem = $this->getOrientationForItem(
249 33
                $this->items->top(),
250 33
                $prevItem,
251 33
                $nextItem,
252 33
                $this->items->count() === 1,
253 33
                $maxWidth,
254 33
                $maxLength,
255 33
                $maxDepth
256
            );
257 33
            if ($stackedItem) {
258 3
                $this->remainingWeight -= $this->items->top()->getWeight();
259 3
                $packedItems->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $z));
260 3
                $this->items->extract();
261 3
                $maxDepth -= $stackedItem->getDepth();
262 3
                $z += $stackedItem->getDepth();
263
            } else {
264
                break;
265
            }
266
        }
267
    }
268
269
    /**
270
     * Check item generally fits into box.
271
     *
272
     * @param Item           $itemToPack
273
     * @param PackedItemList $packedItems
274
     *
275
     * @return bool
276
     */
277 37
    protected function checkConstraints(
278
        Item $itemToPack,
279
        PackedItemList $packedItems
280
    ): bool {
281 37
        return $this->checkNonDimensionalConstraints($itemToPack, $packedItems) &&
282 37
               $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 37
    protected function checkNonDimensionalConstraints(Item $itemToPack, PackedItemList $packedItems): bool
295
    {
296 37
        $weightOK = $itemToPack->getWeight() <= $this->remainingWeight;
297
298 37
        if ($itemToPack instanceof ConstrainedItem) {
299 1
            return $weightOK && $itemToPack->canBePackedInBox($packedItems, $this->box);
300
        }
301
302
        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 37
    protected function checkDimensionalConstraints(Item $itemToPack): bool
313
    {
314 37
        $orientatedItemFactory = new OrientatedItemFactory();
315 37
        $orientatedItemFactory->setLogger($this->logger);
316
317 37
        return (bool) $orientatedItemFactory->getPossibleOrientationsInEmptyBox($itemToPack, $this->box);
318
    }
319
320
    /**
321
     * Reintegrate skipped items into main list
322
     * @param Item|null $currentItem item from current iteration
323
     */
324 37
    protected function rebuildItemList(?Item $currentItem = null): void
325
    {
326 37
        if (count($this->items) === 0) {
327 37
            $this->items = $this->skippedItems;
328 37
            $this->skippedItems = new ItemList();
329
        }
330
331 37
        if ($currentItem instanceof Item) {
332 25
            $this->items->insert($currentItem);
333
        }
334
    }
335
336
    /**
337
     * @param PackedItemList $packedItems
338
     *
339
     * @return PackedBox
340
     */
341 37
    protected function createPackedBox(PackedItemList $packedItems): PackedBox
342
    {
343
        //if we rotated the box for packing, need to swap back width/length of the packed items
344 37
        if ($this->boxRotated) {
345 9
            $items = iterator_to_array($packedItems, false);
346 9
            $packedItems = new PackedItemList();
347
            /** @var PackedItem $item */
348 9
            foreach ($items as $item) {
349 9
                $packedItems->insert(
350 9
                    new PackedItem(
351 9
                        $item->getItem(),
352 9
                        $item->getY(),
353 9
                        $item->getX(),
354 9
                        $item->getZ(),
355 9
                        $item->getLength(),
356 9
                        $item->getWidth(),
357 9
                        $item->getDepth()
358
                    )
359
                );
360
            }
361
        }
362
363 37
        return new PackedBox($this->box, $packedItems);
364
    }
365
366
    /**
367
     * Are there items left to pack?
368
     *
369
     * @return bool
370
     */
371 37
    protected function hasItemsLeftToPack(): bool
372
    {
373 37
        return count($this->skippedItems) + count($this->items) === 0;
374
    }
375
376
377
    /**
378
     * Return next item in line for packing
379
     *
380
     * @return Item|null
381
     */
382 37
    protected function getNextItem(): ?Item
383
    {
384 37
        return count($this->items) ? $this->items->top() : null;
385
    }
386
}
387