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