Passed
Push — refactor_prep ( b6784f )
by Doug
06:51
created

VolumePacker   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 346
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 33
Bugs 2 Features 0
Metric Value
eloc 127
dl 0
loc 346
ccs 136
cts 136
cp 1
rs 8.96
c 33
b 2
f 0
wmc 43

15 Methods

Rating   Name   Duplication   Size   Complexity  
A setLookAheadMode() 0 3 1
A getPackedItemList() 0 10 3
A __construct() 0 16 3
A rotateLayersNinetyDegrees() 0 9 3
A getRemainingWeightAllowed() 0 8 2
A getOrientationForItem() 0 29 3
A stabiliseLayers() 0 5 1
A getCurrentPackedDepth() 0 8 2
A pack() 0 20 4
C packLayer() 0 68 13
A setLogger() 0 4 1
A rebuildItemList() 0 4 1
A checkNonDimensionalConstraints() 0 8 3
A isSameDimensions() 0 11 2
A hasItemsLeftToPack() 0 3 1

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