Passed
Push — master ( ce89c2...2d80ec )
by Doug
05:15
created

OrientatedItemFactory::getPossibleOrientations()   B

Complexity

Conditions 9
Paths 18

Size

Total Lines 44
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 9

Importance

Changes 9
Bugs 1 Features 0
Metric Value
cc 9
eloc 18
nc 18
nop 9
dl 0
loc 44
ccs 18
cts 18
cp 1
crap 9
rs 8.0555
c 9
b 1
f 0

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 && $prevItem->isSameDimensions($item)) {
162 46
            $orientationsDimensions[] = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
163
        } else {
164
            //simple 2D rotation
165 57
            $orientationsDimensions[] = [$item->getWidth(), $item->getLength(), $item->getDepth()];
166 57
            $orientationsDimensions[] = [$item->getLength(), $item->getWidth(), $item->getDepth()];
167
168
            //add 3D rotation if we're allowed
169 57
            if (!$item->getKeepFlat()) {
170 39
                $orientationsDimensions[] = [$item->getWidth(), $item->getDepth(), $item->getLength()];
171 39
                $orientationsDimensions[] = [$item->getLength(), $item->getDepth(), $item->getWidth()];
172 39
                $orientationsDimensions[] = [$item->getDepth(), $item->getWidth(), $item->getLength()];
173 39
                $orientationsDimensions[] = [$item->getDepth(), $item->getLength(), $item->getWidth()];
174
            }
175
        }
176
177
        //remove any that simply don't fit
178 57
        foreach ($orientationsDimensions as $dimensions) {
179 57
            if ($dimensions[0] <= $widthLeft && $dimensions[1] <= $lengthLeft && $dimensions[2] <= $depthLeft) {
180 56
                $orientations[] = new OrientatedItem($item, $dimensions[0], $dimensions[1], $dimensions[2]);
181
            }
182
        }
183
184 57
        if ($item instanceof ConstrainedPlacementItem) {
185
            $orientations = array_filter($orientations, function (OrientatedItem $i) use ($x, $y, $z, $prevPackedItemList) {
186 2
                return $i->getItem()->canBePacked($this->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

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