Passed
Push — master ( 5d8812...a1d92b )
by Doug
02:07
created

VolumePacker::__construct()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 3

Importance

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