Completed
Push — 2.x-dev ( b3ed4d...f6da7f )
by Doug
17:05
created

VolumePacker::createPackedBox()   B

Complexity

Conditions 3
Paths 2

Size

Total Lines 24
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 24
ccs 15
cts 15
cp 1
rs 8.9713
c 0
b 0
f 0
cc 3
eloc 15
nc 2
nop 1
crap 3
1
<?php
2
/**
3
 * Box packing (3D bin packing, knapsack problem)
4
 * @package BoxPacker
5
 * @author Doug Wright
6
 */
7
namespace DVDoug\BoxPacker;
8
9
use Psr\Log\LoggerAwareInterface;
10
use Psr\Log\LoggerAwareTrait;
11
use Psr\Log\LoggerInterface;
12
use Psr\Log\NullLogger;
13
14
/**
15
 * Actual packer
16
 * @author Doug Wright
17
 * @package BoxPacker
18
 */
19
class VolumePacker implements LoggerAwareInterface
20
{
21
    use LoggerAwareTrait;
22
23
    /**
24
     * Box to pack items into
25
     * @var Box
26
     */
27
    protected $box;
28
29
    /**
30
     * @var int
31
     */
32
    protected $boxWidth;
33
34
    /**
35
     * @var int
36
     */
37
    protected $boxLength;
38
39
    /**
40
     * List of items to be packed
41
     * @var ItemList
42
     */
43
    protected $items;
44
45
    /**
46
     * List of items to be packed
47
     * @var ItemList
48
     */
49
    protected $skippedItems;
50
51
    /**
52
     * Remaining weight capacity of the box
53
     * @var int
54
     */
55
    protected $remainingWeight;
56
57
    /**
58
     * Whether the box was rotated for packing
59
     * @var bool
60
     */
61
    protected $boxRotated = false;
62
63
    /**
64
     * Constructor
65
     *
66
     * @param Box      $box
67
     * @param ItemList $items
68
     */
69 35
    public function __construct(Box $box, ItemList $items)
70
    {
71 35
        $this->box = $box;
72 35
        $this->items = $items;
73
74 35
        $this->boxWidth = max($this->box->getInnerWidth(), $this->box->getInnerLength());
75 35
        $this->boxLength = min($this->box->getInnerWidth(), $this->box->getInnerLength());
76 35
        $this->remainingWeight = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
77 35
        $this->skippedItems = new ItemList();
78 35
        $this->logger = new NullLogger();
79
80
        // we may have just rotated the box for packing purposes, record if we did
81 35
        if ($this->box->getInnerWidth() != $this->boxWidth || $this->box->getInnerLength() != $this->boxLength) {
82 11
            $this->boxRotated = true;
83
        }
84
85
    }
86
87
    /**
88
     * Pack as many items as possible into specific given box
89
     *
90
     * @return PackedBox packed box
91
     */
92 35
    public function pack()
93
    {
94 35
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}");
95
96 35
        $packedItems = new PackedItemList;
97 35
        $prevItem = null;
98
99 35
        $x = $y = $z = $rowWidth = $rowLength = $layerWidth = $layerLength = $layerDepth = 0;
100
101 35
        $packingWidthLeft = $this->boxWidth;
102 35
        $packingLengthLeft = $this->boxLength;
103 35
        $packingDepthLeft = $this->box->getInnerDepth();
104
105 35
        while (!$this->items->isEmpty()) {
106 35
            $itemToPack = $this->items->extract();
107 35
            $isLastItem = $this->skippedItems->isEmpty() && $this->items->isEmpty();
108
109
            //skip items that are simply too heavy or too large
110 35
            if (!$this->checkConstraints($itemToPack, $packedItems)) {
111 9
                $this->rebuildItemList();
112 9
                continue;
113
            }
114
115 35
            $orientatedItem = $this->getOrientationForItem($itemToPack, $prevItem, $isLastItem, $packingWidthLeft, $packingLengthLeft, $packingDepthLeft);
116
117 35
            if ($orientatedItem instanceof OrientatedItem) {
118 35
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $z);
119 35
                $packedItems->insert($packedItem);
120 35
                $this->remainingWeight -= $orientatedItem->getItem()->getWeight();
121 35
                $packingWidthLeft -= $orientatedItem->getWidth();
122
123 35
                $rowWidth += $orientatedItem->getWidth();
124 35
                $rowLength = max($rowLength, $orientatedItem->getLength());
125 35
                $layerDepth = max($layerDepth, $orientatedItem->getDepth());
126
127
                //allow items to be stacked in place within the same footprint up to current layer depth
128 35
                $stackableDepth = $layerDepth - $orientatedItem->getDepth();
129 35
                $this->tryAndStackItemsIntoSpace($packedItems, $prevItem, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $x, $y, $z + $orientatedItem->getDepth());
130 35
                $x += $orientatedItem->getWidth();
131
132 35
                $prevItem = $packedItem;
133
134 35
                $this->rebuildItemList();
135
            } else {
136 25
                if ($layerWidth == 0 && $layerDepth == 0) { // zero items on layer
137 5
                    $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
138 5
                    $prevItem = null;
139 5
                    continue;
140 25
                } elseif (!$this->items->isEmpty()) { // skip for now, move on to the next item
141 21
                    $this->logger->debug("doesn't fit, skipping for now");
142 21
                    $this->skippedItems->insert($itemToPack);
143 25
                } elseif ($x > 0 && $packingLengthLeft >= min($itemToPack->getWidth(), $itemToPack->getLength())) {
144 25
                    $this->logger->debug("No more fit in width wise, resetting for new row");
145 25
                    $layerWidth = max($layerWidth, $rowWidth);
146 25
                    $layerLength += $rowLength;
147 25
                    $packingWidthLeft += $rowWidth;
148 25
                    $packingLengthLeft -= $rowLength;
149 25
                    $y += $rowLength;
150 25
                    $x = $rowWidth = $rowLength = 0;
151 25
                    $this->rebuildItemList();
152 25
                    $this->items->insert($itemToPack);
153 25
                    $prevItem = null;
154 25
                    continue;
155
                } else {
156 18
                    $this->logger->debug("no items fit, so starting next vertical layer");
157
158 18
                    $layerWidth = max($layerWidth, $rowWidth);
159 18
                    $layerLength += $rowLength;
160 18
                    $packingWidthLeft = $rowWidth ? min(floor($layerWidth * 1.1), $this->boxWidth) : $this->boxWidth;
161 18
                    $packingLengthLeft = $rowLength ? min(floor($layerLength * 1.1), $this->boxLength) : $this->boxLength;
162 18
                    $packingDepthLeft -= $layerDepth;
163
164 18
                    $z += $layerDepth;
165 18
                    $x = $y = $rowWidth = $rowLength = $layerWidth = $layerLength = $layerDepth = 0;
166
167 18
                    $this->rebuildItemList();
168 18
                    $this->items->insert($itemToPack);
169 18
                    $prevItem = null;
170
                }
171
            }
172
        }
173 35
        $this->logger->debug("done with this box");
174 35
        return $this->createPackedBox($packedItems);
175
    }
176
177
    /**
178
     * @param Item            $itemToPack
179
     * @param PackedItem|null $prevItem
180
     * @param bool            $isLastItem
181
     * @param int             $maxWidth
182
     * @param int             $maxLength
183
     * @param int             $maxDepth
184
     *
185
     * @return OrientatedItem|false
186
     */
187 35
    protected function getOrientationForItem(
188
        Item $itemToPack,
189
        PackedItem $prevItem = null,
190
        $isLastItem,
191
        $maxWidth,
192
        $maxLength,
193
        $maxDepth
194
    ) {
195 35
        $this->logger->debug(
196 35
            "evaluating item {$itemToPack->getDescription()} for fit",
197
            [
198 35
                'item' => $itemToPack,
199
                'space' => [
200 35
                    'maxWidth'    => $maxWidth,
201 35
                    'maxLength'   => $maxLength,
202 35
                    'maxDepth'    => $maxDepth,
203
                ],
204
            ]
205
        );
206
207 35
        $orientatedItemFactory = new OrientatedItemFactory();
208 35
        $orientatedItemFactory->setLogger($this->logger);
209 35
        $orientatedItem = $orientatedItemFactory->getBestOrientation($this->box, $itemToPack, $prevItem, $isLastItem, $maxWidth, $maxLength, $maxDepth);
210
211 35
        return $orientatedItem;
212
    }
213
214
    /**
215
     * Figure out if we can stack the next item vertically on top of this rather than side by side
216
     * Used when we've packed a tall item, and have just put a shorter one next to it
217
     *
218
     * @param PackedItemList $packedItems
219
     * @param PackedItem|null $prevItem
220
     * @param int $maxWidth
221
     * @param int $maxLength
222
     * @param int $maxDepth
223
     * @param int $x
224
     * @param int $y
225
     * @param int $z
226
     */
227 35
    protected function tryAndStackItemsIntoSpace(
228
        PackedItemList $packedItems,
229
        PackedItem $prevItem = null,
230
        $maxWidth,
231
        $maxLength,
232
        $maxDepth,
233
        $x,
234
        $y,
235
        $z
236
    ) {
237 35
        while (!$this->items->isEmpty() && $this->checkNonDimensionalConstraints($this->items->top(), $packedItems)) {
238 33
            $stackedItem = $this->getOrientationForItem(
239 33
                $this->items->top(),
240 33
                $prevItem,
241 33
                $this->items->count() === 1,
242 33
                $maxWidth,
243 33
                $maxLength,
244 33
                $maxDepth
245
            );
246 33
            if ($stackedItem) {
247 3
                $this->remainingWeight -= $this->items->top()->getWeight();
248 3
                $packedItems->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $z));
249 3
                $this->items->extract();
250 3
                $maxDepth -= $stackedItem->getDepth();
251 3
                $z += $stackedItem->getDepth();
252
            } else {
253 33
                break;
254
            }
255
        }
256
    }
257
258
    /**
259
     * Check item generally fits into box
260
     *
261
     * @param Item            $itemToPack
262
     * @param PackedItemList  $packedItems
263
     *
264
     * @return bool
265
     */
266 35
    protected function checkConstraints(
267
        Item $itemToPack,
268
        PackedItemList $packedItems
269
    ) {
270 35
        return $this->checkNonDimensionalConstraints($itemToPack, $packedItems) &&
271 35
               $this->checkDimensionalConstraints($itemToPack);
272
    }
273
274
    /**
275
     * As well as purely dimensional constraints, there are other constraints that need to be met
276
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box)
277
     *
278
     * @param Item     $itemToPack
279
     * @param PackedItemList $packedItems
280
     *
281
     * @return bool
282
     */
283 35
    protected function checkNonDimensionalConstraints(Item $itemToPack, PackedItemList $packedItems)
284
    {
285 35
        $weightOK = $itemToPack->getWeight() <= $this->remainingWeight;
286
287 35
        if ($itemToPack instanceof ConstrainedItem) {
288 1
            return $weightOK && $itemToPack->canBePackedInBox($packedItems->asItemList(), $this->box);
289
        }
290
291 34
        return $weightOK;
292
    }
293
294
    /**
295
     * Check the item physically fits in the box (at all)
296
     *
297
     * @param Item            $itemToPack
298
     *
299
     * @return bool
300
     */
301 35
    protected function checkDimensionalConstraints(Item $itemToPack)
302
    {
303 35
        $orientatedItemFactory = new OrientatedItemFactory();
304 35
        $orientatedItemFactory->setLogger($this->logger);
305 35
        return !!$orientatedItemFactory->getPossibleOrientationsInEmptyBox($itemToPack, $this->box);
306
    }
307
308
    /**
309
     * Reintegrate skipped items into main list when nothing left to process
310
     */
311 35
    protected function rebuildItemList() {
312 35
        if ($this->items->isEmpty()) {
313 35
            $this->items = $this->skippedItems;
314 35
            $this->skippedItems = new ItemList();
315
        }
316
    }
317
318
    /**
319
     * @param PackedItemList $packedItems
320
     *
321
     * @return PackedBox
322
     */
323 35
    protected function createPackedBox(PackedItemList $packedItems)
324
    {
325
        //if we rotated the box for packing, need to swap back width/length of the packed items
326 35
        if ($this->boxRotated) {
327 11
            $items = iterator_to_array($packedItems);
328 11
            $packedItems = new PackedItemList();
329
            /** @var PackedItem $item */
330 11
            foreach($items as $item) {
331 11
                $packedItems->insert(
332 11
                    new PackedItem(
333 11
                        $item->getItem(),
334 11
                        $item->getY(),
335 11
                        $item->getX(),
336 11
                        $item->getZ(),
337 11
                        $item->getLength(),
338 11
                        $item->getWidth(),
339 11
                        $item->getDepth()
340
                    )
341
                );
342
            }
343
        }
344
345 35
        return PackedBox::fromPackedItemList($this->box, $packedItems);
346
    }
347
}
348