Passed
Push — issue_147 ( 80ed33 )
by Doug
04:13
created

OrientatedItemFactory::getUsableOrientations()   B

Complexity

Conditions 7
Paths 12

Size

Total Lines 32
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 7.6024

Importance

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