Passed
Push — master ( 73ddcb...629397 )
by Doug
02:34
created

Packer::findBestBoxFromIteration()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 2

Importance

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