Passed
Push — behat ( a74de5...4872f1 )
by Doug
02:39
created

VolumePacker::checkNonDimensionalConstraints()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 10
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 3

Importance

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