Completed
Push — master ( 69c82e...13ea61 )
by Doug
08:37
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 53
            if ($rotation) {
110 17
                $boxWidth = $this->box->getInnerLength();
111 17
                $boxLength = $this->box->getInnerWidth();
112
            } else {
113 53
                $boxWidth = $this->box->getInnerWidth();
114 53
                $boxLength = $this->box->getInnerLength();
115
            }
116
117 53
            $boxPermutation = $this->packRotation($boxWidth, $boxLength);
118 53
            if ($boxPermutation->getItems()->count() === $this->items->count()) {
119 50
                return $boxPermutation;
120
            }
121
122 36
            $boxPermutations[] = $boxPermutation;
123
        }
124
125
        usort($boxPermutations, static function (PackedBox $a, PackedBox $b) {
126 14
            return $b->getVolumeUtilisation() <=> $a->getVolumeUtilisation();
127 36
        });
128
129 36
        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 53
    protected function packRotation(int $boxWidth, int $boxLength): PackedBox
138
    {
139 53
        $this->logger->debug("[EVALUATING ROTATION] {$this->box->getReference()}", ['width' => $boxWidth, 'length' => $boxLength]);
140
141
        /** @var PackedLayer[] $layers */
142 53
        $layers = [];
143 53
        $items = clone $this->items;
144
145 53
        while ($items->count() > 0) {
146 53
            $layerStartDepth = static::getCurrentPackedDepth($layers);
147
148
            //do a preliminary layer pack to get the depth used
149 53
            $preliminaryItems = clone $items;
150 53
            $preliminaryLayer = $this->packLayer($preliminaryItems, $layers, $layerStartDepth, $boxWidth, $boxLength, $this->box->getInnerDepth() - $layerStartDepth, 0);
151 53
            if (count($preliminaryLayer->getItems()) === 0) {
152 33
                break;
153
            }
154
155 53
            if ($preliminaryLayer->getDepth() === $preliminaryLayer->getItems()[0]->getDepth()) { // preliminary === final
156 53
                $layers[] = $preliminaryLayer;
157 53
                $items = $preliminaryItems;
158
            } else { //redo with now-known-depth so that we can stack to that height from the first item
159 9
                $layers[] = $this->packLayer($items, $layers, $layerStartDepth, $boxWidth, $boxLength, $this->box->getInnerDepth() - $layerStartDepth, $preliminaryLayer->getDepth());
160
            }
161
        }
162
163 53
        if ($this->box->getInnerWidth() !== $boxWidth) {
164 7
            $layers = static::rotateLayersNinetyDegrees($layers);
165
        }
166
167 53
        if (!$this->lookAheadMode && !$this->hasConstrainedItems) {
168 53
            $layers = static::stabiliseLayers($layers);
169
        }
170
171 53
        return new PackedBox($this->box, $this->getPackedItemList($layers));
172
    }
173
174
    /**
175
     * Pack items into an individual vertical layer.
176
     * @internal
177
     */
178 53
    protected function packLayer(ItemList &$items, array $layers, int $z, int $layerWidth, int $lengthLeft, int $depthLeft, int $guidelineLayerDepth): PackedLayer
179
    {
180 53
        $layers[] = $layer = new PackedLayer();
181 53
        $prevItem = null;
182 53
        $x = $y = $rowLength = 0;
183 53
        $skippedItems = [];
184 53
        $remainingWeightAllowed = $this->getRemainingWeightAllowed($layers);
185
186 53
        while ($items->count() > 0) {
187 53
            $itemToPack = $items->extract();
188
189
            //skip items that are simply too heavy or too large
190 53
            if (!$this->checkNonDimensionalConstraints($itemToPack, $layers, $remainingWeightAllowed)) {
191 7
                continue;
192
            }
193
194 53
            $orientatedItem = $this->getOrientationForItem($itemToPack, $prevItem, $items, $layers, $layerWidth - $x, $lengthLeft, $depthLeft, $rowLength, $x, $y, $z);
195
196 53
            if ($orientatedItem instanceof OrientatedItem) {
197 53
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $z);
198 53
                $layer->insert($packedItem);
199 53
                $remainingWeightAllowed -= $itemToPack->getWeight();
200
201 53
                $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 53
                $stackableDepth = ($guidelineLayerDepth ?: $layer->getDepth()) - $orientatedItem->getDepth();
206 53
                $stackedZ = $z + $orientatedItem->getDepth();
207 53
                $stackSkippedItems = [];
208 53
                while ($stackableDepth > 0 && $items->count() > 0) {
209 15
                    $itemToTryStacking = $items->extract();
210 15
                    $stackedItem = $this->getOrientationForItem($itemToTryStacking, $prevItem, $items, $layers, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $rowLength, $x, $y, $stackedZ);
211 15
                    if ($stackedItem && $this->checkNonDimensionalConstraints($itemToTryStacking, $layers, $remainingWeightAllowed)) {
212 9
                        $layer->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $stackedZ));
213 9
                        $remainingWeightAllowed -= $itemToTryStacking->getWeight();
214 9
                        $stackableDepth -= $stackedItem->getDepth();
215 9
                        $stackedZ += $stackedItem->getDepth();
216
                    } else {
217 11
                        $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 11
                        while ($items->count() > 0 && static::isSameDimensions($itemToTryStacking, $items->top())) {
220 9
                            $stackSkippedItems[] = $items->extract();
221
                        }
222
                    }
223
                }
224 53
                if ($stackSkippedItems) {
225 11
                    $items = ItemList::fromArray(array_merge($stackSkippedItems, iterator_to_array($items)), true);
226
                }
227 53
                $x += $orientatedItem->getWidth();
228
229 53
                $prevItem = $packedItem;
230 53
                if ($items->count() === 0) {
231 50
                    $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
232 53
                    $skippedItems = [];
233
                }
234 47
            } elseif (count($layer->getItems()) === 0) { // zero items on layer
235 33
                $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
236 33
                continue;
237 46
            } elseif ($items->count() > 0) { // skip for now, move on to the next item
238 39
                $this->logger->debug("doesn't fit, skipping for now");
239 39
                $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 39
                while ($items->count() > 2 && static::isSameDimensions($itemToPack, $items->top())) {
242 21
                    $skippedItems[] = $items->extract();
243
                }
244 46
            } elseif ($x > 0) {
245 46
                $this->logger->debug('No more fit in width wise, resetting for new row');
246 46
                $lengthLeft -= $rowLength;
247 46
                $y += $rowLength;
248 46
                $x = $rowLength = 0;
249 46
                $skippedItems[] = $itemToPack;
250 46
                $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
251 46
                $skippedItems = [];
252 46
                $prevItem = null;
253 46
                continue;
254
            } else {
255 41
                $this->logger->debug('no items fit, so starting next vertical layer');
256 41
                $skippedItems[] = $itemToPack;
257
258 41
                $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
259
260 41
                return $layer;
261
            }
262
        }
263
264 53
        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 53
    protected static function stabiliseLayers(array $layers): array
275
    {
276 53
        $stabiliser = new LayerStabiliser();
277
278 53
        return $stabiliser->stabilise($layers);
279
    }
280
281 53
    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 53
        $this->logger->debug(
295 53
            "evaluating item {$itemToPack->getDescription()} for fit",
296
            [
297 53
                'item' => $itemToPack,
298
                'space' => [
299 53
                    'maxWidth' => $maxWidth,
300 53
                    'maxLength' => $maxLength,
301 53
                    'maxDepth' => $maxDepth,
302
                ],
303
            ]
304
        );
305
306 53
        $prevOrientatedItem = $prevItem ? $prevItem->toOrientatedItem() : null;
307 53
        $prevPackedItemList = $itemToPack instanceof ConstrainedPlacementItem ? $this->getPackedItemList($layers) : new PackedItemList(); // don't calculate it if not going to be used
308
309 53
        return $this->orientatedItemFactory->getBestOrientation($itemToPack, $prevOrientatedItem, $nextItems, $maxWidth, $maxLength, $maxDepth, $rowLength, $x, $y, $z, $prevPackedItemList, $this->lookAheadMode);
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 53
    protected function checkNonDimensionalConstraints(Item $itemToPack, array $layers, int $remainingWeightAllowed): bool
317
    {
318 53
        $customConstraintsOK = true;
319 53
        if ($itemToPack instanceof ConstrainedItem) {
320 1
            $customConstraintsOK = $itemToPack->canBePackedInBox($this->getPackedItemList($layers), $this->box);
321
        }
322
323 53
        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 7
    protected static function rotateLayersNinetyDegrees($oldLayers): array
331
    {
332 7
        $newLayers = [];
333 7
        foreach ($oldLayers as $originalLayer) {
334 7
            $newLayer = new PackedLayer();
335 7
            foreach ($originalLayer->getItems() as $item) {
336 7
                $packedItem = new PackedItem($item->getItem(), $item->getY(), $item->getX(), $item->getZ(), $item->getLength(), $item->getWidth(), $item->getDepth());
337 7
                $newLayer->insert($packedItem);
338
            }
339 7
            $newLayers[] = $newLayer;
340
        }
341
342 7
        return $newLayers;
343
    }
344
345
    /**
346
     * Generate a single list of items packed.
347
     */
348 53
    protected function getPackedItemList(array $layers): PackedItemList
349
    {
350 53
        $packedItemList = new PackedItemList();
351 53
        foreach ($layers as $layer) {
352 53
            foreach ($layer->getItems() as $packedItem) {
353 53
                $packedItemList->insert($packedItem);
354
            }
355
        }
356
357 53
        return $packedItemList;
358
    }
359
360
    /**
361
     * Return the current packed depth.
362
     * @param PackedLayer[] $layers
363
     */
364 53
    protected static function getCurrentPackedDepth(array $layers): int
365
    {
366 53
        $depth = 0;
367 53
        foreach ($layers as $layer) {
368 41
            $depth += $layer->getDepth();
369
        }
370
371 53
        return $depth;
372
    }
373
374 53
    private function getRemainingWeightAllowed(array $layers): int
375
    {
376 53
        $remainingWeightAllowed = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
377 53
        foreach ($layers as $layer) {
378 53
            $remainingWeightAllowed -= $layer->getWeight();
379
        }
380
381 53
        return $remainingWeightAllowed;
382
    }
383
384
    /**
385
     * Compare two items to see if they have same dimensions.
386
     */
387 25
    protected static function isSameDimensions(Item $itemA, Item $itemB): bool
388
    {
389 25
        if ($itemA === $itemB) {
390 18
            return true;
391
        }
392 9
        $itemADimensions = [$itemA->getWidth(), $itemA->getLength(), $itemA->getDepth()];
393 9
        $itemBDimensions = [$itemB->getWidth(), $itemB->getLength(), $itemB->getDepth()];
394 9
        sort($itemADimensions);
395 9
        sort($itemBDimensions);
396
397 9
        return $itemADimensions === $itemBDimensions;
398
    }
399
}
400