Completed
Push — 1.x-dev ( f03c49...17b59f )
by Doug
68:27 queued 64:38
created

VolumePacker::getPackedItemList()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

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