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