Passed
Push — 2.x-dev ( 794087...7794d8 )
by Doug
01:28
created

VolumePacker::tryAndStackItemsIntoSpace()   A

Complexity

Conditions 4
Paths 3

Size

Total Lines 29
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 17
nc 3
nop 9
dl 0
loc 29
ccs 17
cts 17
cp 1
crap 4
rs 9.7
c 0
b 0
f 0

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