Completed
Push — test_jit ( c34f6e...87859e )
by Doug
10:03
created

OrientatedItemFactory::getBestOrientation()   D

Complexity

Conditions 24
Paths 2

Size

Total Lines 96
Code Lines 50

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 50
CRAP Score 24

Importance

Changes 10
Bugs 3 Features 0
Metric Value
cc 24
eloc 50
c 10
b 3
f 0
nc 2
nop 12
dl 0
loc 96
ccs 50
cts 50
cp 1
crap 24
rs 4.1666

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 usort;
19
20
/**
21
 * Figure out orientations for an item and a given set of dimensions.
22
 *
23
 * @author Doug Wright
24
 * @internal
25
 */
26
class OrientatedItemFactory implements LoggerAwareInterface
27
{
28
    use LoggerAwareTrait;
29
30
    /** @var Box */
31
    protected $box;
32
33
    /**
34
     * @var OrientatedItem[]
35
     */
36
    protected static $emptyBoxCache = [];
37
38
    /**
39
     * @var int[]
40
     */
41
    protected static $lookaheadCache = [];
42
43 58
    public function __construct(Box $box)
44
    {
45 58
        $this->box = $box;
46 58
        $this->logger = new NullLogger();
47 58
    }
48
49
    /**
50
     * Get the best orientation for an item.
51
     */
52 56
    public function getBestOrientation(
53
        Item $item,
54
        ?OrientatedItem $prevItem,
55
        ItemList $nextItems,
56
        int $widthLeft,
57
        int $lengthLeft,
58
        int $depthLeft,
59
        int $rowLength,
60
        int $x,
61
        int $y,
62
        int $z,
63
        PackedItemList $prevPackedItemList,
64
        bool $singlePassMode
65
    ): ?OrientatedItem {
66 56
        $this->logger->debug(
67 56
            "evaluating item {$item->getDescription()} for fit",
68
            [
69 56
                'item' => $item,
70
                'space' => [
71 56
                    'widthLeft' => $widthLeft,
72 56
                    'lengthLeft' => $lengthLeft,
73 56
                    'depthLeft' => $depthLeft,
74
                ],
75
            ]
76
        );
77
78 56
        $possibleOrientations = $this->getPossibleOrientations($item, $prevItem, $widthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
79 56
        $usableOrientations = $this->getUsableOrientations($item, $possibleOrientations);
80
81 56
        if (empty($usableOrientations)) {
82 50
            return null;
83
        }
84
85
        usort($usableOrientations, function (OrientatedItem $a, OrientatedItem $b) use ($widthLeft, $lengthLeft, $depthLeft, $nextItems, $rowLength, $x, $y, $z, $prevPackedItemList, $singlePassMode) {
86
            //Prefer exact fits in width/length/depth order
87 52
            $orientationAWidthLeft = $widthLeft - $a->getWidth();
88 52
            $orientationBWidthLeft = $widthLeft - $b->getWidth();
89 52
            if ($orientationAWidthLeft === 0 && $orientationBWidthLeft > 0) {
90 7
                return -1;
91
            }
92 50
            if ($orientationAWidthLeft > 0 && $orientationBWidthLeft === 0) {
93 5
                return 1;
94
            }
95
96 50
            $orientationALengthLeft = $lengthLeft - $a->getLength();
97 50
            $orientationBLengthLeft = $lengthLeft - $b->getLength();
98 50
            if ($orientationALengthLeft === 0 && $orientationBLengthLeft > 0) {
99 14
                return -1;
100
            }
101 50
            if ($orientationALengthLeft > 0 && $orientationBLengthLeft === 0) {
102 15
                return 1;
103
            }
104
105 50
            $orientationADepthLeft = $depthLeft - $a->getDepth();
106 50
            $orientationBDepthLeft = $depthLeft - $b->getDepth();
107 50
            if ($orientationADepthLeft === 0 && $orientationBDepthLeft > 0) {
108 4
                return -1;
109
            }
110 50
            if ($orientationADepthLeft > 0 && $orientationBDepthLeft === 0) {
111 2
                return 1;
112
            }
113
114 49
            $orientationAMinGap = min($orientationAWidthLeft, $orientationALengthLeft);
115 49
            $orientationBMinGap = min($orientationBWidthLeft, $orientationBLengthLeft);
116 49
            if ($orientationAMinGap === 0 && $orientationBMinGap === 0) {
117 27
                return $b->getDepth() <=> $a->getDepth();
118
            }
119
120
            // prefer leaving room for next item in current row
121 41
            if ($nextItems->count()) {
122 38
                $nextItemFitA = $this->getPossibleOrientations($nextItems->top(), $a, $orientationAWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
123 38
                $nextItemFitB = $this->getPossibleOrientations($nextItems->top(), $b, $orientationBWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
124 38
                if ($nextItemFitA && !$nextItemFitB) {
125 9
                    return -1;
126
                }
127 38
                if ($nextItemFitB && !$nextItemFitA) {
128 12
                    return 1;
129
                }
130
131 35
                if (!$singlePassMode) {
132
                    // if not an easy either/or, do a partial lookahead
133 35
                    $additionalPackedA = $this->calculateAdditionalItemsPackedWithThisOrientation($a, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
134 35
                    $additionalPackedB = $this->calculateAdditionalItemsPackedWithThisOrientation($b, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
135 35
                    if ($additionalPackedA !== $additionalPackedB) {
136 9
                        return $additionalPackedB <=> $additionalPackedA;
137
                    }
138
                }
139
            }
140
            // otherwise prefer leaving minimum possible gap, or the greatest footprint
141 36
            return $orientationAMinGap <=> $orientationBMinGap ?: $a->getSurfaceFootprint() <=> $b->getSurfaceFootprint();
142 56
        });
143
144 56
        $bestFit = reset($usableOrientations);
145 56
        $this->logger->debug('Selected best fit orientation', ['orientation' => $bestFit]);
146
147 56
        return $bestFit;
148
    }
149
150
    /**
151
     * Find all possible orientations for an item.
152
     *
153
     * @return OrientatedItem[]
154
     */
155 57
    public function getPossibleOrientations(
156
        Item $item,
157
        ?OrientatedItem $prevItem,
158
        int $widthLeft,
159
        int $lengthLeft,
160
        int $depthLeft,
161
        int $x,
162
        int $y,
163
        int $z,
164
        PackedItemList $prevPackedItemList
165
    ): array {
166 57
        $orientations = $orientationsDimensions = [];
167
168
        //Special case items that are the same as what we just packed - keep orientation
169 57
        if ($prevItem && $item === $prevItem->getItem() && $prevItem->getWidth() <= $widthLeft && $prevItem->getLength() <= $lengthLeft && $prevItem->getDepth() <= $depthLeft) {
170 22
            $orientations[] = $prevItem; // reuse the existing object for a small speed boost
171
        } else {
172
            //Might be different a item but having same dimensions - apply same rule
173 57
            if ($prevItem && $prevItem->isSameDimensions($item)) {
174 39
                $orientationsDimensions[] = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
175
            } else {
176
                //simple 2D rotation
177 57
                $orientationsDimensions[] = [$item->getWidth(), $item->getLength(), $item->getDepth()];
178 57
                $orientationsDimensions[] = [$item->getLength(), $item->getWidth(), $item->getDepth()];
179
180
                //add 3D rotation if we're allowed
181 57
                if (!$item->getKeepFlat()) {
182 39
                    $orientationsDimensions[] = [$item->getWidth(), $item->getDepth(), $item->getLength()];
183 39
                    $orientationsDimensions[] = [$item->getLength(), $item->getDepth(), $item->getWidth()];
184 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getWidth(), $item->getLength()];
185 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getLength(), $item->getWidth()];
186
                }
187
            }
188
189
            //remove any that simply don't fit
190 57
            foreach ($orientationsDimensions as $dimensions) {
191 57
                if ($dimensions[0] <= $widthLeft && $dimensions[1] <= $lengthLeft && $dimensions[2] <= $depthLeft) {
192 56
                    $orientations[] = new OrientatedItem($item, $dimensions[0], $dimensions[1], $dimensions[2]);
193
                }
194
            }
195
        }
196
197 57
        if ($item instanceof ConstrainedPlacementItem) {
198 2
            $box = $this->box;
199
            $orientations = array_filter($orientations, static function (OrientatedItem $i) use ($box, $x, $y, $z, $prevPackedItemList) {
200 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

200
                return $i->getItem()->/** @scrutinizer ignore-call */ canBePacked($box, $prevPackedItemList, $x, $y, $z, $i->getWidth(), $i->getLength(), $i->getDepth());
Loading history...
201 2
            });
202
        }
203
204 57
        return $orientations;
205
    }
206
207
    /**
208
     * @return OrientatedItem[]
209
     */
210 32
    public function getPossibleOrientationsInEmptyBox(Item $item): array
211
    {
212 32
        $cacheKey = $item->getWidth() .
213 32
            '|' .
214 32
            $item->getLength() .
215 32
            '|' .
216 32
            $item->getDepth() .
217 32
            '|' .
218 32
            ($item->getKeepFlat() ? '2D' : '3D') .
219 32
            '|' .
220 32
            $this->box->getInnerWidth() .
221 32
            '|' .
222 32
            $this->box->getInnerLength() .
223 32
            '|' .
224 32
            $this->box->getInnerDepth();
225
226 32
        if (isset(static::$emptyBoxCache[$cacheKey])) {
227 26
            $orientations = static::$emptyBoxCache[$cacheKey];
228
        } else {
229 28
            $orientations = $this->getPossibleOrientations(
230 28
                $item,
231 28
                null,
232 28
                $this->box->getInnerWidth(),
233 28
                $this->box->getInnerLength(),
234 28
                $this->box->getInnerDepth(),
235 28
                0,
236 28
                0,
237 28
                0,
238 28
                new PackedItemList()
239
            );
240 28
            static::$emptyBoxCache[$cacheKey] = $orientations;
241
        }
242
243 32
        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...
244
    }
245
246
    /**
247
     * @param OrientatedItem[] $possibleOrientations
248
     *
249
     * @return OrientatedItem[]
250
     */
251 56
    protected function getUsableOrientations(
252
        Item $item,
253
        array $possibleOrientations
254
    ): array {
255 56
        $orientationsToUse = $stableOrientations = $unstableOrientations = [];
256
257
        // Divide possible orientations into stable (low centre of gravity) and unstable (high centre of gravity)
258 56
        foreach ($possibleOrientations as $orientation) {
259 56
            if ($orientation->isStable() || $this->box->getInnerDepth() === $orientation->getDepth()) {
260 54
                $stableOrientations[] = $orientation;
261
            } else {
262 9
                $unstableOrientations[] = $orientation;
263
            }
264
        }
265
266
        /*
267
         * We prefer to use stable orientations only, but allow unstable ones if
268
         * the item doesn't fit in the box any other way
269
         */
270 56
        if (count($stableOrientations) > 0) {
271 54
            $orientationsToUse = $stableOrientations;
272 50
        } elseif (count($unstableOrientations) > 0) {
273 9
            $stableOrientationsInEmptyBox = $this->getStableOrientationsInEmptyBox($item);
274
275 9
            if (count($stableOrientationsInEmptyBox) === 0) {
276 6
                $orientationsToUse = $unstableOrientations;
277
            }
278
        }
279
280 56
        return $orientationsToUse;
281
    }
282
283
    /**
284
     * Return the orientations for this item if it were to be placed into the box with nothing else.
285
     */
286 9
    protected function getStableOrientationsInEmptyBox(Item $item): array
287
    {
288 9
        $orientationsInEmptyBox = $this->getPossibleOrientationsInEmptyBox($item);
289
290 9
        return array_filter(
291 9
            $orientationsInEmptyBox,
292
            function (OrientatedItem $orientation) {
293 9
                return $orientation->isStable();
294 9
            }
295
        );
296
    }
297
298
    /**
299
     * Approximation of a forward-looking packing.
300
     *
301
     * Not an actual packing, that has additional logic regarding constraints and stackability, this focuses
302
     * purely on fit.
303
     */
304 35
    protected function calculateAdditionalItemsPackedWithThisOrientation(
305
        OrientatedItem $prevItem,
306
        ItemList $nextItems,
307
        int $originalWidthLeft,
308
        int $originalLengthLeft,
309
        int $depthLeft,
310
        int $currentRowLengthBeforePacking
311
    ): int {
312 35
        $currentRowLength = max($prevItem->getLength(), $currentRowLengthBeforePacking);
313
314 35
        $itemsToPack = $nextItems->topN(8); // cap lookahead as this gets recursive and slow
315
316
        $cacheKey = $originalWidthLeft .
317 35
            '|' .
318 35
            $originalLengthLeft .
319 35
            '|' .
320 35
            $prevItem->getWidth() .
321 35
            '|' .
322 35
            $prevItem->getLength() .
323 35
            '|' .
324 35
            $currentRowLength .
325 35
            '|'
326 35
            . $depthLeft;
327
328
        /** @var Item $itemToPack */
329 35
        foreach ($itemsToPack as $itemToPack) {
330
            $cacheKey .= '|' .
331 35
                $itemToPack->getWidth() .
332 35
                '|' .
333 35
                $itemToPack->getLength() .
334 35
                '|' .
335 35
                $itemToPack->getDepth() .
336 35
                '|' .
337 35
                $itemToPack->getWeight() .
338 35
                '|' .
339 35
                ($itemToPack->getKeepFlat() ? '1' : '0');
340
        }
341
342 35
        if (!isset(static::$lookaheadCache[$cacheKey])) {
343 34
            $tempBox = new WorkingVolume($originalWidthLeft - $prevItem->getWidth(), $currentRowLength, $depthLeft, PHP_INT_MAX);
344 34
            $tempPacker = new VolumePacker($tempBox, $itemsToPack);
345 34
            $tempPacker->setSinglePassMode(true);
346 34
            $remainingRowPacked = $tempPacker->pack();
347
            /** @var PackedItem $packedItem */
348 34
            foreach ($remainingRowPacked->getItems() as $packedItem) {
349 24
                $itemsToPack->remove($packedItem->getItem());
350
            }
351
352 34
            $tempBox = new WorkingVolume($originalWidthLeft, $originalLengthLeft - $currentRowLength, $depthLeft, PHP_INT_MAX);
353 34
            $tempPacker = new VolumePacker($tempBox, $itemsToPack);
354 34
            $tempPacker->setSinglePassMode(true);
355 34
            $nextRowsPacked = $tempPacker->pack();
356
            /** @var PackedItem $packedItem */
357 34
            foreach ($nextRowsPacked->getItems() as $packedItem) {
358 21
                $itemsToPack->remove($packedItem->getItem());
359
            }
360
361 34
            $packedCount = $nextItems->count() - $itemsToPack->count();
362 34
            $this->logger->debug('Lookahead with orientation', ['packedCount' => $packedCount, 'orientatedItem' => $prevItem]);
363
364 34
            static::$lookaheadCache[$cacheKey] = $packedCount;
365
        }
366
367 35
        return static::$lookaheadCache[$cacheKey];
368
    }
369
}
370