Completed
Push — issue_187 ( 8952f9...e99851 )
by Doug
12:21
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

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