Passed
Push — 3.x ( ccf3ce...719869 )
by Doug
02:50
created

Packer   A

Complexity

Total Complexity 40

Size/Duplication

Total Lines 256
Duplicated Lines 0 %

Test Coverage

Coverage 99.01%

Importance

Changes 11
Bugs 0 Features 0
Metric Value
eloc 86
c 11
b 0
f 0
dl 0
loc 256
ccs 100
cts 101
cp 0.9901
rs 9.2
wmc 40

14 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 7 1
A addItem() 0 6 2
A setBoxes() 0 5 3
A setMaxBoxesToBalanceWeight() 0 3 1
A setBoxQuantity() 0 3 1
A addBox() 0 5 2
A getMaxBoxesToBalanceWeight() 0 3 1
A pack() 0 15 3
A compare() 0 12 3
A findBestBoxFromIteration() 0 9 2
A sanityPrecheck() 0 15 5
B doVolumePacking() 0 41 7
A getBoxList() 0 20 6
A setItems() 0 8 3

How to fix   Complexity   

Complex Class

Complex classes like Packer 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 Packer, 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 Psr\Log\LoggerAwareInterface;
13
use Psr\Log\LoggerAwareTrait;
14
use Psr\Log\LogLevel;
15
use Psr\Log\NullLogger;
16
use SplObjectStorage;
17
use function usort;
18
19
/**
20
 * Actual packer.
21
 *
22
 * @author Doug Wright
23
 */
24
class Packer implements LoggerAwareInterface
25
{
26
    use LoggerAwareTrait;
27
28
    /**
29
     * Number of boxes at which balancing weight is deemed not worth it.
30
     *
31
     * @var int
32
     */
33
    protected $maxBoxesToBalanceWeight = 12;
34
35
    /**
36
     * List of items to be packed.
37
     *
38
     * @var ItemList
39
     */
40
    protected $items;
41
42
    /**
43
     * List of box sizes available to pack items into.
44
     *
45
     * @var BoxList
46
     */
47
    protected $boxes;
48
49
    /**
50
     * Quantities available of each box type.
51
     *
52
     * @var SplObjectStorage
53
     */
54
    protected $boxesQtyAvailable;
55
56
    /**
57
     * Constructor.
58
     */
59 42
    public function __construct()
60
    {
61 42
        $this->items = new ItemList();
62 42
        $this->boxes = new BoxList();
63 42
        $this->boxesQtyAvailable = new SplObjectStorage();
64
65 42
        $this->logger = new NullLogger();
66 42
    }
67
68
    /**
69
     * Add item to be packed.
70
     */
71 40
    public function addItem(Item $item, int $qty = 1): void
72
    {
73 40
        for ($i = 0; $i < $qty; ++$i) {
74 40
            $this->items->insert($item);
75
        }
76 40
        $this->logger->log(LogLevel::INFO, "added {$qty} x {$item->getDescription()}", ['item' => $item]);
77 40
    }
78
79
    /**
80
     * Set a list of items all at once.
81
     * @param iterable|Item[] $items
82
     */
83 6
    public function setItems(iterable $items): void
84
    {
85 6
        if ($items instanceof ItemList) {
86
            $this->items = clone $items;
87
        } else {
88 6
            $this->items = new ItemList();
89 6
            foreach ($items as $item) {
90 6
                $this->items->insert($item);
91
            }
92
        }
93 6
    }
94
95
    /**
96
     * Add box size.
97
     */
98 38
    public function addBox(Box $box): void
99
    {
100 38
        $this->boxes->insert($box);
101 38
        $this->setBoxQuantity($box, $box instanceof LimitedSupplyBox ? $box->getQuantityAvailable() : PHP_INT_MAX);
102 38
        $this->logger->log(LogLevel::INFO, "added box {$box->getReference()}", ['box' => $box]);
103 38
    }
104
105
    /**
106
     * Add a pre-prepared set of boxes all at once.
107
     */
108 6
    public function setBoxes(BoxList $boxList): void
109
    {
110 6
        $this->boxes = $boxList;
111 6
        foreach ($this->boxes as $box) {
112 6
            $this->setBoxQuantity($box, $box instanceof LimitedSupplyBox ? $box->getQuantityAvailable() : PHP_INT_MAX);
113
        }
114 6
    }
115
116
    /**
117
     * Set the quantity of this box type available.
118
     */
119 38
    public function setBoxQuantity(Box $box, int $qty): void
120
    {
121 38
        $this->boxesQtyAvailable[$box] = $qty;
122 38
    }
123
124
    /**
125
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
126
     */
127 2
    public function getMaxBoxesToBalanceWeight(): int
128
    {
129 2
        return $this->maxBoxesToBalanceWeight;
130
    }
131
132
    /**
133
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
134
     */
135 4
    public function setMaxBoxesToBalanceWeight(int $maxBoxesToBalanceWeight): void
136
    {
137 4
        $this->maxBoxesToBalanceWeight = $maxBoxesToBalanceWeight;
138 4
    }
139
140
    /**
141
     * Pack items into boxes.
142
     */
143 40
    public function pack(): PackedBoxList
144
    {
145 40
        $this->sanityPrecheck();
146 36
        $packedBoxes = $this->doVolumePacking();
147
148
        //If we have multiple boxes, try and optimise/even-out weight distribution
149 34
        if ($packedBoxes->count() > 1 && $packedBoxes->count() <= $this->maxBoxesToBalanceWeight) {
150 16
            $redistributor = new WeightRedistributor($this->boxes, $this->boxesQtyAvailable);
151 16
            $redistributor->setLogger($this->logger);
152 16
            $packedBoxes = $redistributor->redistributeWeight($packedBoxes);
153
        }
154
155 34
        $this->logger->log(LogLevel::INFO, "[PACKING COMPLETED], {$packedBoxes->count()} boxes");
156
157 34
        return $packedBoxes;
158
    }
159
160
    /**
161
     * Pack items into boxes using the principle of largest volume item first.
162
     *
163
     * @throws NoBoxesAvailableException
164
     */
165 36
    public function doVolumePacking(bool $singlePassMode = false, bool $enforceSingleBox = false): PackedBoxList
166
    {
167 36
        $packedBoxes = new PackedBoxList();
168
169
        //Keep going until everything packed
170 36
        while ($this->items->count()) {
171 36
            $packedBoxesIteration = [];
172
173
            //Loop through boxes starting with smallest, see what happens
174 36
            foreach ($this->getBoxList($enforceSingleBox) as $box) {
175 36
                $volumePacker = new VolumePacker($box, $this->items);
176 36
                $volumePacker->setLogger($this->logger);
177 36
                $volumePacker->setSinglePassMode($singlePassMode);
178 36
                $packedBox = $volumePacker->pack();
179 36
                if ($packedBox->getItems()->count()) {
180 36
                    $packedBoxesIteration[] = $packedBox;
181
182
                    //Have we found a single box that contains everything?
183 36
                    if ($packedBox->getItems()->count() === $this->items->count()) {
184 34
                        break;
185
                    }
186
                }
187
            }
188
189
            try {
190
                //Find best box of iteration, and remove packed items from unpacked list
191 36
                $bestBox = $this->findBestBoxFromIteration($packedBoxesIteration);
192 4
            } catch (NoBoxesAvailableException $e) {
193 4
                if ($enforceSingleBox) {
194 2
                    return new PackedBoxList();
195
                }
196 2
                throw $e;
197
            }
198
199 36
            $this->items->removePackedItems($bestBox->getItems());
200
201 36
            $packedBoxes->insert($bestBox);
202 36
            $this->boxesQtyAvailable[$bestBox->getBox()] = $this->boxesQtyAvailable[$bestBox->getBox()] - 1;
203
        }
204
205 34
        return $packedBoxes;
206
    }
207
208
    /**
209
     * Get a "smart" ordering of the boxes to try packing items into. The initial BoxList is already sorted in order
210
     * so that the smallest boxes are evaluated first, but this means that time is spent on boxes that cannot possibly
211
     * hold the entire set of items due to volume limitations. These should be evaluated first.
212
     */
213 36
    protected function getBoxList(bool $enforceSingleBox = false): iterable
214
    {
215 36
        $itemVolume = 0;
216 36
        foreach ($this->items as $item) {
217 36
            $itemVolume += $item->getWidth() * $item->getLength() * $item->getDepth();
218
        }
219
220 36
        $preferredBoxes = [];
221 36
        $otherBoxes = [];
222 36
        foreach ($this->boxes as $box) {
223 36
            if ($this->boxesQtyAvailable[$box] > 0) {
224 36
                if ($box->getInnerWidth() * $box->getInnerLength() * $box->getInnerDepth() >= $itemVolume) {
225 34
                    $preferredBoxes[] = $box;
226 10
                } elseif (!$enforceSingleBox) {
227 10
                    $otherBoxes[] = $box;
228
                }
229
            }
230
        }
231
232 36
        return array_merge($preferredBoxes, $otherBoxes);
233
    }
234
235
    /**
236
     * @param PackedBox[] $packedBoxes
237
     */
238 36
    protected function findBestBoxFromIteration(array $packedBoxes): PackedBox
239
    {
240 36
        if (count($packedBoxes) === 0) {
241 4
            throw new NoBoxesAvailableException("No boxes could be found for item '{$this->items->top()->getDescription()}'", $this->items->top());
242
        }
243
244 36
        usort($packedBoxes, [$this, 'compare']);
245
246 36
        return $packedBoxes[0];
247
    }
248
249 40
    private function sanityPrecheck(): void
250
    {
251
        /** @var Item $item */
252 40
        foreach ($this->items as $item) {
253 40
            $possibleFits = 0;
254
255
            /** @var Box $box */
256 40
            foreach ($this->boxes as $box) {
257 38
                if ($item->getWeight() <= ($box->getMaxWeight() - $box->getEmptyWeight())) {
258 38
                    $possibleFits += count((new OrientatedItemFactory($box))->getPossibleOrientationsInEmptyBox($item));
259
                }
260
            }
261
262 40
            if ($possibleFits === 0) {
263 4
                throw new ItemTooLargeException("Item '{$item->getDescription()}' is too large to fit into any box", $item);
264
            }
265
        }
266 36
    }
267
268 6
    private static function compare(PackedBox $boxA, PackedBox $boxB): int
269
    {
270 6
        $choice = $boxB->getItems()->count() <=> $boxA->getItems()->count();
271
272 6
        if ($choice === 0) {
273 6
            $choice = $boxB->getVolumeUtilisation() <=> $boxA->getVolumeUtilisation();
274
        }
275 6
        if ($choice === 0) {
276 6
            $choice = $boxB->getUsedVolume() <=> $boxA->getUsedVolume();
277
        }
278
279 6
        return $choice;
280
    }
281
}
282