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

VolumePacker::getOrientationForItem()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 36
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 4

Importance

Changes 5
Bugs 0 Features 0
Metric Value
cc 4
eloc 13
nc 5
nop 12
dl 0
loc 36
ccs 13
cts 13
cp 1
crap 4
rs 9.8333
c 5
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 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