Completed
Push — test_jit ( c34f6e...87859e )
by Doug
10:03
created

VolumePacker::hasItemsLeftToPack()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
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 function count;
12
use function max;
13
use Psr\Log\LoggerAwareInterface;
14
use Psr\Log\LoggerInterface;
15
use Psr\Log\NullLogger;
16
17
/**
18
 * Actual packer.
19
 *
20
 * @author Doug Wright
21
 */
22
class VolumePacker implements LoggerAwareInterface
23
{
24
    /**
25
     * The logger instance.
26
     *
27
     * @var LoggerInterface
28
     */
29
    protected $logger;
30
31
    /**
32
     * Box to pack items into.
33
     *
34
     * @var Box
35
     */
36
    protected $box;
37
38
    /**
39
     * List of items to be packed.
40
     *
41
     * @var ItemList
42
     */
43
    protected $items;
44
45
    /**
46
     * Whether the packer is in look-ahead mode (i.e. working ahead of the main packing).
47
     *
48
     * @var bool
49
     */
50
    protected $singlePassMode = false;
51
52
    /**
53
     * @var OrientatedItemFactory
54
     */
55
    private $orientatedItemFactory;
56
57
    /** @var bool */
58
    private $hasConstrainedItems;
59
60
    /**
61
     * Constructor.
62
     */
63 56
    public function __construct(Box $box, ItemList $items)
64
    {
65 56
        $this->box = $box;
66 56
        $this->items = clone $items;
67
68 56
        $this->logger = new NullLogger();
69
70 56
        $this->hasConstrainedItems = $items->hasConstrainedItems();
71
72 56
        $this->orientatedItemFactory = new OrientatedItemFactory($this->box);
73 56
        $this->orientatedItemFactory->setLogger($this->logger);
74 56
    }
75
76
    /**
77
     * Sets a logger.
78
     */
79 26
    public function setLogger(LoggerInterface $logger): void
80
    {
81 26
        $this->logger = $logger;
82 26
        $this->orientatedItemFactory->setLogger($logger);
83 26
    }
84
85
    /**
86
     * @internal
87
     */
88 47
    public function setSinglePassMode(bool $singlePassMode): void
89
    {
90 47
        $this->singlePassMode = $singlePassMode;
91 47
    }
92
93
    /**
94
     * Pack as many items as possible into specific given box.
95
     *
96
     * @return PackedBox packed box
97
     */
98 56
    public function pack(): PackedBox
99
    {
100 56
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}", ['box' => $this->box]);
101
102 56
        $rotationsToTest = [false];
103 56
        if (!$this->singlePassMode) {
104 56
            $rotationsToTest[] = true;
105
        }
106
107 56
        $boxPermutations = [];
108 56
        foreach ($rotationsToTest as $rotation) {
109 56
            if ($rotation) {
110 18
                $boxWidth = $this->box->getInnerLength();
111 18
                $boxLength = $this->box->getInnerWidth();
112
            } else {
113 56
                $boxWidth = $this->box->getInnerWidth();
114 56
                $boxLength = $this->box->getInnerLength();
115
            }
116
117 56
            $boxPermutation = $this->packRotation($boxWidth, $boxLength);
118 56
            if ($boxPermutation->getItems()->count() === $this->items->count()) {
119 53
                return $boxPermutation;
120
            }
121
122 38
            $boxPermutations[] = $boxPermutation;
123
        }
124
125
        usort($boxPermutations, static function (PackedBox $a, PackedBox $b) {
126 14
            return $b->getVolumeUtilisation() <=> $a->getVolumeUtilisation();
127 38
        });
128
129 38
        return reset($boxPermutations);
130
    }
131
132
    /**
133
     * Pack as many items as possible into specific given box.
134
     *
135
     * @return PackedBox packed box
136
     */
137 56
    protected function packRotation(int $boxWidth, int $boxLength): PackedBox
138
    {
139 56
        $this->logger->debug("[EVALUATING ROTATION] {$this->box->getReference()}", ['width' => $boxWidth, 'length' => $boxLength]);
140
141
        /** @var PackedLayer[] $layers */
142 56
        $layers = [];
143 56
        $items = clone $this->items;
144
145 56
        while ($items->count() > 0) {
146 56
            $layerStartDepth = static::getCurrentPackedDepth($layers);
147
148
            //do a preliminary layer pack to get the depth used
149 56
            $preliminaryItems = clone $items;
150 56
            $preliminaryLayer = $this->packLayer($preliminaryItems, $layers, $layerStartDepth, $boxWidth, $boxLength, $this->box->getInnerDepth() - $layerStartDepth, 0);
151 56
            if (count($preliminaryLayer->getItems()) === 0) {
152 35
                break;
153
            }
154
155 56
            if ($preliminaryLayer->getDepth() === $preliminaryLayer->getItems()[0]->getDepth()) { // preliminary === final
156 56
                $layers[] = $preliminaryLayer;
157 56
                $items = $preliminaryItems;
158
            } else { //redo with now-known-depth so that we can stack to that height from the first item
159 11
                $layers[] = $this->packLayer($items, $layers, $layerStartDepth, $boxWidth, $boxLength, $this->box->getInnerDepth() - $layerStartDepth, $preliminaryLayer->getDepth());
160
            }
161
        }
162
163 56
        if ($this->box->getInnerWidth() !== $boxWidth) {
164 8
            $layers = static::rotateLayersNinetyDegrees($layers);
165
        }
166
167 56
        if (!$this->singlePassMode && !$this->hasConstrainedItems) {
168 56
            $layers = static::stabiliseLayers($layers);
169
        }
170
171 56
        return new PackedBox($this->box, $this->getPackedItemList($layers));
172
    }
173
174
    /**
175
     * Pack items into an individual vertical layer.
176
     * @internal
177
     */
178 56
    protected function packLayer(ItemList &$items, array $layers, int $z, int $layerWidth, int $lengthLeft, int $depthLeft, int $guidelineLayerDepth): PackedLayer
179
    {
180 56
        $layers[] = $layer = new PackedLayer();
181 56
        $prevItem = null;
182 56
        $x = $y = $rowLength = 0;
183 56
        $skippedItems = [];
184 56
        $remainingWeightAllowed = $this->getRemainingWeightAllowed($layers);
185 56
        $packedItemList = $this->getPackedItemList($layers);
186
187 56
        while ($items->count() > 0) {
188 56
            $itemToPack = $items->extract();
189
190
            //skip items that are simply too heavy or too large
191 56
            if (!$this->checkNonDimensionalConstraints($itemToPack, $layers, $remainingWeightAllowed)) {
192 7
                continue;
193
            }
194
195 56
            $orientatedItem = $this->orientatedItemFactory->getBestOrientation($itemToPack, $prevItem instanceof PackedItem ? $prevItem->toOrientatedItem() : null, $items, $layerWidth - $x, $lengthLeft, $depthLeft, $rowLength, $x, $y, $z, $packedItemList, $this->singlePassMode);
196
197 56
            if ($orientatedItem instanceof OrientatedItem) {
198 56
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $z);
199 56
                $layer->insert($packedItem);
200 56
                $remainingWeightAllowed -= $itemToPack->getWeight();
201 56
                $packedItemList->insert($packedItem);
202
203 56
                $rowLength = max($rowLength, $orientatedItem->getLength());
204
205
                //Figure out if we can stack the next item vertically on top of this rather than side by side
206
                //e.g. when we've packed a tall item, and have just put a shorter one next to it.
207 56
                $stackableDepth = ($guidelineLayerDepth ?: $layer->getDepth()) - $orientatedItem->getDepth();
208 56
                $stackedZ = $z + $orientatedItem->getDepth();
209 56
                $stackSkippedItems = [];
210 56
                while ($stackableDepth > 0 && $items->count() > 0) {
211 17
                    $itemToTryStacking = $items->extract();
212 17
                    $stackedItem = $this->orientatedItemFactory->getBestOrientation($itemToTryStacking, $prevItem instanceof PackedItem ? $prevItem->toOrientatedItem() : null, $items, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $rowLength, $x, $y, $stackedZ, $packedItemList, $this->singlePassMode);
213 17
                    if ($stackedItem && $this->checkNonDimensionalConstraints($itemToTryStacking, $layers, $remainingWeightAllowed)) {
214 11
                        $layer->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $stackedZ));
215 11
                        $remainingWeightAllowed -= $itemToTryStacking->getWeight();
216 11
                        $packedItemList->insert($packedItem);
217 11
                        $stackableDepth -= $stackedItem->getDepth();
218 11
                        $stackedZ += $stackedItem->getDepth();
219
                    } else {
220 13
                        $stackSkippedItems[] = $itemToTryStacking;
221
                        // 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
222 13
                        while ($items->count() > 0 && static::isSameDimensions($itemToTryStacking, $items->top())) {
223 10
                            $stackSkippedItems[] = $items->extract();
224
                        }
225
                    }
226
                }
227 56
                if ($stackSkippedItems) {
228 13
                    $items = ItemList::fromArray(array_merge($stackSkippedItems, iterator_to_array($items)), true);
229
                }
230 56
                $x += $orientatedItem->getWidth();
231
232 56
                $prevItem = $packedItem;
233 56
                if ($items->count() === 0) {
234 53
                    $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
235 56
                    $skippedItems = [];
236
                }
237 50
            } elseif (count($layer->getItems()) === 0) { // zero items on layer
238 35
                $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
239 35
                continue;
240 49
            } elseif ($items->count() > 0) { // skip for now, move on to the next item
241 41
                $this->logger->debug("doesn't fit, skipping for now");
242 41
                $skippedItems[] = $itemToPack;
243
                // 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
244 41
                while ($items->count() > 2 && static::isSameDimensions($itemToPack, $items->top())) {
245 22
                    $skippedItems[] = $items->extract();
246
                }
247 49
            } elseif ($x > 0) {
248 49
                $this->logger->debug('No more fit in width wise, resetting for new row');
249 49
                $lengthLeft -= $rowLength;
250 49
                $y += $rowLength;
251 49
                $x = $rowLength = 0;
252 49
                $skippedItems[] = $itemToPack;
253 49
                $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
254 49
                $skippedItems = [];
255 49
                $prevItem = null;
256 49
                continue;
257
            } else {
258 43
                $this->logger->debug('no items fit, so starting next vertical layer');
259 43
                $skippedItems[] = $itemToPack;
260
261 43
                $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
262
263 43
                return $layer;
264
            }
265
        }
266
267 56
        return $layer;
268
    }
269
270
    /**
271
     * During packing, it is quite possible that layers have been created that aren't physically stable
272
     * i.e. they overhang the ones below.
273
     *
274
     * This function reorders them so that the ones with the greatest surface area are placed at the bottom
275
     * @param PackedLayer[] $layers
276
     */
277 56
    protected static function stabiliseLayers(array $layers): array
278
    {
279 56
        $stabiliser = new LayerStabiliser();
280
281 56
        return $stabiliser->stabilise($layers);
282
    }
283
284
    /**
285
     * As well as purely dimensional constraints, there are other constraints that need to be met
286
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box).
287
     */
288 56
    protected function checkNonDimensionalConstraints(Item $itemToPack, array $layers, int $remainingWeightAllowed): bool
289
    {
290 56
        $customConstraintsOK = true;
291 56
        if ($itemToPack instanceof ConstrainedItem) {
292 1
            $customConstraintsOK = $itemToPack->canBePackedInBox($this->getPackedItemList($layers), $this->box);
293
        }
294
295 56
        return $customConstraintsOK && $itemToPack->getWeight() <= $remainingWeightAllowed;
296
    }
297
298
    /**
299
     * Swap back width/length of the packed items to match orientation of the box if needed.
300
     * @param PackedLayer[] $oldLayers
301
     */
302 8
    protected static function rotateLayersNinetyDegrees($oldLayers): array
303
    {
304 8
        $newLayers = [];
305 8
        foreach ($oldLayers as $originalLayer) {
306 8
            $newLayer = new PackedLayer();
307 8
            foreach ($originalLayer->getItems() as $item) {
308 8
                $packedItem = new PackedItem($item->getItem(), $item->getY(), $item->getX(), $item->getZ(), $item->getLength(), $item->getWidth(), $item->getDepth());
309 8
                $newLayer->insert($packedItem);
310
            }
311 8
            $newLayers[] = $newLayer;
312
        }
313
314 8
        return $newLayers;
315
    }
316
317
    /**
318
     * Generate a single list of items packed.
319
     */
320 56
    protected function getPackedItemList(array $layers): PackedItemList
321
    {
322 56
        $packedItemList = new PackedItemList();
323 56
        foreach ($layers as $layer) {
324 56
            foreach ($layer->getItems() as $packedItem) {
325 56
                $packedItemList->insert($packedItem);
326
            }
327
        }
328
329 56
        return $packedItemList;
330
    }
331
332
    /**
333
     * Return the current packed depth.
334
     * @param PackedLayer[] $layers
335
     */
336 56
    protected static function getCurrentPackedDepth(array $layers): int
337
    {
338 56
        $depth = 0;
339 56
        foreach ($layers as $layer) {
340 43
            $depth += $layer->getDepth();
341
        }
342
343 56
        return $depth;
344
    }
345
346 56
    private function getRemainingWeightAllowed(array $layers): int
347
    {
348 56
        $remainingWeightAllowed = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
349 56
        foreach ($layers as $layer) {
350 56
            $remainingWeightAllowed -= $layer->getWeight();
351
        }
352
353 56
        return $remainingWeightAllowed;
354
    }
355
356
    /**
357
     * Compare two items to see if they have same dimensions.
358
     */
359 26
    protected static function isSameDimensions(Item $itemA, Item $itemB): bool
360
    {
361 26
        if ($itemA === $itemB) {
362 19
            return true;
363
        }
364 10
        $itemADimensions = [$itemA->getWidth(), $itemA->getLength(), $itemA->getDepth()];
365 10
        $itemBDimensions = [$itemB->getWidth(), $itemB->getLength(), $itemB->getDepth()];
366 10
        sort($itemADimensions);
367 10
        sort($itemBDimensions);
368
369 10
        return $itemADimensions === $itemBDimensions;
370
    }
371
}
372