Passed
Push — master ( b79dbd...29a054 )
by Doug
08:25 queued 05:38
created

Packer::setItems()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
eloc 6
nc 3
nop 1
dl 0
loc 8
ccs 6
cts 6
cp 1
crap 3
rs 10
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 array_merge;
12
use function count;
13
use DVDoug\BoxPacker\Exception\NoBoxesAvailableException;
14
use const PHP_INT_MAX;
15
use Psr\Log\LoggerAwareInterface;
16
use Psr\Log\LoggerAwareTrait;
17
use Psr\Log\LogLevel;
18
use Psr\Log\NullLogger;
19
use function spl_object_id;
20
use function usort;
21
22
/**
23
 * Actual packer.
24
 *
25
 * @author Doug Wright
26
 */
27
class Packer implements LoggerAwareInterface
28
{
29
    use LoggerAwareTrait;
30
31
    /**
32
     * Number of boxes at which balancing weight is deemed not worth it.
33
     *
34
     * @var int
35
     */
36
    protected $maxBoxesToBalanceWeight = 12;
37
38
    /**
39
     * List of items to be packed.
40
     *
41
     * @var ItemList
42
     */
43
    protected $items;
44
45
    /**
46
     * List of box sizes available to pack items into.
47
     *
48
     * @var BoxList
49
     */
50
    protected $boxes;
51
52
    /**
53
     * Quantities available of each box type.
54
     *
55
     * @var array<int, int>
56
     */
57
    protected $boxesQtyAvailable = [];
58
59
    /**
60
     * Constructor.
61
     */
62 50
    public function __construct()
63
    {
64 50
        $this->items = new ItemList();
65 50
        $this->boxes = new BoxList();
66
67 50
        $this->logger = new NullLogger();
68 50
    }
69
70
    /**
71
     * Add item to be packed.
72
     */
73 46
    public function addItem(Item $item, int $qty = 1): void
74
    {
75 46
        $this->items->insert($item, $qty);
76 46
        $this->logger->log(LogLevel::INFO, "added {$qty} x {$item->getDescription()}", ['item' => $item]);
0 ignored issues
show
Bug introduced by
The method log() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

76
        $this->logger->/** @scrutinizer ignore-call */ 
77
                       log(LogLevel::INFO, "added {$qty} x {$item->getDescription()}", ['item' => $item]);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
77 46
    }
78
79
    /**
80
     * Set a list of items all at once.
81
     * @param iterable<Item>|ItemList $items
82
     */
83 8
    public function setItems(iterable $items): void
84
    {
85 8
        if ($items instanceof ItemList) {
86 2
            $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 8
    }
94
95
    /**
96
     * Add box size.
97
     */
98 46
    public function addBox(Box $box): void
99
    {
100 46
        $this->boxes->insert($box);
101 46
        $this->setBoxQuantity($box, $box instanceof LimitedSupplyBox ? $box->getQuantityAvailable() : PHP_INT_MAX);
102 46
        $this->logger->log(LogLevel::INFO, "added box {$box->getReference()}", ['box' => $box]);
103 46
    }
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 46
    public function setBoxQuantity(Box $box, int $qty): void
120
    {
121 46
        $this->boxesQtyAvailable[spl_object_id($box)] = $qty;
122 46
    }
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 6
    public function setMaxBoxesToBalanceWeight(int $maxBoxesToBalanceWeight): void
136
    {
137 6
        $this->maxBoxesToBalanceWeight = $maxBoxesToBalanceWeight;
138 6
    }
139
140
    /**
141
     * Pack items into boxes.
142
     */
143 48
    public function pack(): PackedBoxList
144
    {
145 48
        $packedBoxes = $this->doVolumePacking();
146
147
        //If we have multiple boxes, try and optimise/even-out weight distribution
148 42
        if ($packedBoxes->count() > 1 && $packedBoxes->count() <= $this->maxBoxesToBalanceWeight) {
149 14
            $redistributor = new WeightRedistributor($this->boxes, $this->boxesQtyAvailable);
150 14
            $redistributor->setLogger($this->logger);
0 ignored issues
show
Bug introduced by
It seems like $this->logger can also be of type null; however, parameter $logger of DVDoug\BoxPacker\WeightRedistributor::setLogger() does only seem to accept Psr\Log\LoggerInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

150
            $redistributor->setLogger(/** @scrutinizer ignore-type */ $this->logger);
Loading history...
151 14
            $packedBoxes = $redistributor->redistributeWeight($packedBoxes);
152
        }
153
154 42
        $this->logger->log(LogLevel::INFO, "[PACKING COMPLETED], {$packedBoxes->count()} boxes");
155
156 42
        return $packedBoxes;
157
    }
158
159
    /**
160
     * Pack items into boxes using the principle of largest volume item first.
161
     *
162
     * @throws NoBoxesAvailableException
163
     */
164 48
    public function doVolumePacking(bool $singlePassMode = false, bool $enforceSingleBox = false): PackedBoxList
165
    {
166 48
        $packedBoxes = new PackedBoxList();
167
168
        //Keep going until everything packed
169 48
        while ($this->items->count()) {
170 48
            $packedBoxesIteration = [];
171
172
            //Loop through boxes starting with smallest, see what happens
173 48
            foreach ($this->getBoxList($enforceSingleBox) as $box) {
174 46
                $volumePacker = new VolumePacker($box, $this->items);
175 46
                $volumePacker->setLogger($this->logger);
0 ignored issues
show
Bug introduced by
It seems like $this->logger can also be of type null; however, parameter $logger of DVDoug\BoxPacker\VolumePacker::setLogger() does only seem to accept Psr\Log\LoggerInterface, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

175
                $volumePacker->setLogger(/** @scrutinizer ignore-type */ $this->logger);
Loading history...
176 46
                $volumePacker->setSinglePassMode($singlePassMode);
177 46
                $packedBox = $volumePacker->pack();
178 46
                if ($packedBox->getItems()->count()) {
179 44
                    $packedBoxesIteration[] = $packedBox;
180
181
                    //Have we found a single box that contains everything?
182 44
                    if ($packedBox->getItems()->count() === $this->items->count()) {
183 40
                        break;
184
                    }
185
                }
186
            }
187
188
            try {
189
                //Find best box of iteration, and remove packed items from unpacked list
190 48
                $bestBox = $this->findBestBoxFromIteration($packedBoxesIteration);
191 10
            } catch (NoBoxesAvailableException $e) {
192 10
                if ($enforceSingleBox) {
193 2
                    return new PackedBoxList();
194
                }
195 8
                throw $e;
196
            }
197
198 44
            $this->items->removePackedItems($bestBox->getItems());
199
200 44
            $packedBoxes->insert($bestBox);
201 44
            --$this->boxesQtyAvailable[spl_object_id($bestBox->getBox())];
202
        }
203
204 42
        return $packedBoxes;
205
    }
206
207
    /**
208
     * Get a "smart" ordering of the boxes to try packing items into. The initial BoxList is already sorted in order
209
     * so that the smallest boxes are evaluated first, but this means that time is spent on boxes that cannot possibly
210
     * hold the entire set of items due to volume limitations. These should be evaluated first.
211
     *
212
     * @return iterable<Box>
213
     */
214 48
    protected function getBoxList(bool $enforceSingleBox = false): iterable
215
    {
216 48
        $itemVolume = 0;
217 48
        foreach ($this->items as $item) {
218 48
            $itemVolume += $item->getWidth() * $item->getLength() * $item->getDepth();
219
        }
220
221 48
        $preferredBoxes = [];
222 48
        $otherBoxes = [];
223 48
        foreach ($this->boxes as $box) {
224 46
            if ($this->boxesQtyAvailable[spl_object_id($box)] > 0) {
225 46
                if ($box->getInnerWidth() * $box->getInnerLength() * $box->getInnerDepth() >= $itemVolume) {
226 40
                    $preferredBoxes[] = $box;
227 18
                } elseif (!$enforceSingleBox) {
228 18
                    $otherBoxes[] = $box;
229
                }
230
            }
231
        }
232
233 48
        return array_merge($preferredBoxes, $otherBoxes);
234
    }
235
236
    /**
237
     * @param PackedBox[] $packedBoxes
238
     */
239 48
    protected function findBestBoxFromIteration(array $packedBoxes): PackedBox
240
    {
241 48
        if (count($packedBoxes) === 0) {
242 10
            throw new NoBoxesAvailableException("No boxes could be found for item '{$this->items->top()->getDescription()}'", $this->items->top());
243
        }
244
245 44
        usort($packedBoxes, [$this, 'compare']);
246
247 44
        return $packedBoxes[0];
248
    }
249
250 6
    private static function compare(PackedBox $boxA, PackedBox $boxB): int
251
    {
252 6
        $choice = $boxB->getItems()->count() <=> $boxA->getItems()->count();
253
254 6
        if ($choice === 0) {
255 6
            $choice = $boxB->getVolumeUtilisation() <=> $boxA->getVolumeUtilisation();
256
        }
257 6
        if ($choice === 0) {
258 6
            $choice = $boxB->getUsedVolume() <=> $boxA->getUsedVolume();
259
        }
260
261 6
        return $choice;
262
    }
263
}
264