Completed
Push — master ( ab3db1...67bebb )
by Doug
28:56
created

VolumePacker::checkNonDimensionalConstraints()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3

Importance

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