Completed
Push — master ( 67bebb...73147e )
by Doug
35:04
created

VolumePacker::setLogger()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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