Mix.install([
{:curves, "~> 1.0"},
{:kino, "~> 0.19"}
])Many curves, one path
A spline joins several curves end to end into one smooth path. Each piece is a segment, and the points where segments meet are knots.
knots = [{0, 0}, {2, 3}, {4, 1}, {6, 4}, {8, 2}]
knots
|> Curves.define_catmull_rom()
|> Curves.Svg.render(width: 480, height: 360, markers: knots)
|> Kino.Image.new(:svg)Walking along a spline with u
On a single curve you use t from 0.0 to 1.0. On a spline you use u, and each segment adds one
to it: 0..1 is the first segment, 1..2 the second, and so on. Whole numbers land exactly on the
knots, and Curves.max_u/1 is the end.
Drag u and watch which segment you're in:
curve = Curves.define_catmull_rom(knots)
max_u = Curves.max_u(curve)
form =
Kino.Control.form(
[u: Kino.Input.range("u", min: 0, max: max_u, step: 0.05, default: 1.5)],
report_changes: true
)
frame = Kino.Frame.new()
render = fn %{u: u} ->
{x, y} = Curves.solve!(curve, u)
segment = min(trunc(u), trunc(max_u) - 1)
t = u - segment
Kino.Layout.grid(
[
Kino.Markdown.new("""
`u = #{u}` is in **segment #{segment + 1} of #{trunc(max_u)}**, #{round(t * 100)}% of the way along it.
The point is `{#{Float.round(x * 1.0, 2)}, #{Float.round(y * 1.0, 2)}}`.
"""),
curve
|> Curves.Svg.render(width: 480, height: 360, markers: [{x, y}])
|> Kino.Image.new(:svg)
],
columns: 1
)
end
Kino.Frame.render(frame, render.(%{u: 1.5}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 1)Nine kinds of spline (and a polyline)
Curves has nine kinds of spline. They all join curves together, but they bend differently. A
polyline (define_polyline/2) is the simplest path of all: straight lines from knot to knot,
like SVG's <polyline>.
| Kind | Passes through the knots? | What you give it |
|---|---|---|
| Polyline | yes, with straight lines | just the knots |
| Catmull-Rom | yes | just the knots; it works out the bends |
| B-spline | no, only near them | just the knots; the smoothest of all |
| Bezier spline | yes | each knot plus the control points to the next one |
| Hermite | yes | each knot plus a tangent (direction and speed) |
| Cardinal | yes | just the knots, plus a tension |
| Centripetal Catmull-Rom | yes | just the knots; it never loops |
| Kochanek-Bartels | yes | just the knots, plus three dials |
| Monotone | yes | data points; it never overshoots them |
| Natural cubic | yes | just the knots; the smoothest through them |
Move the knots up and down, and switch between the kinds. The purple dots are your knots: see which splines go through them, and which only get close. (For the Bezier spline we use straight lines here, and for the Hermite spline a flat tangent at every knot. The rest use their default settings.)
form =
Kino.Control.form(
[
kind:
Kino.Input.select("Kind",
catmull_rom: "Catmull-Rom",
polyline: "Polyline (straight lines)",
b_spline: "B-spline",
bezier_spline: "Bezier spline (straight lines)",
hermite: "Hermite (flat tangents)",
cardinal: "Cardinal",
centripetal_catmull_rom: "Centripetal Catmull-Rom",
kochanek_bartels: "Kochanek-Bartels",
monotone: "Monotone",
natural_cubic: "Natural cubic"
),
y0: Kino.Input.range("Knot 1", min: -2, max: 6, step: 0.5, default: 0),
y1: Kino.Input.range("Knot 2", min: -2, max: 6, step: 0.5, default: 3),
y2: Kino.Input.range("Knot 3", min: -2, max: 6, step: 0.5, default: 1),
y3: Kino.Input.range("Knot 4", min: -2, max: 6, step: 0.5, default: 4),
y4: Kino.Input.range("Knot 5", min: -2, max: 6, step: 0.5, default: 2)
],
report_changes: true
)
frame = Kino.Frame.new()
render = fn data ->
knots = for {key, x} <- [y0: 0, y1: 2, y2: 4, y3: 6, y4: 8], do: {x, data[key]}
curve =
case data.kind do
:catmull_rom -> Curves.define_catmull_rom(knots)
:polyline -> Curves.define_polyline(knots)
:b_spline -> Curves.define_b_spline(knots)
:bezier_spline -> Curves.define_bezier_spline(Enum.map(knots, &[&1]))
:hermite -> Curves.define_hermite(Enum.map(knots, &[&1, {2, 0}]))
:cardinal -> Curves.define_cardinal(knots)
:centripetal_catmull_rom -> Curves.define_centripetal_catmull_rom(knots)
:kochanek_bartels -> Curves.define_kochanek_bartels(knots)
:monotone -> Curves.define_monotone(knots)
:natural_cubic -> Curves.define_natural_cubic(knots)
end
curve
|> Curves.Svg.render(width: 480, height: 360, markers: knots, show_points: false)
|> Kino.Image.new(:svg)
end
Kino.Frame.render(frame, render.(%{kind: :catmull_rom, y0: 0, y1: 3, y2: 1, y3: 4, y4: 2}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)Turn the dials
Five more splines are Catmull-Rom with a different recipe for the tangents. Three of them have dials:
- Cardinal:
tensionpulls the curve tighter at each knot (0is Catmull-Rom,1is straight lines). - Centripetal Catmull-Rom:
alphasets how much the distances between knots count. At0it's plain Catmull-Rom, which makes a little loop between the two bunched-up knots here; at0.5(centripetal) the loop is gone. - Kochanek-Bartels:
tension, pluscontinuity(corners or bulges at the knots) andbias(turn early, or late and overshoot).
Each kind only uses its own dials.
dial_knots = [{0, 0}, {2, 3}, {2.4, 3}, {5, 1}, {7, 4}, {9, 2}]
form =
Kino.Control.form(
[
kind:
Kino.Input.select("Kind",
cardinal: "Cardinal (tension)",
centripetal_catmull_rom: "Centripetal Catmull-Rom (alpha)",
kochanek_bartels: "Kochanek-Bartels (tension, continuity, bias)"
),
tension: Kino.Input.range("Tension", min: -1, max: 1, step: 0.1, default: 0),
continuity: Kino.Input.range("Continuity", min: -1, max: 1, step: 0.1, default: 0),
bias: Kino.Input.range("Bias", min: -1, max: 1, step: 0.1, default: 0),
alpha: Kino.Input.range("Alpha", min: 0, max: 1, step: 0.05, default: 0)
],
report_changes: true
)
frame = Kino.Frame.new()
render = fn data ->
# Sliders can give values like 0.30000000000000004, so round them for display.
dial = fn key -> Float.round(data[key] * 1.0, 2) end
{code, curve} =
case data.kind do
:cardinal ->
opts = [tension: dial.(:tension)]
{"Curves.define_cardinal(knots, #{inspect(opts)})", Curves.define_cardinal(dial_knots, opts)}
:centripetal_catmull_rom ->
opts = [alpha: dial.(:alpha)]
{"Curves.define_centripetal_catmull_rom(knots, #{inspect(opts)})",
Curves.define_centripetal_catmull_rom(dial_knots, opts)}
:kochanek_bartels ->
opts = [tension: dial.(:tension), continuity: dial.(:continuity), bias: dial.(:bias)]
{"Curves.define_kochanek_bartels(knots, #{inspect(opts)})",
Curves.define_kochanek_bartels(dial_knots, opts)}
end
Kino.Layout.grid(
[
Kino.Markdown.new("`#{code}`"),
curve
|> Curves.Svg.render(width: 480, height: 360, markers: dial_knots, show_points: false)
|> Kino.Image.new(:svg)
],
columns: 1
)
end
Kino.Frame.render(
frame,
render.(%{kind: :cardinal, tension: 0, continuity: 0, bias: 0, alpha: 0})
)
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)Smooth a chart
Most splines swing above and below your points. On a chart, that shows peaks and dips that aren't in the data. A monotone spline never does: between two data points it only goes up, only goes down, or stays flat. Drag the data around and compare it with the others.
form =
Kino.Control.form(
[
kind:
Kino.Input.select("Kind",
monotone: "Monotone",
catmull_rom: "Catmull-Rom",
natural_cubic: "Natural cubic",
polyline: "Polyline"
),
y0: Kino.Input.range("Day 1", min: 0, max: 10, step: 0.5, default: 2),
y1: Kino.Input.range("Day 2", min: 0, max: 10, step: 0.5, default: 2),
y2: Kino.Input.range("Day 3", min: 0, max: 10, step: 0.5, default: 8),
y3: Kino.Input.range("Day 4", min: 0, max: 10, step: 0.5, default: 8.5),
y4: Kino.Input.range("Day 5", min: 0, max: 10, step: 0.5, default: 3),
y5: Kino.Input.range("Day 6", min: 0, max: 10, step: 0.5, default: 3)
],
report_changes: true
)
frame = Kino.Frame.new()
render = fn data ->
points = for {key, x} <- [y0: 1, y1: 2, y2: 3, y3: 4, y4: 5, y5: 6], do: {x, data[key]}
curve =
case data.kind do
:monotone -> Curves.define_monotone(points)
:catmull_rom -> Curves.define_catmull_rom(points)
:natural_cubic -> Curves.define_natural_cubic(points)
:polyline -> Curves.define_polyline(points)
end
{curve_low, curve_high} = curve |> Curves.take!(300) |> Enum.map(&elem(&1, 1)) |> Enum.min_max()
{data_low, data_high} = points |> Enum.map(&elem(&1, 1)) |> Enum.min_max()
Kino.Layout.grid(
[
Kino.Markdown.new("""
The data goes from `#{data_low}` to `#{data_high}`.
The curve goes from `#{Float.round(curve_low, 2)}` to `#{Float.round(curve_high, 2)}`.
"""),
curve
|> Curves.Svg.render(width: 480, height: 360, markers: points, show_points: false)
|> Kino.Image.new(:svg)
],
columns: 1
)
end
Kino.Frame.render(
frame,
render.(%{kind: :monotone, y0: 2, y1: 2, y2: 8, y3: 8.5, y4: 3, y5: 3})
)
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)Choosing a curve compares every kind of curve, with real-world examples of where each is used.
Build a Bezier spline
A Bezier spline is written like an SVG path: each point is a knot, followed by the control points of the curve to the next knot.
| Point | The curve to the next knot | Like SVG's |
|---|---|---|
[knot] | a straight line | L |
[knot, c] | a quadratic curve, pulled towards c | Q |
[knot, c0, c1] | a cubic curve: leaves knot towards c0, arrives from c1 | C |
The last point only ends the curve, so it is just [knot]. Pick what each of the three segments
should be:
kinds = [line: "Line (L)", quadratic: "Quadratic (Q)", cubic: "Cubic (C)"]
form =
Kino.Control.form(
[
first: Kino.Input.select("Segment 1", kinds),
second: Kino.Input.select("Segment 2", kinds),
third: Kino.Input.select("Segment 3", kinds)
],
report_changes: true
)
# The three segments run from knot to knot; each kind adds its own control points.
point = fn kind, {x, y} ->
case kind do
:line -> [{x, y}]
:quadratic -> [{x, y}, {x + 1.5, y + 3}]
:cubic -> [{x, y}, {x + 1, y - 2}, {x + 2, y + 3}]
end
end
frame = Kino.Frame.new()
render = fn %{first: first, second: second, third: third} ->
points = [
point.(first, {0, 0}),
point.(second, {3, 1}),
point.(third, {6, 0}),
[{9, 1}]
]
Kino.Layout.grid(
[
Kino.Markdown.new("`Curves.define_bezier_spline(#{inspect(points)})`"),
points
|> Curves.define_bezier_spline()
|> Curves.Svg.render(width: 480, height: 360)
|> Kino.Image.new(:svg)
],
columns: 1
)
end
Kino.Frame.render(frame, render.(%{first: :line, second: :line, third: :line}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)Arcs and ellipses
A Bezier spline can also hold an arc of an ellipse, just like SVG's A command:
[knot, {:arc, rx, ry, rotation, large_arc, sweep}] goes from that knot to the next one. There are
usually four arcs of the same ellipse between two points: large_arc takes the long way round, and
sweep picks the direction. Radii too small to reach are made just big enough.
form =
Kino.Control.form(
[
large_arc: Kino.Input.select("large_arc", [{false, "false: the short way"}, {true, "true: the long way"}]),
sweep: Kino.Input.select("sweep", [{true, "true: angle going up"}, {false, "false: angle going down"}]),
rx: Kino.Input.range("rx", min: 1, max: 10, step: 0.5, default: 5),
ry: Kino.Input.range("ry", min: 1, max: 10, step: 0.5, default: 5),
rotation: Kino.Input.range("rotation (degrees)", min: -90, max: 90, step: 5, default: 0)
],
report_changes: true
)
frame = Kino.Frame.new()
render = fn data ->
arc = {:arc, data.rx, data.ry, data.rotation, data.large_arc, data.sweep}
points = [[{0, 0}, arc], [{8, 0}]]
Kino.Layout.grid(
[
Kino.Markdown.new("`Curves.define_bezier_spline(#{inspect(points)})`"),
points
|> Curves.define_bezier_spline()
|> Curves.Svg.render(width: 480, height: 360, markers: [{0, 0}, {8, 0}], view: {{-12, -12}, {20, 12}})
|> Kino.Image.new(:svg)
],
columns: 1
)
end
Kino.Frame.render(frame, render.(%{large_arc: false, sweep: true, rx: 5, ry: 5, rotation: 0}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)For a whole ellipse or circle, use Curves.define_ellipse/3 or Curves.define_circle/3. It is one
segment, so u goes from 0.0 to 1.0, once round. :from and :to (in degrees) cut out an arc,
like a canvas's arc(). Arcs are exact: they aren't made of Bezier curves.
form =
Kino.Control.form(
[
rx: Kino.Input.range("rx", min: 0, max: 6, step: 0.5, default: 4),
ry: Kino.Input.range("ry", min: 0, max: 6, step: 0.5, default: 2),
rotation: Kino.Input.range("rotation", min: -180, max: 180, step: 5, default: 30),
from: Kino.Input.range("from", min: -360, max: 360, step: 15, default: 0),
to: Kino.Input.range("to", min: -360, max: 360, step: 15, default: 360)
],
report_changes: true
)
frame = Kino.Frame.new()
render = fn data ->
opts = [rotation: data.rotation, from: data.from, to: data.to]
Kino.Layout.grid(
[
Kino.Markdown.new("`Curves.define_ellipse({0, 0}, {#{data.rx}, #{data.ry}}, #{inspect(opts)})`"),
Curves.define_ellipse({0, 0}, {data.rx, data.ry}, opts)
|> Curves.Svg.render(width: 480, height: 360, view: {{-7, -7}, {7, 7}}, markers: [{0, 0}])
|> Kino.Image.new(:svg)
],
columns: 1
)
end
Kino.Frame.render(frame, render.(%{rx: 4, ry: 2, rotation: 30, from: 0, to: 360}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)Bend a Hermite tangent
A Hermite spline passes through every knot, and at each knot you choose a tangent: the direction the curve travels and how fast. Turn and stretch the tangent at the middle knot:
form =
Kino.Control.form(
[
angle: Kino.Input.range("Direction (degrees)", min: -180, max: 180, step: 5, default: 0),
length: Kino.Input.range("Length", min: 0, max: 20, step: 0.5, default: 4)
],
report_changes: true
)
frame = Kino.Frame.new()
render = fn %{angle: angle, length: length} ->
radians = angle * :math.pi() / 180
tangent = {length * :math.cos(radians), length * :math.sin(radians)}
[[{0, 0}, {4, 0}], [{4, 2}, tangent], [{8, 0}, {4, 0}]]
|> Curves.define_hermite()
|> Curves.Svg.render(width: 480, height: 360, markers: [{4, 2}])
|> Kino.Image.new(:svg)
end
Kino.Frame.render(frame, render.(%{angle: 0, length: 4}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)A long tangent makes the curve keep going in that direction for longer before it turns. A tangent of
length 0 makes a sharp corner. (Catmull-Rom, and the five splines in "Turn the dials" and "Smooth a
chart", are Hermite splines that work out the tangents for you.)
Next
Chapter 4: moving, scaling and rotating curves, and drawing them your way.