Passed
Push — repack_layers_for_new_height ( d25c8c...96fee3 )
by Doug
02:02
created

VolumePacker::pack()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 3

Importance

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