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

OrientatedItemFactory::getBestOrientation()   B

Complexity

Conditions 11
Paths 2

Size

Total Lines 63
Code Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 31
CRAP Score 11

Importance

Changes 10
Bugs 3 Features 0
Metric Value
cc 11
eloc 30
c 10
b 3
f 0
nc 2
nop 12
dl 0
loc 63
ccs 31
cts 31
cp 1
crap 11
rs 7.3166

How to fix   Long Method    Complexity    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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