Passed
Push — master ( 816a9c...aa6a1b )
by Doug
03:36
created

VolumePacker::tryAndStackItemsIntoSpace()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 29
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 17
nc 3
nop 9
dl 0
loc 29
ccs 17
cts 17
cp 1
crap 4
rs 9.7
c 0
b 0
f 0

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