Passed
Push — issue_147 ( 80ed33 )
by Doug
04:13
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 31
    public function __construct(Box $box, ItemList $items)
84
    {
85 31
        $this->box = $box;
86 31
        $this->items = $items;
87
88 31
        $this->boxWidth = max($this->box->getInnerWidth(), $this->box->getInnerLength());
89 31
        $this->boxLength = min($this->box->getInnerWidth(), $this->box->getInnerLength());
90 31
        $this->remainingWeight = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
91 31
        $this->skippedItems = new ItemList();
92 31
        $this->logger = new NullLogger();
93
94
        // we may have just rotated the box for packing purposes, record if we did
95 31
        if ($this->box->getInnerWidth() !== $this->boxWidth || $this->box->getInnerLength() !== $this->boxLength) {
96 9
            $this->boxRotated = true;
97
        }
98 31
    }
99
100
    /**
101
     * Pack as many items as possible into specific given box.
102
     *
103
     * @return PackedBox packed box
104
     */
105 31
    public function pack(): PackedBox
106
    {
107 31
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}");
108
109 31
        while (count($this->items) > 0) {
110 31
            $layerStartDepth = $this->getCurrentPackedDepth();
111 31
            $this->packLayer($layerStartDepth, $this->boxWidth, $this->boxLength, $this->box->getInnerDepth() - $layerStartDepth);
112
        }
113
114 31
        if ($this->boxRotated) {
115 9
            $this->rotateLayersNinetyDegrees();
116
        }
117
118 31
        $this->stabiliseLayers();
119
120 31
        $this->logger->debug('done with this box');
121
122 31
        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 31
    protected function packLayer(int $startDepth, int $widthLeft, int $lengthLeft, int $depthLeft): void
134
    {
135 31
        $this->layers[] = $layer = new PackedLayer();
136 31
        $prevItem = null;
137 31
        $x = $y = $rowWidth = $rowLength = $layerDepth = 0;
138
139 31
        while (count($this->items) > 0) {
140 31
            $itemToPack = $this->items->extract();
141
142
            //skip items that are simply too heavy or too large
143 31
            if (!$this->checkConstraints($itemToPack)) {
144 8
                $this->rebuildItemList();
145 8
                continue;
146
            }
147
148 30
            $orientatedItem = $this->getOrientationForItem($itemToPack, $prevItem, $this->items, $this->hasItemsLeftToPack(), $widthLeft, $lengthLeft, $depthLeft);
149
150 30
            if ($orientatedItem instanceof OrientatedItem) {
151 30
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $startDepth);
152 30
                $layer->insert($packedItem);
153 30
                $this->remainingWeight -= $orientatedItem->getItem()->getWeight();
154 30
                $widthLeft -= $orientatedItem->getWidth();
155
156 30
                $rowWidth += $orientatedItem->getWidth();
157 30
                $rowLength = max($rowLength, $orientatedItem->getLength());
158 30
                $layerDepth = max($layerDepth, $orientatedItem->getDepth());
159
160
                //allow items to be stacked in place within the same footprint up to current layer depth
161 30
                $stackableDepth = $layerDepth - $orientatedItem->getDepth();
162 30
                $this->tryAndStackItemsIntoSpace($layer, $prevItem, $this->items, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $x, $y, $startDepth + $orientatedItem->getDepth());
163 30
                $x += $orientatedItem->getWidth();
164
165 30
                $prevItem = $packedItem;
166 30
                $this->rebuildItemList();
167
            } else {
168 21
                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 21
                } 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 21
                } elseif ($x > 0 && $lengthLeft >= min($itemToPack->getWidth(), $itemToPack->getLength())) {
175 21
                    $this->logger->debug('No more fit in width wise, resetting for new row');
176 21
                    $widthLeft += $rowWidth;
177 21
                    $lengthLeft -= $rowLength;
178 21
                    $y += $rowLength;
179 21
                    $x = $rowWidth = $rowLength = 0;
180 21
                    $this->rebuildItemList($itemToPack);
181 21
                    $prevItem = null;
182 21
                    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 31
    }
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 31
    public function stabiliseLayers(): void
200
    {
201 31
        $stabiliser = new LayerStabiliser();
202 31
        $this->layers = $stabiliser->stabilise($this->layers);
203 31
    }
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 30
    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 30
        $this->logger->debug(
226 30
            "evaluating item {$itemToPack->getDescription()} for fit",
227
            [
228 30
                'item'  => $itemToPack,
229
                'space' => [
230 30
                    'maxWidth'    => $maxWidth,
231 30
                    'maxLength'   => $maxLength,
232 30
                    'maxDepth'    => $maxDepth,
233
                ],
234
            ]
235
        );
236
237 30
        $prevOrientatedItem = $prevItem ? $prevItem->toOrientatedItem() : null;
238
239 30
        $orientatedItemFactory = new OrientatedItemFactory($this->box);
240 30
        $orientatedItemFactory->setLogger($this->logger);
241 30
        $orientatedItemDecision = $orientatedItemFactory->getBestOrientation($itemToPack, $prevOrientatedItem, $nextItems, $isLastItem, $maxWidth, $maxLength, $maxDepth);
242
243 30
        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 30
    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 30
        while (count($this->items) > 0 && $this->checkNonDimensionalConstraints($this->items->top())) {
272 29
            $stackedItem = $this->getOrientationForItem(
273 29
                $this->items->top(),
274 29
                $prevItem,
275 29
                $nextItems,
276 29
                $this->items->count() === 1,
277 29
                $maxWidth,
278 29
                $maxLength,
279 29
                $maxDepth
280
            );
281 29
            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 29
                break;
289
            }
290
        }
291 30
    }
292
293
    /**
294
     * Check item generally fits into box.
295
     *
296
     * @param Item $itemToPack
297
     *
298
     * @return bool
299
     */
300 31
    protected function checkConstraints(
301
        Item $itemToPack
302
    ): bool {
303 31
        return $this->checkNonDimensionalConstraints($itemToPack) &&
304 31
               $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 31
    protected function checkNonDimensionalConstraints(Item $itemToPack): bool
316
    {
317 31
        $weightOK = $itemToPack->getWeight() <= $this->remainingWeight;
318
319 31
        if ($itemToPack instanceof ConstrainedItem) {
320 1
            return $weightOK && $itemToPack->canBePackedInBox($this->getPackedItemList(), $this->box);
321
        }
322
323 31
        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 31
    protected function checkDimensionalConstraints(Item $itemToPack): bool
334
    {
335 31
        $orientatedItemFactory = new OrientatedItemFactory($this->box);
336 31
        $orientatedItemFactory->setLogger($this->logger);
337
338 31
        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 31
    protected function rebuildItemList(?Item $currentItem = null): void
347
    {
348 31
        if (count($this->items) === 0) {
349 31
            $this->items = $this->skippedItems;
350 31
            $this->skippedItems = new ItemList();
351
        }
352
353 31
        if ($currentItem instanceof Item) {
354 21
            $this->items->insert($currentItem);
355
        }
356 31
    }
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 30
    protected function hasItemsLeftToPack(): bool
379
    {
380 30
        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 31
    protected function getPackedItemList(): PackedItemList
389
    {
390 31
        $packedItemList = new PackedItemList();
391 31
        foreach ($this->layers as $layer) {
392 31
            foreach ($layer->getItems() as $packedItem) {
393 31
                $packedItemList->insert($packedItem);
394
            }
395
        }
396
397 31
        return $packedItemList;
398
    }
399
400
    /**
401
     * Return the current packed depth.
402
     *
403
     * @return int
404
     */
405 31
    protected function getCurrentPackedDepth(): int
406
    {
407 31
        $depth = 0;
408 31
        foreach ($this->layers as $layer) {
409 14
            $depth += $layer->getDepth();
410
        }
411
412 31
        return $depth;
413
    }
414
}
415