Issues (30)

Branch: master

src/VolumePacker.php (3 issues)

Labels
Severity
1
<?php
2
3
/**
4
 * Box packing (3D bin packing, knapsack problem).
5
 *
6
 * @author Doug Wright
7
 */
8
declare(strict_types=1);
9
10
namespace DVDoug\BoxPacker;
11
12
use Psr\Log\LoggerAwareInterface;
13
use Psr\Log\LoggerInterface;
14
use Psr\Log\NullLogger;
15
16
use function array_map;
17
use function count;
18
use function max;
19
use function reset;
20
use function usort;
21
22
/**
23
 * Actual packer.
24
 */
25
class VolumePacker implements LoggerAwareInterface
26
{
27
    protected LoggerInterface $logger;
28
29
    protected ItemList $items;
30
31
    protected bool $singlePassMode = false;
32
33
    protected bool $packAcrossWidthOnly = false;
34
35
    private readonly LayerPacker $layerPacker;
36
37
    protected bool $beStrictAboutItemOrdering = false;
38
39
    private readonly bool $hasConstrainedItems;
40
41
    private readonly bool $hasNoRotationItems;
42 101
43
    public function __construct(protected Box $box, ItemList $items)
44 101
    {
45
        $this->items = clone $items;
46 101
47
        $this->logger = new NullLogger();
48 101
49 101
        $this->hasConstrainedItems = $items->hasConstrainedItems();
0 ignored issues
show
The property hasConstrainedItems is declared read-only in DVDoug\BoxPacker\VolumePacker.
Loading history...
50
        $this->hasNoRotationItems = $items->hasNoRotationItems();
0 ignored issues
show
The property hasNoRotationItems is declared read-only in DVDoug\BoxPacker\VolumePacker.
Loading history...
51 101
52 101
        $this->layerPacker = new LayerPacker($this->box);
0 ignored issues
show
The property layerPacker is declared read-only in DVDoug\BoxPacker\VolumePacker.
Loading history...
53
        $this->layerPacker->setLogger($this->logger);
54
    }
55
56
    /**
57
     * Sets a logger.
58 49
     */
59
    public function setLogger(LoggerInterface $logger): void
60 49
    {
61 49
        $this->logger = $logger;
62
        $this->layerPacker->setLogger($logger);
63
    }
64 1
65
    public function packAcrossWidthOnly(): void
66 1
    {
67
        $this->packAcrossWidthOnly = true;
68
    }
69 42
70
    public function beStrictAboutItemOrdering(bool $beStrict): void
71 42
    {
72 42
        $this->beStrictAboutItemOrdering = $beStrict;
73
        $this->layerPacker->beStrictAboutItemOrdering($beStrict);
74
    }
75
76
    /**
77
     * @internal
78 19
     */
79
    public function setSinglePassMode(bool $singlePassMode): void
80 19
    {
81 19
        $this->singlePassMode = $singlePassMode;
82 19
        if ($singlePassMode) {
83
            $this->packAcrossWidthOnly = true;
84 19
        }
85
        $this->layerPacker->setSinglePassMode($singlePassMode);
86
    }
87
88
    /**
89
     * Pack as many items as possible into specific given box.
90
     *
91
     * @return PackedBox packed box
92 101
     */
93
    public function pack(): PackedBox
94 101
    {
95 101
        $orientatedItemFactory = new OrientatedItemFactory($this->box);
96 101
        $orientatedItemFactory->setLogger($this->logger);
97
        $this->logger->debug("[EVALUATING BOX] {$this->box->getReference()}", ['box' => $this->box]);
98
99 101
        // Sometimes "space available" decisions depend on orientation of the box, so try both ways
100 101
        $rotationsToTest = [false];
101 101
        if (!$this->packAcrossWidthOnly && !$this->hasNoRotationItems) {
102
            $rotationsToTest[] = true;
103
        }
104
105
        // The orientation of the first item can have an outsized effect on the rest of the placement, so special-case
106
        // that and try everything
107 101
108 101
        $boxPermutations = [];
109 101
        foreach ($rotationsToTest as $rotation) {
110 34
            if ($rotation) {
111 34
                $boxWidth = $this->box->getInnerLength();
112
                $boxLength = $this->box->getInnerWidth();
113 101
            } else {
114 101
                $boxWidth = $this->box->getInnerWidth();
115
                $boxLength = $this->box->getInnerLength();
116
            }
117 101
118 101
            $specialFirstItemOrientations = [null];
119 101
            if (!$this->singlePassMode) {
120
                $specialFirstItemOrientations = $orientatedItemFactory->getPossibleOrientations($this->items->top(), null, $boxWidth, $boxLength, $this->box->getInnerDepth(), 0, 0, 0, new PackedItemList()) ?: [null];
121
            }
122 101
123 101
            foreach ($specialFirstItemOrientations as $firstItemOrientation) {
124 101
                $boxPermutation = $this->packRotation($boxWidth, $boxLength, $firstItemOrientation);
125 86
                if ($boxPermutation->items->count() === $this->items->count()) {
126
                    return $boxPermutation;
127
                }
128 55
129
                $boxPermutations[] = $boxPermutation;
130
            }
131
        }
132 48
133
        usort($boxPermutations, static fn (PackedBox $a, PackedBox $b) => $b->getVolumeUtilisation() <=> $a->getVolumeUtilisation());
134 48
135
        return reset($boxPermutations);
136
    }
137
138
    /**
139
     * Pack as many items as possible into specific given box.
140
     *
141
     * @return PackedBox packed box
142 101
     */
143
    private function packRotation(int $boxWidth, int $boxLength, ?OrientatedItem $firstItemOrientation): PackedBox
144 101
    {
145 101
        $this->logger->debug("[EVALUATING ROTATION] {$this->box->getReference()}", ['width' => $boxWidth, 'length' => $boxLength]);
146
        $this->layerPacker->setBoxIsRotated($this->box->getInnerWidth() !== $boxWidth);
147 101
148 101
        $layers = [];
149
        $items = clone $this->items;
150 101
151 101
        while ($items->count() > 0) {
152 101
            $layerStartDepth = self::getCurrentPackedDepth($layers);
153
            $packedItemList = $this->getPackedItemList($layers);
154 101
155 73
            if ($packedItemList->count() > 0) {
156
                $firstItemOrientation = null;
157
            }
158
159 101
            // do a preliminary layer pack to get the depth used
160 101
            $preliminaryItems = clone $items;
161 101
            $preliminaryLayer = $this->layerPacker->packLayer($preliminaryItems, clone $packedItemList, 0, 0, $layerStartDepth, $boxWidth, $boxLength, $this->box->getInnerDepth() - $layerStartDepth, 0, true, $firstItemOrientation);
162 51
            if (count($preliminaryLayer->getItems()) === 0) {
163
                break;
164
            }
165 98
166 98
            $preliminaryLayerDepth = $preliminaryLayer->getDepth();
167 95
            if ($preliminaryLayerDepth === $preliminaryLayer->getItems()[0]->depth) { // preliminary === final
168 95
                $layers[] = $preliminaryLayer;
169
                $items = $preliminaryItems;
170 17
            } else { // redo with now-known-depth so that we can stack to that height from the first item
171
                $layers[] = $this->layerPacker->packLayer($items, $packedItemList, 0, 0, $layerStartDepth, $boxWidth, $boxLength, $this->box->getInnerDepth() - $layerStartDepth, $preliminaryLayerDepth, true, $firstItemOrientation);
172
            }
173
        }
174 101
175 98
        if (!$this->singlePassMode && $layers) {
176
            $layers = $this->stabiliseLayers($layers);
177
178 98
            // having packed layers, there may be tall, narrow gaps at the ends that can be utilised
179 98
            $maxLayerWidth = max(array_map(static fn (PackedLayer $layer) => $layer->getEndX(), $layers));
180
            $layers[] = $this->layerPacker->packLayer($items, $this->getPackedItemList($layers), $maxLayerWidth, 0, 0, $boxWidth, $boxLength, $this->box->getInnerDepth(), $this->box->getInnerDepth(), false, null);
181 98
182 98
            $maxLayerLength = max(array_map(static fn (PackedLayer $layer) => $layer->getEndY(), $layers));
183
            $layers[] = $this->layerPacker->packLayer($items, $this->getPackedItemList($layers), 0, $maxLayerLength, 0, $boxWidth, $boxLength, $this->box->getInnerDepth(), $this->box->getInnerDepth(), false, null);
184
        }
185 101
186
        $layers = $this->correctLayerRotation($layers, $boxWidth);
187 101
188
        return new PackedBox($this->box, $this->getPackedItemList($layers));
189
    }
190
191
    /**
192
     * During packing, it is quite possible that layers have been created that aren't physically stable
193
     * i.e. they overhang the ones below.
194
     *
195
     * This function reorders them so that the ones with the greatest surface area are placed at the bottom
196
     *
197
     * @param  PackedLayer[] $oldLayers
198
     * @return PackedLayer[]
199 98
     */
200
    private function stabiliseLayers(array $oldLayers): array
201 98
    {
202 3
        if ($this->hasConstrainedItems || $this->beStrictAboutItemOrdering) { // constraints include position, so cannot change
203
            return $oldLayers;
204
        }
205 97
206
        $stabiliser = new LayerStabiliser();
207 97
208
        return $stabiliser->stabilise($oldLayers);
209
    }
210
211
    /**
212
     * Swap back width/length of the packed items to match orientation of the box if needed.
213
     *
214
     * @param PackedLayer[] $oldLayers
215
     *
216
     * @return PackedLayer[]
217 101
     */
218
    private function correctLayerRotation(array $oldLayers, int $boxWidth): array
219 101
    {
220 101
        if ($this->box->getInnerWidth() === $boxWidth) {
221
            return $oldLayers;
222
        }
223 9
224 9
        $newLayers = [];
225 9
        foreach ($oldLayers as $originalLayer) {
226 9
            $newLayer = new PackedLayer();
227 9
            foreach ($originalLayer->getItems() as $item) {
228 9
                $packedItem = new PackedItem($item->item, $item->y, $item->x, $item->z, $item->length, $item->width, $item->depth);
229
                $newLayer->insert($packedItem);
230 9
            }
231
            $newLayers[] = $newLayer;
232
        }
233 9
234
        return $newLayers;
235
    }
236
237
    /**
238
     * Generate a single list of items packed.
239
     * @param PackedLayer[] $layers
240 101
     */
241
    private function getPackedItemList(array $layers): PackedItemList
242 101
    {
243 101
        $packedItemList = new PackedItemList();
244 98
        foreach ($layers as $layer) {
245 98
            foreach ($layer->getItems() as $packedItem) {
246
                $packedItemList->insert($packedItem);
247
            }
248
        }
249 101
250
        return $packedItemList;
251
    }
252
253
    /**
254
     * Return the current packed depth.
255
     *
256
     * @param PackedLayer[] $layers
257 101
     */
258
    private static function getCurrentPackedDepth(array $layers): int
259 101
    {
260 101
        $depth = 0;
261 73
        foreach ($layers as $layer) {
262
            $depth += $layer->getDepth();
263
        }
264 101
265
        return $depth;
266
    }
267
}
268