Passed
Push — master ( 738fab...86bf03 )
by Doug
04:27
created

VolumePacker::getOrientationForItem()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 29
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 2

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 12
c 2
b 0
f 0
nc 2
nop 8
dl 0
loc 29
ccs 12
cts 12
cp 1
crap 2
rs 9.8666

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