Passed
Push — weight_redistributor ( 060e5b...8de9a1 )
by Doug
03:51 queued 02:01
created

VolumePacker::getCurrentPackedDepth()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 9
ccs 5
cts 5
cp 1
rs 9.6666
c 0
b 0
f 0
cc 2
eloc 5
nc 2
nop 0
crap 2
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
185 13
                    return;
186
                }
187
            }
188
        }
189 27
    }
190
191
    /**
192
     * During packing, it is quite possible that layers have been created that aren't physically stable
193
     * i.e. they overhang the ones below.
194
     *
195
     * This function reorders them so that the ones with the greatest surface area are placed at the bottom
196
     */
197 27
    public function stabiliseLayers(): void
198
    {
199 27
        $stabiliser = new LayerStabiliser();
200 27
        $this->layers = $stabiliser->stabilise($this->layers);
201 27
    }
202
203
    /**
204
     * @param Item            $itemToPack
205
     * @param PackedItem|null $prevItem
206
     * @param Item|null       $nextItem
207
     * @param bool            $isLastItem
208
     * @param int             $maxWidth
209
     * @param int             $maxLength
210
     * @param int             $maxDepth
211
     *
212
     * @return OrientatedItem|null
213
     */
214 27
    protected function getOrientationForItem(
215
        Item $itemToPack,
216
        ?PackedItem $prevItem,
217
        ?Item $nextItem,
218
        bool $isLastItem,
219
        int $maxWidth,
220
        int $maxLength,
221
        int $maxDepth
222
    ): ?OrientatedItem {
223 27
        $this->logger->debug(
224 27
            "evaluating item {$itemToPack->getDescription()} for fit",
225
            [
226 27
                'item'  => $itemToPack,
227
                'space' => [
228 27
                    'maxWidth'    => $maxWidth,
229 27
                    'maxLength'   => $maxLength,
230 27
                    'maxDepth'    => $maxDepth,
231
                ],
232
            ]
233
        );
234
235 27
        $prevOrientatedItem = $prevItem ? $prevItem->toOrientatedItem() : null;
236
237 27
        $orientatedItemFactory = new OrientatedItemFactory();
238 27
        $orientatedItemFactory->setLogger($this->logger);
239 27
        $orientatedItem = $orientatedItemFactory->getBestOrientation($this->box, $itemToPack, $prevOrientatedItem, $nextItem, $isLastItem, $maxWidth, $maxLength, $maxDepth);
240
241 27
        return $orientatedItem;
242
    }
243
244
    /**
245
     * Figure out if we can stack the next item vertically on top of this rather than side by side
246
     * Used when we've packed a tall item, and have just put a shorter one next to it.
247
     *
248
     * @param PackedLayer     $layer
249
     * @param PackedItem|null $prevItem
250
     * @param Item|null       $nextItem
251
     * @param int             $maxWidth
252
     * @param int             $maxLength
253
     * @param int             $maxDepth
254
     * @param int             $x
255
     * @param int             $y
256
     * @param int             $z
257
     */
258 27
    protected function tryAndStackItemsIntoSpace(
259
        PackedLayer $layer,
260
        ?PackedItem $prevItem,
261
        ?Item $nextItem,
262
        int $maxWidth,
263
        int $maxLength,
264
        int $maxDepth,
265
        int $x,
266
        int $y,
267
        int $z
268
    ): void {
269 27
        while (count($this->items) > 0 && $this->checkNonDimensionalConstraints($this->items->top())) {
270 26
            $stackedItem = $this->getOrientationForItem(
271 26
                $this->items->top(),
272 26
                $prevItem,
273 26
                $nextItem,
274 26
                $this->items->count() === 1,
275 26
                $maxWidth,
276 26
                $maxLength,
277 26
                $maxDepth
278
            );
279 26
            if ($stackedItem) {
280 2
                $this->remainingWeight -= $this->items->top()->getWeight();
281 2
                $layer->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $z));
282 2
                $this->items->extract();
283 2
                $maxDepth -= $stackedItem->getDepth();
284 2
                $z += $stackedItem->getDepth();
285
            } else {
286 26
                break;
287
            }
288
        }
289 27
    }
290
291
    /**
292
     * Check item generally fits into box.
293
     *
294
     * @param Item $itemToPack
295
     *
296
     * @return bool
297
     */
298 27
    protected function checkConstraints(
299
        Item $itemToPack
300
    ): bool {
301 27
        return $this->checkNonDimensionalConstraints($itemToPack) &&
302 27
               $this->checkDimensionalConstraints($itemToPack);
303
    }
304
305
    /**
306
     * As well as purely dimensional constraints, there are other constraints that need to be met
307
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box).
308
     *
309
     * @param Item $itemToPack
310
     *
311
     * @return bool
312
     */
313 27
    protected function checkNonDimensionalConstraints(Item $itemToPack): bool
314
    {
315 27
        $weightOK = $itemToPack->getWeight() <= $this->remainingWeight;
316
317 27
        if ($itemToPack instanceof ConstrainedItem) {
318 1
            return $weightOK && $itemToPack->canBePackedInBox($this->getPackedItemList(), $this->box);
319
        }
320
321 27
        return $weightOK;
322
    }
323
324
    /**
325
     * Check the item physically fits in the box (at all).
326
     *
327
     * @param Item $itemToPack
328
     *
329
     * @return bool
330
     */
331 27
    protected function checkDimensionalConstraints(Item $itemToPack): bool
332
    {
333 27
        $orientatedItemFactory = new OrientatedItemFactory();
334 27
        $orientatedItemFactory->setLogger($this->logger);
335
336 27
        return (bool) $orientatedItemFactory->getPossibleOrientationsInEmptyBox($itemToPack, $this->box);
337
    }
338
339
    /**
340
     * Reintegrate skipped items into main list.
341
     *
342
     * @param Item|null $currentItem item from current iteration
343
     */
344 27
    protected function rebuildItemList(?Item $currentItem = null): void
345
    {
346 27
        if (count($this->items) === 0) {
347 27
            $this->items = $this->skippedItems;
348 27
            $this->skippedItems = new ItemList();
349
        }
350
351 27
        if ($currentItem instanceof Item) {
352 20
            $this->items->insert($currentItem);
353
        }
354 27
    }
355
356
    /**
357
     * Swap back width/length of the packed items to match orientation of the box if needed.
358
     */
359 7
    protected function rotateLayersNinetyDegrees(): void
360
    {
361 7
        foreach ($this->layers as $i => $originalLayer) {
362 7
            $newLayer = new PackedLayer();
363 7
            foreach ($originalLayer->getItems() as $item) {
364 7
                $packedItem = new PackedItem($item->getItem(), $item->getY(), $item->getX(), $item->getZ(), $item->getLength(), $item->getWidth(), $item->getDepth());
365 7
                $newLayer->insert($packedItem);
366
            }
367 7
            $this->layers[$i] = $newLayer;
368
        }
369 7
    }
370
371
    /**
372
     * Are there items left to pack?
373
     *
374
     * @return bool
375
     */
376 27
    protected function hasItemsLeftToPack(): bool
377
    {
378 27
        return count($this->skippedItems) + count($this->items) === 0;
379
    }
380
381
    /**
382
     * Return next item in line for packing.
383
     *
384
     * @return Item|null
385
     */
386 27
    protected function getNextItem(): ?Item
387
    {
388 27
        return count($this->items) ? $this->items->top() : null;
389
    }
390
391
    /**
392
     * Generate a single list of items packed.
393
     *
394
     * @return PackedItemList
395
     */
396 27
    protected function getPackedItemList(): PackedItemList
397
    {
398 27
        $packedItemList = new PackedItemList();
399 27
        foreach ($this->layers as $layer) {
400 27
            foreach ($layer->getItems() as $packedItem) {
401 27
                $packedItemList->insert($packedItem);
402
            }
403
        }
404
405 27
        return $packedItemList;
406
    }
407
408
    /**
409
     * Return the current packed depth.
410
     *
411
     * @return int
412
     */
413 27
    protected function getCurrentPackedDepth(): int
414
    {
415 27
        $depth = 0;
416 27
        foreach ($this->layers as $layer) {
417 13
            $depth += $layer->getDepth();
418
        }
419
420 27
        return $depth;
421
    }
422
}
423