Completed
Push — master ( 0d251f...460ca4 )
by Doug
11:25
created

OrientatedItemFactory::getUsableOrientations()   B

Complexity

Conditions 7
Paths 12

Size

Total Lines 32
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 13
CRAP Score 7

Importance

Changes 4
Bugs 3 Features 0
Metric Value
cc 7
eloc 13
c 4
b 3
f 0
nc 12
nop 3
dl 0
loc 32
ccs 13
cts 13
cp 1
crap 7
rs 8.8333
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_filter;
12
use function count;
13
use function min;
14
use Psr\Log\LoggerAwareInterface;
15
use Psr\Log\LoggerAwareTrait;
16
use Psr\Log\NullLogger;
17
use function reset;
18
use function sort;
19
use function usort;
20
21
/**
22
 * Figure out orientations for an item and a given set of dimensions.
23
 *
24
 * @author Doug Wright
25
 * @internal
26
 */
27
class OrientatedItemFactory implements LoggerAwareInterface
28
{
29
    use LoggerAwareTrait;
30
31
    /** @var Box */
32
    protected $box;
33
34
    /**
35
     * @var OrientatedItem[]
36
     */
37
    protected static $emptyBoxCache = [];
38
39
    /**
40
     * @var int[]
41
     */
42
    protected static $lookaheadCache = [];
43
44 54
    public function __construct(Box $box)
45
    {
46 54
        $this->box = $box;
47 54
        $this->logger = new NullLogger();
48 54
    }
49
50
    /**
51
     * Get the best orientation for an item.
52
     */
53 52
    public function getBestOrientation(
54
        Item $item,
55
        ?OrientatedItem $prevItem,
56
        ItemList $nextItems,
57
        bool $isLastItem,
58
        int $widthLeft,
59
        int $lengthLeft,
60
        int $depthLeft,
61
        int $rowLength,
62
        int $x,
63
        int $y,
64
        int $z,
65
        PackedItemList $prevPackedItemList
66
    ): ?OrientatedItem {
67 52
        $possibleOrientations = $this->getPossibleOrientations($item, $prevItem, $widthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
68 52
        $usableOrientations = $this->getUsableOrientations($item, $possibleOrientations, $isLastItem);
69
70 52
        if (empty($usableOrientations)) {
71 49
            return null;
72
        }
73
74
        usort($usableOrientations, function (OrientatedItem $a, OrientatedItem $b) use ($widthLeft, $lengthLeft, $depthLeft, $nextItems, $rowLength, $x, $y, $z, $prevPackedItemList) {
75 28
            $orientationAWidthLeft = $widthLeft - $a->getWidth();
76 28
            $orientationALengthLeft = $lengthLeft - $a->getLength();
77 28
            $orientationBWidthLeft = $widthLeft - $b->getWidth();
78 28
            $orientationBLengthLeft = $lengthLeft - $b->getLength();
79
80 28
            $orientationAMinGap = min($orientationAWidthLeft, $orientationALengthLeft);
81 28
            $orientationBMinGap = min($orientationBWidthLeft, $orientationBLengthLeft);
82
83 28
            if ($orientationAMinGap === 0) { // prefer A if it leaves no gap
84 15
                return -1;
85
            }
86 25
            if ($orientationBMinGap === 0) { // prefer B if it leaves no gap
87 16
                return 1;
88
            }
89
90
            // prefer leaving room for next item in current row
91 24
            if ($nextItems->count()) {
92 21
                $nextItemFitA = $this->getPossibleOrientations($nextItems->top(), $a, $orientationAWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
93 21
                $nextItemFitB = $this->getPossibleOrientations($nextItems->top(), $b, $orientationBWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
94 21
                if ($nextItemFitA && !$nextItemFitB) {
95 9
                    return -1;
96
                }
97 19
                if ($nextItemFitB && !$nextItemFitA) {
98 8
                    return 1;
99
                }
100
101
                // if not an easy either/or, do a partial lookahead
102 17
                $additionalPackedA = $this->calculateAdditionalItemsPackedWithThisOrientation($a, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
103 17
                $additionalPackedB = $this->calculateAdditionalItemsPackedWithThisOrientation($b, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
104 17
                if ($additionalPackedA !== $additionalPackedB) {
105 8
                    return $additionalPackedB <=> $additionalPackedA;
106
                }
107
            }
108
            // otherwise prefer leaving minimum possible gap, or the greatest footprint
109 20
            return $orientationAMinGap <=> $orientationBMinGap ?: $a->getSurfaceFootprint() <=> $b->getSurfaceFootprint();
110 52
        });
111
112 52
        $bestFit = reset($usableOrientations);
113 52
        $this->logger->debug('Selected best fit orientation', ['orientation' => $bestFit]);
114
115 52
        return $bestFit;
116
    }
117
118
    /**
119
     * Find all possible orientations for an item.
120
     *
121
     * @return OrientatedItem[]
122
     */
123 53
    public function getPossibleOrientations(
124
        Item $item,
125
        ?OrientatedItem $prevItem,
126
        int $widthLeft,
127
        int $lengthLeft,
128
        int $depthLeft,
129
        int $x,
130
        int $y,
131
        int $z,
132
        PackedItemList $prevPackedItemList
133
    ): array {
134 53
        $orientations = $orientationsDimensions = [];
135
136 53
        $isSame = false;
137 53
        if ($prevItem) {
138 49
            $itemADimensions = [$item->getWidth(), $item->getLength(), $item->getDepth()];
139 49
            $itemBDimensions = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
140 49
            sort($itemADimensions);
141 49
            sort($itemBDimensions);
142 49
            $isSame = ($itemADimensions === $itemBDimensions);
143
        }
144
145
        //Special case items that are the same as what we just packed - keep orientation
146 53
        if ($isSame && $prevItem) {
147 42
            $orientationsDimensions[] = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
148
        } else {
149
            //simple 2D rotation
150 53
            $orientationsDimensions[] = [$item->getWidth(), $item->getLength(), $item->getDepth()];
151 53
            $orientationsDimensions[] = [$item->getLength(), $item->getWidth(), $item->getDepth()];
152
153
            //add 3D rotation if we're allowed
154 53
            if (!$item->getKeepFlat()) {
155 34
                $orientationsDimensions[] = [$item->getWidth(), $item->getDepth(), $item->getLength()];
156 34
                $orientationsDimensions[] = [$item->getLength(), $item->getDepth(), $item->getWidth()];
157 34
                $orientationsDimensions[] = [$item->getDepth(), $item->getWidth(), $item->getLength()];
158 34
                $orientationsDimensions[] = [$item->getDepth(), $item->getLength(), $item->getWidth()];
159
            }
160
        }
161
162
        //remove any that simply don't fit
163 53
        $orientationsDimensions = array_unique($orientationsDimensions, SORT_REGULAR);
164
        $orientationsDimensions = array_filter($orientationsDimensions, static function (array $i) use ($widthLeft, $lengthLeft, $depthLeft) {
165 53
            return $i[0] <= $widthLeft && $i[1] <= $lengthLeft && $i[2] <= $depthLeft;
166 53
        });
167
168 53
        foreach ($orientationsDimensions as $dimensions) {
169 52
            $orientations[] = new OrientatedItem($item, $dimensions[0], $dimensions[1], $dimensions[2]);
170
        }
171
172 53
        if ($item instanceof ConstrainedPlacementItem) {
173 2
            $box = $this->box;
174
            $orientations = array_filter($orientations, static function (OrientatedItem $i) use ($box, $x, $y, $z, $prevPackedItemList) {
175 2
                return $i->getItem()->canBePacked($box, $prevPackedItemList, $x, $y, $z, $i->getWidth(), $i->getLength(), $i->getDepth());
1 ignored issue
show
Bug introduced by
The method canBePacked() does not exist on DVDoug\BoxPacker\Item. It seems like you code against a sub-type of DVDoug\BoxPacker\Item such as DVDoug\BoxPacker\ConstrainedPlacementItem or DVDoug\BoxPacker\Test\Co...lacementByCountTestItem or DVDoug\BoxPacker\Test\Co...ementNoStackingTestItem. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

175
                return $i->getItem()->/** @scrutinizer ignore-call */ canBePacked($box, $prevPackedItemList, $x, $y, $z, $i->getWidth(), $i->getLength(), $i->getDepth());
Loading history...
176 2
            });
177
        }
178
179 53
        return $orientations;
180
    }
181
182
    /**
183
     * @return OrientatedItem[]
184
     */
185 29
    public function getPossibleOrientationsInEmptyBox(Item $item): array
186
    {
187 29
        $cacheKey = $item->getWidth() .
188 29
            '|' .
189 29
            $item->getLength() .
190 29
            '|' .
191 29
            $item->getDepth() .
192 29
            '|' .
193 29
            ($item->getKeepFlat() ? '2D' : '3D') .
194 29
            '|' .
195 29
            $this->box->getInnerWidth() .
196 29
            '|' .
197 29
            $this->box->getInnerLength() .
198 29
            '|' .
199 29
            $this->box->getInnerDepth();
200
201 29
        if (isset(static::$emptyBoxCache[$cacheKey])) {
202 24
            $orientations = static::$emptyBoxCache[$cacheKey];
203
        } else {
204 25
            $orientations = $this->getPossibleOrientations(
205 25
                $item,
206 25
                null,
207 25
                $this->box->getInnerWidth(),
208 25
                $this->box->getInnerLength(),
209 25
                $this->box->getInnerDepth(),
210 25
                0,
211 25
                0,
212 25
                0,
213 25
                new PackedItemList()
214
            );
215 25
            static::$emptyBoxCache[$cacheKey] = $orientations;
216
        }
217
218 29
        return $orientations;
1 ignored issue
show
Bug Best Practice introduced by
The expression return $orientations could return the type DVDoug\BoxPacker\OrientatedItem which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
219
    }
220
221
    /**
222
     * @param OrientatedItem[] $possibleOrientations
223
     *
224
     * @return OrientatedItem[]
225
     */
226 52
    protected function getUsableOrientations(
227
        Item $item,
228
        $possibleOrientations,
229
        bool $isLastItem
230
    ): array {
231 52
        $orientationsToUse = $stableOrientations = $unstableOrientations = [];
232
233
        // Divide possible orientations into stable (low centre of gravity) and unstable (high centre of gravity)
234 52
        foreach ($possibleOrientations as $orientation) {
235 52
            if ($orientation->isStable()) {
236 50
                $stableOrientations[] = $orientation;
237
            } else {
238 52
                $unstableOrientations[] = $orientation;
239
            }
240
        }
241
242
        /*
243
         * We prefer to use stable orientations only, but allow unstable ones if either
244
         * the item is the last one left to pack OR
245
         * the item doesn't fit in the box any other way
246
         */
247 52
        if (count($stableOrientations) > 0) {
248 50
            $orientationsToUse = $stableOrientations;
249 49
        } elseif (count($unstableOrientations) > 0) {
250 6
            $stableOrientationsInEmptyBox = $this->getStableOrientationsInEmptyBox($item);
251
252 6
            if ($isLastItem || count($stableOrientationsInEmptyBox) === 0) {
253 4
                $orientationsToUse = $unstableOrientations;
254
            }
255
        }
256
257 52
        return $orientationsToUse;
258
    }
259
260
    /**
261
     * Return the orientations for this item if it were to be placed into the box with nothing else.
262
     */
263 6
    protected function getStableOrientationsInEmptyBox(Item $item): array
264
    {
265 6
        $orientationsInEmptyBox = $this->getPossibleOrientationsInEmptyBox($item);
266
267 6
        return array_filter(
268 6
            $orientationsInEmptyBox,
269
            function (OrientatedItem $orientation) {
270 6
                return $orientation->isStable();
271 6
            }
272
        );
273
    }
274
275
    /**
276
     * Approximation of a forward-looking packing.
277
     *
278
     * Not an actual packing, that has additional logic regarding constraints and stackability, this focuses
279
     * purely on fit.
280
     */
281 17
    protected function calculateAdditionalItemsPackedWithThisOrientation(
282
        OrientatedItem $prevItem,
283
        ItemList $nextItems,
284
        int $originalWidthLeft,
285
        int $originalLengthLeft,
286
        int $depthLeft,
287
        int $currentRowLengthBeforePacking
288
    ): int {
289 17
        $packedCount = 0;
290
291 17
        $currentRowLength = max($prevItem->getLength(), $currentRowLengthBeforePacking);
292
293 17
        $itemsToPack = $nextItems->topN(8); // cap lookahead as this gets recursive and slow
294
295
        $cacheKey = $originalWidthLeft .
296 17
            '|' .
297 17
            $originalLengthLeft .
298 17
            '|' .
299 17
            $prevItem->getWidth() .
300 17
            '|' .
301 17
            $prevItem->getLength() .
302 17
            '|' .
303 17
            $currentRowLength .
304 17
            '|'
305 17
            . $depthLeft;
306
307
        /** @var Item $itemToPack */
308 17
        foreach ($itemsToPack as $itemToPack) {
309
            $cacheKey .= '|' .
310 17
                $itemToPack->getWidth() .
311 17
                '|' .
312 17
                $itemToPack->getLength() .
313 17
                '|' .
314 17
                $itemToPack->getDepth() .
315 17
                '|' .
316 17
                $itemToPack->getWeight() .
317 17
                '|' .
318 17
                ($itemToPack->getKeepFlat() ? '1' : '0');
319
        }
320
321 17
        if (!isset(static::$lookaheadCache[$cacheKey])) {
322 17
            $tempBox = new WorkingVolume($originalWidthLeft - $prevItem->getWidth(), $currentRowLength, $depthLeft, PHP_INT_MAX);
323 17
            $tempPacker = new VolumePacker($tempBox, clone $itemsToPack);
324 17
            $tempPacker->setLookAheadMode(true);
325 17
            $remainingRowPacked = $tempPacker->pack();
326
            /** @var PackedItem $packedItem */
327 17
            foreach ($remainingRowPacked->getItems() as $packedItem) {
328 17
                $itemsToPack->remove($packedItem->getItem());
329
            }
330
331 17
            $tempBox = new WorkingVolume($originalWidthLeft, $originalLengthLeft - $currentRowLength, $depthLeft, PHP_INT_MAX);
332 17
            $tempPacker = new VolumePacker($tempBox, clone $itemsToPack);
333 17
            $tempPacker->setLookAheadMode(true);
334 17
            $nextRowsPacked = $tempPacker->pack();
335
            /** @var PackedItem $packedItem */
336 17
            foreach ($nextRowsPacked->getItems() as $packedItem) {
337 13
                $itemsToPack->remove($packedItem->getItem());
338
            }
339
340 17
            $this->logger->debug('Lookahead with orientation', ['packedCount' => $packedCount, 'orientatedItem' => $prevItem]);
341
342 17
            static::$lookaheadCache[$cacheKey] = $nextItems->count() - $itemsToPack->count();
343
        }
344
345 17
        return static::$lookaheadCache[$cacheKey];
346
    }
347
}
348