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

OrientatedItemFactory   C

Complexity

Total Complexity 56

Size/Duplication

Total Lines 330
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 18
Bugs 3 Features 0
Metric Value
eloc 155
c 18
b 3
f 0
dl 0
loc 330
ccs 156
cts 156
cp 1
rs 5.5199
wmc 56

7 Methods

Rating   Name   Duplication   Size   Complexity  
D getBestOrientation() 0 84 24
C getPossibleOrientations() 0 50 14
A getStableOrientationsInEmptyBox() 0 8 1
A __construct() 0 4 1
B calculateAdditionalItemsPackedWithThisOrientation() 0 64 6
B getUsableOrientations() 0 30 7
A getPossibleOrientationsInEmptyBox() 0 34 3

How to fix   Complexity   

Complex Class

Complex classes like OrientatedItemFactory often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use OrientatedItemFactory, and based on these observations, apply Extract Interface, too.

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