Completed
Push — 2.x-dev ( 5f5b4d...48a1b4 )
by Doug
61:10 queued 51:42
created

VolumePacker::tryAndStackItemsIntoSpace()   B

Complexity

Conditions 4
Paths 3

Size

Total Lines 32
Code Lines 27

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