From 323dab2614033090ced8bad7d9ee8f3bb2a658fe Mon Sep 17 00:00:00 2001 From: Joseph Bielawski Date: Fri, 8 Jul 2022 18:17:48 +0200 Subject: [PATCH] Initial commit --- .github/FUNDING.yml | 1 + .github/workflows/tests.yml | 32 ++ .gitignore | 6 + .php-cs-fixer.php | 42 +++ LICENSE | 21 ++ README.md | 119 +++++++ composer.json | 37 ++ phpunit.xml.dist | 20 ++ src/LAFF/Analyzer/Analyzer.php | 323 ++++++++++++++++++ src/LAFF/Analyzer/Model/Box.php | 50 +++ src/LAFF/Analyzer/Model/Container.php | 93 +++++ src/LAFF/Analyzer/Model/Layer.php | 18 + src/LAFF/Analyzer/Model/Package.php | 22 ++ src/LAFF/Analyzer/Model/PackageEdge.php | 68 ++++ tests/LAFF/Analyzer/Tests/AnalyzerTest.php | 192 +++++++++++ .../Analyzer/Tests/Model/PackageEdgeTest.php | 55 +++ 16 files changed, 1099 insertions(+) create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 .php-cs-fixer.php create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml.dist create mode 100644 src/LAFF/Analyzer/Analyzer.php create mode 100644 src/LAFF/Analyzer/Model/Box.php create mode 100644 src/LAFF/Analyzer/Model/Container.php create mode 100644 src/LAFF/Analyzer/Model/Layer.php create mode 100644 src/LAFF/Analyzer/Model/Package.php create mode 100644 src/LAFF/Analyzer/Model/PackageEdge.php create mode 100644 tests/LAFF/Analyzer/Tests/AnalyzerTest.php create mode 100644 tests/LAFF/Analyzer/Tests/Model/PackageEdgeTest.php diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..564246f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: stloyd diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1e12cfb --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Tests +on: [push, pull_request] +jobs: + tests: + name: PHP ${{ matrix.php-version }} + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + php-version: + - "8.1" + + env: + php-extensions: xdebug, yaml + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: ${{ env.php-extensions }} + + - uses: "ramsey/composer-install@v2" + + - name: Running static analyse + run: php vendor/bin/phpstan analyse src/ tests/ --level max + + - name: Running tests + run: php vendor/bin/phpunit --coverage-text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d60105 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/composer.phar +/composer.lock +/.php-cs-fixer.cache +/.phpunit.result.cache +/.idea/ +/vendor/ diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..101609d --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +$fileHeaderComment = <<<'EOF' +This file is part of the PHP-LAFF package. + +(c) Joseph Bielawski + +For the full copyright and license information, please view the LICENSE +file that was distributed with this source code. +EOF; + +$finder = (new PhpCsFixer\Finder()) + ->in(__DIR__.'/src') + ->in(__DIR__.'/tests') + ->append([__FILE__]) +; + +return (new PhpCsFixer\Config()) + ->setRules( + [ + '@Symfony' => true, + '@Symfony:risky' => true, + 'header_comment' => ['header' => $fileHeaderComment], + 'protected_to_private' => false, + 'native_constant_invocation' => ['strict' => false], + 'modernize_strpos' => true, + ] + ) + ->setRiskyAllowed(true) + ->setFinder($finder) +; diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14c06d9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Joseph Bielawski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8eccdcb --- /dev/null +++ b/README.md @@ -0,0 +1,119 @@ +# PHP-LAFF Analyzer +PHP Implementation of the Largest Area Fit First (LAFF) 3D (three dimensions: length, width, height) box packing algorithm. + +With this library you can easily: +- get the required dimensions of the container that will fit all given packages, +- split packages per defined amount of containers, +- split packages per layer in a given container, +- get information about the wasted amount of space per container and per layer, +- get the number of remaining packages that couldn't fit into given containers, + +## Algorithm definition + +Implementation of the used algorithm was defined by M. Zahid Gürbüz, Selim Akyokus, Ibrahim Emiroglu, and Aysun Güran in a paper called ["An Efficient Algorithm for 3D Rectangular Box Packing"](http://www.zahidgurbuz.com/yayinlar/An%20Efficient%20Algorithm%20for%203D%20Rectangular%20Box%20Packing.pdf). + +## Installation + +> **Note** +> To use this library you need PHP in version 8.1+ + +```bash +composer require php-laff/analyzer +``` + +## Usage + +### Get the size of the required container for selected packages +```php +analyze($packages); + +$containers = $analyzer->getContainers(); +/** @var Container $container */ +$container = reset($containers); + +var_dump($container->toArray()); +// Output: +// array(3) { +// ["length"]=> +// int(50) +// ["width"]=> +// int(50) +// ["height"]=> +// int(16) +// } +var_dump($container->countLayers()); +// Output: +// int(2) +var_dump($analyzer->getWastePercentage()); +// Output (%): +// int(32) +var_dump($analyzer->getWasteVolume()); +// Output (cm3): +// int(13552) +``` + +### Check how many packages can be fitter into a given container +```php +analyze($packages, [$container]); + +var_dump($container->full); +// Output: +// bool(true) +var_dump($container->countLayers()); +// Output: +// int(1) +var_dump($container->getWastePercentage()); +// Output (%) +//: int(15) +var_dump($container->getWasteVolume()); +// Output (cm3): +// int(4752) +``` + +## Development +To install dependencies, launch the following commands: +```bash +composer install +``` + +## Run Tests +To execute full test suite, static analyse or coding style fixed, launch the following commands: +```bash +composer test +composer phpstan +composer cs-fixer +``` + +## Kudos +There is already a library for LAFF in PHP: [Cloudstek/php-laff](https://github.com/Cloudstek/php-laff); while both use the same algorithm, the internals is different. The main difference between those libraries is that this one can work on an array of containers. I want to say "thank you" for the work on that library in the past! diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..ba18ffc --- /dev/null +++ b/composer.json @@ -0,0 +1,37 @@ +{ + "name": "php-laff/analazer", + "description": "PHP Implementation of the Largest Area Fit First (LAFF) 3D (three dimension) box packing algorithm", + "license": "MIT", + "keywords": ["largest area fit first", "box packing algorithm", "packaging", "optimization problems", "three-dimensional packing", "combinatorial problem"], + "authors": [ + { + "name": "Joseph Bielawski", + "email": "stloyd@gmail.com" + } + ], + "autoload": { + "psr-4": { + "LAFF\\Analyzer\\": "src/LAFF/Analyzer/" + } + }, + "autoload-dev": { + "psr-4": { + "LAFF\\Analyzer\\Tests\\": "tests/LAFF/Analyzer/" + } + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.8", + "phpunit/phpunit": "^9.5", + "phpstan/phpstan": "^1.8" + }, + "minimum-stability": "stable", + "scripts": { + "cs-fixer": "@php vendor/bin/php-cs-fixer fix", + "test": "@php vendor/bin/phpunit", + "coverage": "@php vendor/bin/phpunit --coverage-text", + "phpstan": "@php vendor/bin/phpstan analyse src/ tests/ --level max" + } +} diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..c5cc90d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + + tests/LAFF/Analyzer/ + + + + + + src/LAFF/Analyzer + + + diff --git a/src/LAFF/Analyzer/Analyzer.php b/src/LAFF/Analyzer/Analyzer.php new file mode 100644 index 0000000..58e12b6 --- /dev/null +++ b/src/LAFF/Analyzer/Analyzer.php @@ -0,0 +1,323 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace LAFF\Analyzer; + +use LAFF\Analyzer\Model\Box; +use LAFF\Analyzer\Model\Container; +use LAFF\Analyzer\Model\Package; +use LAFF\Analyzer\Model\PackageEdge; + +final class Analyzer +{ + /** + * @var array + */ + private array $containers; + + /** + * @var array + */ + private array $packages; + + public function __construct() + { + } + + /** + * @param Package[] $packages + * @param Container[] $containers + */ + public function analyze(iterable $packages, iterable $containers = []): void + { + if (!$packages) { + throw new \InvalidArgumentException('No packages passed to analyze!'); + } + + foreach ($packages as $package) { + $this->packages[$package->identifier] = $package; + } + + foreach ($containers ?: [$this->createVirtualContainer()] as $container) { + $this->containers[$container->identifier] = $container; + } + + $this->startNewLayer($this->containers[array_key_first($this->containers)]); + } + + public function getWasteVolume(): int + { + return $this->getContainersVolume() - $this->getPackedVolume(); + } + + public function getWastePercentage(): int + { + $containersVolume = $this->getContainersVolume(); + $packedVolume = $this->getPackedVolume(); + + return $containersVolume > 0 && $packedVolume > 0 ? (int) ((($containersVolume - $packedVolume) / $containersVolume) * 100) : 0; + } + + /** + * @return array + */ + public function getContainers(): array + { + return $this->containers; + } + + public function getContainersVolume(): int + { + $volume = 0; + foreach ($this->containers as $container) { + $volume += $container->getVolume(); + } + + return $volume; + } + + public function countPackedPackages(): int + { + $i = 0; + foreach ($this->containers as $container) { + foreach ($container->packages as $layer) { + $i += \count($layer); + } + } + + return $i; + } + + public function getPackedVolume(): int + { + $volume = 0; + foreach ($this->containers as $container) { + foreach ($container->packages as $layer) { + foreach ($layer as $package) { + $volume += $package->getVolume(); + } + } + } + + return $volume; + } + + /** + * @return array + */ + public function getRemainingBoxes(): array + { + return $this->packages; + } + + public function getRemainingVolume(): int + { + $volume = 0; + foreach ($this->packages as $package) { + $volume += $package->getVolume(); + } + + return $volume; + } + + private function createVirtualContainer(): Container + { + $edges = [Box::LENGTH, Box::WIDTH, Box::HEIGHT]; + + $longestEdge = PackageEdge::calculateLongestEdge($this->packages, $edges); + $secondLongestEdge = PackageEdge::calculateLongestEdge($this->packages, array_diff($edges, [$longestEdge->name])); + + return new Container($longestEdge->size, $secondLongestEdge->size, 0, virtual: true); + } + + private function checkIfPackageFitsWithRotation(Package $existingPackage, Package $package): bool + { + if ($this->checkIfPackageFits($existingPackage, $package)) { + return true; + } + + return $this->checkIfPackageFits(Package::rotate($existingPackage), $package); + } + + private function checkIfPackageFits(Package $existingPackage, Package $package): bool + { + if ($existingPackage->length > $package->length) { + return false; + } + + if ($existingPackage->width > $package->width) { + return false; + } + + if ($existingPackage->height > $package->height) { + return false; + } + + return true; + } + + private function startNewLayer(Container $container): void + { + // Skip full containers + if ($container->full) { + return; + } + + $biggestPackage = $this->findBiggestPackage(); + + // For virtual container we can increase its height (ck = ck + ci) + if ($container->virtual) { + $container = Container::increaseHeight($container, $biggestPackage->height); + + $this->containers[$container->identifier] = $container; + } + + $layer = array_key_last($container->packages) + 1; + $container->packages[$layer][] = $biggestPackage; + + // Package is in container (ki = ki - 1) + unset($this->packages[$biggestPackage->identifier]); + + if (!$this->packages) { + return; + } + + // No space left + if (($container->getArea() - $biggestPackage->getArea()) <= 0) { + // Predefined container cannot be resized + if (!$container->virtual) { + $container->full = true; + + // If available, start layer on next container + $nextContainer = next($this->containers); + if (false === $nextContainer) { + return; + } + + $this->startNewLayer($nextContainer); + } else { + $this->startNewLayer($container); + } + + return; + } + + // Fill the space if package fits + if (($container->length - $biggestPackage->length) > 0) { + $this->insertPackageIntoContainer( + $container, + new Package( + $container->length - $biggestPackage->length, + $container->width, + $biggestPackage->height + ), + $layer + ); + } + + if (($container->width - $biggestPackage->width) > 0) { + $this->insertPackageIntoContainer( + $container, + new Package( + $biggestPackage->length, + $container->width - $biggestPackage->width, + $biggestPackage->height + ), + $layer + ); + } + + // Remaining packages must go on a new layer + if ($this->packages) { + $this->startNewLayer($container); + } + } + + private function insertPackageIntoContainer(Container $container, Package $package, int $layer): void + { + $spaceVolume = $package->getVolume(); + + $fittingPackageIndex = null; + $fittingPackageVolume = null; + foreach ($this->packages as $index => $existingPackage) { + $packageVolume = $existingPackage->getVolume(); + + // Packages with higher volume than target space should be ignored + if ($packageVolume > $spaceVolume) { + continue; + } + + if ($this->checkIfPackageFitsWithRotation($existingPackage, $package)) { + if (null !== $fittingPackageVolume || $packageVolume > $fittingPackageVolume) { + $fittingPackageIndex = $index; + $fittingPackageVolume = $packageVolume; + } + } + } + + if (null === $fittingPackageIndex) { + return; + } + + $existingPackage = $this->packages[$fittingPackageIndex]; + + $container->packages[$layer][] = $existingPackage; + unset($this->packages[$fittingPackageIndex]); + + if (($package->length - $existingPackage->length) > 0) { + $this->insertPackageIntoContainer( + $container, + new Package( + $package->length - $existingPackage->length, + $package->width, + $existingPackage->height + ), + $layer + ); + } + + if (($package->width - $existingPackage->width) > 0) { + $this->insertPackageIntoContainer( + $container, + new Package( + $existingPackage->length, + $package->width - $existingPackage->width, + $existingPackage->height + ), + $layer + ); + } + } + + private function findBiggestPackage(): Package + { + $biggestPackageIndex = null; + $biggestPackageArea = 0; + + // Find package with the biggest volume and minimum height + foreach ($this->packages as $index => $package) { + $packageArea = $package->getArea(); + + if ($packageArea > $biggestPackageArea) { + $biggestPackageArea = $packageArea; + $biggestPackageIndex = $index; + } elseif ($packageArea === $biggestPackageArea) { + if (null === $biggestPackageIndex || ($package->height < $this->packages[$biggestPackageIndex]->height)) { + $biggestPackageIndex = $index; + } + } + } + + return $this->packages[$biggestPackageIndex]; + } +} diff --git a/src/LAFF/Analyzer/Model/Box.php b/src/LAFF/Analyzer/Model/Box.php new file mode 100644 index 0000000..e32bba8 --- /dev/null +++ b/src/LAFF/Analyzer/Model/Box.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace LAFF\Analyzer\Model; + +abstract class Box +{ + public const HEIGHT = 'height'; + public const LENGTH = 'length'; + public const WIDTH = 'width'; + + public readonly string $identifier; + + public function __construct(public readonly int $length, public readonly int $width, public readonly int $height, ?string $identifier = null) + { + $this->identifier = $identifier ?: bin2hex(random_bytes(10)); + } + + public function getArea(): int + { + return $this->length * $this->width; + } + + public function getVolume(): int + { + return $this->length * $this->width * $this->height; + } + + /** + * @return array{length: int, width: int, height: int} + */ + public function toArray(): array + { + return [ + self::LENGTH => $this->length, + self::WIDTH => $this->width, + self::HEIGHT => $this->height, + ]; + } +} diff --git a/src/LAFF/Analyzer/Model/Container.php b/src/LAFF/Analyzer/Model/Container.php new file mode 100644 index 0000000..37974b1 --- /dev/null +++ b/src/LAFF/Analyzer/Model/Container.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace LAFF\Analyzer\Model; + +final class Container extends Box +{ + public readonly bool $virtual; + /** + * @var array> + */ + public array $packages = []; + public bool $full = false; + + public function __construct(int $length, int $width, int $height, ?string $identifier = null, bool $virtual = false) + { + parent::__construct($length, $width, $height, $identifier); + + $this->virtual = $virtual; + } + + public static function increaseHeight(self $container, int $height): self + { + if (!$container->virtual) { + throw new \RuntimeException('Only virtual container can be resized!'); + } + + $newContainer = new self($container->length, $container->width, $container->height + $height, $container->identifier, true); + $newContainer->packages = $container->packages; + + return $newContainer; + } + + public function countLayers(): int + { + return \count($this->packages); + } + + public function getLayerDimensions(int $layer): Layer + { + if ($layer < 0 || $layer > \count($this->packages) || !isset($this->packages[$layer])) { + throw new \OutOfRangeException(sprintf('Passed layer %d was not found!', $layer)); + } + + $edges = [self::LENGTH, self::WIDTH, self::HEIGHT]; + + $longestEdge = PackageEdge::calculateLongestEdge($this->packages[$layer], $edges); + $secondLongestEdge = PackageEdge::calculateLongestEdge($this->packages[$layer], array_diff($edges, [$longestEdge->name])); + + return new Layer( + $longestEdge->size, + $secondLongestEdge->size, + // Height of each layer is determined by height of the biggest package + $this->packages[$layer][0]->height + ); + } + + public function getPackedVolume(): int + { + $volume = 0; + + foreach ($this->packages as $packages) { + foreach ($packages as $package) { + $volume += $package->getVolume(); + } + } + + return $volume; + } + + public function getWasteVolume(): int + { + return $this->getVolume() - $this->getPackedVolume(); + } + + public function getWastePercentage(): int + { + $containersVolume = $this->getVolume(); + $packedVolume = $this->getPackedVolume(); + + return $containersVolume > 0 && $packedVolume > 0 ? (int) ((($containersVolume - $packedVolume) / $containersVolume) * 100) : 0; + } +} diff --git a/src/LAFF/Analyzer/Model/Layer.php b/src/LAFF/Analyzer/Model/Layer.php new file mode 100644 index 0000000..0367524 --- /dev/null +++ b/src/LAFF/Analyzer/Model/Layer.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace LAFF\Analyzer\Model; + +final class Layer extends Box +{ +} diff --git a/src/LAFF/Analyzer/Model/Package.php b/src/LAFF/Analyzer/Model/Package.php new file mode 100644 index 0000000..e69d4ff --- /dev/null +++ b/src/LAFF/Analyzer/Model/Package.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace LAFF\Analyzer\Model; + +final class Package extends Box +{ + public static function rotate(self $box): self + { + return new self($box->width, $box->length, $box->height); + } +} diff --git a/src/LAFF/Analyzer/Model/PackageEdge.php b/src/LAFF/Analyzer/Model/PackageEdge.php new file mode 100644 index 0000000..8b3bd48 --- /dev/null +++ b/src/LAFF/Analyzer/Model/PackageEdge.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace LAFF\Analyzer\Model; + +final class PackageEdge +{ + public function __construct(public readonly int $size, public readonly string $name) + { + } + + /** + * @param Package[] $packages + * @param string[] $edges Edges to select the longest from + */ + public static function calculateLongestEdge(array $packages, array $edges = [Box::LENGTH, Box::WIDTH, Box::HEIGHT]): self + { + self::checkEdgeName($edges); + + if (!$packages) { + throw new \InvalidArgumentException('You must pass packages information!'); + } + + $longestEdge = 0; + $longestEdgeField = null; + + foreach ($packages as $package) { + if (!$package instanceof Package) { + throw new \InvalidArgumentException('Element of packages is not a Package class!'); + } + + foreach ($edges as $edge) { + if ($package->{$edge} > $longestEdge) { + $longestEdge = (int) $package->{$edge}; + $longestEdgeField = $edge; + } + } + } + + if (!$longestEdge || !$longestEdgeField) { + throw new \InvalidArgumentException('Cannot find longest edge!'); + } + + return new self($longestEdge, $longestEdgeField); + } + + /** + * @param string[] $edges + */ + private static function checkEdgeName(array $edges): void + { + foreach ($edges as $edge) { + if (!\in_array($edge, [Box::HEIGHT, Box::LENGTH, Box::WIDTH], true)) { + throw new \InvalidArgumentException(sprintf('Unknown edge name given: %s', $edge)); + } + } + } +} diff --git a/tests/LAFF/Analyzer/Tests/AnalyzerTest.php b/tests/LAFF/Analyzer/Tests/AnalyzerTest.php new file mode 100644 index 0000000..9e79c36 --- /dev/null +++ b/tests/LAFF/Analyzer/Tests/AnalyzerTest.php @@ -0,0 +1,192 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace LAFF\Analyzer\Tests; + +use LAFF\Analyzer\Analyzer; +use LAFF\Analyzer\Model\Container; +use LAFF\Analyzer\Model\Package; +use PHPUnit\Framework\TestCase; + +final class AnalyzerTest extends TestCase +{ + public function testEmptyPackerFails(): void + { + $this->expectExceptionMessage('No packages passed to analyze!'); + $this->expectException(\InvalidArgumentException::class); + + (new Analyzer())->analyze([]); + } + + public function testCalculationOfContainerHeight(): void + { + $analyzer = new Analyzer(); + $analyzer->analyze( + [ + new Package(50, 50, 8), + new Package(33, 8, 8), + new Package(16, 20, 8), + new Package(3, 18, 8), + new Package(14, 12, 8), + ] + ); + + $containers = $analyzer->getContainers(); + $this->assertCount(1, $containers); + + /** @var Container $container */ + $container = reset($containers); + + $this->assertTrue($container->virtual); + $this->assertSame(['length' => 50, 'width' => 50, 'height' => 16], $container->toArray()); + + $this->assertSame(2, $container->countLayers()); + + $this->assertSame(5, $analyzer->countPackedPackages()); + $this->assertSame(26448, $analyzer->getPackedVolume()); + + $this->assertCount(0, $analyzer->getRemainingBoxes()); + + $this->assertSame(33, $analyzer->getWastePercentage()); + $this->assertSame(13552, $analyzer->getWasteVolume()); + + $layerOne = $container->getLayerDimensions(1); + $this->assertSame(['length' => 50, 'width' => 50, 'height' => 8], $layerOne->toArray()); + + $layerTwo = $container->getLayerDimensions(2); + $this->assertSame(['length' => 33, 'width' => 20, 'height' => 8], $layerTwo->toArray()); + } + + public function testPackingWithPredefinedContainer(): void + { + $container = new Container(65, 60, 8); + + $analyzer = new Analyzer(); + $analyzer->analyze( + [ + new Package(50, 50, 8), + new Package(33, 8, 8), + new Package(16, 20, 8), + new Package(3, 18, 8), + new Package(14, 12, 8), + ], + [$container] + ); + + $this->assertSame(1, $container->countLayers()); + $this->assertFalse($container->full); + + $this->assertSame(5, $analyzer->countPackedPackages()); + + $this->assertSame(15, $analyzer->getWastePercentage()); + $this->assertSame(4752, $analyzer->getWasteVolume()); + } + + public function testPackingPackagesWithDifferentHeight(): void + { + $analyzer = new Analyzer(); + $analyzer->analyze( + [ + new Package(33, 8, 12), + new Package(16, 20, 8), + new Package(3, 18, 3), + new Package(14, 12, 5), + ] + ); + + $containers = $analyzer->getContainers(); + $this->assertCount(1, $containers); + + /** @var Container $container */ + $container = reset($containers); + + $this->assertTrue($container->virtual); + $this->assertSame(['length' => 33, 'width' => 20, 'height' => 20], $container->toArray()); + + $this->assertSame(2, $container->countLayers()); + + $this->assertSame(4, $analyzer->countPackedPackages()); + $this->assertCount(0, $analyzer->getRemainingBoxes()); + + // Container must have 2 layers with given amount of packages on each + $layers = [1 => 3, 2 => 1]; + foreach ($container->packages as $layer => $packages) { + $this->assertCount($layers[$layer], $packages); + } + + $this->assertSame(49, $analyzer->getWastePercentage()); + $this->assertSame(6470, $analyzer->getWasteVolume()); + } + + public function testPackingWithPredefinedContainerLeavesSomePackage(): void + { + $container = new Container(50, 50, 8); + + $analyzer = new Analyzer(); + $analyzer->analyze( + [ + new Package(50, 50, 8), + new Package(33, 8, 8), + new Package(16, 20, 8), + new Package(3, 18, 8), + new Package(14, 12, 8), + ], + [$container] + ); + + $this->assertSame(1, $container->countLayers()); + $this->assertTrue($container->full); + + $this->assertSame(1, $analyzer->countPackedPackages()); + $this->assertCount(4, $analyzer->getRemainingBoxes()); + $this->assertSame(6448, $analyzer->getRemainingVolume()); + + // Waste is zero cause only one package fit into the container + $this->assertSame(0, $analyzer->getWastePercentage()); + $this->assertSame(0, $analyzer->getWasteVolume()); + } + + public function testPackingWithPredefinedContainers(): void + { + $container1 = new Container(50, 50, 8); + $container2 = new Container(50, 50, 8); + + $analyzer = new Analyzer(); + $analyzer->analyze( + [ + new Package(50, 50, 8), + new Package(33, 8, 8), + new Package(16, 20, 8), + new Package(3, 18, 8), + new Package(14, 12, 8), + ], + [$container1, $container2] + ); + + $this->assertSame(1, $container1->countLayers()); + $this->assertTrue($container1->full); + $this->assertSame(1, $container2->countLayers()); + $this->assertFalse($container2->full); + + $this->assertSame(5, $analyzer->countPackedPackages()); + $this->assertCount(0, $analyzer->getRemainingBoxes()); + + $this->assertSame(33, $analyzer->getWastePercentage()); + $this->assertSame(13552, $analyzer->getWasteVolume()); + + $this->assertSame(0, $container1->getWastePercentage()); + $this->assertSame(0, $container1->getWasteVolume()); + $this->assertSame(67, $container2->getWastePercentage()); + $this->assertSame(13552, $container2->getWasteVolume()); + } +} diff --git a/tests/LAFF/Analyzer/Tests/Model/PackageEdgeTest.php b/tests/LAFF/Analyzer/Tests/Model/PackageEdgeTest.php new file mode 100644 index 0000000..4c75d68 --- /dev/null +++ b/tests/LAFF/Analyzer/Tests/Model/PackageEdgeTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace LAFF\Analyzer\Tests\Model; + +use LAFF\Analyzer\Model\Box; +use LAFF\Analyzer\Model\Package; +use LAFF\Analyzer\Model\PackageEdge; +use PHPUnit\Framework\TestCase; + +final class PackageEdgeTest extends TestCase +{ + public function testCalculateFailsWithUnknownEdge(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown edge name given: test'); + + PackageEdge::calculateLongestEdge([], ['test']); + } + + public function testCalculateFailsWithoutAnyPackage(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('You must pass packages information!'); + + PackageEdge::calculateLongestEdge([], [Box::LENGTH]); + } + + public function testCalculateFailsWithoutAnyRealPackage(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Element of packages is not a Package class!'); + + // @phpstan-ignore-next-line + PackageEdge::calculateLongestEdge(['test'], [Box::LENGTH]); + } + + public function testCalculateWorks(): void + { + $this->assertEquals( + new PackageEdge(10, Box::LENGTH), + PackageEdge::calculateLongestEdge([new Package(10, 10, 5)], [Box::LENGTH]) + ); + } +}