Passed
Push — 1.x-dev ( cc1b76...09423a )
by Doug
02:10
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.4285
c 0
b 0
f 0
1
<?php
2
/**
3
 * Box packing (3D bin packing, knapsack problem).
4
 *
5
 * @author Doug Wright
6
 */
7
8
namespace DVDoug\BoxPacker;
9
10
use Psr\Log\LoggerAwareInterface;
11
use Psr\Log\LoggerAwareTrait;
12
use Psr\Log\NullLogger;
13
14
/**
15
 * Actual packer.
16
 *
17
 * @author Doug Wright
18
 */
19
class VolumePacker implements LoggerAwareInterface
20
{
21
    use LoggerAwareTrait;
22
23
    /**
24
     * Box to pack items into.
25
     *
26
     * @var Box
27
     */
28
    protected $box;
29
30
    /**
31
     * @var int
32
     */
33
    protected $boxWidth;
34
35
    /**
36
     * @var int
37
     */
38
    protected $boxLength;
39
40
    /**
41
     * List of items to be packed.
42
     *
43
     * @var ItemList
44
     */
45
    protected $items;
46
47
    /**
48
     * List of items temporarily skipped to be packed.
49
     *
50
     * @var ItemList
51
     */
52
    protected $skippedItems;
53
54
    /**
55
     * Remaining weight capacity of the box.
56
     *
57
     * @var int
58
     */
59
    protected $remainingWeight;
60
61
    /**
62
     * Whether the box was rotated for packing.
63
     *
64
     * @var bool
65
     */
66
    protected $boxRotated = false;
67
68
    /**
69
     * @var PackedLayer[]
70
     */
71
    protected $layers = [];
72
73
    /**
74
     * Constructor.
75
     *
76
     * @param Box      $box
77
     * @param ItemList $items
78
     */
79 26
    public function __construct(Box $box, ItemList $items)
80
    {
81 26
        $this->box = $box;
82 26
        $this->items = $items;
83
84 26
        $this->boxWidth = max($this->box->getInnerWidth(), $this->box->getInnerLength());
85 26
        $this->boxLength = min($this->box->getInnerWidth(), $this->box->getInnerLength());
86 26
        $this->remainingWeight = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
87 26
        $this->skippedItems = new ItemList();
88 26
        $this->logger = new NullLogger();
89
90
        // we may have just rotated the box for packing purposes, record if we did
91 26
        if ($this->box->getInnerWidth() != $this->boxWidth || $this->box->getInnerLength() != $this->boxLength) {
92 7
            $this->boxRotated = true;
93
        }
94 26
    }
95
96
    /**
97
     * Pack as many items as possible into specific given box.
98
     *
99
     * @return PackedBox packed box
100
     */
101 26
    public function pack()
102
    {
103 26
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}");
104
105 26
        while (count($this->items) > 0) {
106 26
            $layerStartDepth = $this->getCurrentPackedDepth();
107 26
            $this->packLayer($layerStartDepth, $this->boxWidth, $this->boxLength, $this->box->getInnerDepth() - $layerStartDepth);
108
        }
109
110 26
        if ($this->boxRotated) {
111 7
            $this->rotateLayersNinetyDegrees();
112
        }
113
114 26
        $this->stabiliseLayers();
115
116 26
        $this->logger->debug('done with this box');
117
118 26
        return PackedBox::fromPackedItemList($this->box, $this->getPackedItemList());
119
    }
120
121
    /**
122
     * Pack items into an individual vertical layer.
123
     *
124
     * @param int $startDepth
125
     * @param int $widthLeft
126
     * @param int $lengthLeft
127
     * @param int $depthLeft
128
     */
129 26
    protected function packLayer($startDepth, $widthLeft, $lengthLeft, $depthLeft)
130
    {
131 26
        $this->layers[] = $layer = new PackedLayer();
132 26
        $prevItem = null;
133 26
        $x = $y = $rowWidth = $rowLength = $layerDepth = 0;
134
135 26
        while (count($this->items) > 0) {
136 26
            $itemToPack = $this->items->extract();
137 26
            $nextItem = $this->getNextItem();
138
139
            //skip items that are simply too heavy or too large
140 26
            if (!$this->checkConstraints($itemToPack)) {
141 6
                $this->rebuildItemList();
142 6
                continue;
143
            }
144
145 26
            $orientatedItem = $this->getOrientationForItem($itemToPack, $prevItem, $nextItem, $this->hasItemsLeftToPack(), $widthLeft, $lengthLeft, $depthLeft);
146
147 26
            if ($orientatedItem instanceof OrientatedItem) {
148 26
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $startDepth);
149 26
                $layer->insert($packedItem);
150 26
                $this->remainingWeight -= $orientatedItem->getItem()->getWeight();
151 26
                $widthLeft -= $orientatedItem->getWidth();
152
153 26
                $rowWidth += $orientatedItem->getWidth();
154 26
                $rowLength = max($rowLength, $orientatedItem->getLength());
155 26
                $layerDepth = max($layerDepth, $orientatedItem->getDepth());
156
157
                //allow items to be stacked in place within the same footprint up to current layer depth
158 26
                $stackableDepth = $layerDepth - $orientatedItem->getDepth();
159 26
                $this->tryAndStackItemsIntoSpace($layer, $prevItem, $nextItem, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $x, $y, $startDepth + $orientatedItem->getDepth());
160 26
                $x += $orientatedItem->getWidth();
161
162 26
                $prevItem = $packedItem;
163 26
                $this->rebuildItemList();
164
            } else {
165 20
                if (count($layer->getItems()) === 0) { // zero items on layer
166 1
                    $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
167 1
                    continue;
168 20
                } elseif (count($this->items) > 0) { // skip for now, move on to the next item
169 18
                    $this->logger->debug("doesn't fit, skipping for now");
170 18
                    $this->skippedItems->insert($itemToPack);
171 20
                } elseif ($x > 0 && $lengthLeft >= min($itemToPack->getWidth(), $itemToPack->getLength())) {
172 20
                    $this->logger->debug('No more fit in width wise, resetting for new row');
173 20
                    $widthLeft += $rowWidth;
174 20
                    $lengthLeft -= $rowLength;
175 20
                    $y += $rowLength;
176 20
                    $x = $rowWidth = $rowLength = 0;
177 20
                    $this->rebuildItemList($itemToPack);
178 20
                    $prevItem = null;
179 20
                    continue;
180
                } else {
181 13
                    $this->logger->debug('no items fit, so starting next vertical layer');
182 13
                    $this->rebuildItemList($itemToPack);
183
184 13
                    return;
185
                }
186
            }
187
        }
188 26
    }
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 26
    public function stabiliseLayers()
197
    {
198 26
        $stabiliser = new LayerStabiliser();
199 26
        $this->layers = $stabiliser->stabilise($this->layers);
200 26
    }
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 26
    protected function getOrientationForItem(
214
        Item $itemToPack,
215
        PackedItem $prevItem = null,
216
        Item $nextItem = null,
217
        $isLastItem,
218
        $maxWidth,
219
        $maxLength,
220
        $maxDepth
221
    ) {
222 26
        $this->logger->debug(
223 26
            "evaluating item {$itemToPack->getDescription()} for fit",
224
            [
225 26
                'item'  => $itemToPack,
226
                'space' => [
227 26
                    'maxWidth'    => $maxWidth,
228 26
                    'maxLength'   => $maxLength,
229 26
                    'maxDepth'    => $maxDepth,
230
                ],
231
            ]
232
        );
233
234 26
        $prevOrientatedItem = $prevItem ? $prevItem->toOrientatedItem() : null;
235
236 26
        $orientatedItemFactory = new OrientatedItemFactory($itemToPack, $this->box);
237 26
        $orientatedItemFactory->setLogger($this->logger);
238 26
        $orientatedItem = $orientatedItemFactory->getBestOrientation($prevOrientatedItem, $nextItem, $isLastItem, $maxWidth, $maxLength, $maxDepth);
239
240 26
        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 26
    protected function tryAndStackItemsIntoSpace(
258
        PackedLayer $layer,
259
        PackedItem $prevItem = null,
260
        Item $nextItem = null,
261
        $maxWidth,
262
        $maxLength,
263
        $maxDepth,
264
        $x,
265
        $y,
266
        $z
267
    ) {
268 26
        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 26
    }
289
290
    /**
291
     * Check item generally fits into box.
292
     *
293
     * @param Item $itemToPack
294
     *
295
     * @return bool
296
     */
297 26
    protected function checkConstraints(
298
        Item $itemToPack
299
    ) {
300 26
        return $this->checkNonDimensionalConstraints($itemToPack) &&
301 26
               $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 26
    protected function checkNonDimensionalConstraints(Item $itemToPack)
313
    {
314 26
        $weightOK = $itemToPack->getWeight() <= $this->remainingWeight;
315
316 26
        if ($itemToPack instanceof ConstrainedItem) {
317 1
            return $weightOK && $itemToPack->canBePackedInBox($this->getPackedItemList()->asItemList(), $this->box);
318
        }
319
320 26
        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 26
    protected function checkDimensionalConstraints(Item $itemToPack)
331
    {
332 26
        $orientatedItemFactory = new OrientatedItemFactory($itemToPack, $this->box);
333 26
        $orientatedItemFactory->setLogger($this->logger);
334
335 26
        return (bool) $orientatedItemFactory->getPossibleOrientationsInEmptyBox();
336
    }
337
338
    /**
339
     * Reintegrate skipped items into main list.
340
     *
341
     * @param Item|null $currentItem item from current iteration
342
     */
343 26
    protected function rebuildItemList(Item $currentItem = null)
344
    {
345 26
        if (count($this->items) === 0) {
346 26
            $this->items = $this->skippedItems;
347 26
            $this->skippedItems = new ItemList();
348
        }
349
350 26
        if ($currentItem instanceof Item) {
351 20
            $this->items->insert($currentItem);
352
        }
353 26
    }
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()
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 26
    protected function hasItemsLeftToPack()
376
    {
377 26
        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 26
    protected function getNextItem()
386
    {
387 26
        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 26
    protected function getPackedItemList()
396
    {
397 26
        $packedItemList = new PackedItemList();
398 26
        foreach ($this->layers as $layer) {
399 26
            foreach ($layer->getItems() as $packedItem) {
400 26
                $packedItemList->insert($packedItem);
401
            }
402
        }
403
404 26
        return $packedItemList;
405
    }
406
407
    /**
408
     * Return the current packed depth.
409
     *
410
     * @return int
411
     */
412 26
    protected function getCurrentPackedDepth()
413
    {
414 26
        $depth = 0;
415 26
        foreach ($this->layers as $layer) {
416 13
            $depth += $layer->getDepth();
417
        }
418
419 26
        return $depth;
420
    }
421
}
422