Completed
Push — master ( 4b02cd...33e086 )
by Doug
14:15
created

VolumePacker   C

Complexity

Total Complexity 53

Size/Duplication

Total Lines 360
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 50
Bugs 2 Features 0
Metric Value
eloc 153
c 50
b 2
f 0
dl 0
loc 360
ccs 158
cts 158
cp 1
rs 6.96
wmc 53

13 Methods

Rating   Name   Duplication   Size   Complexity  
A setLookAheadMode() 0 3 1
A getPackedItemList() 0 10 3
A __construct() 0 11 1
A rotateLayersNinetyDegrees() 0 13 3
A getOrientationForItem() 0 29 3
A getRemainingWeightAllowed() 0 8 2
A stabiliseLayers() 0 5 1
A getCurrentPackedDepth() 0 8 2
C pack() 0 63 12
D packLayer() 0 87 20
A setLogger() 0 4 1
A checkNonDimensionalConstraints() 0 3 2
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 ($itemToPack->getWeight() > $remainingWeightAllowed || !$this->checkNonDimensionalConstraints($itemToPack, $layers)) {
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 && $itemToTryStacking->getWeight() <= $remainingWeightAllowed && $this->checkNonDimensionalConstraints($itemToTryStacking, $layers)) {
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): bool
306
    {
307 53
        return !$itemToPack instanceof ConstrainedItem || $customConstraintsOK = $itemToPack->canBePackedInBox($this->getPackedItemList($layers), $this->box);
0 ignored issues
show
Unused Code introduced by
The assignment to $customConstraintsOK is dead and can be removed.
Loading history...
308
    }
309
310
    /**
311
     * Swap back width/length of the packed items to match orientation of the box if needed.
312
     * @param PackedLayer[] $oldLayers
313
     */
314 19
    protected static function rotateLayersNinetyDegrees($oldLayers): array
315
    {
316 19
        $newLayers = [];
317 19
        foreach ($oldLayers as $originalLayer) {
318 18
            $newLayer = new PackedLayer();
319 18
            foreach ($originalLayer->getItems() as $item) {
320 18
                $packedItem = new PackedItem($item->getItem(), $item->getY(), $item->getX(), $item->getZ(), $item->getLength(), $item->getWidth(), $item->getDepth());
321 18
                $newLayer->insert($packedItem);
322
            }
323 18
            $newLayers[] = $newLayer;
324
        }
325
326 19
        return $newLayers;
327
    }
328
329
    /**
330
     * Generate a single list of items packed.
331
     */
332 53
    protected function getPackedItemList(array $layers): PackedItemList
333
    {
334 53
        $packedItemList = new PackedItemList();
335 53
        foreach ($layers as $layer) {
336 53
            foreach ($layer->getItems() as $packedItem) {
337 53
                $packedItemList->insert($packedItem);
338
            }
339
        }
340
341 53
        return $packedItemList;
342
    }
343
344
    /**
345
     * Return the current packed depth.
346
     * @param PackedLayer[] $layers
347
     */
348 53
    protected static function getCurrentPackedDepth(array $layers): int
349
    {
350 53
        $depth = 0;
351 53
        foreach ($layers as $layer) {
352 41
            $depth += $layer->getDepth();
353
        }
354
355 53
        return $depth;
356
    }
357
358 53
    private function getRemainingWeightAllowed(array $layers): int
359
    {
360 53
        $remainingWeightAllowed = $this->box->getMaxWeight() - $this->box->getEmptyWeight();
361 53
        foreach ($layers as $layer) {
362 53
            $remainingWeightAllowed -= $layer->getWeight();
363
        }
364
365 53
        return $remainingWeightAllowed;
366
    }
367
368
    /**
369
     * Compare two items to see if they have same dimensions.
370
     */
371 25
    protected static function isSameDimensions(Item $itemA, Item $itemB): bool
372
    {
373 25
        if ($itemA === $itemB) {
374 18
            return true;
375
        }
376 9
        $itemADimensions = [$itemA->getWidth(), $itemA->getLength(), $itemA->getDepth()];
377 9
        $itemBDimensions = [$itemB->getWidth(), $itemB->getLength(), $itemB->getDepth()];
378 9
        sort($itemADimensions);
379 9
        sort($itemBDimensions);
380
381 9
        return $itemADimensions === $itemBDimensions;
382
    }
383
}
384