Completed
Push — issue_187 ( fb1b49...8952f9 )
by Doug
158:46 queued 155:58
created

Packer::sanityPrecheck()   A

Complexity

Conditions 5
Paths 7

Size

Total Lines 15
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 5

Importance

Changes 0
Metric Value
cc 5
eloc 7
c 0
b 0
f 0
nc 7
nop 0
dl 0
loc 15
ccs 8
cts 8
cp 1
crap 5
rs 9.6111
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_shift;
12
use function count;
13
use Psr\Log\LoggerAwareInterface;
14
use Psr\Log\LoggerAwareTrait;
15
use Psr\Log\LogLevel;
16
use Psr\Log\NullLogger;
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
     * Constructor.
51
     */
52 8
    public function __construct()
53
    {
54 8
        $this->items = new ItemList();
55 8
        $this->boxes = new BoxList();
56
57 8
        $this->logger = new NullLogger();
58 8
    }
59
60
    /**
61
     * Add item to be packed.
62
     */
63 1
    public function addItem(Item $item, int $qty = 1): void
64
    {
65 1
        for ($i = 0; $i < $qty; ++$i) {
66 1
            $this->items->insert($item);
67
        }
68 1
        $this->logger->log(LogLevel::INFO, "added {$qty} x {$item->getDescription()}", ['item' => $item]);
69 1
    }
70
71
    /**
72
     * Set a list of items all at once.
73
     */
74 7
    public function setItems(iterable $items): void
75
    {
76 7
        if ($items instanceof ItemList) {
77 7
            $this->items = clone $items;
78
        } else {
79
            $this->items = new ItemList();
80
            foreach ($items as $item) {
81
                $this->items->insert($item);
82
            }
83
        }
84 7
    }
85
86
    /**
87
     * Add box size.
88
     */
89 1
    public function addBox(Box $box): void
90
    {
91 1
        $this->boxes->insert($box);
92 1
        $this->logger->log(LogLevel::INFO, "added box {$box->getReference()}", ['box' => $box]);
93 1
    }
94
95
    /**
96
     * Add a pre-prepared set of boxes all at once.
97
     */
98 7
    public function setBoxes(BoxList $boxList): void
99
    {
100 7
        $this->boxes = $boxList;
101 7
    }
102
103
    /**
104
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
105
     */
106
    public function getMaxBoxesToBalanceWeight(): int
107
    {
108
        return $this->maxBoxesToBalanceWeight;
109
    }
110
111
    /**
112
     * Number of boxes at which balancing weight is deemed not worth the extra computation time.
113
     */
114
    public function setMaxBoxesToBalanceWeight(int $maxBoxesToBalanceWeight): void
115
    {
116
        $this->maxBoxesToBalanceWeight = $maxBoxesToBalanceWeight;
117
    }
118
119
    /**
120
     * Pack items into boxes.
121
     */
122 8
    public function pack(): PackedBoxList
123
    {
124 8
        $packedBoxes = $this->doVolumePacking();
125
126
        //If we have multiple boxes, try and optimise/even-out weight distribution
127 8
        if ($packedBoxes->count() > 1 && $packedBoxes->count() <= $this->maxBoxesToBalanceWeight) {
128 2
            $redistributor = new WeightRedistributor($this->boxes);
129 2
            $redistributor->setLogger($this->logger);
130 2
            $packedBoxes = $redistributor->redistributeWeight($packedBoxes);
131
        }
132
133 8
        $this->logger->log(LogLevel::INFO, "[PACKING COMPLETED], {$packedBoxes->count()} boxes");
134
135 8
        return $packedBoxes;
136
    }
137
138
    /**
139
     * Pack items into boxes using the principle of largest volume item first.
140
     *
141
     * @throws ItemTooLargeException
142
     */
143 8
    public function doVolumePacking(): PackedBoxList
144
    {
145 8
        $packedBoxes = new PackedBoxList();
146
147 8
        $this->sanityPrecheck();
148
149
        //Keep going until everything packed
150 8
        while ($this->items->count()) {
151 7
            $packedBoxesIteration = [];
152
153
            //Loop through boxes starting with smallest, see what happens
154 7
            foreach ($this->boxes as $box) {
155 7
                $volumePacker = new VolumePacker($box, clone $this->items);
156 7
                $volumePacker->setLogger($this->logger);
157 7
                $packedBox = $volumePacker->pack();
158 7
                if ($packedBox->getItems()->count()) {
159 7
                    $packedBoxesIteration[] = $packedBox;
160
161
                    //Have we found a single box that contains everything?
162 7
                    if ($packedBox->getItems()->count() === $this->items->count()) {
163 7
                        break;
164
                    }
165
                }
166
            }
167
168
            //Find best box of iteration, and remove packed items from unpacked list
169 7
            $bestBox = $this->findBestBoxFromIteration($packedBoxesIteration);
170
171
            /** @var PackedItem $packedItem */
172 7
            foreach ($bestBox->getItems() as $packedItem) {
173 7
                $this->items->remove($packedItem->getItem());
174
            }
175
176 7
            $packedBoxes->insert($bestBox);
177
        }
178
179 8
        return $packedBoxes;
180
    }
181
182
    /**
183
     * @param PackedBox[] $packedBoxes
184
     */
185 7
    protected function findBestBoxFromIteration(array $packedBoxes): PackedBox
186
    {
187
        //Check iteration was productive
188 7
        if (count($packedBoxes) === 0) {
189
            throw new ItemTooLargeException('Item ' . $this->items->top()->getDescription() . ' is too large to fit into any box', $this->items->top());
190
        }
191
192 7
        usort($packedBoxes, [$this, 'compare']);
193
194 7
        return array_shift($packedBoxes);
195
    }
196
197 8
    private function sanityPrecheck(): void
198
    {
199
        /** @var Item $item */
200 8
        foreach ($this->items as $item) {
201 8
            $possibleFits = 0;
202
203
            /** @var Box $box */
204 8
            foreach ($this->boxes as $box) {
205 8
                if ($item->getWeight() <= ($box->getMaxWeight() - $box->getEmptyWeight())) {
206 8
                    $possibleFits += count((new OrientatedItemFactory($box))->getPossibleOrientationsInEmptyBox($item));
207
                }
208
            }
209
210 8
            if ($possibleFits === 0) {
211 2
                throw new ItemTooLargeException('Item ' . $item->getDescription() . ' is too large to fit into any box', $item);
212
            }
213
        }
214 8
    }
215
216 3
    private static function compare(PackedBox $boxA, PackedBox $boxB): int
217
    {
218 3
        $choice = $boxB->getItems()->count() <=> $boxA->getItems()->count();
219
220 3
        if ($choice === 0) {
221 1
            $choice = $boxB->getVolumeUtilisation() <=> $boxA->getVolumeUtilisation();
222
        }
223 3
        if ($choice === 0) {
224 1
            $choice = $boxB->getUsedVolume() <=> $boxA->getUsedVolume();
225
        }
226 3
        if ($choice === 0) {
227 1
            $choice = $boxA->getWeight() <=> $boxB->getWeight();
228
        }
229
230 3
        return $choice;
231
    }
232
}
233