Completed
Push — issue_187 ( 8952f9...e99851 )
by Doug
12:21
created

OrientatedItemFactory::isSameDimensions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 2
dl 0
loc 8
ccs 6
cts 6
cp 1
crap 1
rs 10
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 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 sort;
19
use function usort;
20
21
/**
22
 * Figure out orientations for an item and a given set of dimensions.
23
 *
24
 * @author Doug Wright
25
 * @internal
26
 */
27
class OrientatedItemFactory implements LoggerAwareInterface
28
{
29
    use LoggerAwareTrait;
30
31
    /** @var Box */
32
    protected $box;
33
34
    /**
35
     * @var OrientatedItem[]
36
     */
37
    protected static $emptyBoxCache = [];
38
39
    /**
40
     * @var int[]
41
     */
42
    protected static $lookaheadCache = [];
43
44 23
    public function __construct(Box $box)
45
    {
46 23
        $this->box = $box;
47 23
        $this->logger = new NullLogger();
48 23
    }
49
50
    /**
51
     * Get the best orientation for an item.
52
     */
53 22
    public function getBestOrientation(
54
        Item $item,
55
        ?OrientatedItem $prevItem,
56
        ItemList $nextItems,
57
        bool $isLastItem,
58
        int $widthLeft,
59
        int $lengthLeft,
60
        int $depthLeft,
61
        int $rowLength,
62
        int $x,
63
        int $y,
64
        int $z,
65
        PackedItemList $prevPackedItemList
66
    ): ?OrientatedItem {
67 22
        $possibleOrientations = $this->getPossibleOrientations($item, $prevItem, $widthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
68 22
        $usableOrientations = $this->getUsableOrientations($item, $possibleOrientations, $isLastItem);
69
70 22
        if (empty($usableOrientations)) {
71 21
            return null;
72
        }
73
74
        usort($usableOrientations, function (OrientatedItem $a, OrientatedItem $b) use ($widthLeft, $lengthLeft, $depthLeft, $nextItems, $rowLength, $x, $y, $z, $prevPackedItemList) {
75 10
            $orientationAWidthLeft = $widthLeft - $a->getWidth();
76 10
            $orientationALengthLeft = $lengthLeft - $a->getLength();
77 10
            $orientationBWidthLeft = $widthLeft - $b->getWidth();
78 10
            $orientationBLengthLeft = $lengthLeft - $b->getLength();
79
80 10
            $orientationAMinGap = min($orientationAWidthLeft, $orientationALengthLeft);
81 10
            $orientationBMinGap = min($orientationBWidthLeft, $orientationBLengthLeft);
82
83 10
            if ($orientationAMinGap === 0 && $orientationBMinGap === 0) {
84 4
                return $a->getDepth() <=> $b->getDepth();
85
            }
86 7
            if ($orientationAMinGap === 0) { // prefer A if it leaves no gap
87 2
                return -1;
88
            }
89 7
            if ($orientationBMinGap === 0) { // prefer B if it leaves no gap
90 3
                return 1;
91
            }
92
93
            // prefer leaving room for next item in current row
94 6
            if ($nextItems->count()) {
95 5
                $nextItemFitA = $this->getPossibleOrientations($nextItems->top(), $a, $orientationAWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
96 5
                $nextItemFitB = $this->getPossibleOrientations($nextItems->top(), $b, $orientationBWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
97 5
                if ($nextItemFitA && !$nextItemFitB) {
98 1
                    return -1;
99
                }
100 5
                if ($nextItemFitB && !$nextItemFitA) {
101 3
                    return 1;
102
                }
103
104
                // if not an easy either/or, do a partial lookahead
105 3
                $additionalPackedA = $this->calculateAdditionalItemsPackedWithThisOrientation($a, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
106 3
                $additionalPackedB = $this->calculateAdditionalItemsPackedWithThisOrientation($b, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
107 3
                if ($additionalPackedA !== $additionalPackedB) {
108 2
                    return $additionalPackedB <=> $additionalPackedA;
109
                }
110
            }
111
            // otherwise prefer leaving minimum possible gap, or the greatest footprint
112 3
            return $orientationAMinGap <=> $orientationBMinGap ?: $a->getSurfaceFootprint() <=> $b->getSurfaceFootprint();
113 22
        });
114
115 22
        $bestFit = reset($usableOrientations);
116 22
        $this->logger->debug('Selected best fit orientation', ['orientation' => $bestFit]);
117
118 22
        return $bestFit;
119
    }
120
121
    /**
122
     * Find all possible orientations for an item.
123
     *
124
     * @return OrientatedItem[]
125
     */
126 22
    public function getPossibleOrientations(
127
        Item $item,
128
        ?OrientatedItem $prevItem,
129
        int $widthLeft,
130
        int $lengthLeft,
131
        int $depthLeft,
132
        int $x,
133
        int $y,
134
        int $z,
135
        PackedItemList $prevPackedItemList
136
    ): array {
137 22
        $orientations = $orientationsDimensions = [];
138
139 22
        $isSame = false;
140 22
        if ($prevItem) {
141 21
            if ($item === $prevItem->getItem()) {
142 14
                $isSame = true;
143
            } else {
144 14
                $itemADimensions = [$item->getWidth(), $item->getLength(), $item->getDepth()];
145 14
                $itemBDimensions = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
146 14
                sort($itemADimensions);
147 14
                sort($itemBDimensions);
148 14
                $isSame = ($itemADimensions === $itemBDimensions);
149
            }
150
        }
151
152
        //Special case items that are the same as what we just packed - keep orientation
153 22
        if ($isSame && $prevItem) {
154 17
            $orientationsDimensions[] = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
155
        } else {
156
            //simple 2D rotation
157 22
            $orientationsDimensions[] = [$item->getWidth(), $item->getLength(), $item->getDepth()];
158 22
            $orientationsDimensions[] = [$item->getLength(), $item->getWidth(), $item->getDepth()];
159
160
            //add 3D rotation if we're allowed
161 22
            if (!$item->getKeepFlat()) {
162 14
                $orientationsDimensions[] = [$item->getWidth(), $item->getDepth(), $item->getLength()];
163 14
                $orientationsDimensions[] = [$item->getLength(), $item->getDepth(), $item->getWidth()];
164 14
                $orientationsDimensions[] = [$item->getDepth(), $item->getWidth(), $item->getLength()];
165 14
                $orientationsDimensions[] = [$item->getDepth(), $item->getLength(), $item->getWidth()];
166
            }
167
        }
168
169
        //remove any that simply don't fit
170 22
        $orientationsDimensions = array_unique($orientationsDimensions, SORT_REGULAR);
171
        $orientationsDimensions = array_filter($orientationsDimensions, static function (array $i) use ($widthLeft, $lengthLeft, $depthLeft) {
172 22
            return $i[0] <= $widthLeft && $i[1] <= $lengthLeft && $i[2] <= $depthLeft;
173 22
        });
174
175 22
        foreach ($orientationsDimensions as $dimensions) {
176 22
            $orientations[] = new OrientatedItem($item, $dimensions[0], $dimensions[1], $dimensions[2]);
177
        }
178
179 22
        if ($item instanceof ConstrainedPlacementItem) {
180
            $box = $this->box;
181
            $orientations = array_filter($orientations, static function (OrientatedItem $i) use ($box, $x, $y, $z, $prevPackedItemList) {
182
                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

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