From 6d1741d3741bf8e99d4bcfab494a5a19c1253fe8 Mon Sep 17 00:00:00 2001 From: Noel Welsh Date: Wed, 10 Apr 2024 17:17:34 +0100 Subject: [PATCH] Add `arc` paths - `PathElement` has a convenience constructor to create a circular arc - `OpenPath` provides `arc` - `ClosedPath` provides `pie`, which is the closed path equivalent of an arc (a pie slice) - `Image` and the `Path` algebra provide `arc` and `pie` convenience constructors - Update `CHANGELOG` with recent changes --- CHANGELOG.md | 14 ++- .../src/main/scala/doodle/algebra/Path.scala | 7 ++ .../src/main/scala/doodle/core/Angle.scala | 3 + .../main/scala/doodle/core/ClosedPath.scala | 19 ++++ .../src/main/scala/doodle/core/OpenPath.scala | 6 ++ .../main/scala/doodle/core/PathElement.scala | 87 +++++++++++++++++++ .../src/main/scala/doodle/image/Image.scala | 6 ++ .../scala/doodle/java2d/FileWriterSuite.scala | 4 +- 8 files changed, 143 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1d69e1c..34e8ee33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog -## 0.21.0 +## Next + +- Arcs are available as paths on `OpenPath`, `ClosedPath`, and `PathElement`, + and as conveniences on `Image` and `Picture`. + +- Add `BufferedImageWriter` algebra (by `@jCabala`) + +- Refactoring of writers (`@jCabala`) + +- SVG documentation (`@vosid8`) + + +## 0.21.0 30-Nov-2023 - `Image` supports landmarks for layout with `at` and `originAt`. diff --git a/core/shared/src/main/scala/doodle/algebra/Path.scala b/core/shared/src/main/scala/doodle/algebra/Path.scala index 8cdf51e3..4ca1af7c 100644 --- a/core/shared/src/main/scala/doodle/algebra/Path.scala +++ b/core/shared/src/main/scala/doodle/algebra/Path.scala @@ -17,6 +17,7 @@ package doodle package algebra +import doodle.core.Angle import doodle.core.ClosedPath import doodle.core.OpenPath import doodle.core.PathElement @@ -76,6 +77,12 @@ trait PathConstructor { algebra.path(path) } + def arc(diameter: Double, angle: Angle): Picture[Unit] = + path(doodle.core.OpenPath.arc(0.0, 0.0, diameter, angle)) + + def pie(diameter: Double, angle: Angle): Picture[Unit] = + path(doodle.core.ClosedPath.pie(0.0, 0.0, diameter, angle)) + def regularPolygon(sides: Int, radius: Double): Picture[Unit] = new Picture[Unit] { def apply(implicit algebra: Algebra): algebra.Drawing[Unit] = diff --git a/core/shared/src/main/scala/doodle/core/Angle.scala b/core/shared/src/main/scala/doodle/core/Angle.scala index cb464316..3c957ac7 100644 --- a/core/shared/src/main/scala/doodle/core/Angle.scala +++ b/core/shared/src/main/scala/doodle/core/Angle.scala @@ -87,6 +87,9 @@ final class Angle(val toRadians: Double) { object Angle { val TwoPi = math.Pi * 2 val zero = Angle(0.0) + val oneQuarter = turns(0.25) + val oneHalf = Angle(math.Pi) + val threeQuarters = turns(0.75) val one = Angle(TwoPi) def degrees(deg: Double): Angle = diff --git a/core/shared/src/main/scala/doodle/core/ClosedPath.scala b/core/shared/src/main/scala/doodle/core/ClosedPath.scala index 59999480..58324b8c 100644 --- a/core/shared/src/main/scala/doodle/core/ClosedPath.scala +++ b/core/shared/src/main/scala/doodle/core/ClosedPath.scala @@ -97,6 +97,25 @@ object ClosedPath { def line(x: Double, y: Double): ClosedPath = ClosedPath(PathElement.line(x, y)) + def pie(center: Point, diameter: Double, angle: Angle): ClosedPath = + pie(center.x, center.y, diameter, angle) + + /** Create a `ClosedPath` representing a pie slice: a line from `(x, y)` to + * `(x + diameter / 2, y)`, followed by a circular arc, followed by a line + * back to the start. + */ + def pie(x: Double, y: Double, diameter: Double, angle: Angle): ClosedPath = + ClosedPath( + List( + PathElement.moveTo(x, y), + PathElement.lineTo(x + (diameter / 2.0), y) + ) ++ + PathElement.arc(x, y, diameter, angle).drop(1) :+ PathElement.lineTo( + x, + y + ) + ) + def regularPolygon(sides: Int, radius: Double): ClosedPath = ClosedPath(PathElement.regularPolygon(sides, radius)) diff --git a/core/shared/src/main/scala/doodle/core/OpenPath.scala b/core/shared/src/main/scala/doodle/core/OpenPath.scala index 9f13c183..08bc88bc 100644 --- a/core/shared/src/main/scala/doodle/core/OpenPath.scala +++ b/core/shared/src/main/scala/doodle/core/OpenPath.scala @@ -88,6 +88,12 @@ object OpenPath { def apply(elts: List[PathElement]): OpenPath = new OpenPath(elts.reverse) + def arc(center: Point, diameter: Double, angle: Angle): OpenPath = + arc(center.x, center.y, diameter, angle) + + def arc(x: Double, y: Double, diameter: Double, angle: Angle): OpenPath = + OpenPath(PathElement.arc(x, y, diameter, angle)) + def circle(center: Point, diameter: Double): OpenPath = circle(center.x, center.y, diameter) diff --git a/core/shared/src/main/scala/doodle/core/PathElement.scala b/core/shared/src/main/scala/doodle/core/PathElement.scala index 40033edf..1fabf95b 100644 --- a/core/shared/src/main/scala/doodle/core/PathElement.scala +++ b/core/shared/src/main/scala/doodle/core/PathElement.scala @@ -119,6 +119,93 @@ object PathElement { ) } + /** Utility to construct a `List[PathElement]` that represents a circular arc. + * The arc starts at the 3-o'clock position and rotates counter-clockwise to + * the given `angle`. + */ + def arc( + x: Double, + y: Double, + diameter: Double, + angle: Angle + ): List[PathElement] = { + val r = diameter / 2.0 + val c = 0.551915024494 + val cR = c * r + + // Create a bezier curve describing an arc from start to end angle + // Arc should be less than 90 degrees to have acceptable error + // Formula for arc from https://ecridge.com/bezier.pdf + def smallArc(startAngle: Angle, endAngle: Angle): PathElement = { + val origin = Point(x, y) + val angle = endAngle - startAngle + val alpha = angle / 2.0 + val lambda = (4.0 - alpha.cos) / 3.0 + val mu = + alpha.sin + (((4.0 * (alpha.cos - 1)) / 3.0) * (alpha.cos / alpha.sin)) + + // The points that define the bezier relative to the origin. We don't need + // the start (a) so it's commented out but left in the the code to make it + // clearer. + // + // val a = Vec(r, -alpha).rotate(startAngle + angle) + val b = (Vec(lambda, -mu) * r).rotate(startAngle + alpha) + val c = (Vec(lambda, mu) * r).rotate(startAngle + alpha) + val d = Vec(r, alpha).rotate(startAngle + alpha) + + // Displace relative to x and y + BezierCurveTo(origin + b, origin + c, origin + d) + } + + if (angle > Angle.one) circle(Point.zero, diameter) + else if (angle > Angle.threeQuarters) { + List( + moveTo(x + r, y), + BezierCurveTo( + Point(x + r, y + cR), + Point(x + cR, y + r), + Point(x, y + r) + ), + BezierCurveTo( + Point(x + -cR, y + r), + Point(x + -r, y + cR), + Point(x + -r, y) + ), + BezierCurveTo( + Point(x + -r, y + -cR), + Point(x + -cR, y + -r), + Point(x, y + -r) + ), + smallArc(Angle.threeQuarters, angle) + ) + } else if (angle > Angle.oneHalf) { + List( + moveTo(x + r, y), + BezierCurveTo( + Point(x + r, y + cR), + Point(x + cR, y + r), + Point(x, y + r) + ), + BezierCurveTo( + Point(x + -cR, y + r), + Point(x + -r, y + cR), + Point(x + -r, y) + ), + smallArc(Angle.oneHalf, angle) + ) + } else if (angle > Angle.oneQuarter) { + List( + moveTo(x + r, y), + BezierCurveTo( + Point(x + r, y + cR), + Point(x + cR, y + r), + Point(x, y + r) + ), + smallArc(Angle.oneQuarter, angle) + ) + } else List(moveTo(x + r, y), smallArc(Angle.zero, angle)) + } + /** Construct a regular polygon */ def regularPolygon( diff --git a/image/shared/src/main/scala/doodle/image/Image.scala b/image/shared/src/main/scala/doodle/image/Image.scala index 08182f52..31a24196 100644 --- a/image/shared/src/main/scala/doodle/image/Image.scala +++ b/image/shared/src/main/scala/doodle/image/Image.scala @@ -226,9 +226,15 @@ object Image { def line(x: Double, y: Double): Image = path(doodle.core.OpenPath.line(x, y)) + def arc(diameter: Double, angle: Angle): Image = + path(doodle.core.OpenPath.arc(0.0, 0.0, diameter, angle)) + def circle(diameter: Double): Image = Circle(diameter) + def pie(diameter: Double, angle: Angle): Image = + path(doodle.core.ClosedPath.pie(0.0, 0.0, diameter, angle)) + def rectangle(width: Double, height: Double): Image = Rectangle(width, height) diff --git a/java2d/src/test/scala/doodle/java2d/FileWriterSuite.scala b/java2d/src/test/scala/doodle/java2d/FileWriterSuite.scala index a85aba21..8ec1fcf0 100644 --- a/java2d/src/test/scala/doodle/java2d/FileWriterSuite.scala +++ b/java2d/src/test/scala/doodle/java2d/FileWriterSuite.scala @@ -18,8 +18,8 @@ package doodle package java2d import cats.effect.unsafe.implicits.global -import doodle.core.format.* -import doodle.syntax.all.* +import doodle.core.format._ +import doodle.syntax.all._ import munit.FunSuite import java.io.File