Completed
Push — refactor_layerpacker ( dfc43c...84dfe0 )
by Doug
07:56
created

OrientatedItemFactory::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 2
c 0
b 0
f 0
nc 1
nop 1
dl 0
loc 4
ccs 3
cts 3
cp 1
crap 1
rs 10
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
        bool $singlePassMode
76
    ): ?OrientatedItem {
77 56
        $this->logger->debug(
78 56
            "evaluating item {$item->getDescription()} for fit",
79
            [
80 56
                'item' => $item,
81
                'space' => [
82 56
                    'widthLeft' => $widthLeft,
83 56
                    'lengthLeft' => $lengthLeft,
84 56
                    'depthLeft' => $depthLeft,
85
                ],
86
            ]
87
        );
88
89 56
        $possibleOrientations = $this->getPossibleOrientations($item, $prevItem, $widthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
90 56
        $usableOrientations = $this->getUsableOrientations($item, $possibleOrientations);
91
92 56
        if (empty($usableOrientations)) {
93 50
            return null;
94
        }
95
96
        usort($usableOrientations, function (OrientatedItem $a, OrientatedItem $b) use ($widthLeft, $lengthLeft, $depthLeft, $nextItems, $rowLength, $x, $y, $z, $prevPackedItemList, $singlePassMode) {
0 ignored issues
show
Unused Code introduced by
The import $singlePassMode is not used and could be removed.

This check looks for imports that have been defined, but are not used in the scope.

Loading history...
97
            //Prefer exact fits in width/length/depth order
98 52
            $orientationAWidthLeft = $widthLeft - $a->getWidth();
99 52
            $orientationBWidthLeft = $widthLeft - $b->getWidth();
100 52
            if ($orientationAWidthLeft === 0 && $orientationBWidthLeft > 0) {
101 7
                return -1;
102
            }
103 50
            if ($orientationAWidthLeft > 0 && $orientationBWidthLeft === 0) {
104 5
                return 1;
105
            }
106
107 50
            $orientationALengthLeft = $lengthLeft - $a->getLength();
108 50
            $orientationBLengthLeft = $lengthLeft - $b->getLength();
109 50
            if ($orientationALengthLeft === 0 && $orientationBLengthLeft > 0) {
110 14
                return -1;
111
            }
112 50
            if ($orientationALengthLeft > 0 && $orientationBLengthLeft === 0) {
113 15
                return 1;
114
            }
115
116 50
            $orientationADepthLeft = $depthLeft - $a->getDepth();
117 50
            $orientationBDepthLeft = $depthLeft - $b->getDepth();
118 50
            if ($orientationADepthLeft === 0 && $orientationBDepthLeft > 0) {
119 4
                return -1;
120
            }
121 50
            if ($orientationADepthLeft > 0 && $orientationBDepthLeft === 0) {
122 2
                return 1;
123
            }
124
125
            // prefer leaving room for next item in current row
126 49
            if ($nextItems->count()) {
127 46
                $nextItemFitA = $this->getPossibleOrientations($nextItems->top(), $a, $orientationAWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
128 46
                $nextItemFitB = $this->getPossibleOrientations($nextItems->top(), $b, $orientationBWidthLeft, $lengthLeft, $depthLeft, $x, $y, $z, $prevPackedItemList);
129 46
                if ($nextItemFitA && !$nextItemFitB) {
130 9
                    return -1;
131
                }
132 46
                if ($nextItemFitB && !$nextItemFitA) {
133 12
                    return 1;
134
                }
135
136
                // if not an easy either/or, do a partial lookahead
137 44
                $additionalPackedA = $this->calculateAdditionalItemsPackedWithThisOrientation($a, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
138 44
                $additionalPackedB = $this->calculateAdditionalItemsPackedWithThisOrientation($b, $nextItems, $widthLeft, $lengthLeft, $depthLeft, $rowLength);
139 44
                if ($additionalPackedA !== $additionalPackedB) {
140 9
                    return $additionalPackedB <=> $additionalPackedA;
141
                }
142
            }
143 45
            $orientationAMinGap = min($orientationAWidthLeft, $orientationALengthLeft);
144 45
            $orientationBMinGap = min($orientationBWidthLeft, $orientationBLengthLeft);
145
            // otherwise prefer leaving minimum possible gap, or the greatest footprint
146 45
            return $orientationAMinGap <=> $orientationBMinGap ?: $a->getSurfaceFootprint() <=> $b->getSurfaceFootprint();
147 56
        });
148
149 56
        $this->logger->debug('Selected best fit orientation', ['orientation' => $usableOrientations[0]]);
150
151 56
        return $usableOrientations[0];
152
    }
153
154
    /**
155
     * Find all possible orientations for an item.
156
     *
157
     * @return OrientatedItem[]
158
     */
159 57
    public function getPossibleOrientations(
160
        Item $item,
161
        ?OrientatedItem $prevItem,
162
        int $widthLeft,
163
        int $lengthLeft,
164
        int $depthLeft,
165
        int $x,
166
        int $y,
167
        int $z,
168
        PackedItemList $prevPackedItemList
169
    ): array {
170 57
        $orientations = $orientationsDimensions = [];
171
172
        //Special case items that are the same as what we just packed - keep orientation
173 57
        if ($prevItem && $item === $prevItem->getItem() && $prevItem->getWidth() <= $widthLeft && $prevItem->getLength() <= $lengthLeft && $prevItem->getDepth() <= $depthLeft) {
174 23
            $orientations[] = $prevItem; // reuse the existing object for a small speed boost
175
        } else {
176
            //Might be different a item but having same dimensions - apply same rule
177 57
            if ($prevItem && $prevItem->isSameDimensions($item)) {
178 41
                $orientationsDimensions[] = [$prevItem->getWidth(), $prevItem->getLength(), $prevItem->getDepth()];
179
            } else {
180
                //simple 2D rotation
181 57
                $orientationsDimensions[] = [$item->getWidth(), $item->getLength(), $item->getDepth()];
182 57
                $orientationsDimensions[] = [$item->getLength(), $item->getWidth(), $item->getDepth()];
183
184
                //add 3D rotation if we're allowed
185 57
                if (!$item->getKeepFlat()) {
186 39
                    $orientationsDimensions[] = [$item->getWidth(), $item->getDepth(), $item->getLength()];
187 39
                    $orientationsDimensions[] = [$item->getLength(), $item->getDepth(), $item->getWidth()];
188 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getWidth(), $item->getLength()];
189 39
                    $orientationsDimensions[] = [$item->getDepth(), $item->getLength(), $item->getWidth()];
190
                }
191
            }
192
193
            //remove any that simply don't fit
194 57
            foreach ($orientationsDimensions as $dimensions) {
195 57
                if ($dimensions[0] <= $widthLeft && $dimensions[1] <= $lengthLeft && $dimensions[2] <= $depthLeft) {
196 56
                    $orientations[] = new OrientatedItem($item, $dimensions[0], $dimensions[1], $dimensions[2]);
197
                }
198
            }
199
        }
200
201 57
        if ($item instanceof ConstrainedPlacementItem) {
202 2
            $box = $this->box;
203
            $orientations = array_filter($orientations, static function (OrientatedItem $i) use ($box, $x, $y, $z, $prevPackedItemList) {
204 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

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