Completed
Push — master ( ab3db1...67bebb )
by Doug
28:56
created

VolumePacker::tryAndStackItemsIntoSpace()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 34
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 4

Importance

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