Passed
Push — 3.x ( d2d64a...d739e8 )
by Doug
02:33
created

Packer::setLogger()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

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