Passed
Push — master ( ce89c2...6da5d1 )
by Doug
05:15
created

OrientatedItemFactory   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 371
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 23
Bugs 3 Features 0
Metric Value
eloc 169
c 23
b 3
f 0
dl 0
loc 371
ccs 172
cts 172
cp 1
rs 4.08
wmc 59

10 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 4 1
A setSinglePassMode() 0 3 1
A getBestOrientation() 0 55 5
C getPossibleOrientations() 0 50 14
A getStableOrientationsInEmptyBox() 0 8 1
B calculateAdditionalItemsPackedWithThisOrientation() 0 68 7
B lookAheadDecider() 0 20 7
C exactFitDecider() 0 30 13
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 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
            $exactFitDecider = $this->exactFitDecider($a, $b, $widthLeft, $lengthLeft, $depthLeft);
98 52
            if ($exactFitDecider !== 0) {
99 24
                return $exactFitDecider;
100
            }
101
102
            // prefer leaving room for next item(s)
103 49
            $followingItemDecider = $this->lookAheadDecider($nextItems, $a, $b, $widthLeft, $lengthLeft, $depthLeft, $rowLength, $x, $y, $z, $prevPackedItemList);
104 49
            if ($followingItemDecider !== 0) {
105 18
                return $followingItemDecider;
106
            }
107
108
            // otherwise prefer leaving minimum possible gap, or the greatest footprint
109 45
            $orientationAMinGap = min($widthLeft - $a->getWidth(), $lengthLeft - $a->getLength());
110 45
            $orientationBMinGap = min($widthLeft - $b->getWidth(), $lengthLeft - $b->getLength());
111
112 45
            return $orientationAMinGap <=> $orientationBMinGap ?: $a->getSurfaceFootprint() <=> $b->getSurfaceFootprint();
113 56
        });
114
115 56
        $this->logger->debug('Selected best fit orientation', ['orientation' => $usableOrientations[0]]);
116
117 56
        return $usableOrientations[0];
118
    }
119
120
    /**
121
     * Find all possible orientations for an item.
122
     *
123
     * @return OrientatedItem[]
124
     */
125 57
    public function getPossibleOrientations(
126
        Item $item,
127
        ?OrientatedItem $prevItem,
128
        int $widthLeft,
129
        int $lengthLeft,
130
        int $depthLeft,
131
        int $x,
132
        int $y,
133
        int $z,
134
        PackedItemList $prevPackedItemList
135
    ): array {
136 57
        $orientations = $orientationsDimensions = [];
137
138
        //Special case items that are the same as what we just packed - keep orientation
139 57
        if ($prevItem && $item === $prevItem->getItem() && $prevItem->getWidth() <= $widthLeft && $prevItem->getLength() <= $lengthLeft && $prevItem->getDepth() <= $depthLeft) {
140 23
            $orientations[] = $prevItem; // reuse the existing object for a small speed boost
141
        } else {
142
            //Might be different a item but having same dimensions - apply same rule
143 57
            if ($prevItem && $prevItem->isSameDimensions($item)) {
144 42
                $orientationsDimensions[] = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
145
            } else {
146
                //simple 2D rotation
147 57
                $orientationsDimensions[] = [$item->getWidth(), $item->getLength(), $item->getDepth()];
148 57
                $orientationsDimensions[] = [$item->getLength(), $item->getWidth(), $item->getDepth()];
149
150
                //add 3D rotation if we're allowed
151 57
                if (!$item->getKeepFlat()) {
152 39
                    $orientationsDimensions[] = [$item->getWidth(), $item->getDepth(), $item->getLength()];
153 39
                    $orientationsDimensions[] = [$item->getLength(), $item->getDepth(), $item->getWidth()];
154 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getWidth(), $item->getLength()];
155 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getLength(), $item->getWidth()];
156
                }
157
            }
158
159
            //remove any that simply don't fit
160 57
            foreach ($orientationsDimensions as $dimensions) {
161 57
                if ($dimensions[0] <= $widthLeft && $dimensions[1] <= $lengthLeft && $dimensions[2] <= $depthLeft) {
162 56
                    $orientations[] = new OrientatedItem($item, $dimensions[0], $dimensions[1], $dimensions[2]);
163
                }
164
            }
165
        }
166
167 57
        if ($item instanceof ConstrainedPlacementItem) {
168 2
            $box = $this->box;
169
            $orientations = array_filter($orientations, static function (OrientatedItem $i) use ($box, $x, $y, $z, $prevPackedItemList) {
170 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

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