Completed
Push — 2.x-dev ( 5f5b4d...48a1b4 )
by Doug
61:10 queued 51:42
created

VolumePacker::getNextItem()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 2
cts 2
cp 1
rs 10
c 0
b 0
f 0
cc 2
eloc 2
nc 2
nop 0
crap 2
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 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
     * Constructor.
70
     *
71
     * @param Box      $box
72
     * @param ItemList $items
73
     */
74 27
    public function __construct(Box $box, ItemList $items)
75
    {
76 27
        $this->box = $box;
77 27
        $this->items = $items;
78
79 27
        $this->boxWidth = max($this->box->getInnerWidth(), $this->box->getInnerLength());
80 27
        $this->boxLength = min($this->box->getInnerWidth(), $this->box->getInnerLength());
81 27
        $this->remainingWeight = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
82 27
        $this->skippedItems = new ItemList();
83 27
        $this->logger = new NullLogger();
84
85
        // we may have just rotated the box for packing purposes, record if we did
86 27
        if ($this->box->getInnerWidth() != $this->boxWidth || $this->box->getInnerLength() != $this->boxLength) {
87 7
            $this->boxRotated = true;
88
        }
89 27
    }
90
91
    /**
92
     * Pack as many items as possible into specific given box.
93
     *
94
     * @return PackedBox packed box
95
     */
96 27
    public function pack()
97
    {
98 27
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}");
99
100 27
        $packedItems = new PackedItemList();
101 27
        $prevItem = null;
102
103 27
        $x = $y = $z = $rowWidth = $rowLength = $layerWidth = $layerLength = $layerDepth = 0;
104
105 27
        $packingWidthLeft = $this->boxWidth;
106 27
        $packingLengthLeft = $this->boxLength;
107 27
        $packingDepthLeft = $this->box->getInnerDepth();
108
109 27
        while (!$this->items->isEmpty()) {
110 27
            $itemToPack = $this->items->extract();
111 27
            $nextItem = $this->getNextItem();
112
113
            //skip items that are simply too heavy or too large
114 27
            if (!$this->checkConstraints($itemToPack, $packedItems)) {
115 6
                $this->rebuildItemList();
116 6
                continue;
117
            }
118
119 27
            $orientatedItem = $this->getOrientationForItem($itemToPack, $prevItem, $nextItem, $this->hasItemsLeftToPack(), $packingWidthLeft, $packingLengthLeft, $packingDepthLeft);
120
121 27
            if ($orientatedItem instanceof OrientatedItem) {
122 27
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $z);
123 27
                $packedItems->insert($packedItem);
124 27
                $this->remainingWeight -= $orientatedItem->getItem()->getWeight();
125 27
                $packingWidthLeft -= $orientatedItem->getWidth();
126
127 27
                $rowWidth += $orientatedItem->getWidth();
128 27
                $rowLength = max($rowLength, $orientatedItem->getLength());
129 27
                $layerDepth = max($layerDepth, $orientatedItem->getDepth());
130
131
                //allow items to be stacked in place within the same footprint up to current layer depth
132 27
                $stackableDepth = $layerDepth - $orientatedItem->getDepth();
133 27
                $this->tryAndStackItemsIntoSpace($packedItems, $prevItem, $nextItem, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $x, $y, $z + $orientatedItem->getDepth());
134 27
                $x += $orientatedItem->getWidth();
135
136 27
                $prevItem = $packedItem;
137
138 27
                $this->rebuildItemList();
139
            } else {
140 20
                if ($layerWidth == 0 && $layerDepth == 0) { // zero items on layer
141 1
                    $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
142 1
                    $prevItem = null;
143 1
                    continue;
144 20
                } elseif (!$this->items->isEmpty()) { // skip for now, move on to the next item
145 18
                    $this->logger->debug("doesn't fit, skipping for now");
146 18
                    $this->skippedItems->insert($itemToPack);
147 20
                } elseif ($x > 0 && $packingLengthLeft >= min($itemToPack->getWidth(), $itemToPack->getLength())) {
148 20
                    $this->logger->debug('No more fit in width wise, resetting for new row');
149 20
                    $layerWidth = max($layerWidth, $rowWidth);
150 20
                    $layerLength += $rowLength;
151 20
                    $packingWidthLeft += $rowWidth;
152 20
                    $packingLengthLeft -= $rowLength;
153 20
                    $y += $rowLength;
154 20
                    $x = $rowWidth = $rowLength = 0;
155 20
                    $this->rebuildItemList($itemToPack);
156 20
                    $prevItem = null;
157 20
                    continue;
158
                } else {
159 13
                    $this->logger->debug('no items fit, so starting next vertical layer');
160
161 13
                    $layerWidth = max($layerWidth, $rowWidth);
162 13
                    $layerLength += $rowLength;
163 13
                    $packingWidthLeft = $rowWidth ? min(intval($layerWidth * 1.1), $this->boxWidth) : $this->boxWidth;
164 13
                    $packingLengthLeft = $rowLength ? min(intval($layerLength * 1.1), $this->boxLength) : $this->boxLength;
165 13
                    $packingDepthLeft -= $layerDepth;
166
167 13
                    $z += $layerDepth;
168 13
                    $x = $y = $rowWidth = $rowLength = $layerWidth = $layerLength = $layerDepth = 0;
169
170 13
                    $this->rebuildItemList($itemToPack);
171 13
                    $prevItem = null;
172
                }
173
            }
174
        }
175 27
        $this->logger->debug('done with this box');
176
177 27
        return $this->createPackedBox($packedItems);
178
    }
179
180
    /**
181
     * @param Item            $itemToPack
182
     * @param PackedItem|null $prevItem
183
     * @param Item|null       $nextItem
184
     * @param bool            $isLastItem
185
     * @param int             $maxWidth
186
     * @param int             $maxLength
187
     * @param int             $maxDepth
188
     *
189
     * @return OrientatedItem|null
190
     */
191 27
    protected function getOrientationForItem(
192
        Item $itemToPack,
193
        $prevItem,
194
        $nextItem,
195
        $isLastItem,
196
        $maxWidth,
197
        $maxLength,
198
        $maxDepth
199
    ) {
200 27
        $this->logger->debug(
201 27
            "evaluating item {$itemToPack->getDescription()} for fit",
202
            [
203 27
                'item'  => $itemToPack,
204
                'space' => [
205 27
                    'maxWidth'    => $maxWidth,
206 27
                    'maxLength'   => $maxLength,
207 27
                    'maxDepth'    => $maxDepth,
208
                ],
209
            ]
210
        );
211
212 27
        $prevOrientatedItem = $prevItem ? $prevItem->toOrientatedItem() : null;
213
214 27
        $orientatedItemFactory = new OrientatedItemFactory();
215 27
        $orientatedItemFactory->setLogger($this->logger);
216 27
        $orientatedItem = $orientatedItemFactory->getBestOrientation($this->box, $itemToPack, $prevOrientatedItem, $nextItem, $isLastItem, $maxWidth, $maxLength, $maxDepth);
217
218 27
        return $orientatedItem;
219
    }
220
221
    /**
222
     * Figure out if we can stack the next item vertically on top of this rather than side by side
223
     * Used when we've packed a tall item, and have just put a shorter one next to it.
224
     *
225
     * @param PackedItemList  $packedItems
226
     * @param PackedItem|null $prevItem
227
     * @param Item|null       $nextItem
228
     * @param int             $maxWidth
229
     * @param int             $maxLength
230
     * @param int             $maxDepth
231
     * @param int             $x
232
     * @param int             $y
233
     * @param int             $z
234
     */
235 27
    protected function tryAndStackItemsIntoSpace(
236
        PackedItemList $packedItems,
237
        $prevItem,
238
        $nextItem,
239
        $maxWidth,
240
        $maxLength,
241
        $maxDepth,
242
        $x,
243
        $y,
244
        $z
245
    ) {
246 27
        while (count($this->items) > 0 && $this->checkNonDimensionalConstraints($this->items->top(), $packedItems)) {
247 26
            $stackedItem = $this->getOrientationForItem(
248 26
                $this->items->top(),
249 26
                $prevItem,
250 26
                $nextItem,
251 26
                $this->items->count() === 1,
252 26
                $maxWidth,
253 26
                $maxLength,
254 26
                $maxDepth
255
            );
256 26
            if ($stackedItem) {
257 2
                $this->remainingWeight -= $this->items->top()->getWeight();
258 2
                $packedItems->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $z));
259 2
                $this->items->extract();
260 2
                $maxDepth -= $stackedItem->getDepth();
261 2
                $z += $stackedItem->getDepth();
262
            } else {
263 26
                break;
264
            }
265
        }
266 27
    }
267
268
    /**
269
     * Check item generally fits into box.
270
     *
271
     * @param Item           $itemToPack
272
     * @param PackedItemList $packedItems
273
     *
274
     * @return bool
275
     */
276 27
    protected function checkConstraints(
277
        Item $itemToPack,
278
        PackedItemList $packedItems
279
    ) {
280 27
        return $this->checkNonDimensionalConstraints($itemToPack, $packedItems) &&
281 27
               $this->checkDimensionalConstraints($itemToPack);
282
    }
283
284
    /**
285
     * As well as purely dimensional constraints, there are other constraints that need to be met
286
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box).
287
     *
288
     * @param Item           $itemToPack
289
     * @param PackedItemList $packedItems
290
     *
291
     * @return bool
292
     */
293 27
    protected function checkNonDimensionalConstraints(Item $itemToPack, PackedItemList $packedItems)
294
    {
295 27
        $weightOK = $itemToPack->getWeight() <= $this->remainingWeight;
296
297 27
        if ($itemToPack instanceof ConstrainedItem) {
298 1
            return $weightOK && $itemToPack->canBePackedInBox($packedItems->asItemList(), $this->box);
299
        }
300
301 27
        return $weightOK;
302
    }
303
304
    /**
305
     * Check the item physically fits in the box (at all).
306
     *
307
     * @param Item $itemToPack
308
     *
309
     * @return bool
310
     */
311 27
    protected function checkDimensionalConstraints(Item $itemToPack)
312
    {
313 27
        $orientatedItemFactory = new OrientatedItemFactory();
314 27
        $orientatedItemFactory->setLogger($this->logger);
315
316 27
        return (bool) $orientatedItemFactory->getPossibleOrientationsInEmptyBox($itemToPack, $this->box);
317
    }
318
319
    /**
320
     * Reintegrate skipped items into main list.
321
     *
322
     * @param Item|null $currentItem item from current iteration
323
     */
324 27
    protected function rebuildItemList($currentItem = null)
325
    {
326 27
        if (count($this->items) === 0) {
327 27
            $this->items = $this->skippedItems;
328 27
            $this->skippedItems = new ItemList();
329
        }
330
331 27
        if ($currentItem instanceof Item) {
332 20
            $this->items->insert($currentItem);
333
        }
334 27
    }
335
336
    /**
337
     * @param PackedItemList $packedItems
338
     *
339
     * @return PackedBox
340
     */
341 27
    protected function createPackedBox(PackedItemList $packedItems)
342
    {
343
        //if we rotated the box for packing, need to swap back width/length of the packed items
344 27
        if ($this->boxRotated) {
345 7
            $items = iterator_to_array($packedItems, false);
346 7
            $packedItems = new PackedItemList();
347
            /** @var PackedItem $item */
348 7
            foreach ($items as $item) {
349 7
                $packedItems->insert(
350 7
                    new PackedItem(
351 7
                        $item->getItem(),
352 7
                        $item->getY(),
353 7
                        $item->getX(),
354 7
                        $item->getZ(),
355 7
                        $item->getLength(),
356 7
                        $item->getWidth(),
357 7
                        $item->getDepth()
358
                    )
359
                );
360
            }
361
        }
362
363 27
        return PackedBox::fromPackedItemList($this->box, $packedItems);
364
    }
365
366
    /**
367
     * Are there items left to pack?
368
     *
369
     * @return bool
370
     */
371 27
    protected function hasItemsLeftToPack()
372
    {
373 27
        return count($this->skippedItems) + count($this->items) === 0;
374
    }
375
376
    /**
377
     * Return next item in line for packing.
378
     *
379
     * @return Item|null
380
     */
381 27
    protected function getNextItem()
382
    {
383 27
        return count($this->items) ? $this->items->top() : null;
384
    }
385
}
386