Passed
Push — refactor_prep ( b6784f...5f8508 )
by Doug
62:10
created

VolumePacker::packLayer()   C

Complexity

Conditions 17
Paths 19

Size

Total Lines 81
Code Lines 57

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 56
CRAP Score 17

Importance

Changes 16
Bugs 1 Features 0
Metric Value
cc 17
eloc 57
nc 19
nop 7
dl 0
loc 81
ccs 56
cts 56
cp 1
crap 17
rs 5.2166
c 16
b 1
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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