Passed
Push — 2.x-dev ( 47ad03...8ee2a1 )
by Doug
03:36
created

OrientatedItemFactory   A

Complexity

Total Complexity 40

Size/Duplication

Total Lines 301
Duplicated Lines 0 %

Test Coverage

Coverage 91.8%

Importance

Changes 0
Metric Value
eloc 121
dl 0
loc 301
ccs 112
cts 122
cp 0.918
rs 9.2
c 0
b 0
f 0
wmc 40

8 Methods

Rating   Name   Duplication   Size   Complexity  
C getBestOrientation() 0 60 14
A getPossibleOrientations() 0 29 6
A getStableOrientationsInEmptyBox() 0 8 1
A __construct() 0 3 1
B calculateAdditionalItemsPackedWithThisOrientation() 0 40 7
A isSameDimensions() 0 8 1
B getUsableOrientations() 0 32 7
A getPossibleOrientationsInEmptyBox() 0 30 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
8
namespace DVDoug\BoxPacker;
9
10
use Psr\Log\LoggerAwareInterface;
11
use Psr\Log\LoggerAwareTrait;
12
13
/**
14
 * Figure out orientations for an item and a given set of dimensions.
15
 *
16
 * @author Doug Wright
17
 * @internal
18
 */
19
class OrientatedItemFactory implements LoggerAwareInterface
20
{
21
    use LoggerAwareTrait;
22
23
    /** @var Box */
24
    protected $box;
25
26
    /**
27
     * @var OrientatedItem[]
28
     */
29
    protected static $emptyBoxCache = [];
30
31 20
    public function __construct(Box $box)
32
    {
33 20
        $this->box = $box;
34 20
    }
35
36
    /**
37
     * Get the best orientation for an item.
38
     *
39
     * @param Item                $item
40
     * @param OrientatedItem|null $prevItem
41
     * @param ItemList            $nextItems
42
     * @param bool                $isLastItem
43
     * @param int                 $widthLeft
44
     * @param int                 $lengthLeft
45
     * @param int                 $depthLeft
46
     *
47
     * @return OrientatedItem|null
48
     */
49 20
    public function getBestOrientation(
50
        Item $item,
51
        OrientatedItem $prevItem = null,
52
        ItemList $nextItems,
53
        $isLastItem,
54
        $widthLeft,
55
        $lengthLeft,
56
        $depthLeft
57
    ) {
58 20
        $possibleOrientations = $this->getPossibleOrientations($item, $prevItem, $widthLeft, $lengthLeft, $depthLeft);
59 20
        $usableOrientations = $this->getUsableOrientations($item, $possibleOrientations, $isLastItem);
60
61 20
        if (empty($usableOrientations)) {
62 19
            return null;
63
        }
64
65
        usort($usableOrientations, function (OrientatedItem $a, OrientatedItem $b) use ($widthLeft, $lengthLeft, $depthLeft, $nextItems) {
66 20
            $orientationAWidthLeft = $widthLeft - $a->getWidth();
67 20
            $orientationALengthLeft = $lengthLeft - $a->getLength();
68 20
            $orientationADepthLeft = $depthLeft - $a->getDepth();
69 20
            $orientationBWidthLeft = $widthLeft - $b->getWidth();
70 20
            $orientationBLengthLeft = $lengthLeft - $b->getLength();
71 20
            $orientationBDepthLeft = $depthLeft - $b->getDepth();
72
73 20
            $orientationAMinGap = min($orientationAWidthLeft, $orientationALengthLeft);
74 20
            $orientationBMinGap = min($orientationBWidthLeft, $orientationBLengthLeft);
75
76 20
            if ($orientationAMinGap === 0 && ($orientationBMinGap !== 0 || PHP_MAJOR_VERSION > 5)) { // prefer A if it leaves no gap
77 7
                return -1;
78
            }
79 16
            if ($orientationBMinGap === 0) { // prefer B if it leaves no gap
80 1
                return 1;
81
            }
82
83
            // prefer leaving room for next item in current row
84 15
            if ($nextItems->count()) {
85 14
                $nextItemFitA = count($this->getPossibleOrientations($nextItems->top(), $a, $orientationAWidthLeft, $lengthLeft, $depthLeft));
86 14
                $nextItemFitB = count($this->getPossibleOrientations($nextItems->top(), $b, $orientationBWidthLeft, $lengthLeft, $depthLeft));
87 14
                if ($nextItemFitA && !$nextItemFitB) {
88
                    return -1;
89
                }
90 14
                if ($nextItemFitB && !$nextItemFitA) {
91 2
                    return 1;
92
                }
93
94
                // if not an easy either/or, do a partial lookahead
95 12
                $additionalPackedA = $this->calculateAdditionalItemsPackedWithThisOrientation($a, $nextItems, $widthLeft, $lengthLeft, $depthLeft);
96 12
                $additionalPackedB = $this->calculateAdditionalItemsPackedWithThisOrientation($b, $nextItems, $widthLeft, $lengthLeft, $depthLeft);
97 12
                if ($additionalPackedA !== $additionalPackedB) {
98 2
                    return $additionalPackedB - $additionalPackedA;
99
                }
100
            }
101
            // otherwise prefer leaving minimum possible gap, or the greatest footprint
102 12
            return ($orientationADepthLeft - $orientationBDepthLeft) ?: ($orientationAMinGap - $orientationBMinGap) ?: ($a->getSurfaceFootprint() - $b->getSurfaceFootprint());
103 20
        });
104
105 20
        $bestFit = reset($usableOrientations);
106 20
        $this->logger->debug('Selected best fit orientation', ['orientation' => $bestFit]);
107
108 20
        return $bestFit;
109
    }
110
111
    /**
112
     * Find all possible orientations for an item.
113
     *
114
     * @param Item                $item
115
     * @param OrientatedItem|null $prevItem
116
     * @param int                 $widthLeft
117
     * @param int                 $lengthLeft
118
     * @param int                 $depthLeft
119
     *
120
     * @return OrientatedItem[]
121
     */
122 20
    public function getPossibleOrientations(
123
        Item $item,
124
        OrientatedItem $prevItem = null,
125
        $widthLeft,
126
        $lengthLeft,
127
        $depthLeft
128
    ) {
129 20
        $orientations = [];
130
131
        //Special case items that are the same as what we just packed - keep orientation
132 20
        if ($prevItem && $this->isSameDimensions($prevItem->getItem(), $item)) {
133 15
            $orientations[] = new OrientatedItem($item, $prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth());
134
        } else {
135
            //simple 2D rotation
136 20
            $orientations[] = new OrientatedItem($item, $item->getWidth(), $item->getLength(), $item->getDepth());
137 20
            $orientations[] = new OrientatedItem($item, $item->getLength(), $item->getWidth(), $item->getDepth());
138
139
            //add 3D rotation if we're allowed
140 20
            if (!$item->getKeepFlat()) {
141 12
                $orientations[] = new OrientatedItem($item, $item->getWidth(), $item->getDepth(), $item->getLength());
142 12
                $orientations[] = new OrientatedItem($item, $item->getLength(), $item->getDepth(), $item->getWidth());
143 12
                $orientations[] = new OrientatedItem($item, $item->getDepth(), $item->getWidth(), $item->getLength());
144 12
                $orientations[] = new OrientatedItem($item, $item->getDepth(), $item->getLength(), $item->getWidth());
145
            }
146
        }
147
148
        //remove any that simply don't fit
149
        return array_filter($orientations, function (OrientatedItem $i) use ($widthLeft, $lengthLeft, $depthLeft) {
150 20
            return $i->getWidth() <= $widthLeft && $i->getLength() <= $lengthLeft && $i->getDepth() <= $depthLeft;
151 20
        });
152
    }
153
154
    /**
155
     * @param  Item             $item
156
     * @return OrientatedItem[]
157
     */
158 20
    public function getPossibleOrientationsInEmptyBox(Item $item)
159
    {
160 20
        $cacheKey = $item->getWidth() .
161 20
            '|' .
162 20
            $item->getLength() .
163 20
            '|' .
164 20
            $item->getDepth() .
165 20
            '|' .
166 20
            ($item->getKeepFlat() ? '2D' : '3D') .
167 20
            '|' .
168 20
            $this->box->getInnerWidth() .
169 20
            '|' .
170 20
            $this->box->getInnerLength() .
171 20
            '|' .
172 20
            $this->box->getInnerDepth();
173
174 20
        if (isset(static::$emptyBoxCache[$cacheKey])) {
175 16
            $orientations = static::$emptyBoxCache[$cacheKey];
176
        } else {
177 19
            $orientations = $this->getPossibleOrientations(
178 19
                $item,
179 19
                null,
180 19
                $this->box->getInnerWidth(),
181 19
                $this->box->getInnerLength(),
182 19
                $this->box->getInnerDepth()
183
            );
184 19
            static::$emptyBoxCache[$cacheKey] = $orientations;
185
        }
186
187 20
        return $orientations;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $orientations also could return the type DVDoug\BoxPacker\OrientatedItem which is incompatible with the documented return type DVDoug\BoxPacker\OrientatedItem[].
Loading history...
188
    }
189
190
    /**
191
     * @param Item             $item
192
     * @param OrientatedItem[] $possibleOrientations
193
     * @param bool             $isLastItem
194
     *
195
     * @return OrientatedItem[]
196
     */
197 20
    protected function getUsableOrientations(
198
        Item $item,
199
        $possibleOrientations,
200
        $isLastItem
201
    ) {
202 20
        $orientationsToUse = $stableOrientations = $unstableOrientations = [];
203
204
        // Divide possible orientations into stable (low centre of gravity) and unstable (high centre of gravity)
205 20
        foreach ($possibleOrientations as $orientation) {
206 20
            if ($orientation->isStable()) {
207 20
                $stableOrientations[] = $orientation;
208
            } else {
209 20
                $unstableOrientations[] = $orientation;
210
            }
211
        }
212
213
        /*
214
         * We prefer to use stable orientations only, but allow unstable ones if either
215
         * the item is the last one left to pack OR
216
         * the item doesn't fit in the box any other way
217
         */
218 20
        if (count($stableOrientations) > 0) {
219 20
            $orientationsToUse = $stableOrientations;
220 19
        } elseif (count($unstableOrientations) > 0) {
221
            $stableOrientationsInEmptyBox = $this->getStableOrientationsInEmptyBox($item);
222
223
            if ($isLastItem || count($stableOrientationsInEmptyBox) === 0) {
224
                $orientationsToUse = $unstableOrientations;
225
            }
226
        }
227
228 20
        return $orientationsToUse;
229
    }
230
231
    /**
232
     * Return the orientations for this item if it were to be placed into the box with nothing else.
233
     *
234
     * @param  Item  $item
235
     * @return array
236
     */
237
    protected function getStableOrientationsInEmptyBox(Item $item)
238
    {
239
        $orientationsInEmptyBox = $this->getPossibleOrientationsInEmptyBox($item);
240
241
        return array_filter(
242
            $orientationsInEmptyBox,
243
            function (OrientatedItem $orientation) {
244
                return $orientation->isStable();
245
            }
246
        );
247
    }
248
249
    /**
250
     * Compare two items to see if they have same dimensions.
251
     *
252
     * @param Item $itemA
253
     * @param Item $itemB
254
     *
255
     * @return bool
256
     */
257 18
    protected function isSameDimensions(Item $itemA, Item $itemB)
258
    {
259 18
        $itemADimensions = [$itemA->getWidth(), $itemA->getLength(), $itemA->getDepth()];
260 18
        $itemBDimensions = [$itemB->getWidth(), $itemB->getLength(), $itemB->getDepth()];
261 18
        sort($itemADimensions);
262 18
        sort($itemBDimensions);
263
264 18
        return $itemADimensions === $itemBDimensions;
265
    }
266
267
    /**
268
     * Approximation of a forward-looking packing.
269
     *
270
     * Not an actual packing, that has additional logic regarding constraints and stackability, this focuses
271
     * purely on fit.
272
     *
273
     * @param  OrientatedItem $prevItem
274
     * @param  ItemList       $nextItems
275
     * @param  int            $originalWidthLeft
276
     * @param  int            $originalLengthLeft
277
     * @param  int            $depthLeft
278
     * @return int
279
     */
280 12
    protected function calculateAdditionalItemsPackedWithThisOrientation(
281
        OrientatedItem $prevItem,
282
        ItemList $nextItems,
283
        $originalWidthLeft,
284
        $originalLengthLeft,
285
        $depthLeft
286
    ) {
287 12
        $packedCount = 0;
288
289
        // first try packing into current row
290 12
        $currentRowWorkingSetItems = clone $nextItems;
291 12
        $nextRowWorkingSetItems = new ItemList();
292 12
        $widthLeft = $originalWidthLeft - $prevItem->getWidth();
293 12
        $lengthLeft = $originalLengthLeft;
294 12
        while (count($currentRowWorkingSetItems) > 0 && $widthLeft > 0) {
295 12
            $itemToPack = $currentRowWorkingSetItems->extract();
296 12
            $orientatedItem = $this->getBestOrientation($itemToPack, $prevItem, $currentRowWorkingSetItems, !count($currentRowWorkingSetItems), $widthLeft, $lengthLeft, $depthLeft);
297 12
            if ($orientatedItem instanceof OrientatedItem) {
298 6
                ++$packedCount;
299 6
                $widthLeft -= $orientatedItem->getWidth();
300 6
                $prevItem = $orientatedItem;
301
            } else {
302 8
                $nextRowWorkingSetItems->insert($itemToPack);
303
            }
304
        }
305
306
        // then see what happens if we try in the next row
307 12
        $widthLeft = $originalWidthLeft;
308 12
        $lengthLeft = $originalLengthLeft - $prevItem->getLength();
309 12
        while (count($nextRowWorkingSetItems) > 0 && $widthLeft > 0) {
310 8
            $itemToPack = $nextRowWorkingSetItems->extract();
311 8
            $orientatedItem = $this->getBestOrientation($itemToPack, $prevItem, $nextRowWorkingSetItems, !count($nextRowWorkingSetItems), $widthLeft, $lengthLeft, $depthLeft);
312 8
            if ($orientatedItem instanceof OrientatedItem) {
313 1
                ++$packedCount;
314 1
                $widthLeft -= $orientatedItem->getWidth();
315 1
                $prevItem = $orientatedItem;
316
            }
317
        }
318
319 12
        return $packedCount; // this isn't scientific, but is a reasonable proxy for success from an actual forward packing
320
    }
321
}
322