Passed
Push — master ( 34b8f8...cb2e0a )
by Doug
05:48
created

OrientatedItemFactory::lookAheadDecider()   B

Complexity

Conditions 7
Paths 4

Size

Total Lines 20
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 12
CRAP Score 7

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 11
c 1
b 0
f 0
nc 4
nop 13
dl 0
loc 20
ccs 12
cts 12
cp 1
crap 7
rs 8.8333

How to fix   Many Parameters   

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

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