diff --git a/.gitignore b/.gitignore index e26f45e..568ed18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ composer.phar /vendor/ composer.lock +.phpunit.result.cache +docker-compose.yml +Dockerfile diff --git a/.phpunit.result.cache b/.phpunit.result.cache deleted file mode 100644 index a2b6887..0000000 --- a/.phpunit.result.cache +++ /dev/null @@ -1 +0,0 @@ -C:37:"PHPUnit\Runner\DefaultTestResultCache":947:{a:2:{s:7:"defects";a:7:{s:49:"Padam87\BinPacker\Tests\BinPackerTest::testSimple";i:4;s:51:"Padam87\BinPacker\Tests\BinPackerTest::testRotation";i:3;s:51:"Padam87\BinPacker\Tests\BinPackerTest::testOverflow";i:3;s:49:"Padam87\BinPacker\Tests\BinPackerTest::testGrowth";i:3;s:50:"Padam87\BinPacker\Tests\VisualizerTest::testSimple";i:5;s:54:"Padam87\BinPacker\Tests\VisualizerTest::testStepByStep";i:5;s:52:"Padam87\BinPacker\Tests\GifMakerTest::testStepByStep";i:4;}s:5:"times";a:7:{s:49:"Padam87\BinPacker\Tests\BinPackerTest::testSimple";d:0.003;s:51:"Padam87\BinPacker\Tests\BinPackerTest::testRotation";d:0;s:51:"Padam87\BinPacker\Tests\BinPackerTest::testOverflow";d:0.009;s:49:"Padam87\BinPacker\Tests\BinPackerTest::testGrowth";d:0.01;s:50:"Padam87\BinPacker\Tests\VisualizerTest::testSimple";d:0.116;s:54:"Padam87\BinPacker\Tests\VisualizerTest::testStepByStep";d:0.232;s:52:"Padam87\BinPacker\Tests\GifMakerTest::testStepByStep";d:3.206;}}} \ No newline at end of file diff --git a/README.md b/README.md index 7ad9d0d..6bd841c 100644 --- a/README.md +++ b/README.md @@ -5,18 +5,19 @@ ### Basic ```php -$bin = new Bin(1000, 1000); -$blocks = [ - new Block(100, 100), - new Block(300, 100), - new Block(175, 125), - new Block(200, 75), - new Block(200, 75), -]; - -$packer = new BinPacker(); - -$blocks = $packer->pack($bin, $blocks); +$fitHeuristic = new BestShortSideFit(); +$splitHeuristic = new MaximizeAreaSplit(); + +$binPacker = new BinPacker($fitHeuristic, $splitHeuristic); + +$bin = new Bin(680, 980); +$blocks = []; + +for ($i = 0; $i < 30; $i++) { + $blocks[] = new Block(148, 210, true, $i + 1); +} + +$state = $binPacker->pack($bin, $blocks); ``` #### Determining the result (was a block packed?) @@ -29,9 +30,20 @@ foreach ($blocks as $block) { } ``` +Or the other way around: + +```php +foreach ($state->getUsedNodes() as $node) { + if ($node->isUsed()) { + $node->getBlock(); // packed block + } +} +``` + + ### Rotation -By default, all blocks are allowed to rotate. Rotation occures only if a fit is not found with the initial orientation. +By default, all blocks are allowed to rotate. Rotation occurs only if a fit is not found with the initial orientation. You can disable rotation by passing `false` as the 3rd parameter to the block's constructor. ```php @@ -60,45 +72,44 @@ $bin = new Bin(1000, 1000, true); You can use the visualizer to create pictures of the packed bin. ```php -$bin = new Bin(1000, 1000); -$blocks = [ - new Block(100, 100), - new Block(300, 100), - new Block(175, 125), - new Block(200, 75), - new Block(200, 75), -]; - -$packer = new BinPacker(); +$state = //...; -$blocks = $packer->pack($bin, $blocks); +$visualizer = new Visualizer(); -$image = $visualizer->visualize($bin, $blocks); +$image = $visualizer->visualize($bin, $state); ``` -This feature uses the Imagick extension, and returns an \Imagick class. You can use the result to save, or display the image. +This feature uses the GD extension, and returns a `\GdImage` class. You can use the result to save, or display the image. ```php -$image->setFormat('jpg'); -$image->writeImage('bin.jpg'); +ob_start(); + +imagejpeg($image); +$content = ob_get_contents(); + +ob_end_clean(); + + +file_put_contents($filename, $content); ``` -![visualizer](docs/bin.jpg) +![visualizer](docs/A5_B1/BSSF_MAXAS.jpg) ## GIF creator **WARNING** -The GIF creators performance is very slow. I would suggest only using it for debug purposes, or non real-time scenarios +The GIF creators performance is relatively slow. I would suggest only using it for debug purposes, or non real-time scenarios. ```php -$packer = new BinPacker(); $gifMaker = new GifMaker(new Visualizer()); - -$blocks = $packer->pack($bin, $blocks, $gifMaker); +$state = $binPacker->pack($bin, $blocks, $gifMaker); $gif = $gifMaker->create(); - -$gif->writeImages('bin.gif', true); +$gif->writeImages($filename, true); ``` -![visualizer](docs/bin.gif) +![visualizer](docs/demo.gif) + +## Growth + +![visualizer](docs/growth.gif) diff --git a/composer.json b/composer.json index 1024509..021b781 100644 --- a/composer.json +++ b/composer.json @@ -3,14 +3,16 @@ "description": "2D bin packing for PHP", "type": "library", "require": { - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "require-dev": { "phpunit/phpunit": "^8.1", + "ext-gd": "*", "ext-imagick": "*" }, "suggest": { - "ext-imagick": "To use the visualizer and GIF maker" + "ext-gd": "To use the visualizer.", + "ext-imagick": "To use the GIF maker." }, "autoload": { "psr-4": { diff --git a/docs/A5_B1/ASBF_LAS.jpg b/docs/A5_B1/ASBF_LAS.jpg new file mode 100644 index 0000000..e321380 Binary files /dev/null and b/docs/A5_B1/ASBF_LAS.jpg differ diff --git a/docs/A5_B1/ASBF_LLAS.jpg b/docs/A5_B1/ASBF_LLAS.jpg new file mode 100644 index 0000000..ce858b0 Binary files /dev/null and b/docs/A5_B1/ASBF_LLAS.jpg differ diff --git a/docs/A5_B1/ASBF_MAXAS.jpg b/docs/A5_B1/ASBF_MAXAS.jpg new file mode 100644 index 0000000..36ec363 Binary files /dev/null and b/docs/A5_B1/ASBF_MAXAS.jpg differ diff --git a/docs/A5_B1/ASBF_MINAS.jpg b/docs/A5_B1/ASBF_MINAS.jpg new file mode 100644 index 0000000..b8f02b8 Binary files /dev/null and b/docs/A5_B1/ASBF_MINAS.jpg differ diff --git a/docs/A5_B1/ASBF_SAS.jpg b/docs/A5_B1/ASBF_SAS.jpg new file mode 100644 index 0000000..e733b33 Binary files /dev/null and b/docs/A5_B1/ASBF_SAS.jpg differ diff --git a/docs/A5_B1/ASBF_SLAS.jpg b/docs/A5_B1/ASBF_SLAS.jpg new file mode 100644 index 0000000..a5979d0 Binary files /dev/null and b/docs/A5_B1/ASBF_SLAS.jpg differ diff --git a/docs/A5_B1/BLSF_LAS.jpg b/docs/A5_B1/BLSF_LAS.jpg new file mode 100644 index 0000000..6e68dbe Binary files /dev/null and b/docs/A5_B1/BLSF_LAS.jpg differ diff --git a/docs/A5_B1/BLSF_LLAS.jpg b/docs/A5_B1/BLSF_LLAS.jpg new file mode 100644 index 0000000..597811e Binary files /dev/null and b/docs/A5_B1/BLSF_LLAS.jpg differ diff --git a/docs/A5_B1/BLSF_MAXAS.jpg b/docs/A5_B1/BLSF_MAXAS.jpg new file mode 100644 index 0000000..282b982 Binary files /dev/null and b/docs/A5_B1/BLSF_MAXAS.jpg differ diff --git a/docs/A5_B1/BLSF_MINAS.jpg b/docs/A5_B1/BLSF_MINAS.jpg new file mode 100644 index 0000000..3425df3 Binary files /dev/null and b/docs/A5_B1/BLSF_MINAS.jpg differ diff --git a/docs/A5_B1/BLSF_SAS.jpg b/docs/A5_B1/BLSF_SAS.jpg new file mode 100644 index 0000000..aabce6a Binary files /dev/null and b/docs/A5_B1/BLSF_SAS.jpg differ diff --git a/docs/A5_B1/BLSF_SLAS.jpg b/docs/A5_B1/BLSF_SLAS.jpg new file mode 100644 index 0000000..dd0adae Binary files /dev/null and b/docs/A5_B1/BLSF_SLAS.jpg differ diff --git a/docs/A5_B1/BSSF_LAS.jpg b/docs/A5_B1/BSSF_LAS.jpg new file mode 100644 index 0000000..9d5c2de Binary files /dev/null and b/docs/A5_B1/BSSF_LAS.jpg differ diff --git a/docs/A5_B1/BSSF_LLAS.jpg b/docs/A5_B1/BSSF_LLAS.jpg new file mode 100644 index 0000000..a53760c Binary files /dev/null and b/docs/A5_B1/BSSF_LLAS.jpg differ diff --git a/docs/A5_B1/BSSF_MAXAS.jpg b/docs/A5_B1/BSSF_MAXAS.jpg new file mode 100644 index 0000000..7d52947 Binary files /dev/null and b/docs/A5_B1/BSSF_MAXAS.jpg differ diff --git a/docs/A5_B1/BSSF_MINAS.jpg b/docs/A5_B1/BSSF_MINAS.jpg new file mode 100644 index 0000000..ef12e1a Binary files /dev/null and b/docs/A5_B1/BSSF_MINAS.jpg differ diff --git a/docs/A5_B1/BSSF_SAS.jpg b/docs/A5_B1/BSSF_SAS.jpg new file mode 100644 index 0000000..ebba0db Binary files /dev/null and b/docs/A5_B1/BSSF_SAS.jpg differ diff --git a/docs/A5_B1/BSSF_SLAS.jpg b/docs/A5_B1/BSSF_SLAS.jpg new file mode 100644 index 0000000..b5fe48c Binary files /dev/null and b/docs/A5_B1/BSSF_SLAS.jpg differ diff --git a/docs/A5_B1/NEG_BLSF_LAS.jpg b/docs/A5_B1/NEG_BLSF_LAS.jpg new file mode 100644 index 0000000..166cfe6 Binary files /dev/null and b/docs/A5_B1/NEG_BLSF_LAS.jpg differ diff --git a/docs/A5_B1/NEG_BLSF_LLAS.jpg b/docs/A5_B1/NEG_BLSF_LLAS.jpg new file mode 100644 index 0000000..0e214e6 Binary files /dev/null and b/docs/A5_B1/NEG_BLSF_LLAS.jpg differ diff --git a/docs/A5_B1/NEG_BLSF_MAXAS.jpg b/docs/A5_B1/NEG_BLSF_MAXAS.jpg new file mode 100644 index 0000000..6d4d1d2 Binary files /dev/null and b/docs/A5_B1/NEG_BLSF_MAXAS.jpg differ diff --git a/docs/A5_B1/NEG_BLSF_MINAS.jpg b/docs/A5_B1/NEG_BLSF_MINAS.jpg new file mode 100644 index 0000000..1befe71 Binary files /dev/null and b/docs/A5_B1/NEG_BLSF_MINAS.jpg differ diff --git a/docs/A5_B1/NEG_BLSF_SAS.jpg b/docs/A5_B1/NEG_BLSF_SAS.jpg new file mode 100644 index 0000000..f8f88d4 Binary files /dev/null and b/docs/A5_B1/NEG_BLSF_SAS.jpg differ diff --git a/docs/A5_B1/NEG_BLSF_SLAS.jpg b/docs/A5_B1/NEG_BLSF_SLAS.jpg new file mode 100644 index 0000000..914c0d6 Binary files /dev/null and b/docs/A5_B1/NEG_BLSF_SLAS.jpg differ diff --git a/docs/A5_B1/NEG_BSSF_LAS.jpg b/docs/A5_B1/NEG_BSSF_LAS.jpg new file mode 100644 index 0000000..69f84cd Binary files /dev/null and b/docs/A5_B1/NEG_BSSF_LAS.jpg differ diff --git a/docs/A5_B1/NEG_BSSF_LLAS.jpg b/docs/A5_B1/NEG_BSSF_LLAS.jpg new file mode 100644 index 0000000..a3751fe Binary files /dev/null and b/docs/A5_B1/NEG_BSSF_LLAS.jpg differ diff --git a/docs/A5_B1/NEG_BSSF_MAXAS.jpg b/docs/A5_B1/NEG_BSSF_MAXAS.jpg new file mode 100644 index 0000000..f054b7e Binary files /dev/null and b/docs/A5_B1/NEG_BSSF_MAXAS.jpg differ diff --git a/docs/A5_B1/NEG_BSSF_MINAS.jpg b/docs/A5_B1/NEG_BSSF_MINAS.jpg new file mode 100644 index 0000000..929ce16 Binary files /dev/null and b/docs/A5_B1/NEG_BSSF_MINAS.jpg differ diff --git a/docs/A5_B1/NEG_BSSF_SAS.jpg b/docs/A5_B1/NEG_BSSF_SAS.jpg new file mode 100644 index 0000000..20674e6 Binary files /dev/null and b/docs/A5_B1/NEG_BSSF_SAS.jpg differ diff --git a/docs/A5_B1/NEG_BSSF_SLAS.jpg b/docs/A5_B1/NEG_BSSF_SLAS.jpg new file mode 100644 index 0000000..8e7cdf7 Binary files /dev/null and b/docs/A5_B1/NEG_BSSF_SLAS.jpg differ diff --git a/docs/bin.gif b/docs/bin.gif deleted file mode 100644 index a54736b..0000000 Binary files a/docs/bin.gif and /dev/null differ diff --git a/docs/bin.jpg b/docs/bin.jpg deleted file mode 100644 index a5341db..0000000 Binary files a/docs/bin.jpg and /dev/null differ diff --git a/docs/demo.gif b/docs/demo.gif new file mode 100644 index 0000000..5bf217c Binary files /dev/null and b/docs/demo.gif differ diff --git a/docs/generate_doc_images.php b/docs/generate_doc_images.php new file mode 100644 index 0000000..2184fda --- /dev/null +++ b/docs/generate_doc_images.php @@ -0,0 +1,212 @@ +pack($bin, $blocks); + + $filename = implode('_', [$fitHeuristic->getName(), $splitHeuristic->getName()]) . '.jpg'; + + createImage($state, $fitHeuristic, $splitHeuristic, 'A5_B1', $filename); + } + } +} + +function mergerAndSlider() +{ + $fitHeuristic = new FitHeuristic\BestLongSideFit(); + $splitHeuristic = new SplitHeuristic\LongerLeftoverAxisSplit(); + + $binPacker = new BinPacker($fitHeuristic, $splitHeuristic); + + $bin = new Bin(680, 980); + $blocks = []; + + for ($i = 0; $i < 30; $i++) { + $blocks[] = new Block(148, 210, true, $i + 1); + } + + $state = $binPacker->pack($bin, $blocks); + + createImage($state, $fitHeuristic, $splitHeuristic, 'merger_slider', '1_regular.jpg'); + + // merger + + $bin = new Bin(680, 980); + $blocks = []; + + for ($i = 0; $i < 30; $i++) { + $blocks[] = new Block(148, 210, true, $i + 1); + } + + $merger = new NodeMerger(); + $state = $binPacker->pack($bin, $blocks, function ($state, $node, $block) use ($merger) { + $merger($state); + }); + + createImage($state, $fitHeuristic, $splitHeuristic, 'merger_slider', '2_merger.jpg'); + + // slider + + $bin = new Bin(680, 980); + $blocks = []; + + for ($i = 0; $i < 30; $i++) { + $blocks[] = new Block(148, 210, true, $i + 1); + } + + $slider = new NodeSlider(); + $state = $binPacker->pack($bin, $blocks, function ($state, $node, $block) use ($slider) { + $slider($state); + }); + + createImage($state, $fitHeuristic, $splitHeuristic, 'merger_slider', '3_slider.jpg'); + + // both + + $bin = new Bin(680, 980); + $blocks = []; + + for ($i = 0; $i < 30; $i++) { + $blocks[] = new Block(148, 210, true, $i + 1); + } + + $merger = new NodeMerger(); + $slider = new NodeSlider(); + $state = $binPacker->pack($bin, $blocks, function ($state, $node, $block) use ($slider) { + $slider($state); + }); + $merger($state); + + $state = $binPacker->pack($bin, $blocks, null, $state); + + createImage($state, $fitHeuristic, $splitHeuristic, 'merger_slider', '4_both.jpg'); +} + +function gif() +{ + ini_set('memory_limit', -1); + + $fitHeuristic = new FitHeuristic\AssumeSameBlocksFit(); + $splitHeuristic = new SplitHeuristic\MaximizeAreaSplit(); + + $binPacker = new BinPacker($fitHeuristic, $splitHeuristic); + + $bin = new Bin(680, 980); + $blocks = []; + + for ($i = 0; $i < 30; $i++) { + $blocks[] = new Block(148, 210, true, $i + 1); + } + + $gifMaker = new GifMaker(new Visualizer()); + $state = $binPacker->pack($bin, $blocks, $gifMaker); + + $gif = $gifMaker->create(); + $gif->writeImages(__DIR__ . DIRECTORY_SEPARATOR . 'demo.gif', true); +} + +function growth() +{ + ini_set('memory_limit', -1); + + $fitHeuristic = new FitHeuristic\BestAreaFit(); + $splitHeuristic = new SplitHeuristic\MaximizeAreaSplit(); + $growthHeuristic = new GrowthHeuristic\GrowRightFirst(); + + $binPacker = new BinPacker($fitHeuristic, $splitHeuristic, $growthHeuristic); + + $bin = new Bin(400, 400); + $blocks = []; + + for ($i = 0; $i < 15; $i++) { + $blocks[] = new Block(200, 100, true, $i + 1); + } + + $blocks[] = new Block(300, 300, true, $i + 1); + + for ($i = 16; $i < 41; $i++) { + $blocks[] = new Block(100, 200, true, $i + 1); + } + + $blocks[] = new Block(100, 100, true, $i + 1); + + $gifMaker = new GifMaker(new Visualizer()); + $state = $binPacker->pack($bin, $blocks, $gifMaker); + + $gif = $gifMaker->create(); + $gif->writeImages(__DIR__ . DIRECTORY_SEPARATOR . 'growth.gif', true); +} + +function createImage(State $state, FitHeuristic\FitHeuristic $fitHeuristic, SplitHeuristic\SplitHeuristic $splitHeuristic, string $directory, string $filename) +{ + $visualizer = new Visualizer(); + + $image = $visualizer->visualize($state, sprintf( + 'Fit heuristic: %s; Split heuristic: %s; Fit count: %s', + $fitHeuristic->getName(), + $splitHeuristic->getName(), + count($state->getUsedNodes()) + )); + + ob_start(); + + imagejpeg($image); + $content = ob_get_contents(); + + ob_end_clean(); + + echo $filename . PHP_EOL; + + @mkdir(__DIR__ . DIRECTORY_SEPARATOR . $directory); + file_put_contents(__DIR__ . DIRECTORY_SEPARATOR . $directory . DIRECTORY_SEPARATOR . $filename, $content); +} diff --git a/docs/growth.gif b/docs/growth.gif new file mode 100644 index 0000000..647c3c4 Binary files /dev/null and b/docs/growth.gif differ diff --git a/docs/merger_slider/1_regular.jpg b/docs/merger_slider/1_regular.jpg new file mode 100644 index 0000000..597811e Binary files /dev/null and b/docs/merger_slider/1_regular.jpg differ diff --git a/docs/merger_slider/2_merger.jpg b/docs/merger_slider/2_merger.jpg new file mode 100644 index 0000000..53b337d Binary files /dev/null and b/docs/merger_slider/2_merger.jpg differ diff --git a/docs/merger_slider/3_slider.jpg b/docs/merger_slider/3_slider.jpg new file mode 100644 index 0000000..e248faf Binary files /dev/null and b/docs/merger_slider/3_slider.jpg differ diff --git a/docs/merger_slider/4_both.jpg b/docs/merger_slider/4_both.jpg new file mode 100644 index 0000000..a36da81 Binary files /dev/null and b/docs/merger_slider/4_both.jpg differ diff --git a/src/BinPacker.php b/src/BinPacker.php index 7e3b48c..e7a41ec 100644 --- a/src/BinPacker.php +++ b/src/BinPacker.php @@ -2,151 +2,125 @@ namespace Padam87\BinPacker; +use Padam87\BinPacker\Enum\SplitDirection; +use Padam87\BinPacker\FitHeuristic\FitHeuristic; +use Padam87\BinPacker\GrowthHeuristic\GrowthHeuristic; use Padam87\BinPacker\Model\Bin; use Padam87\BinPacker\Model\Block; use Padam87\BinPacker\Model\Node; +use Padam87\BinPacker\SplitHeuristic\SplitHeuristic; class BinPacker { - /** - * @param Bin $bin - * @param Block[] $blocks - * @param callable|null $stepCallback - * - * @return Block[] - */ - public function pack(Bin $bin, array $blocks, ?callable $stepCallback = null): array - { - $blocks = $this->sort($blocks); + public function __construct( + private FitHeuristic $fitHeuristic, + private SplitHeuristic $splitHeuristic, + private ?GrowthHeuristic $growthHeuristic = null + ) { + } + public function pack(Bin $bin, array $blocks, ?callable $stepCallback = null, ?State $state = null): State + { $root = new Node(0, 0, $bin->getWidth(), $bin->getHeight()); + if ($state === null) { + $state = new State($root); + } + $bin->setNode($root); /** @var Block $block */ foreach ($blocks as $block) { - $node = $this->findNodeWithRotation($root, $block); + if ($block->getNode()) { + continue; + } - if ($node === null && $bin->isGrowthAllowed()) { - $this->grow($bin, $block->getWidth(), $block->getHeight()); + $node = $this->findBestNodeForBlock($state, $block); - $node = $this->findNodeWithRotation($root, $block); + if ($node === null && $this->growthHeuristic !== null) { + ($this->growthHeuristic)($state, $block); + + if (null === $node = $this->findBestNodeForBlock($state, $block)) { + throw new \LogicException('Growth function failed.'); + } } if ($node !== null) { - $block->setNode($this->splitNode($node, $block->getWidth(), $block->getHeight())); - } + $this->splitNode($state, $node, $block); - if ($stepCallback) { - $stepCallback($bin, $blocks, $block); + $block->setNode($node); } - } - return $blocks; - } - - private function findNodeWithRotation(Node $root, Block $block) - { - if (null === $node = $this->findNode($root, $block->getWidth(), $block->getHeight())) { - if ($block->isRotatable()) { - $block->rotate(); - - if (null === $node = $this->findNode($root, $block->getWidth(), $block->getHeight())) { - $block->rotate(); // if it still won't fit, rotate it back -> prefer original if growth is enabled - } + if ($stepCallback) { + $stepCallback($state, $node, $block); } } - return $node; + return $state; } - private function findNode(Node $node, $w, $h): ?Node + private function findBestNodeForBlock(State $state, Block $block): ?Node { - if ($node->isUsed()) { - return $this->findNode($node->getRight(), $w, $h) ?: $this->findNode($node->getDown(), $w, $h); - } elseif ($w <= $node->getWidth() && $h <= $node->getHeight()) { - return $node; - } - - return null; - } - - private function splitNode(Node $node, $w, $h) - { - $node->setUsed(true); - $node->setDown(new Node($node->getX(), $node->getY() + $h, $node->getWidth(), $node->getHeight() - $h)); - $node->setRight(new Node($node->getX() + $w, $node->getY(), $node->getWidth() - $w, $h)); - $node->setWidth($w); - $node->setHeight($h); - - return $node; - } + $rotated = $block->getRotatedClone(); - private function grow(Bin $bin, $w, $h) - { - $canGrowRight = false; - $this->canGrowRight($bin->getNode(), $w, $h, $canGrowRight); + $bestNode = null; + $bestScore = PHP_INT_MAX; + $shouldRotate = false; - $shouldGrowRight = !($bin->getWidth() >= $bin->getHeight() + $h); + foreach ($state->getFreeNodes() as $freeNode) { - if ($canGrowRight && $shouldGrowRight) { - $bin->setWidth($bin->getWidth() + $w); + if ($freeNode->canContain($block)) { + $score = ($this->fitHeuristic)($freeNode, $block); - $this->growRight($bin->getNode(), $w, $h); - } else { - $bin->setHeight($bin->getHeight() + $h); + if ($score < $bestScore) { + $bestNode = $freeNode; + $bestScore = $score; + $shouldRotate = false; + } + } - $this->growDown($bin->getNode(), $w, $h); - } - } + if ($block->isRotatable() && $freeNode->canContain($rotated)) { + $score = ($this->fitHeuristic)($freeNode, $rotated); - public function canGrowRight(Node $node, $w, $h, &$can) - { - if (!$node->isUsed() && $node->getRight() === null) { - if ($node->getHeight() >= $h) { - $can = true; + if ($score < $bestScore) { + $bestNode = $freeNode; + $bestScore = $score; + $shouldRotate = true; + } } } - if ($node->getRight()) { - $this->canGrowRight($node->getRight(), $w, $h, $can); + if ($shouldRotate) { + $block->rotate(); } - if ($node->getDown()) { - $this->canGrowRight($node->getDown(), $w, $h, $can); - } + return $bestNode; } - public function growRight(Node $node, $w, $h) - { - if (!$node->isUsed() && $node->getRight() === null) { - $node->setWidth($node->getWidth() + $w); - } + private function splitNode(State $state, Node $node, Block $block) + { $w = $block->getWidth(); + $h = $block->getHeight(); - if ($node->getRight()) { - $this->growRight($node->getRight(), $w, $h); - } + $direction = ($this->splitHeuristic)($node, $block); - if ($node->getDown()) { - $this->growRight($node->getDown(), $w, $h); + if ($direction === SplitDirection::Horizontal) { + $down = new Node($node->getX(), $node->getY() + $h, $node->getWidth(), $node->getHeight() - $h); + $right = new Node($node->getX() + $w, $node->getY(), $node->getWidth() - $w, $h); + } elseif ($direction === SplitDirection::Vertical) { + $down = new Node($node->getX(), $node->getY() + $h, $w, $node->getHeight() - $h); + $right = new Node($node->getX() + $w, $node->getY(), $node->getWidth() - $w, $node->getHeight()); } - } - public function growDown(Node $node, $w, $h) - { - if ($node->getDown()) { - $this->growDown($node->getDown(), $w, $h); - } else { - $node->setHeight($node->getHeight() + $h); - } - } + $node->setWidth($w); + $node->setHeight($h); + $node->setBlock($block); - private function sort($blocks) - { - usort($blocks, function (Block $a, Block $b) { - return $a->getHeight() < $b->getHeight() ; - }); + if ($right->isValid()) { + $state->addNode($right); + } - return $blocks; + if ($down->isValid()) { + $state->addNode($down); + } } } diff --git a/src/Enum/Orientation.php b/src/Enum/Orientation.php new file mode 100644 index 0000000..17a9a4c --- /dev/null +++ b/src/Enum/Orientation.php @@ -0,0 +1,9 @@ +getHeight() % $block->getHeight(); + $percentH = $remainderH / $node->getHeight() * 100; + + $remainderW = $node->getWidth() % $block->getWidth(); + $percentW = $remainderW / $node->getWidth() * 100; + + return $percentH + $percentW; + } + + public function getName(): string + { + return 'ASBF'; + } +} diff --git a/src/FitHeuristic/BestAreaFit.php b/src/FitHeuristic/BestAreaFit.php new file mode 100644 index 0000000..0ca85ef --- /dev/null +++ b/src/FitHeuristic/BestAreaFit.php @@ -0,0 +1,22 @@ +getWidth() * $node->getHeight() - $block->getWidth() * $block->getHeight(); + } + + public function getName(): string + { + return 'BLSF'; + } +} diff --git a/src/FitHeuristic/BestLongSideFit.php b/src/FitHeuristic/BestLongSideFit.php new file mode 100644 index 0000000..064895c --- /dev/null +++ b/src/FitHeuristic/BestLongSideFit.php @@ -0,0 +1,22 @@ +getWidth() - $block->getWidth()), $node->getHeight() - $block->getHeight()); + } + + public function getName(): string + { + return 'BLSF'; + } +} diff --git a/src/FitHeuristic/BestShortSideFit.php b/src/FitHeuristic/BestShortSideFit.php new file mode 100644 index 0000000..fadaed1 --- /dev/null +++ b/src/FitHeuristic/BestShortSideFit.php @@ -0,0 +1,22 @@ +getWidth() - $block->getWidth()), $node->getHeight() - $block->getHeight()); + } + + public function getName(): string + { + return 'BSSF'; + } +} diff --git a/src/FitHeuristic/FitHeuristic.php b/src/FitHeuristic/FitHeuristic.php new file mode 100644 index 0000000..a92afbb --- /dev/null +++ b/src/FitHeuristic/FitHeuristic.php @@ -0,0 +1,13 @@ +fitHeuristic)($node, $block); + } + + public function getName(): string + { + return 'NEG_' . $this->fitHeuristic->getName(); + } +} diff --git a/src/GifMaker.php b/src/GifMaker.php index 1a5b8bc..1f33092 100644 --- a/src/GifMaker.php +++ b/src/GifMaker.php @@ -4,27 +4,22 @@ use Padam87\BinPacker\Model\Bin; use Padam87\BinPacker\Model\Block; +use Padam87\BinPacker\Model\Node; class GifMaker { - private $visualizer; - /** - * @var \Imagick[] + * @var \GdImage[] */ - private $images = []; + private array $images = []; - public function __construct(Visualizer $visualizer) + public function __construct(private Visualizer $visualizer) { - $this->visualizer = $visualizer; } - public function __invoke(Bin $bin, array $blocks, Block $currentBlock) + public function __invoke(State $state, ?Node $node, Block $block) { - $visualizer = new Visualizer(); - $image = $visualizer->visualize($bin, $blocks); - - $this->images[] = $image; + $this->images[] = $this->visualizer->visualize($state); } public function reset() @@ -37,16 +32,31 @@ public function getImages(): array return $this->images; } - public function create(int $delay = 50): \Imagick + public function create(int $delay = 100): \Imagick { $gif = new \Imagick(); $gif->setFormat("gif"); - $last = $this->images[count($this->images) - 1]; + $width = 0; + $height = 0; + + foreach ($this->images as $gdImage) { + $width = max($width, imagesx($gdImage)); + $height = max($height, imagesy($gdImage)); + } + + $gif->newImage($width, $height, 'white'); + $gif->setImageIterations(1); + + foreach ($this->images as $gdImage) { + ob_start(); + imagegif($gdImage); + $content = ob_get_contents(); + ob_end_clean(); - foreach ($this->images as $image) { + $image = new \Imagick(); + $image->readImageBlob($content); $image->setImageDelay($delay); - $image->setImageExtent($last->getImageWidth(), $last->getImageHeight()); $gif->addImage($image); } diff --git a/src/GrowthHeuristic/GrowRightFirst.php b/src/GrowthHeuristic/GrowRightFirst.php new file mode 100644 index 0000000..e74601e --- /dev/null +++ b/src/GrowthHeuristic/GrowRightFirst.php @@ -0,0 +1,127 @@ +getFreeNodes() as $node) { + if ($this->testNode($state, $node, $block)) { + return; + } + } + + if ($block->isRotatable()) { + $block->rotate(); + + foreach ($state->getFreeNodes() as $node) { + if ($this->testNode($state, $node, $block)) { + return; + } + } + + $block->rotate(); // prefer original orientation if still won't fit well + } + + //could not find suitable node to use, so just attach it to the right + $new = new Node( + $state->getWidth(), + 0, + $block->getWidth(), + $state->getHeight() + ); + + $state->addNode($new); + $state->setWidth($state->getWidth() + $block->getWidth()); + } + + public function testNode(State $state, Node $node, Block $block): bool + { + if ($node->getHeight() >= $block->getHeight() && null === $state->getNodeAtCoordinates($node->getX2() + 1, $node->getY())) { + // empty node tall enough to fit the block, but needs to be widened + $this->growNodeRight($state, $node, $block->getWidth() - $node->getWidth()); + + return true; + } elseif ($node->getWidth() >= $block->getWidth() && null === $state->getNodeAtCoordinates($node->getX(), $node->getY2() + 1)) { + // empty node wide enough to fit the block, but needs to be taller + $this->growNodeDown($state, $node, $block->getHeight() - $node->getHeight()); + + return true; + } elseif ($node->getX2() === $state->getWidth() && $node->getY2() === $state->getHeight()) { + // empty node in the bottom-right corner of the bin, can grow both ways + $this->growNodeRight($state, $node, $block->getWidth() - $node->getWidth()); + $this->growNodeDown($state, $node, $block->getHeight() - $node->getHeight()); + + return true; + } + + return false; + } + + private function growNodeRight(State $state, Node $node, float|int $growth) + { + $node->setWidth($node->getWidth() + $growth); + + if (0 !== $node->getY()) { + $above = new Node( + $state->getWidth(), + 0, + $growth, + $node->getY() + ); + + $state->addNode($above); + } + + if ($node->getY2() !== $state->getHeight()) { + $below = new Node( + $state->getWidth(), + $node->getY2(), + $growth, + $state->getHeight() - $node->getY2() + ); + + $state->addNode($below); + } + + $state->setWidth($state->getWidth() + $growth); + } + + private function growNodeDown(State $state, Node $node, float|int $growth) + { + if (0 !== $node->getX()) { + $left = new Node( + 0, + $state->getHeight(), + $node->getX(), + $growth + ); + + $state->addNode($left); + } + + if ($node->getX2() !== $state->getWidth()) { + $left = new Node( + $node->getX2(), + $node->getY2(), + $state->getWidth() - $node->getX2(), + $growth + ); + + $state->addNode($left); + } + + $node->setHeight($node->getHeight() + $growth); + $state->setHeight($state->getHeight() + $growth); + } + + public function getName(): string + { + return 'GRF'; + } +} diff --git a/src/GrowthHeuristic/GrowthHeuristic.php b/src/GrowthHeuristic/GrowthHeuristic.php new file mode 100644 index 0000000..4fa80d4 --- /dev/null +++ b/src/GrowthHeuristic/GrowthHeuristic.php @@ -0,0 +1,13 @@ +width = $width; - $this->height = $height; - $this->growthAllowed = $growthAllowed; + public function __construct( + private int|float $width, + private int|float $height, + private bool $growthAllowed = false + ) { } - public function getHeight() + public function getWidth(): int|float { - return $this->height; + return $this->width; } - public function setHeight($height) + public function setWidth(int|float $width): self { - $this->height = $height; + $this->width = $width; return $this; } - public function getWidth() + public function getHeight(): int|float { - return $this->width; + return $this->height; } - public function setWidth($width) + public function setHeight(int|float $height): self { - $this->width = $width; + $this->height = $height; return $this; - } + } public function isGrowthAllowed(): bool { @@ -79,4 +55,9 @@ public function setNode(?Node $node): self return $this; } + + public function getOrientation(): Orientation + { + return $this->getWidth() > $this->getHeight() ? Orientation::Landscape : Orientation::Portrait; + } } diff --git a/src/Model/Block.php b/src/Model/Block.php index 6943d98..4fbf775 100644 --- a/src/Model/Block.php +++ b/src/Model/Block.php @@ -2,93 +2,62 @@ namespace Padam87\BinPacker\Model; +use Padam87\BinPacker\Enum\Orientation; + class Block { - /** - * @var int|float|string - */ - private $height; - - /** - * @var int|float|string - */ - private $width; - - /** - * @var bool - */ - private $rotatable; - - /** - * An ID or name to identify the block for your own purposes. - * - * @var mixed|null - */ - private $id; - - /** - * @var Node - */ - private $node; - - public function __construct($width, $height, bool $rotatable = true, $id = null) - { - $this->setWidth($width); - $this->setHeight($height); - $this->setRotatable($rotatable); - $this->id = $id; + private ?Node $node = null; + + public function __construct( + private int|float $width, + private int|float $height, + private bool $rotatable = true, + private mixed $id = null + ) { } - public function getHeight() + public function getWidth(): int|float { - return $this->height; + return $this->width; } - public function setHeight($height): self + public function setWidth(int|float $width): self { - if (!is_numeric($height)) { - throw new \InvalidArgumentException(sprintf('Block height must be numeric, "%s" given', $height)); - } - - $this->height = $height; + $this->width = $width; return $this; } - public function getWidth() + public function getHeight(): int|float { - return $this->width; + return $this->height; } - public function setWidth($width): self + public function setHeight(int|float $height): self { - if (!is_numeric($width)) { - throw new \InvalidArgumentException(sprintf('Block width must be numeric, "%s" given', $width)); - } - - $this->width = $width; + $this->height = $height; return $this; } - public function isRotatable(): ?bool + public function isRotatable(): bool { return $this->rotatable; } - public function setRotatable(?bool $rotatable): self + public function setRotatable(bool $rotatable): self { $this->rotatable = $rotatable; return $this; } - public function getId() + public function getId(): mixed { return $this->id; } - public function setId($id): self + public function setId(mixed $id): self { $this->id = $id; @@ -109,6 +78,11 @@ public function rotate(): self return $this; } + public function getRotatedClone(): Block + { + return new Block($this->getHeight(), $this->getWidth(), $this->isRotatable(), $this->getId()); + } + public function getNode(): ?Node { return $this->node; @@ -120,4 +94,9 @@ public function setNode(?Node $node): self return $this; } + + public function getOrientation(): Orientation + { + return $this->getWidth() > $this->getHeight() ? Orientation::Landscape : Orientation::Portrait; + } } diff --git a/src/Model/Node.php b/src/Model/Node.php index 0c9c4a8..d16788b 100644 --- a/src/Model/Node.php +++ b/src/Model/Node.php @@ -2,128 +2,106 @@ namespace Padam87\BinPacker\Model; +use Padam87\BinPacker\Enum\Orientation; + class Node { - /** - * @var int|float|string - */ - private $x; - - /** - * @var int|float|string - */ - private $y; - - /** - * @var int|float|string - */ - private $width; - - /** - * @var int|float|string - */ - private $height; - - /** - * @var bool - */ - private $used; - - /** - * @var Node|null - */ - private $right; - - /** - * @var Node|null - */ - private $down; - - public function __construct($x, $y, $width, $height, bool $used = false, ?Node $right = null, ?Node $down = null) - { - foreach (['x' => $x, 'y' => $y, 'width' => $width, 'height' => $height] as $key => $value) { - if (!is_numeric($value)) { - throw new \InvalidArgumentException(sprintf('Block %s must be numeric, "%s" given', $key, $value)); - } - } + public function __construct( + private int|float $x, + private int|float $y, + private int|float $width, + private int|float $height, + private ?Block $block = null + ) { + } + public function getX(): int|float + { + return $this->x; + } + + public function setX(int|float $x): self + { $this->x = $x; - $this->y = $y; - $this->width = $width; - $this->height = $height; - $this->used = $used; - $this->right = $right; - $this->down = $down; + return $this; } - public function getX() + public function getY(): int|float { - return $this->x; + return $this->y; } - public function getY() + public function setY(int|float $y): self { - return $this->y; + $this->y = $y; + + return $this; } - public function getWidth() + public function getWidth(): int|float { return $this->width; } - public function setWidth($width) + public function setWidth(int|float $width): self { $this->width = $width; return $this; } - public function getHeight() + public function getHeight(): int|float { return $this->height; } - public function setHeight($height) + public function setHeight(int|float $height): self { $this->height = $height; return $this; } - public function isUsed(): bool + public function getX2(): int|float { - return $this->used; + return $this->x + $this->width; } - public function setUsed(bool $used): self + public function getY2(): int|float { - $this->used = $used; + return $this->y + $this->height; + } - return $this; + public function isUsed(): bool + { + return $this->block !== null; } - public function getRight(): ?Node + public function getBlock(): ?Block { - return $this->right; + return $this->block; } - - public function setRight(?Node $right): self + + public function setBlock(?Block $block): self { - $this->right = $right; - + $this->block = $block; + return $this; } - public function getDown(): ?Node + public function canContain(Block $block): bool { - return $this->down; + return $this->getHeight() >= $block->getHeight() && $this->getWidth() >= $block->getWidth(); } - public function setDown(?Node $down): self + public function getOrientation(): Orientation { - $this->down = $down; + return $this->getWidth() > $this->getHeight() ? Orientation::Landscape : Orientation::Portrait; + } - return $this; + public function isValid(): bool + { + return $this->getHeight() > 0 && $this->getWidth() > 0; } } diff --git a/src/NodeMerger.php b/src/NodeMerger.php new file mode 100644 index 0000000..14964b6 --- /dev/null +++ b/src/NodeMerger.php @@ -0,0 +1,56 @@ +getFreeNodes(); + $count = count($freeNodes); + + for ($i = 0; $i < $count; $i++) { + /** @var Node $a */ + if (null === $a = $freeNodes[$i]) { + continue; + } + + for ($j = 0; $j < $count; $j++) { + /** @var Node $b */ + if (null === $b = $freeNodes[$j]) { + continue; + } + + if ($a->getWidth() == $b->getWidth() && $a->getX() == $b->getX()) { + if ($a->getY() == $b->getY() + $b->getHeight()) { + $a->setY($a->getY() - $b->getHeight()); + $a->setHeight($a->getHeight() + $b->getHeight()); + + $state->removeNode($b); + $freeNodes[$j] = null; + } elseif ($a->getY() + $a->getHeight() == $b->getY()) { + $a->setHeight($a->getHeight() + $b->getHeight()); + + $state->removeNode($b); + $freeNodes[$j] = null; + } + } elseif ($a->getHeight() == $b->getHeight() && $a->getY() == $b->getY()) { + if ($a->getX() == $b->getX() + $b->getWidth()) { + $a->setX($a->getX() - $b->getWidth()); + $a->setWidth($a->getWidth() + $b->getWidth()); + + $state->removeNode($b); + $freeNodes[$j] = null; + } elseif ($a->getX() + $a->getWidth() == $b->getX()) { + $a->setWidth($a->getWidth() + $b->getWidth()); + + $state->removeNode($b); + $freeNodes[$j] = null; + } + } + } + } + } +} diff --git a/src/NodeSlider.php b/src/NodeSlider.php new file mode 100644 index 0000000..41b4caf --- /dev/null +++ b/src/NodeSlider.php @@ -0,0 +1,31 @@ +getFreeNodes() as $free) { + /** @var Node $used */ + foreach ($state->getUsedNodes() as $used) { + if ($free->getWidth() == $used->getWidth() && $free->getX() == $used->getX() && $free->getY() + $free->getHeight() === $used->getY()) { + $used->setY($free->getY()); + $free->setY($used->getY() + $used->getHeight()); + + } elseif ($free->getHeight() == $used->getHeight() && $free->getY() == $used->getY() && $free->getX() + $free->getWidth() === $used->getX()) { + $used->setX($free->getX()); + $free->setX($used->getX() + $used->getWidth()); + } + } + } + } +} diff --git a/src/Resources/Roboto-Light.ttf b/src/Resources/Roboto-Light.ttf new file mode 100644 index 0000000..46deecf Binary files /dev/null and b/src/Resources/Roboto-Light.ttf differ diff --git a/src/SplitHeuristic/LongerAxisSplit.php b/src/SplitHeuristic/LongerAxisSplit.php new file mode 100644 index 0000000..3b179af --- /dev/null +++ b/src/SplitHeuristic/LongerAxisSplit.php @@ -0,0 +1,20 @@ +getWidth() > $node->getHeight() ? SplitDirection::Horizontal : SplitDirection::Vertical; + } + + public function getName(): string + { + return 'LAS'; + } +} diff --git a/src/SplitHeuristic/LongerLeftoverAxisSplit.php b/src/SplitHeuristic/LongerLeftoverAxisSplit.php new file mode 100644 index 0000000..6958620 --- /dev/null +++ b/src/SplitHeuristic/LongerLeftoverAxisSplit.php @@ -0,0 +1,23 @@ +getWidth() - $block->getWidth(); + $h = $node->getWidth() - $block->getHeight(); + + return $w > $h ? SplitDirection::Horizontal : SplitDirection::Vertical; + } + + public function getName(): string + { + return 'LLAS'; + } +} diff --git a/src/SplitHeuristic/MaximizeAreaSplit.php b/src/SplitHeuristic/MaximizeAreaSplit.php new file mode 100644 index 0000000..cbe6e0b --- /dev/null +++ b/src/SplitHeuristic/MaximizeAreaSplit.php @@ -0,0 +1,23 @@ +getWidth() - $block->getWidth(); + $h = $node->getWidth() - $block->getHeight(); + + return ($block->getWidth() * $h <= $w * $block->getHeight()) ? SplitDirection::Horizontal : SplitDirection::Vertical; + } + + public function getName(): string + { + return 'MAXAS'; + } +} diff --git a/src/SplitHeuristic/MinimizeAreaSplit.php b/src/SplitHeuristic/MinimizeAreaSplit.php new file mode 100644 index 0000000..29023bb --- /dev/null +++ b/src/SplitHeuristic/MinimizeAreaSplit.php @@ -0,0 +1,23 @@ +getWidth() - $block->getWidth(); + $h = $node->getWidth() - $block->getHeight(); + + return ($block->getWidth() * $h > $w * $block->getHeight()) ? SplitDirection::Horizontal : SplitDirection::Vertical; + } + + public function getName(): string + { + return 'MINAS'; + } +} diff --git a/src/SplitHeuristic/ShorterAxisSplit.php b/src/SplitHeuristic/ShorterAxisSplit.php new file mode 100644 index 0000000..2708a1c --- /dev/null +++ b/src/SplitHeuristic/ShorterAxisSplit.php @@ -0,0 +1,20 @@ +getWidth() <= $node->getHeight() ? SplitDirection::Horizontal : SplitDirection::Vertical; + } + + public function getName(): string + { + return 'SAS'; + } +} diff --git a/src/SplitHeuristic/ShorterLeftoverAxisSplit.php b/src/SplitHeuristic/ShorterLeftoverAxisSplit.php new file mode 100644 index 0000000..58a0af9 --- /dev/null +++ b/src/SplitHeuristic/ShorterLeftoverAxisSplit.php @@ -0,0 +1,23 @@ +getWidth() - $block->getWidth(); + $h = $node->getWidth() - $block->getHeight(); + + return $w <= $h ? SplitDirection::Horizontal : SplitDirection::Vertical; + } + + public function getName(): string + { + return 'SLAS'; + } +} diff --git a/src/SplitHeuristic/SplitHeuristic.php b/src/SplitHeuristic/SplitHeuristic.php new file mode 100644 index 0000000..cda7b2d --- /dev/null +++ b/src/SplitHeuristic/SplitHeuristic.php @@ -0,0 +1,14 @@ +nodes[] = $root; + + $this->width = $root->getWidth(); + $this->height = $root->getHeight(); + } + + public function addNode(Node $node): self + { + $this->nodes[] = $node; + + return $this; + } + + public function removeNode(Node $node): self + { + foreach ($this->nodes as $k => $n) { + if ($n !== $node) { + continue; + } + + unset($this->nodes[$k]); + + break; + } + + return $this; + } + + /** + * @return Node[] + */ + public function getNodes(): array + { + return array_values($this->nodes); + } + + /** + * @return Node[] + */ + public function getUsedNodes(): array + { + return array_values(array_filter($this->nodes, fn(Node $node) => $node->isUsed())); + } + + /** + * @return Node[] + */ + public function getFreeNodes(): array + { + return array_values(array_filter($this->nodes, fn(Node $node) => !$node->isUsed())); + } + + public function getWidth(): float|int + { + return $this->width; + } + + public function setWidth(float|int $width): void + { + $this->width = $width; + } + + public function getHeight(): float|int + { + return $this->height; + } + + public function setHeight(float|int $height): void + { + $this->height = $height; + } + + public function getNodeAtCoordinates(float|int $x, float|int $y): ?Node + { + foreach ($this->getNodes() as $node) { + if ($x >= $node->getX() && $x < $node->getX2() && $y >= $node->getY() && $y < $node->getY2()) { + return $node; + } + } + + return null; + } +} diff --git a/src/Visualizer.php b/src/Visualizer.php index fcf4523..b19f646 100644 --- a/src/Visualizer.php +++ b/src/Visualizer.php @@ -3,62 +3,65 @@ namespace Padam87\BinPacker; use Padam87\BinPacker\Model\Bin; -use Padam87\BinPacker\Model\Block; use Padam87\BinPacker\Model\Node; class Visualizer { - public function visualize(Bin $bin, array $blocks) - { - $image = new \Imagick(); - $image->newImage($bin->getWidth(), $bin->getHeight(), new \ImagickPixel('white')); - - $draw = new \ImagickDraw(); + const MARGIN = 50; + const FONT_SIZE = 12; + const LINE_HEIGHT = 20; + const FONT_PATH = __DIR__ . '/Resources/Roboto-Light.ttf'; - $draw->setFillColor(new \ImagickPixel('whitesmoke')); - $draw->setStrokeColor(new \ImagickPixel('black')); - - $this->markFreeSpace($draw, $bin->getNode()); + public function __construct() + { + if (!extension_loaded('gd')) { + throw new \LogicException('The "gd" extension is required to use the visualizer'); + } + } - $draw->setFillColor(new \ImagickPixel('white')); + public function visualize(State $state, ?string $name = null): \GdImage + { + $image = imagecreatetruecolor($state->getWidth() + self::MARGIN * 2, $state->getHeight() + self::MARGIN * 2); + imageantialias($image, true); - /** @var Block $block */ - foreach ($blocks as $block) { - $node = $block->getNode(); + $black = imagecolorallocate($image, 0, 0, 0); + $white = imagecolorallocate($image, 255, 255, 255); - if ($node == null || !$node->isUsed()) { - continue; - } + imagefilledrectangle( + $image, + 0, + 0, + $state->getWidth() + self::MARGIN * 2, + $state->getHeight() + self::MARGIN * 2, + $white + ); - $draw->rectangle($node->getX(), $node->getY(), $node->getX() + $block->getWidth(), $node->getY() + $block->getHeight()); - $draw->annotation($node->getX() + 10, $node->getY() + 20, $block->getId()); - $draw->annotation($node->getX() + 10, $node->getY() + 40, sprintf('%s x %s', $block->getWidth(), $block->getHeight())); + if ($name) { + imagettftext($image, self::FONT_SIZE, 0, 25, 25, $black, self::FONT_PATH, $name); } - $image->drawImage($draw); + foreach ($state->getNodes() as $node) { + $this->drawNode($image, $node); + } return $image; } - public function markFreeSpace(\ImagickDraw $draw, Node $node) + private function drawNode(\GdImage $image, Node $node) { - if (!$node->isUsed()) { - $draw->rectangle( - $node->getX(), - $node->getY(), - $node->getX() + $node->getWidth(), - $node->getY() + $node->getHeight() - ); - $draw->annotation($node->getX() + 10, $node->getY() + 20, 'free'); - $draw->annotation($node->getX() + 10, $node->getY() + 40, sprintf('%s x %s', $node->getWidth(), $node->getHeight())); - } + $x = $node->getX() + self::MARGIN; + $y = $node->getY() + self::MARGIN; - if ($node->getRight()) { - $this->markFreeSpace($draw, $node->getRight()); - } + $black = imagecolorallocate($image, 0, 0, 0); + $white = imagecolorallocate($image, 255, 255, 255); + $whitesmoke = imagecolorallocate($image, 245, 245, 245); - if ($node->getDown()) { - $this->markFreeSpace($draw, $node->getDown()); - } + imagefilledrectangle($image, $x, $y, $x + $node->getWidth(), $y + $node->getHeight(), $node->isUsed() ? $white : $whitesmoke); + imagerectangle($image, $x, $y, $x + $node->getWidth(), $y + $node->getHeight(), $black); + + $name = $node->getBlock() ? $node->getBlock()->getId() ?? '-' : 'free'; + + imagettftext($image, self::FONT_SIZE, 0, $x + 10, $y + self::LINE_HEIGHT, $black, self::FONT_PATH, $name); + imagettftext($image, self::FONT_SIZE, 0, $x + 10, $y + self::LINE_HEIGHT * 2, $black, self::FONT_PATH, sprintf('%s x %s', $node->getWidth(), $node->getHeight())); } } diff --git a/tests/Functional/BinPackerTest.php b/tests/Functional/BinPackerTest.php index 74dcac1..e6c6722 100644 --- a/tests/Functional/BinPackerTest.php +++ b/tests/Functional/BinPackerTest.php @@ -3,8 +3,13 @@ namespace Padam87\BinPacker\Tests; use Padam87\BinPacker\BinPacker; +use Padam87\BinPacker\FitHeuristic\BestAreaFit; +use Padam87\BinPacker\GifMaker; +use Padam87\BinPacker\GrowthHeuristic\GrowRightFirst; use Padam87\BinPacker\Model\Bin; use Padam87\BinPacker\Model\Block; +use Padam87\BinPacker\SplitHeuristic\MaximizeAreaSplit; +use Padam87\BinPacker\Visualizer; use PHPUnit\Framework\TestCase; class BinPackerTest extends TestCase @@ -20,9 +25,8 @@ public function testSimple() new Block(200, 75), ]; - $packer = new BinPacker(); - - $blocks = $packer->pack($bin, $blocks); + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit()); + $packer->pack($bin, $blocks); foreach ($blocks as $block) { $this->assertTrue($block->getNode() && $block->getNode()->isUsed()); @@ -38,9 +42,8 @@ public function testRotation() $blocks = [$rotatable, $nonRotatable]; - $packer = new BinPacker(); - - $blocks = $packer->pack($bin, $blocks); + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit()); + $packer->pack($bin, $blocks); $this->assertTrue($rotatable->getNode() && $rotatable->getNode()->isUsed()); $this->assertFalse($nonRotatable->getNode() && $nonRotatable->getNode()->isUsed()); @@ -58,9 +61,8 @@ public function testOverflow() $blocks[] = clone $blockTemplate; } - $packer = new BinPacker(); - - $blocks = $packer->pack($bin, $blocks); + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit()); + $packer->pack($bin, $blocks); $packed = array_filter($blocks, function (Block $block) { return $block->getNode() && $block->getNode()->isUsed(); @@ -72,28 +74,30 @@ public function testOverflow() public function testGrowth() { - $bin = new Bin(1000, 1000, true); - - $blockTemplate = new Block(100, 100); + $bin = new Bin(400, 400, true); $blocks = []; - for ($i = 1; $i <= 200; $i++) { - $blocks[] = clone $blockTemplate; + for ($i = 1; $i <= 6; $i++) { + $blocks[] = new Block(100, 100); + $blocks[] = new Block(200, 200); } - $packer = new BinPacker(); + $blocks[] = new Block(300, 300); - $blocks = $packer->pack($bin, $blocks); + for ($i = 1; $i <= 6; $i++) { + $blocks[] = new Block(100, 100); + $blocks[] = new Block(200, 200); + } - $packed = array_filter($blocks, function (Block $block) { - return $block->getNode() && $block->getNode()->isUsed(); - }); + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit(), new GrowRightFirst()); - $this->assertCount(200, $blocks); - $this->assertCount(200, $packed); + $state = $packer->pack($bin, $blocks); + + // 6 * 2 + 1 + 6 * 2 + $this->assertCount(25, $blocks); + $this->assertCount(25, $state->getUsedNodes()); - $this->assertEquals(1500, $bin->getWidth()); - $this->assertEquals(1400, $bin->getHeight()); + $this->assertGreaterThanOrEqual(400 * 400, $state->getWidth() * $state->getHeight()); } } diff --git a/tests/Functional/GifMakerTest.php b/tests/Functional/GifMakerTest.php index 6632089..db2735e 100644 --- a/tests/Functional/GifMakerTest.php +++ b/tests/Functional/GifMakerTest.php @@ -3,9 +3,11 @@ namespace Padam87\BinPacker\Tests; use Padam87\BinPacker\BinPacker; +use Padam87\BinPacker\FitHeuristic\BestAreaFit; use Padam87\BinPacker\GifMaker; use Padam87\BinPacker\Model\Bin; use Padam87\BinPacker\Model\Block; +use Padam87\BinPacker\SplitHeuristic\MaximizeAreaSplit; use Padam87\BinPacker\Visualizer; use PHPUnit\Framework\TestCase; @@ -13,7 +15,7 @@ class GifMakerTest extends TestCase { public function testStepByStep() { - $bin = new Bin(300, 300, true); + $bin = new Bin(500, 500); $blocks = []; @@ -26,16 +28,14 @@ public function testStepByStep() } } - $packer = new BinPacker(); + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit()); $gifMaker = new GifMaker(new Visualizer()); - $blocks = $packer->pack($bin, $blocks, $gifMaker); + $packer->pack($bin, $blocks, $gifMaker); $gif = $gifMaker->create(); - //$gif->writeImages('bin.gif', true); - - $this->assertCount(count($blocks), $gifMaker->getImages()); + $this->assertEquals(count($blocks) + 1, $gif->getNumberImages()); $this->assertInstanceOf(\Imagick::class, $gif); } } diff --git a/tests/Functional/GrowRightFirstTest.php b/tests/Functional/GrowRightFirstTest.php new file mode 100644 index 0000000..2dae100 --- /dev/null +++ b/tests/Functional/GrowRightFirstTest.php @@ -0,0 +1,190 @@ +pack($bin, $blocks); + + $this->assertCount(1, $blocks); + $this->assertCount(1, $state->getUsedNodes()); + + $this->assertEquals(300 * 300, $state->getWidth() * $state->getHeight()); + } + + public function testGrowRight() + { + $bin = new Bin(200, 200, true); + + $blocks = []; + $blocks[] = new Block(100, 200); + $blocks[] = new Block(200, 200); + + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit(), new GrowRightFirst()); + + $state = $packer->pack($bin, $blocks); + + $this->assertCount(0, $state->getFreeNodes()); + $this->assertCount(2, $state->getUsedNodes()); + + $this->assertEquals(300, $state->getWidth()); + $this->assertEquals(200, $state->getHeight()); + } + + public function testGrowDown() + { + $bin = new Bin(200, 200, true); + + $blocks = []; + $blocks[] = new Block(200, 100); + $blocks[] = new Block(200, 200); + + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit(), new GrowRightFirst()); + + $state = $packer->pack($bin, $blocks); + + $this->assertCount(0, $state->getFreeNodes()); + $this->assertCount(2, $state->getUsedNodes()); + + $this->assertEquals(200, $state->getWidth()); + $this->assertEquals(300, $state->getHeight()); + } + + public function testGrowCorner() + { + $bin = new Bin(200, 200, true); + + $blocks = []; + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + $blocks[] = new Block(200, 200); + + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit(), new GrowRightFirst()); + + $state = $packer->pack($bin, $blocks); + + $this->assertCount(2, $state->getFreeNodes()); + $this->assertCount(4, $state->getUsedNodes()); + + $this->assertEquals(300, $state->getWidth()); + $this->assertEquals(300, $state->getHeight()); + } + + public function testPutEmptyNodeToTheLeft() + { + $bin = new Bin(200, 200, true); + + $blocks = []; + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit(), new GrowRightFirst()); + $state = $packer->pack($bin, $blocks); + + $state = $packer->pack($bin, [new Block(100, 200)], null, $state); + + $this->assertCount(1, $state->getFreeNodes()); + $this->assertCount(4, $state->getUsedNodes()); + + $this->assertEquals(200, $state->getWidth()); + $this->assertEquals(300, $state->getHeight()); + + $this->assertEquals(0, $state->getFreeNodes()[0]->getX()); + $this->assertEquals(200, $state->getFreeNodes()[0]->getY()); + } + + public function testPutEmptyNodeToTheRight() + { + $bin = new Bin(200, 200, true); + + $blocks = []; + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit(), new GrowRightFirst()); + $state = $packer->pack($bin, $blocks); + $state->getNodes()[2]->setBlock(null); // clear a node to make the exact scenario + + $state = $packer->pack($bin, [new Block(100, 200)], null, $state); + + $this->assertCount(1, $state->getFreeNodes()); + $this->assertCount(4, $state->getUsedNodes()); + + $this->assertEquals(200, $state->getWidth()); + $this->assertEquals(300, $state->getHeight()); + + $this->assertEquals(100, $state->getFreeNodes()[0]->getX()); + $this->assertEquals(200, $state->getFreeNodes()[0]->getY()); + } + + public function testPutEmptyNodeUp() + { + $bin = new Bin(200, 200, true); + + $blocks = []; + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit(), new GrowRightFirst()); + $state = $packer->pack($bin, $blocks); + + $state = $packer->pack($bin, [new Block(200, 100)], null, $state); + + $this->assertCount(1, $state->getFreeNodes()); + $this->assertCount(4, $state->getUsedNodes()); + + $this->assertEquals(300, $state->getWidth()); + $this->assertEquals(200, $state->getHeight()); + + $this->assertEquals(200, $state->getFreeNodes()[0]->getX()); + $this->assertEquals(0, $state->getFreeNodes()[0]->getY()); + } + + public function testPutEmptyNodeDown() + { + $bin = new Bin(200, 200, true); + + $blocks = []; + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + $blocks[] = new Block(100, 100); + + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit(), new GrowRightFirst()); + $state = $packer->pack($bin, $blocks); + $state->getNodes()[1]->setBlock(null); // clear a node to make the exact scenario + + $state = $packer->pack($bin, [new Block(200, 100)], null, $state); + + $this->assertCount(1, $state->getFreeNodes()); + $this->assertCount(4, $state->getUsedNodes()); + + $this->assertEquals(300, $state->getWidth()); + $this->assertEquals(200, $state->getHeight()); + + $this->assertEquals(200, $state->getFreeNodes()[0]->getX()); + $this->assertEquals(100, $state->getFreeNodes()[0]->getY()); + } +} diff --git a/tests/Functional/VisualizerTest.php b/tests/Functional/VisualizerTest.php index 391e6ab..c61793b 100644 --- a/tests/Functional/VisualizerTest.php +++ b/tests/Functional/VisualizerTest.php @@ -3,8 +3,10 @@ namespace Padam87\BinPacker\Tests; use Padam87\BinPacker\BinPacker; +use Padam87\BinPacker\FitHeuristic\BestAreaFit; use Padam87\BinPacker\Model\Bin; use Padam87\BinPacker\Model\Block; +use Padam87\BinPacker\SplitHeuristic\MaximizeAreaSplit; use Padam87\BinPacker\Visualizer; use PHPUnit\Framework\TestCase; @@ -25,16 +27,12 @@ public function testSimple() } } - $packer = new BinPacker(); - - $blocks = $packer->pack($bin, $blocks); + $packer = new BinPacker(new BestAreaFit(), new MaximizeAreaSplit()); + $state = $packer->pack($bin, $blocks); $visualizer = new Visualizer(); - $image = $visualizer->visualize($bin, $blocks); - - //$image->setFormat('jpg'); - //$image->writeImage('bin.jpg'); + $image = $visualizer->visualize($state); - $this->assertInstanceOf(\Imagick::class, $image); + $this->assertInstanceOf(\GdImage::class, $image); } }