Skip to content

Commit

Permalink
Add arc paths
Browse files Browse the repository at this point in the history
- `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
  • Loading branch information
noelwelsh committed Apr 10, 2024
1 parent 72bf78f commit 6d1741d
Show file tree
Hide file tree
Showing 8 changed files with 143 additions and 3 deletions.
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.

Expand Down
7 changes: 7 additions & 0 deletions core/shared/src/main/scala/doodle/algebra/Path.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package doodle
package algebra

import doodle.core.Angle
import doodle.core.ClosedPath
import doodle.core.OpenPath
import doodle.core.PathElement
Expand Down Expand Up @@ -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] =
Expand Down
3 changes: 3 additions & 0 deletions core/shared/src/main/scala/doodle/core/Angle.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
19 changes: 19 additions & 0 deletions core/shared/src/main/scala/doodle/core/ClosedPath.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
6 changes: 6 additions & 0 deletions core/shared/src/main/scala/doodle/core/OpenPath.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
87 changes: 87 additions & 0 deletions core/shared/src/main/scala/doodle/core/PathElement.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions image/shared/src/main/scala/doodle/image/Image.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 2 additions & 2 deletions java2d/src/test/scala/doodle/java2d/FileWriterSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 6d1741d

Please sign in to comment.