Passed
Push — repack_layers_for_new_height ( 75e243...d7d7bc )
by Doug
01:55
created

VolumePacker::createPackedBox()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 3

Importance

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