Passed
Push — master ( 5d8812...a1d92b )
by Doug
02:07
created

VolumePacker::tryAndStackItemsIntoSpace()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 34
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 4

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 21
c 1
b 0
f 0
nc 3
nop 10
dl 0
loc 34
ccs 21
cts 21
cp 1
crap 4
rs 9.584

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