diff --git a/src/Drivers/Gd/Modifiers/DrawBezierModifier.php b/src/Drivers/Gd/Modifiers/DrawBezierModifier.php new file mode 100644 index 00000000..e853a2f7 --- /dev/null +++ b/src/Drivers/Gd/Modifiers/DrawBezierModifier.php @@ -0,0 +1,230 @@ +drawable->count() !== 3 && $this->drawable->count() !== 4) { + throw new GeometryException('You must specify either 3 or 4 points to create a bezier curve'); + } + + list($polygon, $polygon_border_segments) = $this->calculateBezierPoints(); + + if ($this->drawable->hasBackgroundColor() || $this->drawable->hasBorder()) { + imagealphablending($frame->native(), true); + imageantialias($frame->native(), true); + } + + if ($this->drawable->hasBackgroundColor()) { + $background_color = $this->driver()->colorProcessor($image->colorspace())->colorToNative( + $this->backgroundColor() + ); + + imagefilledpolygon( + $frame->native(), + $polygon, + $background_color + ); + } + + if ($this->drawable->hasBorder() && $this->drawable->borderSize() > 0) { + $border_color = $this->driver()->colorProcessor($image->colorspace())->colorToNative( + $this->borderColor() + ); + + if ($this->drawable->borderSize() === 1) { + imagesetthickness($frame->native(), $this->drawable->borderSize()); + + for ($i = 0; $i < count($polygon); $i += 2) { + if (array_key_exists($i + 2, $polygon) && array_key_exists($i + 3, $polygon)) { + imageline( + $frame->native(), + $polygon[$i + 0], + $polygon[$i + 1], + $polygon[$i + 2], + $polygon[$i + 3], + $border_color + ); + } + } + } else { + $polygon_border_segments_total = count($polygon_border_segments); + + for ($i = 0; $i < $polygon_border_segments_total; $i += 1) { + imagefilledpolygon( + $frame->native(), + $polygon_border_segments[$i], + $border_color + ); + } + } + } + } + + return $image; + } + + /** + * Calculate interpolation points for quadratic beziers using the Bernstein polynomial form + * + * @param float $t + * @return array{'x': float, 'y': float} + */ + public function calculateQuadraticBezierInterpolationPoint(float $t = 0.05): array + { + $remainder = 1 - $t; + $control_point_1_multiplier = $remainder * $remainder; + $control_point_2_multiplier = $remainder * $t * 2; + $control_point_3_multiplier = $t * $t; + + $x = ( + $this->drawable->first()->x() * $control_point_1_multiplier + + $this->drawable->second()->x() * $control_point_2_multiplier + + $this->drawable->last()->x() * $control_point_3_multiplier + ); + $y = ( + $this->drawable->first()->y() * $control_point_1_multiplier + + $this->drawable->second()->y() * $control_point_2_multiplier + + $this->drawable->last()->y() * $control_point_3_multiplier + ); + + return ['x' => $x, 'y' => $y]; + } + + /** + * Calculate interpolation points for cubic beziers using the Bernstein polynomial form + * + * @param float $t + * @return array{'x': float, 'y': float} + */ + public function calculateCubicBezierInterpolationPoint(float $t = 0.05): array + { + $remainder = 1 - $t; + $t_squared = $t * $t; + $remainder_squared = $remainder * $remainder; + $control_point_1_multiplier = $remainder_squared * $remainder; + $control_point_2_multiplier = $remainder_squared * $t * 3; + $control_point_3_multiplier = $t_squared * $remainder * 3; + $control_point_4_multiplier = $t_squared * $t; + + $x = ( + $this->drawable->first()->x() * $control_point_1_multiplier + + $this->drawable->second()->x() * $control_point_2_multiplier + + $this->drawable->third()->x() * $control_point_3_multiplier + + $this->drawable->last()->x() * $control_point_4_multiplier + ); + $y = ( + $this->drawable->first()->y() * $control_point_1_multiplier + + $this->drawable->second()->y() * $control_point_2_multiplier + + $this->drawable->third()->y() * $control_point_3_multiplier + + $this->drawable->last()->y() * $control_point_4_multiplier + ); + + return ['x' => $x, 'y' => $y]; + } + + /** + * Calculate the points needed to draw a quadratic or cubic bezier with optional border/stroke + * + * @throws GeometryException + * @return array{0: array, 1: array} + */ + public function calculateBezierPoints(): array + { + if ($this->drawable->count() !== 3 && $this->drawable->count() !== 4) { + throw new GeometryException('You must specify either 3 or 4 points to create a bezier curve'); + } + + $polygon = []; + $inner_polygon = []; + $outer_polygon = []; + $polygon_border_segments = []; + + // define ratio t; equivalent to 5 percent distance along edge + $t = (float) 0.05; + + $polygon[] = $this->drawable->first()->x(); + $polygon[] = $this->drawable->first()->y(); + for ($i = 0 + $t; $i < 1; $i += $t) { + if ($this->drawable->count() === 3) { + $ip = $this->calculateQuadraticBezierInterpolationPoint($i); + } elseif ($this->drawable->count() === 4) { + $ip = $this->calculateCubicBezierInterpolationPoint($i); + } + $polygon[] = (int) $ip['x']; + $polygon[] = (int) $ip['y']; + } + $polygon[] = $this->drawable->last()->x(); + $polygon[] = $this->drawable->last()->y(); + + if ($this->drawable->hasBorder() && $this->drawable->borderSize() > 1) { + // create the border/stroke effect by calculating two new curves with offset positions + // from the main polygon and then connecting the inner/outer curves to create separate + // 4-point polygon segments + $polygon_total_points = count($polygon); + $offset = ($this->drawable->borderSize() / 2); + + for ($i = 0; $i < $polygon_total_points; $i += 2) { + if (array_key_exists($i + 2, $polygon) && array_key_exists($i + 3, $polygon)) { + $dx = $polygon[$i + 2] - $polygon[$i]; + $dy = $polygon[$i + 3] - $polygon[$i + 1]; + $dxy_sqrt = ($dx * $dx + $dy * $dy) ** 0.5; + + // inner polygon + $scale = $offset / $dxy_sqrt; + $ox = -$dy * $scale; + $oy = $dx * $scale; + + $inner_polygon[] = $ox + $polygon[$i + 0]; + $inner_polygon[] = $oy + $polygon[$i + 1]; + $inner_polygon[] = $ox + $polygon[$i + 2]; + $inner_polygon[] = $oy + $polygon[$i + 3]; + + // outer polygon + $scale = -$offset / $dxy_sqrt; + $ox = -$dy * $scale; + $oy = $dx * $scale; + + $outer_polygon[] = $ox + $polygon[$i + 0]; + $outer_polygon[] = $oy + $polygon[$i + 1]; + $outer_polygon[] = $ox + $polygon[$i + 2]; + $outer_polygon[] = $oy + $polygon[$i + 3]; + } + } + + $inner_polygon_total_points = count($inner_polygon); + + for ($i = 0; $i < $inner_polygon_total_points; $i += 2) { + if (array_key_exists($i + 2, $inner_polygon) && array_key_exists($i + 3, $inner_polygon)) { + $polygon_border_segments[] = [ + $inner_polygon[$i + 0], + $inner_polygon[$i + 1], + $outer_polygon[$i + 0], + $outer_polygon[$i + 1], + $outer_polygon[$i + 2], + $outer_polygon[$i + 3], + $inner_polygon[$i + 2], + $inner_polygon[$i + 3], + ]; + } + } + } + + return [$polygon, $polygon_border_segments]; + } +} diff --git a/src/Drivers/Imagick/Modifiers/DrawBezierModifier.php b/src/Drivers/Imagick/Modifiers/DrawBezierModifier.php new file mode 100644 index 00000000..fda3ec45 --- /dev/null +++ b/src/Drivers/Imagick/Modifiers/DrawBezierModifier.php @@ -0,0 +1,77 @@ +drawable->count() !== 3 && $this->drawable->count() !== 4) { + throw new GeometryException('You must specify either 3 or 4 points to create a bezier curve'); + } + + $drawing = new ImagickDraw(); + + if ($this->drawable->hasBackgroundColor()) { + $background_color = $this->driver()->colorProcessor($image->colorspace())->colorToNative( + $this->backgroundColor() + ); + } else { + $background_color = 'transparent'; + } + + $drawing->setFillColor($background_color); + + if ($this->drawable->hasBorder() && $this->drawable->borderSize() > 0) { + $border_color = $this->driver()->colorProcessor($image->colorspace())->colorToNative( + $this->borderColor() + ); + + $drawing->setStrokeColor($border_color); + $drawing->setStrokeWidth($this->drawable->borderSize()); + } + + $drawing->pathStart(); + $drawing->pathMoveToAbsolute( + $this->drawable->first()->x(), + $this->drawable->first()->y() + ); + if ($this->drawable->count() === 3) { + $drawing->pathCurveToQuadraticBezierAbsolute( + $this->drawable->second()->x(), + $this->drawable->second()->y(), + $this->drawable->last()->x(), + $this->drawable->last()->y() + ); + } elseif ($this->drawable->count() === 4) { + $drawing->pathCurveToAbsolute( + $this->drawable->second()->x(), + $this->drawable->second()->y(), + $this->drawable->third()->x(), + $this->drawable->third()->y(), + $this->drawable->last()->x(), + $this->drawable->last()->y() + ); + } + $drawing->pathFinish(); + + foreach ($image as $frame) { + $frame->native()->drawImage($drawing); + } + + return $image; + } +} diff --git a/src/Geometry/Bezier.php b/src/Geometry/Bezier.php new file mode 100644 index 00000000..47082f4d --- /dev/null +++ b/src/Geometry/Bezier.php @@ -0,0 +1,221 @@ + + * @implements ArrayAccess + */ +class Bezier implements IteratorAggregate, Countable, ArrayAccess, DrawableInterface +{ + use HasBorder; + use HasBackgroundColor; + + /** + * Create new bezier instance + * + * @param array $points + * @param PointInterface $pivot + * @return void + */ + public function __construct( + protected array $points = [], + protected PointInterface $pivot = new Point() + ) { + } + + /** + * {@inheritdoc} + * + * @see DrawableInterface::position() + */ + public function position(): PointInterface + { + return $this->pivot; + } + + /** + * Implement iteration through all points of bezier + * + * @return Traversable + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->points); + } + + /** + * Return current pivot point + * + * @return PointInterface + */ + public function pivot(): PointInterface + { + return $this->pivot; + } + + /** + * Change pivot point to given point + * + * @param Point $pivot + * @return Bezier + */ + public function setPivot(Point $pivot): self + { + $this->pivot = $pivot; + + return $this; + } + + /** + * Return first control point of bezier + * + * @return ?Point + */ + public function first(): ?Point + { + if ($point = reset($this->points)) { + return $point; + } + + return null; + } + + /** + * Return second control point of bezier + * + * @return ?Point + */ + public function second(): ?Point + { + if (array_key_exists(1, $this->points)) { + return $this->points[1]; + } + + return null; + } + + /** + * Return third control point of bezier + * + * @return ?Point + */ + public function third(): ?Point + { + if (array_key_exists(2, $this->points)) { + return $this->points[2]; + } + + return null; + } + + /** + * Return last control point of bezier + * + * @return ?Point + */ + public function last(): ?Point + { + if ($point = end($this->points)) { + return $point; + } + + return null; + } + + /** + * Return bezier's point count + * + * @return int + */ + public function count(): int + { + return count($this->points); + } + + /** + * Determine if point exists at given offset + * + * @param mixed $offset + * @return bool + */ + public function offsetExists($offset): bool + { + return array_key_exists($offset, $this->points); + } + + /** + * Return point at given offset + * + * @param mixed $offset + * @return Point + */ + public function offsetGet($offset): mixed + { + return $this->points[$offset]; + } + + /** + * Set point at given offset + * + * @param mixed $offset + * @param Point $value + * @return void + */ + public function offsetSet($offset, $value): void + { + $this->points[$offset] = $value; + } + + /** + * Unset offset at given offset + * + * @param mixed $offset + * @return void + */ + public function offsetUnset($offset): void + { + unset($this->points[$offset]); + } + + /** + * Add given point to bezier + * + * @param Point $point + * @return Bezier + */ + public function addPoint(Point $point): self + { + $this->points[] = $point; + + return $this; + } + + /** + * Return array of all x/y values of all points of bezier + * + * @return array + */ + public function toArray(): array + { + $coordinates = []; + foreach ($this->points as $point) { + $coordinates[] = $point->x(); + $coordinates[] = $point->y(); + } + + return $coordinates; + } +} diff --git a/src/Geometry/Factories/BezierFactory.php b/src/Geometry/Factories/BezierFactory.php new file mode 100644 index 00000000..0a5a14b8 --- /dev/null +++ b/src/Geometry/Factories/BezierFactory.php @@ -0,0 +1,79 @@ +bezier = is_a($init, Bezier::class) ? $init : new Bezier([]); + + if (is_callable($init)) { + $init($this); + } + } + + /** + * Add a point to the bezier to be produced + * + * @param int $x + * @param int $y + * @return BezierFactory + */ + public function point(int $x, int $y): self + { + $this->bezier->addPoint(new Point($x, $y)); + + return $this; + } + + /** + * Set the background color of the bezier to be produced + * + * @param mixed $color + * @return BezierFactory + */ + public function background(mixed $color): self + { + $this->bezier->setBackgroundColor($color); + + return $this; + } + + /** + * Set the border color & border size of the bezier to be produced + * + * @param mixed $color + * @param int $size + * @return BezierFactory + */ + public function border(mixed $color, int $size = 1): self + { + $this->bezier->setBorder($color, $size); + + return $this; + } + + /** + * Produce the bezier + * + * @return Bezier + */ + public function __invoke(): Bezier + { + return $this->bezier; + } +} diff --git a/src/Image.php b/src/Image.php index 6e7eb7d6..8d11aef6 100644 --- a/src/Image.php +++ b/src/Image.php @@ -27,6 +27,7 @@ use Intervention\Image\Encoders\PngEncoder; use Intervention\Image\Encoders\TiffEncoder; use Intervention\Image\Encoders\WebpEncoder; use Intervention\Image\Exceptions\EncoderException; +use Intervention\Image\Geometry\Factories\BezierFactory; use Intervention\Image\Geometry\Factories\CircleFactory; use Intervention\Image\Geometry\Factories\EllipseFactory; use Intervention\Image\Geometry\Factories\LineFactory; @@ -58,6 +59,7 @@ use Intervention\Image\Modifiers\ColorspaceModifier; use Intervention\Image\Modifiers\ContainModifier; use Intervention\Image\Modifiers\ContrastModifier; use Intervention\Image\Modifiers\CropModifier; +use Intervention\Image\Modifiers\DrawBezierModifier; use Intervention\Image\Modifiers\DrawEllipseModifier; use Intervention\Image\Modifiers\DrawLineModifier; use Intervention\Image\Modifiers\DrawPixelModifier; @@ -874,6 +876,20 @@ final class Image implements ImageInterface ); } + /** + * {@inheritdoc} + * + * @see ImageInterface::drawBezier() + */ + public function drawBezier(callable $init): ImageInterface + { + return $this->modify( + new DrawBezierModifier( + call_user_func(new BezierFactory($init)), + ), + ); + } + /** * {@inheritdoc} * diff --git a/src/Interfaces/ImageInterface.php b/src/Interfaces/ImageInterface.php index b70980ed..0ecf2d3d 100644 --- a/src/Interfaces/ImageInterface.php +++ b/src/Interfaces/ImageInterface.php @@ -749,6 +749,16 @@ interface ImageInterface extends IteratorAggregate, Countable */ public function drawLine(callable $init): self; + /** + * Draw a bezier curve on the current image + * + * @link + * @param callable $init + * @throws RuntimeException + * @return ImageInterface + */ + public function drawBezier(callable $init): self; + /** * Encode image to given media (mime) type. If no type is given the image * will be encoded to the format of the originally read image. diff --git a/src/Modifiers/DrawBezierModifier.php b/src/Modifiers/DrawBezierModifier.php new file mode 100644 index 00000000..5bac23ed --- /dev/null +++ b/src/Modifiers/DrawBezierModifier.php @@ -0,0 +1,20 @@ +drawable; + } +} diff --git a/tests/Unit/Drivers/Gd/Modifiers/DrawBezierModifierTest.php b/tests/Unit/Drivers/Gd/Modifiers/DrawBezierModifierTest.php new file mode 100644 index 00000000..26414fd3 --- /dev/null +++ b/tests/Unit/Drivers/Gd/Modifiers/DrawBezierModifierTest.php @@ -0,0 +1,33 @@ +readTestImage('trim.png'); + $this->assertEquals('00aef0', $image->pickColor(14, 14)->toHex()); + $drawable = new Bezier([ + new Point(0, 0), + new Point(15, 0), + new Point(15, 15), + new Point(0, 15) + ]); + $drawable->setBackgroundColor('b53717'); + $image->modify(new DrawBezierModifier($drawable)); + $this->assertEquals('b53717', $image->pickColor(5, 5)->toHex()); + } +} diff --git a/tests/Unit/Drivers/Imagick/Modifiers/DrawBezierModifierTest.php b/tests/Unit/Drivers/Imagick/Modifiers/DrawBezierModifierTest.php new file mode 100644 index 00000000..107482be --- /dev/null +++ b/tests/Unit/Drivers/Imagick/Modifiers/DrawBezierModifierTest.php @@ -0,0 +1,33 @@ +readTestImage('trim.png'); + $this->assertEquals('00aef0', $image->pickColor(14, 14)->toHex()); + $drawable = new Bezier([ + new Point(0, 0), + new Point(15, 0), + new Point(15, 15), + new Point(0, 15) + ]); + $drawable->setBackgroundColor('b53717'); + $image->modify(new DrawBezierModifier($drawable)); + $this->assertEquals('b53717', $image->pickColor(5, 5)->toHex()); + } +} diff --git a/tests/Unit/Geometry/BezierTest.php b/tests/Unit/Geometry/BezierTest.php new file mode 100644 index 00000000..8eed7e91 --- /dev/null +++ b/tests/Unit/Geometry/BezierTest.php @@ -0,0 +1,177 @@ +assertInstanceOf(Bezier::class, $bezier); + $this->assertEquals(0, $bezier->count()); + } + + public function testCount(): void + { + $bezier = new Bezier([ + new Point(), + new Point(), + new Point(), + new Point() + ]); + $this->assertEquals(4, $bezier->count()); + } + + public function testArrayAccess(): void + { + $bezier = new Bezier([ + new Point(), + new Point(), + new Point(), + new Point() + ]); + $this->assertInstanceOf(Point::class, $bezier[0]); + $this->assertInstanceOf(Point::class, $bezier[1]); + $this->assertInstanceOf(Point::class, $bezier[2]); + $this->assertInstanceOf(Point::class, $bezier[3]); + } + + public function testAddPoint(): void + { + $bezier = new Bezier([ + new Point(), + new Point() + ]); + $this->assertEquals(2, $bezier->count()); + $result = $bezier->addPoint(new Point()); + $this->assertEquals(3, $bezier->count()); + $this->assertInstanceOf(Bezier::class, $result); + } + + public function testFirst(): void + { + $bezier = new Bezier([ + new Point(50, 45), + new Point(100, -49), + new Point(-100, 100), + new Point(200, 300), + ]); + $this->assertEquals(50, $bezier->first()->x()); + $this->assertEquals(45, $bezier->first()->y()); + } + + public function testFirstEmpty(): void + { + $bezier = new Bezier(); + $this->assertNull($bezier->first()); + } + + public function testSecond(): void + { + $bezier = new Bezier([ + new Point(50, 45), + new Point(100, -49), + new Point(-100, 100), + new Point(200, 300), + ]); + $this->assertEquals(100, $bezier->second()->x()); + $this->assertEquals(-49, $bezier->second()->y()); + } + + public function testSecondEmpty(): void + { + $bezier = new Bezier(); + $this->assertNull($bezier->second()); + } + + public function testThird(): void + { + $bezier = new Bezier([ + new Point(50, 45), + new Point(100, -49), + new Point(-100, 100), + new Point(200, 300), + ]); + $this->assertEquals(-100, $bezier->third()->x()); + $this->assertEquals(100, $bezier->third()->y()); + } + + public function testThirdEmpty(): void + { + $bezier = new Bezier(); + $this->assertNull($bezier->third()); + } + + public function testLast(): void + { + $bezier = new Bezier([ + new Point(50, 45), + new Point(100, -49), + new Point(-100, 100), + new Point(200, 300), + ]); + $this->assertEquals(200, $bezier->last()->x()); + $this->assertEquals(300, $bezier->last()->y()); + } + + public function testLastEmpty(): void + { + $bezier = new Bezier(); + $this->assertNull($bezier->last()); + } + + public function testOffsetExists(): void + { + $bezier = new Bezier(); + $this->assertFalse($bezier->offsetExists(0)); + $this->assertFalse($bezier->offsetExists(1)); + $bezier->addPoint(new Point(0, 0)); + $this->assertTrue($bezier->offsetExists(0)); + $this->assertFalse($bezier->offsetExists(1)); + } + + public function testOffsetSetUnset(): void + { + $bezier = new Bezier(); + $bezier->offsetSet(0, new Point()); + $bezier->offsetSet(2, new Point()); + $this->assertTrue($bezier->offsetExists(0)); + $this->assertFalse($bezier->offsetExists(1)); + $this->assertTrue($bezier->offsetExists(2)); + $bezier->offsetUnset(2); + $this->assertTrue($bezier->offsetExists(0)); + $this->assertFalse($bezier->offsetExists(1)); + $this->assertFalse($bezier->offsetExists(2)); + } + + public function testGetSetPivotPoint(): void + { + $bezier = new Bezier(); + $this->assertInstanceOf(Point::class, $bezier->pivot()); + $this->assertEquals(0, $bezier->pivot()->x()); + $this->assertEquals(0, $bezier->pivot()->y()); + $result = $bezier->setPivot(new Point(12, 34)); + $this->assertInstanceOf(Bezier::class, $result); + $this->assertEquals(12, $bezier->pivot()->x()); + $this->assertEquals(34, $bezier->pivot()->y()); + } + + public function testToArray(): void + { + $bezier = new Bezier([ + new Point(50, 50), + new Point(100, 50), + new Point(-50, -100), + new Point(50, 100), + ]); + $this->assertEquals([50, 50, 100, 50, -50, -100, 50, 100], $bezier->toArray()); + } +} diff --git a/tests/Unit/Geometry/Factories/BezierFactoryTest.php b/tests/Unit/Geometry/Factories/BezierFactoryTest.php new file mode 100644 index 00000000..a3e4ad30 --- /dev/null +++ b/tests/Unit/Geometry/Factories/BezierFactoryTest.php @@ -0,0 +1,31 @@ +background('f00'); + $bezier->border('ff0', 10); + $bezier->point(300, 260); + $bezier->point(150, 335); + $bezier->point(300, 410); + }); + + $bezier = $factory(); + $this->assertInstanceOf(Bezier::class, $bezier); + $this->assertTrue($bezier->hasBackgroundColor()); + $this->assertEquals('f00', $bezier->backgroundColor()); + $this->assertEquals('ff0', $bezier->borderColor()); + $this->assertEquals(10, $bezier->borderSize()); + $this->assertEquals(3, $bezier->count()); + } +}