Completed
Push — master ( 57e6b1...4bc4cb )
by Doug
09:08
created

VolumePacker::getOrientationForItem()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 31
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3

Importance

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

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