Passed
Push — refactor_prep ( b6784f )
by Doug
06:51
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

2 Methods

Rating   Name   Duplication   Size   Complexity  
A VolumePacker::rotateLayersNinetyDegrees() 0 9 3
A VolumePacker::rebuildItemList() 0 4 1

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 function count;
12
use function max;
13
use function min;
14
use Psr\Log\LoggerAwareInterface;
15
use Psr\Log\LoggerInterface;
16
use Psr\Log\NullLogger;
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
     * Whether the box was rotated for packing.
65
     *
66
     * @var bool
67
     */
68
    protected $boxRotated = false;
69
70
    /**
71
     * @var PackedLayer[]
72
     */
73
    protected $layers = [];
74
75
    /**
76
     * Whether the packer is in look-ahead mode (i.e. working ahead of the main packing).
77
     *
78
     * @var bool
79
     */
80
    protected $lookAheadMode = false;
81
82
    /**
83
     * @var OrientatedItemFactory
84
     */
85
    private $orientatedItemFactory;
86
87
    /**
88
     * Constructor.
89
     */
90 53
    public function __construct(Box $box, ItemList $items)
91
    {
92 53
        $this->box = $box;
93 53
        $this->items = clone $items;
94
95 53
        $this->boxWidth = max($this->box->getInnerWidth(), $this->box->getInnerLength());
96 53
        $this->boxLength = min($this->box->getInnerWidth(), $this->box->getInnerLength());
97 53
        $this->logger = new NullLogger();
98
99
        // we may have just rotated the box for packing purposes, record if we did
100 53
        if ($this->box->getInnerWidth() !== $this->boxWidth || $this->box->getInnerLength() !== $this->boxLength) {
101 18
            $this->boxRotated = true;
102
        }
103
104 53
        $this->orientatedItemFactory = new OrientatedItemFactory($this->box);
105 53
        $this->orientatedItemFactory->setLogger($this->logger);
106 53
    }
107
108
    /**
109
     * Sets a logger.
110
     */
111 24
    public function setLogger(LoggerInterface $logger): void
112
    {
113 24
        $this->logger = $logger;
114 24
        $this->orientatedItemFactory->setLogger($logger);
115 24
    }
116
117
    /**
118
     * @internal
119
     */
120 17
    public function setLookAheadMode(bool $lookAhead): void
121
    {
122 17
        $this->lookAheadMode = $lookAhead;
123 17
    }
124
125
    /**
126
     * Pack as many items as possible into specific given box.
127
     *
128
     * @return PackedBox packed box
129
     */
130 53
    public function pack(): PackedBox
131
    {
132 53
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}", ['box' => $this->box]);
133
134 53
        while ($this->items->count() > 0) {
135 53
            $layerStartDepth = $this->getCurrentPackedDepth();
136 53
            $this->packLayer($layerStartDepth, $this->boxWidth, $this->boxLength, $this->box->getInnerDepth() - $layerStartDepth);
137
        }
138
139 53
        if ($this->boxRotated) {
140 18
            $this->rotateLayersNinetyDegrees();
141
        }
142
143 53
        if (!$this->lookAheadMode) {
144 53
            $this->stabiliseLayers();
145
        }
146
147 53
        $this->logger->debug('done with this box ' . $this->box->getReference());
148
149 53
        return new PackedBox($this->box, $this->getPackedItemList());
150
    }
151
152
    /**
153
     * Pack items into an individual vertical layer.
154
     */
155 53
    protected function packLayer(int $startDepth, int $layerWidth, int $lengthLeft, int $depthLeft): void
156
    {
157 53
        $this->layers[] = $layer = new PackedLayer();
158 53
        $prevItem = null;
159 53
        $x = $y = $rowLength = 0;
160
161 53
        while ($this->items->count() > 0) {
162 53
            $itemToPack = $this->items->extract();
163
164
            //skip items that are simply too heavy or too large
165 53
            if (!$this->checkNonDimensionalConstraints($itemToPack)) {
166 8
                continue;
167
            }
168
169 53
            $orientatedItem = $this->getOrientationForItem($itemToPack, $prevItem, $this->items, !$this->hasItemsLeftToPack(), $layerWidth - $x, $lengthLeft, $depthLeft, $rowLength, $x, $y, $startDepth);
170
171 53
            if ($orientatedItem instanceof OrientatedItem) {
172 53
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $startDepth);
173 53
                $layer->insert($packedItem);
174
175 53
                $rowLength = max($rowLength, $orientatedItem->getLength());
176
177
                //Figure out if we can stack the next item vertically on top of this rather than side by side
178
                //e.g. when we've packed a tall item, and have just put a shorter one next to it.
179 53
                $stackableDepth = $layer->getDepth() - $orientatedItem->getDepth();
180 53
                $stackedZ = $startDepth + $orientatedItem->getDepth();
181 53
                while ($this->items->count() > 0 && $this->checkNonDimensionalConstraints($this->items->top())) {
182 49
                    $stackedItem = $this->getOrientationForItem($this->items->top(), $prevItem, $this->items, $this->items->count() === 1, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $rowLength, $x, $y, $stackedZ);
183 49
                    if ($stackedItem) {
184 7
                        $layer->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $stackedZ));
185 7
                        $this->items->extract();
186 7
                        $stackableDepth -= $stackedItem->getDepth();
187 7
                        $stackedZ += $stackedItem->getDepth();
188
                    } else {
189 49
                        break;
190
                    }
191
                }
192 53
                $x += $orientatedItem->getWidth();
193
194 53
                $prevItem = $packedItem;
195 53
                if ($this->items->count() === 0) {
196 53
                    $this->rebuildItemList();
197
                }
198 44
            } elseif (count($layer->getItems()) === 0) { // zero items on layer
199 23
                $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
200 23
                continue;
201 42
            } elseif ($this->items->count() > 0) { // skip for now, move on to the next item
202 37
                $this->logger->debug("doesn't fit, skipping for now");
203 37
                $this->skippedItems[] = $itemToPack;
204
                // 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
205 37
                while ($this->items->count() > 2 && static::isSameDimensions($itemToPack, $this->items->top())) {
206 20
                    $this->skippedItems[] = $this->items->extract();
207
                }
208 42
            } elseif ($x > 0) {
209 42
                $this->logger->debug('No more fit in width wise, resetting for new row');
210 42
                $lengthLeft -= $rowLength;
211 42
                $y += $rowLength;
212 42
                $x = $rowLength = 0;
213 42
                $this->skippedItems[] = $itemToPack;
214 42
                $this->rebuildItemList();
215 42
                $prevItem = null;
216 42
                continue;
217
            } else {
218 39
                $this->logger->debug('no items fit, so starting next vertical layer');
219 39
                $this->skippedItems[] = $itemToPack;
220 39
                $this->rebuildItemList();
221
222 39
                return;
223
            }
224
        }
225 53
    }
226
227
    /**
228
     * During packing, it is quite possible that layers have been created that aren't physically stable
229
     * i.e. they overhang the ones below.
230
     *
231
     * This function reorders them so that the ones with the greatest surface area are placed at the bottom
232
     */
233 53
    public function stabiliseLayers(): void
234
    {
235 53
        $stabiliser = new LayerStabiliser();
236 53
        $stabiliser->setLogger($this->logger);
237 53
        $this->layers = $stabiliser->stabilise($this->layers);
238 53
    }
239
240 53
    protected function getOrientationForItem(
241
        Item $itemToPack,
242
        ?PackedItem $prevItem,
243
        ItemList $nextItems,
244
        bool $isLastItem,
245
        int $maxWidth,
246
        int $maxLength,
247
        int $maxDepth,
248
        int $rowLength,
249
        int $x,
250
        int $y,
251
        int $z
252
    ): ?OrientatedItem {
253 53
        $this->logger->debug(
254 53
            "evaluating item {$itemToPack->getDescription()} for fit",
255
            [
256 53
                'item' => $itemToPack,
257
                'space' => [
258 53
                    'maxWidth' => $maxWidth,
259 53
                    'maxLength' => $maxLength,
260 53
                    'maxDepth' => $maxDepth,
261
                ],
262
            ]
263
        );
264
265 53
        $prevOrientatedItem = $prevItem ? $prevItem->toOrientatedItem() : null;
266 53
        $prevPackedItemList = $itemToPack instanceof ConstrainedPlacementItem ? $this->getPackedItemList() : new PackedItemList(); // don't calculate it if not going to be used
267
268 53
        return $this->orientatedItemFactory->getBestOrientation($itemToPack, $prevOrientatedItem, $nextItems, $isLastItem, $maxWidth, $maxLength, $maxDepth, $rowLength, $x, $y, $z, $prevPackedItemList);
269
    }
270
271
    /**
272
     * As well as purely dimensional constraints, there are other constraints that need to be met
273
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box).
274
     */
275 53
    protected function checkNonDimensionalConstraints(Item $itemToPack): bool
276
    {
277 53
        $customConstraintsOK = true;
278 53
        if ($itemToPack instanceof ConstrainedItem) {
279 1
            $customConstraintsOK = $itemToPack->canBePackedInBox($this->getPackedItemList(), $this->box);
280
        }
281
282 53
        return $customConstraintsOK && $itemToPack->getWeight() <= $this->getRemainingWeightAllowed();
283
    }
284
285
    /**
286
     * Reintegrate skipped items into main list.
287
     */
288 52
    protected function rebuildItemList(): void
289
    {
290 52
        $this->items = ItemList::fromArray(array_merge($this->skippedItems, iterator_to_array($this->items)), true);
291 52
        $this->skippedItems = [];
292 52
    }
293
294
    /**
295
     * Swap back width/length of the packed items to match orientation of the box if needed.
296
     */
297 18
    protected function rotateLayersNinetyDegrees(): void
298
    {
299 18
        foreach ($this->layers as $i => $originalLayer) {
300 18
            $newLayer = new PackedLayer();
301 18
            foreach ($originalLayer->getItems() as $item) {
302 17
                $packedItem = new PackedItem($item->getItem(), $item->getY(), $item->getX(), $item->getZ(), $item->getLength(), $item->getWidth(), $item->getDepth());
303 17
                $newLayer->insert($packedItem);
304
            }
305 18
            $this->layers[$i] = $newLayer;
306
        }
307 18
    }
308
309
    /**
310
     * Are there items left to pack?
311
     */
312 53
    protected function hasItemsLeftToPack(): bool
313
    {
314 53
        return count($this->skippedItems) + $this->items->count() > 0;
315
    }
316
317
    /**
318
     * Generate a single list of items packed.
319
     */
320 53
    protected function getPackedItemList(): PackedItemList
321
    {
322 53
        $packedItemList = new PackedItemList();
323 53
        foreach ($this->layers as $layer) {
324 53
            foreach ($layer->getItems() as $packedItem) {
325 53
                $packedItemList->insert($packedItem);
326
            }
327
        }
328
329 53
        return $packedItemList;
330
    }
331
332
    /**
333
     * Return the current packed depth.
334
     */
335 53
    protected function getCurrentPackedDepth(): int
336
    {
337 53
        $depth = 0;
338 53
        foreach ($this->layers as $layer) {
339 39
            $depth += $layer->getDepth();
340
        }
341
342 53
        return $depth;
343
    }
344
345 53
    private function getRemainingWeightAllowed(): int
346
    {
347 53
        $remainingWeightAllowed = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
348 53
        foreach ($this->layers as $layer) {
349 53
            $remainingWeightAllowed -= $layer->getWeight();
350
        }
351
352 53
        return $remainingWeightAllowed;
353
    }
354
355
    /**
356
     * Compare two items to see if they have same dimensions.
357
     */
358 21
    protected static function isSameDimensions(Item $itemA, Item $itemB): bool
359
    {
360 21
        if ($itemA === $itemB) {
361 17
            return true;
362
        }
363 7
        $itemADimensions = [$itemA->getWidth(), $itemA->getLength(), $itemA->getDepth()];
364 7
        $itemBDimensions = [$itemB->getWidth(), $itemB->getLength(), $itemB->getDepth()];
365 7
        sort($itemADimensions);
366 7
        sort($itemBDimensions);
367
368 7
        return $itemADimensions === $itemBDimensions;
369
    }
370
}
371