Completed
Push — master ( 33e086...69c82e )
by Doug
08:36
created

VolumePacker   B

Complexity

Total Complexity 52

Size/Duplication

Total Lines 365
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 50
Bugs 2 Features 0
Metric Value
eloc 156
c 50
b 2
f 0
dl 0
loc 365
ccs 161
cts 161
cp 1
rs 7.44
wmc 52

13 Methods

Rating   Name   Duplication   Size   Complexity  
A setLookAheadMode() 0 3 1
A __construct() 0 11 1
A getOrientationForItem() 0 29 3
A stabiliseLayers() 0 5 1
C pack() 0 63 12
A setLogger() 0 4 1
A getPackedItemList() 0 10 3
A rotateLayersNinetyDegrees() 0 13 3
A getRemainingWeightAllowed() 0 8 2
A getCurrentPackedDepth() 0 8 2
D packLayer() 0 87 18
A checkNonDimensionalConstraints() 0 8 3
A isSameDimensions() 0 11 2

How to fix   Complexity   

Complex Class

Complex classes like VolumePacker often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use VolumePacker, and based on these observations, apply Extract Interface, too.

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