Passed
Push — master ( ceb4db...15ba9d )
by Doug
05:20
created

VolumePacker::getOrientationForItem()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 29
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 3

Importance

Changes 7
Bugs 0 Features 0
Metric Value
cc 3
eloc 10
c 7
b 0
f 0
nc 4
nop 11
dl 0
loc 29
ccs 10
cts 10
cp 1
crap 3
rs 9.9332

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