3. Splines (interactive)

Copy Markdown View Source
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>.

KindPasses through the knots?What you give it
Polylineyes, with straight linesjust the knots
Catmull-Romyesjust the knots; it works out the bends
B-splineno, only near themjust the knots; the smoothest of all
Bezier splineyeseach knot plus the control points to the next one
Hermiteyeseach knot plus a tangent (direction and speed)
Cardinalyesjust the knots, plus a tension
Centripetal Catmull-Romyesjust the knots; it never loops
Kochanek-Bartelsyesjust the knots, plus three dials
Monotoneyesdata points; it never overshoots them
Natural cubicyesjust 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: tension pulls the curve tighter at each knot (0 is Catmull-Rom, 1 is straight lines).
  • Centripetal Catmull-Rom: alpha sets how much the distances between knots count. At 0 it's plain Catmull-Rom, which makes a little loop between the two bunched-up knots here; at 0.5 (centripetal) the loop is gone.
  • Kochanek-Bartels: tension, plus continuity (corners or bulges at the knots) and bias (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.

PointThe curve to the next knotLike SVG's
[knot]a straight lineL
[knot, c]a quadratic curve, pulled towards cQ
[knot, c0, c1]a cubic curve: leaves knot towards c0, arrives from c1C

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.