Completed
Push — test_jit ( c34f6e...87859e )
by Doug
10:03
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

1 Method

Rating   Name   Duplication   Size   Complexity  
A VolumePacker::checkNonDimensionalConstraints() 0 8 3

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 56
        $packedItemList = $this->getPackedItemList($layers);
186
187 56
        while ($items->count() > 0) {
188 56
            $itemToPack = $items->extract();
189
190
            //skip items that are simply too heavy or too large
191 56
            if (!$this->checkNonDimensionalConstraints($itemToPack, $layers, $remainingWeightAllowed)) {
192 7
                continue;
193
            }
194
195 56
            $orientatedItem = $this->orientatedItemFactory->getBestOrientation($itemToPack, $prevItem instanceof PackedItem ? $prevItem->toOrientatedItem() : null, $items, $layerWidth - $x, $lengthLeft, $depthLeft, $rowLength, $x, $y, $z, $packedItemList, $this->singlePassMode);
196
197 56
            if ($orientatedItem instanceof OrientatedItem) {
198 56
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $z);
199 56
                $layer->insert($packedItem);
200 56
                $remainingWeightAllowed -= $itemToPack->getWeight();
201 56
                $packedItemList->insert($packedItem);
202
203 56
                $rowLength = max($rowLength, $orientatedItem->getLength());
204
205
                //Figure out if we can stack the next item vertically on top of this rather than side by side
206
                //e.g. when we've packed a tall item, and have just put a shorter one next to it.
207 56
                $stackableDepth = ($guidelineLayerDepth ?: $layer->getDepth()) - $orientatedItem->getDepth();
208 56
                $stackedZ = $z + $orientatedItem->getDepth();
209 56
                $stackSkippedItems = [];
210 56
                while ($stackableDepth > 0 && $items->count() > 0) {
211 17
                    $itemToTryStacking = $items->extract();
212 17
                    $stackedItem = $this->orientatedItemFactory->getBestOrientation($itemToTryStacking, $prevItem instanceof PackedItem ? $prevItem->toOrientatedItem() : null, $items, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $rowLength, $x, $y, $stackedZ, $packedItemList, $this->singlePassMode);
213 17
                    if ($stackedItem && $this->checkNonDimensionalConstraints($itemToTryStacking, $layers, $remainingWeightAllowed)) {
214 11
                        $layer->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $stackedZ));
215 11
                        $remainingWeightAllowed -= $itemToTryStacking->getWeight();
216 11
                        $packedItemList->insert($packedItem);
217 11
                        $stackableDepth -= $stackedItem->getDepth();
218 11
                        $stackedZ += $stackedItem->getDepth();
219
                    } else {
220 13
                        $stackSkippedItems[] = $itemToTryStacking;
221
                        // 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
222 13
                        while ($items->count() > 0 && static::isSameDimensions($itemToTryStacking, $items->top())) {
223 10
                            $stackSkippedItems[] = $items->extract();
224
                        }
225
                    }
226
                }
227 56
                if ($stackSkippedItems) {
228 13
                    $items = ItemList::fromArray(array_merge($stackSkippedItems, iterator_to_array($items)), true);
229
                }
230 56
                $x += $orientatedItem->getWidth();
231
232 56
                $prevItem = $packedItem;
233 56
                if ($items->count() === 0) {
234 53
                    $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
235 56
                    $skippedItems = [];
236
                }
237 50
            } elseif (count($layer->getItems()) === 0) { // zero items on layer
238 35
                $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
239 35
                continue;
240 49
            } elseif ($items->count() > 0) { // skip for now, move on to the next item
241 41
                $this->logger->debug("doesn't fit, skipping for now");
242 41
                $skippedItems[] = $itemToPack;
243
                // 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
244 41
                while ($items->count() > 2 && static::isSameDimensions($itemToPack, $items->top())) {
245 22
                    $skippedItems[] = $items->extract();
246
                }
247 49
            } elseif ($x > 0) {
248 49
                $this->logger->debug('No more fit in width wise, resetting for new row');
249 49
                $lengthLeft -= $rowLength;
250 49
                $y += $rowLength;
251 49
                $x = $rowLength = 0;
252 49
                $skippedItems[] = $itemToPack;
253 49
                $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
254 49
                $skippedItems = [];
255 49
                $prevItem = null;
256 49
                continue;
257
            } else {
258 43
                $this->logger->debug('no items fit, so starting next vertical layer');
259 43
                $skippedItems[] = $itemToPack;
260
261 43
                $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
262
263 43
                return $layer;
264
            }
265
        }
266
267 56
        return $layer;
268
    }
269
270
    /**
271
     * During packing, it is quite possible that layers have been created that aren't physically stable
272
     * i.e. they overhang the ones below.
273
     *
274
     * This function reorders them so that the ones with the greatest surface area are placed at the bottom
275
     * @param PackedLayer[] $layers
276
     */
277 56
    protected static function stabiliseLayers(array $layers): array
278
    {
279 56
        $stabiliser = new LayerStabiliser();
280
281 56
        return $stabiliser->stabilise($layers);
282
    }
283
284
    /**
285
     * As well as purely dimensional constraints, there are other constraints that need to be met
286
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box).
287
     */
288 56
    protected function checkNonDimensionalConstraints(Item $itemToPack, array $layers, int $remainingWeightAllowed): bool
289
    {
290 56
        $customConstraintsOK = true;
291 56
        if ($itemToPack instanceof ConstrainedItem) {
292 1
            $customConstraintsOK = $itemToPack->canBePackedInBox($this->getPackedItemList($layers), $this->box);
293
        }
294
295 56
        return $customConstraintsOK && $itemToPack->getWeight() <= $remainingWeightAllowed;
296
    }
297
298
    /**
299
     * Swap back width/length of the packed items to match orientation of the box if needed.
300
     * @param PackedLayer[] $oldLayers
301
     */
302 8
    protected static function rotateLayersNinetyDegrees($oldLayers): array
303
    {
304 8
        $newLayers = [];
305 8
        foreach ($oldLayers as $originalLayer) {
306 8
            $newLayer = new PackedLayer();
307 8
            foreach ($originalLayer->getItems() as $item) {
308 8
                $packedItem = new PackedItem($item->getItem(), $item->getY(), $item->getX(), $item->getZ(), $item->getLength(), $item->getWidth(), $item->getDepth());
309 8
                $newLayer->insert($packedItem);
310
            }
311 8
            $newLayers[] = $newLayer;
312
        }
313
314 8
        return $newLayers;
315
    }
316
317
    /**
318
     * Generate a single list of items packed.
319
     */
320 56
    protected function getPackedItemList(array $layers): PackedItemList
321
    {
322 56
        $packedItemList = new PackedItemList();
323 56
        foreach ($layers as $layer) {
324 56
            foreach ($layer->getItems() as $packedItem) {
325 56
                $packedItemList->insert($packedItem);
326
            }
327
        }
328
329 56
        return $packedItemList;
330
    }
331
332
    /**
333
     * Return the current packed depth.
334
     * @param PackedLayer[] $layers
335
     */
336 56
    protected static function getCurrentPackedDepth(array $layers): int
337
    {
338 56
        $depth = 0;
339 56
        foreach ($layers as $layer) {
340 43
            $depth += $layer->getDepth();
341
        }
342
343 56
        return $depth;
344
    }
345
346 56
    private function getRemainingWeightAllowed(array $layers): int
347
    {
348 56
        $remainingWeightAllowed = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
349 56
        foreach ($layers as $layer) {
350 56
            $remainingWeightAllowed -= $layer->getWeight();
351
        }
352
353 56
        return $remainingWeightAllowed;
354
    }
355
356
    /**
357
     * Compare two items to see if they have same dimensions.
358
     */
359 26
    protected static function isSameDimensions(Item $itemA, Item $itemB): bool
360
    {
361 26
        if ($itemA === $itemB) {
362 19
            return true;
363
        }
364 10
        $itemADimensions = [$itemA->getWidth(), $itemA->getLength(), $itemA->getDepth()];
365 10
        $itemBDimensions = [$itemB->getWidth(), $itemB->getLength(), $itemB->getDepth()];
366 10
        sort($itemADimensions);
367 10
        sort($itemBDimensions);
368
369 10
        return $itemADimensions === $itemBDimensions;
370
    }
371
}
372