Completed
Push — master ( 4b02cd...33e086 )
by Doug
14:15
created

OrientatedItemFactory::getPossibleOrientations()   C

Complexity

Conditions 14
Paths 8

Size

Total Lines 50
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 21
CRAP Score 14

Importance

Changes 7
Bugs 1 Features 0
Metric Value
cc 14
eloc 22
c 7
b 1
f 0
nc 8
nop 9
dl 0
loc 50
ccs 21
cts 21
cp 1
crap 14
rs 6.2666

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

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