Passed
Push — master ( ceb4db...15ba9d )
by Doug
05:20
created

VolumePacker::packRotation()   B

Complexity

Conditions 7
Paths 8

Size

Total Lines 35
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 19
CRAP Score 7

Importance

Changes 0
Metric Value
cc 7
eloc 19
nc 8
nop 2
dl 0
loc 35
ccs 19
cts 19
cp 1
crap 7
rs 8.8333
c 0
b 0
f 0
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
186 56
        while ($items->count() > 0) {
187 56
            $itemToPack = $items->extract();
188
189
            //skip items that are simply too heavy or too large
190 56
            if (!$this->checkNonDimensionalConstraints($itemToPack, $layers, $remainingWeightAllowed)) {
191 7
                continue;
192
            }
193
194 56
            $orientatedItem = $this->getOrientationForItem($itemToPack, $prevItem, $items, $layers, $layerWidth - $x, $lengthLeft, $depthLeft, $rowLength, $x, $y, $z);
195
196 56
            if ($orientatedItem instanceof OrientatedItem) {
197 56
                $packedItem = PackedItem::fromOrientatedItem($orientatedItem, $x, $y, $z);
198 56
                $layer->insert($packedItem);
199 56
                $remainingWeightAllowed -= $itemToPack->getWeight();
200
201 56
                $rowLength = max($rowLength, $orientatedItem->getLength());
202
203
                //Figure out if we can stack the next item vertically on top of this rather than side by side
204
                //e.g. when we've packed a tall item, and have just put a shorter one next to it.
205 56
                $stackableDepth = ($guidelineLayerDepth ?: $layer->getDepth()) - $orientatedItem->getDepth();
206 56
                $stackedZ = $z + $orientatedItem->getDepth();
207 56
                $stackSkippedItems = [];
208 56
                while ($stackableDepth > 0 && $items->count() > 0) {
209 17
                    $itemToTryStacking = $items->extract();
210 17
                    $stackedItem = $this->getOrientationForItem($itemToTryStacking, $prevItem, $items, $layers, $orientatedItem->getWidth(), $orientatedItem->getLength(), $stackableDepth, $rowLength, $x, $y, $stackedZ);
211 17
                    if ($stackedItem && $this->checkNonDimensionalConstraints($itemToTryStacking, $layers, $remainingWeightAllowed)) {
212 11
                        $layer->insert(PackedItem::fromOrientatedItem($stackedItem, $x, $y, $stackedZ));
213 11
                        $remainingWeightAllowed -= $itemToTryStacking->getWeight();
214 11
                        $stackableDepth -= $stackedItem->getDepth();
215 11
                        $stackedZ += $stackedItem->getDepth();
216
                    } else {
217 13
                        $stackSkippedItems[] = $itemToTryStacking;
218
                        // 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
219 13
                        while ($items->count() > 0 && static::isSameDimensions($itemToTryStacking, $items->top())) {
220 10
                            $stackSkippedItems[] = $items->extract();
221
                        }
222
                    }
223
                }
224 56
                if ($stackSkippedItems) {
225 13
                    $items = ItemList::fromArray(array_merge($stackSkippedItems, iterator_to_array($items)), true);
226
                }
227 56
                $x += $orientatedItem->getWidth();
228
229 56
                $prevItem = $packedItem;
230 56
                if ($items->count() === 0) {
231 53
                    $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
232 56
                    $skippedItems = [];
233
                }
234 50
            } elseif (count($layer->getItems()) === 0) { // zero items on layer
235 35
                $this->logger->debug("doesn't fit on layer even when empty, skipping for good");
236 35
                continue;
237 49
            } elseif ($items->count() > 0) { // skip for now, move on to the next item
238 41
                $this->logger->debug("doesn't fit, skipping for now");
239 41
                $skippedItems[] = $itemToPack;
240
                // 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
241 41
                while ($items->count() > 2 && static::isSameDimensions($itemToPack, $items->top())) {
242 22
                    $skippedItems[] = $items->extract();
243
                }
244 49
            } elseif ($x > 0) {
245 49
                $this->logger->debug('No more fit in width wise, resetting for new row');
246 49
                $lengthLeft -= $rowLength;
247 49
                $y += $rowLength;
248 49
                $x = $rowLength = 0;
249 49
                $skippedItems[] = $itemToPack;
250 49
                $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
251 49
                $skippedItems = [];
252 49
                $prevItem = null;
253 49
                continue;
254
            } else {
255 43
                $this->logger->debug('no items fit, so starting next vertical layer');
256 43
                $skippedItems[] = $itemToPack;
257
258 43
                $items = ItemList::fromArray(array_merge($skippedItems, iterator_to_array($items)), true);
259
260 43
                return $layer;
261
            }
262
        }
263
264 56
        return $layer;
265
    }
266
267
    /**
268
     * During packing, it is quite possible that layers have been created that aren't physically stable
269
     * i.e. they overhang the ones below.
270
     *
271
     * This function reorders them so that the ones with the greatest surface area are placed at the bottom
272
     * @param PackedLayer[] $layers
273
     */
274 56
    protected static function stabiliseLayers(array $layers): array
275
    {
276 56
        $stabiliser = new LayerStabiliser();
277
278 56
        return $stabiliser->stabilise($layers);
279
    }
280
281 56
    protected function getOrientationForItem(
282
        Item $itemToPack,
283
        ?PackedItem $prevItem,
284
        ItemList $nextItems,
285
        array $layers,
286
        int $maxWidth,
287
        int $maxLength,
288
        int $maxDepth,
289
        int $rowLength,
290
        int $x,
291
        int $y,
292
        int $z
293
    ): ?OrientatedItem {
294 56
        $this->logger->debug(
295 56
            "evaluating item {$itemToPack->getDescription()} for fit",
296
            [
297 56
                'item' => $itemToPack,
298
                'space' => [
299 56
                    'maxWidth' => $maxWidth,
300 56
                    'maxLength' => $maxLength,
301 56
                    'maxDepth' => $maxDepth,
302
                ],
303
            ]
304
        );
305
306 56
        $prevOrientatedItem = $prevItem ? $prevItem->toOrientatedItem() : null;
307 56
        $prevPackedItemList = $itemToPack instanceof ConstrainedPlacementItem ? $this->getPackedItemList($layers) : new PackedItemList(); // don't calculate it if not going to be used
308
309 56
        return $this->orientatedItemFactory->getBestOrientation($itemToPack, $prevOrientatedItem, $nextItems, $maxWidth, $maxLength, $maxDepth, $rowLength, $x, $y, $z, $prevPackedItemList, $this->singlePassMode);
310
    }
311
312
    /**
313
     * As well as purely dimensional constraints, there are other constraints that need to be met
314
     * e.g. weight limits or item-specific restrictions (e.g. max <x> batteries per box).
315
     */
316 56
    protected function checkNonDimensionalConstraints(Item $itemToPack, array $layers, int $remainingWeightAllowed): bool
317
    {
318 56
        $customConstraintsOK = true;
319 56
        if ($itemToPack instanceof ConstrainedItem) {
320 1
            $customConstraintsOK = $itemToPack->canBePackedInBox($this->getPackedItemList($layers), $this->box);
321
        }
322
323 56
        return $customConstraintsOK && $itemToPack->getWeight() <= $remainingWeightAllowed;
324
    }
325
326
    /**
327
     * Swap back width/length of the packed items to match orientation of the box if needed.
328
     * @param PackedLayer[] $oldLayers
329
     */
330 8
    protected static function rotateLayersNinetyDegrees($oldLayers): array
331
    {
332 8
        $newLayers = [];
333 8
        foreach ($oldLayers as $originalLayer) {
334 8
            $newLayer = new PackedLayer();
335 8
            foreach ($originalLayer->getItems() as $item) {
336 8
                $packedItem = new PackedItem($item->getItem(), $item->getY(), $item->getX(), $item->getZ(), $item->getLength(), $item->getWidth(), $item->getDepth());
337 8
                $newLayer->insert($packedItem);
338
            }
339 8
            $newLayers[] = $newLayer;
340
        }
341
342 8
        return $newLayers;
343
    }
344
345
    /**
346
     * Generate a single list of items packed.
347
     */
348 56
    protected function getPackedItemList(array $layers): PackedItemList
349
    {
350 56
        $packedItemList = new PackedItemList();
351 56
        foreach ($layers as $layer) {
352 56
            foreach ($layer->getItems() as $packedItem) {
353 56
                $packedItemList->insert($packedItem);
354
            }
355
        }
356
357 56
        return $packedItemList;
358
    }
359
360
    /**
361
     * Return the current packed depth.
362
     * @param PackedLayer[] $layers
363
     */
364 56
    protected static function getCurrentPackedDepth(array $layers): int
365
    {
366 56
        $depth = 0;
367 56
        foreach ($layers as $layer) {
368 43
            $depth += $layer->getDepth();
369
        }
370
371 56
        return $depth;
372
    }
373
374 56
    private function getRemainingWeightAllowed(array $layers): int
375
    {
376 56
        $remainingWeightAllowed = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
377 56
        foreach ($layers as $layer) {
378 56
            $remainingWeightAllowed -= $layer->getWeight();
379
        }
380
381 56
        return $remainingWeightAllowed;
382
    }
383
384
    /**
385
     * Compare two items to see if they have same dimensions.
386
     */
387 26
    protected static function isSameDimensions(Item $itemA, Item $itemB): bool
388
    {
389 26
        if ($itemA === $itemB) {
390 19
            return true;
391
        }
392 10
        $itemADimensions = [$itemA->getWidth(), $itemA->getLength(), $itemA->getDepth()];
393 10
        $itemBDimensions = [$itemB->getWidth(), $itemB->getLength(), $itemB->getDepth()];
394 10
        sort($itemADimensions);
395 10
        sort($itemBDimensions);
396
397 10
        return $itemADimensions === $itemBDimensions;
398
    }
399
}
400