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