Passed
Push — repack_layers_for_new_height ( 96fee3...7073dd )
by Doug
01:59
created

VolumePacker::rebuildItemList()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3

Importance

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