Parametric Curves
Right now we only know how to create basic shapes like circles and rectangles. We'll need more control to create the flower shapes that are our goal. We're going to use a tool from mathematics known as a parametric equation or parametric curve to do so.
A parametric equation is a function from some input (the parameter in "parametric") to a point, a location in space. The input tells us how far along the curve we are. For example, a parametric equation for a circle might have as its input an angle and it would give us the point on the circle at that angle. In Scala we could write
def parametricCircle(angle: Angle): Point =
???
If we choose lots of different values for the input, and then draw a shape at each point we get back from the parametric equation, we can suggest the shape of the curve.
In Figure hof:parametric-circles we give an example of drawing small circles at the points generated by the parametric equation for a circle. Going from left to right we draw points every 90, 45, and 22.5 degrees. You can see how the outline of the shape, the large circle, becomes clearer as we draw more points.
To create parametric curves we need to learn 1) how to represent points in Doodle, 2) how to position an image at a particular point in space, and 3) revise a bit of geometry you might not have touched since high school. Let's look at each item in turn.
Points
In Doodle we have a Point
type to represent a position in two dimensions. We have two equivalent representations in terms of:
- x and y coordinates, called a cartesian representation; and
- in terms of an angle and distance (the radius) at that angle from the origin, called a polar representation.
This difference is shown in Figure hof:representation.
We can create points in the cartesian representation using Point(Double, Double)
where the two parameters are the x and y coordinates, and in the polar representation using Point(Double, Angle)
where we specify the radius and the angle. The table below shows the main methods on Point
.
+----------------------------+---------+----------------------------+---------------------------+
| Constructor |Type | Description | Example |
+============================+=========+============================+===========================+
|Point(Double, Double)
| Point
| Constructs a Point
using | Point(1.0, 1.0)
|
| | | the cartesian | |
| | | representation. | |
+----------------------------+---------+----------------------------+---------------------------+
|Point(Double, Angle)
| Point
| Constructs a Point
using | Point(1.0, 90.degrees)
|
| | | the polar representation. | |
+----------------------------+---------+----------------------------+---------------------------+
|
Point.zero
| Point
| Constructs a Point
at the| Point.zero
|
| | | origin (x and y are zero) | |
+----------------------------+---------+----------------------------+---------------------------+
|Point.x
| Double
| Gets the x coordinate of | Point.zero.x
|
| | | the Point
. | |
+----------------------------+---------+----------------------------+---------------------------+
|Point.y
| Double
| Gets the y coordinate of | Point.zero.y
|
| | | the Point
. | |
+----------------------------+---------+----------------------------+---------------------------+
|Point.r
| Double
| Gets the radius of the | Point.zero.r
|
| | | Point
. | |
+----------------------------+---------+----------------------------+---------------------------+
|Point.angle
| Angle
| Gets the angle of the | Point.zero.angle
|
| | | Point
. | |
+----------------------------+---------+----------------------------+---------------------------+
Flexible Layout
Can we position an Image
at a point?
So far we only know how to layout images with on
, beside
, and above
.
We need an additional tool, the at
method, to achieve more flexible layout.
Here's an example using at
that draws a dot at the corners of a square.
val dot = Image.circle(5).strokeWidth(3).strokeColor(Color.crimson)
val squareDots =
dot.at(0, 0)
.on(dot.at(0, 100))
.on(dot.at(100, 100))
.on(dot.at(100, 0))
This produces the image shown in Figure hof:square-dots.
To understand how at
layout works, and why we have to place the dots on
each other, we need to know a bit more about how Doodle does layout.
Every Image
in Doodle has a point called its origin, and a bounding box which determines the limits of the image. By convention the origin is in the center of the bounding box but this is not required. We can see the origin and bounding box of an Image
by calling the debug
method. In Figure hof:debug we show the output of the code
val c = Image.circle(40)
val c1 = c.beside(c.at(10, 10)).beside(c.at(10, -10)).debug
val c2 = c.debug.beside(c.at(10, 10).debug).beside(c.at(10, -10).debug)
val c3 = c.debug.beside(c.debug.at(10, 10)).beside(c.debug.at(10, -10))
c1.above(c2).above(c3)
This shows how the origin and bounding box change as we combines Images
.
When we layout Images
using above
, beside
, or on
it is the bounding boxes and origins that determine how the individual components are positioned relative to one another. For on
the rule is that the origins are placed on top of one another. For beside
the rule is that origins are horizontally aligned and placed so that the bounding boxes just touch. The origin of the compound image is placed equidistant from the left and right edges of the compound bounding box on the horizontal line that connects the origins of the component images. The rule for above
is the same as beside
, but we use vertical alignment instead of horizontal alignment.
Using at
we can move an Image
relative to its origin.
In the examples we're using here we want all the elements to share the same origin, so we use on
to combine Images
that we have moved using at
.
There are four ways we can call at
:
- by passing the x- and y-offset, as in
dot.at(100, 100)
;
- by passing the radius and angle, as in
dot.at(100, 90.degrees)
;
- by passing a
Point
, as indot.at(Point(100, 100))
; or
- by passing a
Vec
(a vector) giving the offset, as indot.at(Vec(100, 100))
.
We can convert a Point
to a Vec
using the toVec
method.
Point.cartesian(1.0, 1.0).toVec
// res1: Vec = Vec(x = 1.0, y = 1.0)
Geometry
The final building block is the geometry to position points.
If a point is positioned at a distance r
from the origin at an angle a
, the x- and y-coordinates are (a.cos) * r
and (a.sin) * r
respectively.
Alternatively we can just use polar form! For example, here's how we would position a point at a distance of 1 and an angle of 45 degrees.
val polar = Point(1.0, 45.degrees)
// polar: Point = Polar(r = 1.0, angle = Angle(0.7853981633974483))
val cartesian = Point((45.degrees.cos) * 1.0, (45.degrees.sin) * 1.0)
// cartesian: Point = Cartesian(x = 0.7071067811865476, y = 0.7071067811865475)
// They are the same
polar.toCartesian == cartesian
// res2: Boolean = true
cartesian.toPolar == polar
// res3: Boolean = true
Putting It All Together
We can put this all together to create a parametric circle. In cartesian coordinates the code for a parametric circle with radius 200 is
def parametricCircle(angle: Angle): Point =
Point.cartesian(angle.cos * 200, angle.sin * 200)
In polar form it is simply
def parametricCircle(angle: Angle): Point =
Point.polar(200, angle)
Now we could sample a number of points evenly spaced around the circle. To create an image we can draw something at each point (say, a triangle).
def sample(samples: Int): Image = {
// Angle.one is one complete turn. I.e. 360 degrees
val step = Angle.one / samples
val dot = Image
.triangle(10, 10)
.fillColor(Color.limeGreen)
.strokeColor(Color.lawngreen)
def loop(count: Int): Image = {
val angle = step * count
count match {
case 0 => Image.empty
case n =>
dot.at(parametricCircle(angle)).on(loop(n - 1))
}
}
loop(samples)
}
This is a structural recursion, which is hopefully a familiar pattern by now.
If we draw this we'll see the outline of a circle suggested by the triangles.
See Figure hof:triangle-circle, which shows the result of sample(72)
.
Parametric Curves as First-class Functions
So far we haven't seen anything that requires we use our parametric curves as functions instead of methods (and, indeed, we have defined them as method though we know we can easily convert methods to functions.) It's time we got something useful from functions. Remember that functions are first-class values, which means: we can pass them to a method, we can return them from a method, and we can give them a name using val
. We're going to see an example where the first property---the ability to pass them as parameters---is useful.
We've just defined a method called sample
that samples from our parametric curve. Right now it is restricted to sampling from the method parametricCircle
. It would make a lot of sense to reuse this method with different parametric curves, which means we need to be able to pass a parametric curve to sample from to the sample
method. We can do this with a function parameter. Here is what the code might look like.
def sample(samples: Int, dot: Image, curve: Angle => Point): Image = {
val step = Angle.one / samples
def loop(count: Int): Image = {
val angle = step * count
count match {
case 0 => Image.empty
case n =>
dot.at(curve(angle)).on(loop(n - 1))
}
}
loop(samples)
}
In this implementation of sample I have added two new parameters, the parametric curve to sample from and the Image
to use to draw the samples. This gives us more flexibility in the output. Now we just need to define some more parametric curves, which is what the next exercise involves.
Exercises {-}
We have some new tools in our toolbox. It's time to have some fun exploring what we can do with them.
Spirals
To create a circle we keep the radius constant as the angle increases. If, instead, the radius increases as the angle increases we'll get a spiral. (How quickly should the radius increase? It's up to you! Different choices will give you different spirals.)
Implement a function or method parametricSpiral
that creates a spiral.
<div class="solution">
Here's a type of spiral, known as a logarithmic spiral, that has a particularly pleasing shape. sample
it and see for yourself!
def parametricSpiral(angle: Angle): Point =
Point((Math.exp(angle.toTurns) - 1) * 200, angle)
</div>
Samples
Use the parametric curves we have defined so far to create something interesting. There is an example in Figure hof:psychedelic-spirals