Passed
Pull Request — master (#198)
by
unknown
06:07
created

Packer::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 4
c 0
b 0
f 0
nc 1
nop 0
dl 0
loc 7
ccs 5
cts 5
cp 1
crap 1
rs 10
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 31
    public function __construct()
60
    {
61 31
        $this->items = new ItemList();
62 31
        $this->boxes = new BoxList();
63 31
        $this->boxesQtyAvailable = new SplObjectStorage();
64
65 31
        $this->logger = new NullLogger();
66 31
    }
67
68
    /**
69
     * Add item to be packed.
70
     */
71 23
    public function addItem(Item $item, int $qty = 1): void
72
    {
73 23
        for ($i = 0; $i < $qty; ++$i) {
74 23
            $this->items->insert($item);
75
        }
76 23
        $this->logger->log(LogLevel::INFO, "added {$qty} x {$item->getDescription()}", ['item' => $item]);
77 23
    }
78
79
    /**
80
     * Set a list of items all at once.
81
     * @param iterable|Item[] $items
82
     */
83 13
    public function setItems(iterable $items): void
84
    {
85 13
        if ($items instanceof ItemList) {
86 8
            $this->items = clone $items;
87
        } else {
88 5
            $this->items = new ItemList();
89 5
            foreach ($items as $item) {
90 5
                $this->items->insert($item);
91
            }
92
        }
93 13
    }
94
95
    /**
96
     * Add box size.
97
     */
98 22
    public function addBox(Box $box): void
99
    {
100 22
        $this->boxes->insert($box);
101 22
        $this->setBoxQuantity($box, $box instanceof LimitedSupplyBox ? $box->getQuantityAvailable() : PHP_INT_MAX);
102 22
        $this->logger->log(LogLevel::INFO, "added box {$box->getReference()}", ['box' => $box]);
103 22
    }
104
105
    /**
106
     * Add a pre-prepared set of boxes all at once.
107
     */
108 12
    public function setBoxes(BoxList $boxList): void
109
    {
110 12
        $this->boxes = $boxList;
111 12
        foreach ($this->boxes as $box) {
112 12
            $this->setBoxQuantity($box, $box instanceof LimitedSupplyBox ? $box->getQuantityAvailable() : PHP_INT_MAX);
113
        }
114 12
    }
115
116
    /**
117
     * Set the quantity of this box type available.
118
     */
119 29
    public function setBoxQuantity(Box $box, int $qty): void
120
    {
121 29
        $this->boxesQtyAvailable[$box] = $qty;
122 29
    }
123
124
    /**
125
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
126
     */
127 1
    public function getMaxBoxesToBalanceWeight(): int
128
    {
129 1
        return $this->maxBoxesToBalanceWeight;
130
    }
131
132
    /**
133
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
134
     */
135 2
    public function setMaxBoxesToBalanceWeight(int $maxBoxesToBalanceWeight): void
136
    {
137 2
        $this->maxBoxesToBalanceWeight = $maxBoxesToBalanceWeight;
138 2
    }
139
140
    /**
141
     * Pack items into boxes.
142
     */
143 30
    public function pack(): PackedBoxList
144
    {
145 30
        $this->sanityPrecheck();
146 28
        $packedBoxes = $this->doVolumePacking();
147
148
        //If we have multiple boxes, try and optimise/even-out weight distribution
149 27
        if ($packedBoxes->count() > 1 && $packedBoxes->count() <= $this->maxBoxesToBalanceWeight) {
150 11
            $redistributor = new WeightRedistributor($this->boxes, $this->boxesQtyAvailable);
151 11
            $redistributor->setLogger($this->logger);
152 11
            $packedBoxes = $redistributor->redistributeWeight($packedBoxes);
153
        }
154
155 27
        $this->logger->log(LogLevel::INFO, "[PACKING COMPLETED], {$packedBoxes->count()} boxes");
156
157 27
        return $packedBoxes;
158
    }
159
160
    /**
161
     * Pack items into boxes using the principle of largest volume item first.
162
     *
163
     * @throws NoBoxesAvailableException
164
     */
165 28
    public function doVolumePacking(bool $singlePassMode = false): PackedBoxList
166
    {
167 28
        $packedBoxes = new PackedBoxList();
168
169
        //Keep going until everything packed
170 28
        while ($this->items->count()) {
171 27
            $packedBoxesIteration = [];
172
173
            //Loop through boxes starting with smallest, see what happens
174 27
            foreach ($this->getBoxList() as $box) {
175 27
                if ($this->boxesQtyAvailable[$box] > 0) {
176 27
                    $volumePacker = new VolumePacker($box, $this->items);
177 27
                    $volumePacker->setLogger($this->logger);
178 27
                    $volumePacker->setSinglePassMode($singlePassMode);
179 27
                    $packedBox = $volumePacker->pack();
180 27
                    if ($packedBox->getItems()->count()) {
181 27
                        $packedBoxesIteration[] = $packedBox;
182
183
                        //Have we found a single box that contains everything?
184 27
                        if ($packedBox->getItems()->count() === $this->items->count()) {
185 26
                            break;
186
                        }
187
                    }
188
                }
189
            }
190
191
            //Find best box of iteration, and remove packed items from unpacked list
192 27
            $bestBox = $this->findBestBoxFromIteration($packedBoxesIteration);
193
194
            /** @var PackedItem $packedItem */
195 27
            foreach ($bestBox->getItems() as $packedItem) {
196 27
                $this->items->remove($packedItem->getItem());
197
            }
198
199 27
            $packedBoxes->insert($bestBox);
200 27
            $this->boxesQtyAvailable[$bestBox->getBox()] = $this->boxesQtyAvailable[$bestBox->getBox()] - 1;
201
        }
202
203 27
        return $packedBoxes;
204
    }
205
206
    /**
207
     * Pack all items into a single box if possible, otherwise return an empty list.
208
     *
209
     * @throws NoBoxesAvailableException
210
     */
211 5
    public function doVolumePackingSingleBox(): PackedBoxList
212
    {
213 5
        $packedBoxes = new PackedBoxList();
214
215 5
        $itemVolume = 0;
216
        /** @var Item $item */
217 5
        foreach ($this->items as $item) {
218 5
            $itemVolume += $item->getWidth() * $item->getLength() * $item->getDepth();
219
        }
220
221
        // Find smallest box that will hold all items
222 5
        foreach ($this->boxes as $box) {
223
            // Skip boxes that are not available
224 5
            if ($this->boxesQtyAvailable[$box] < 1) {
225
                continue;
226
            }
227
228
            // Skip boxes that are obviously too small
229 5
            if ($box->getInnerLength() * $box->getInnerWidth() * $box->getInnerDepth() < $itemVolume) {
230 3
                continue;
231
            }
232
233
            // Attempt to pack all items into one box
234 4
            $volumePacker = new VolumePacker($box, $this->items);
235 4
            $volumePacker->setLogger($this->logger);
236 4
            $volumePacker->setSinglePassMode(true);
237 4
            $packedBox = $volumePacker->pack();
238 4
            if ($packedBox->getItems()->count() === $this->items->count()) {
239 4
                $packedBoxes->insert($packedBox);
240 4
                $this->boxesQtyAvailable[$packedBox->getBox()] = $this->boxesQtyAvailable[$packedBox->getBox()] - 1;
241 4
                break;
242
            }
243
        }
244
245 5
        return $packedBoxes;
246
    }
247
248
    /**
249
     * Get a "smart" ordering of the boxes to try packing items into. The initial BoxList is already sorted in order
250
     * so that the smallest boxes are evaluated first, but this means that time is spent on boxes that cannot possibly
251
     * hold the entire set of items due to volume limitations. These should be evaluated first.
252
     */
253 27
    protected function getBoxList(): iterable
254
    {
255 27
        $itemVolume = 0;
256
        /** @var Item $item */
257 27
        foreach ($this->items as $item) {
258 27
            $itemVolume += $item->getWidth() * $item->getLength() * $item->getDepth();
259
        }
260
261 27
        $preferredBoxes = [];
262 27
        $otherBoxes = [];
263
        /** @var Box $box */
264 27
        foreach ($this->boxes as $box) {
265 27
            if ($box->getInnerWidth() * $box->getInnerLength() * $box->getInnerDepth() >= $itemVolume) {
266 27
                $preferredBoxes[] = $box;
267
            } else {
268 11
                $otherBoxes[] = $box;
269
            }
270
        }
271
272 27
        return array_merge($preferredBoxes, $otherBoxes);
273
    }
274
275
    /**
276
     * @param PackedBox[] $packedBoxes
277
     */
278 27
    protected function findBestBoxFromIteration(array $packedBoxes): PackedBox
279
    {
280 27
        if (count($packedBoxes) === 0) {
281 1
            throw new NoBoxesAvailableException("No boxes could be found for item '{$this->items->top()->getDescription()}'", $this->items->top());
282
        }
283
284 27
        usort($packedBoxes, [$this, 'compare']);
285
286 27
        return $packedBoxes[0];
287
    }
288
289 30
    private function sanityPrecheck(): void
290
    {
291
        /** @var Item $item */
292 30
        foreach ($this->items as $item) {
293 30
            $possibleFits = 0;
294
295
            /** @var Box $box */
296 30
            foreach ($this->boxes as $box) {
297 29
                if ($item->getWeight() <= ($box->getMaxWeight() - $box->getEmptyWeight())) {
298 29
                    $possibleFits += count((new OrientatedItemFactory($box))->getPossibleOrientationsInEmptyBox($item));
299
                }
300
            }
301
302 30
            if ($possibleFits === 0) {
303 5
                throw new ItemTooLargeException("Item '{$item->getDescription()}' is too large to fit into any box", $item);
304
            }
305
        }
306 28
    }
307
308 7
    private static function compare(PackedBox $boxA, PackedBox $boxB): int
309
    {
310 7
        $choice = $boxB->getItems()->count() <=> $boxA->getItems()->count();
311
312 7
        if ($choice === 0) {
313 5
            $choice = $boxB->getVolumeUtilisation() <=> $boxA->getVolumeUtilisation();
314
        }
315 7
        if ($choice === 0) {
316 4
            $choice = $boxB->getUsedVolume() <=> $boxA->getUsedVolume();
317
        }
318
319 7
        return $choice;
320
    }
321
}
322