4. Transforms and drawing (interactive)

Copy Markdown View Source
Mix.install([
  {:curves, "~> 1.0"},
  {:kino, "~> 0.19"}
])

Move it, grow it, spin it

Curves.Transform changes where a curve is, how big it is and which way it faces, without changing its points. Every example here starts from the same shape:

alias Curves.Transform

shape = Curves.define_bezier([{0, 0}, {1, 3}, {3, 3}, {4, 0}])

shape
|> Curves.Svg.render(width: 480, height: 360)
|> Kino.Image.new(:svg)

Now take the controls! The purple dot is the curve's position: the curve's own {0, 0} lands there. Watch how scaling and rotating happen around that dot. A negative scale flips the curve over.

form =
  Kino.Control.form(
    [
      x: Kino.Input.range("Position x", min: -10, max: 10, step: 0.5, default: 0),
      y: Kino.Input.range("Position y", min: -10, max: 10, step: 0.5, default: 0),
      scale: Kino.Input.range("Scale", min: -3, max: 3, step: 0.1, default: 1),
      rotation: Kino.Input.range("Rotation (degrees)", min: -180, max: 180, step: 5, default: 0)
    ],
    report_changes: true
  )

frame = Kino.Frame.new()

render = fn %{x: x, y: y, scale: scale, rotation: rotation} ->
  curve =
    shape
    |> Transform.set_position({x, y})
    |> Transform.set_scale(scale)
    |> Transform.set_rotation(rotation)

  # A fixed :view keeps the picture the same size, so you can see the curve move and grow.
  # (Without it, Svg fits the picture to the curve.)
  curve
  |> Curves.Svg.render(width: 480, height: 480, view: {{-20, -20}, {20, 20}}, markers: [{x, y}])
  |> Kino.Image.new(:svg)
end

Kino.Frame.render(frame, render.(%{x: 0, y: 0, scale: 1, rotation: 0}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)

The order is always the same

Whatever order you call them in, the curve is first scaled, then rotated, and finally moved to its position. So scaling and rotating never move a curve away from where you put it: the start of the shape (its {0, 0}) is still exactly at the position.

shape
|> Transform.set_rotation(45)
|> Transform.set_scale(2)
|> Transform.set_position({5, 2})
|> Curves.solve!(0.0)

set_ or inc_?

Each transform comes in two flavours: set_position/2, set_scale/2 and set_rotation/2 replace the current value, while inc_position/2, inc_x/2, inc_y/2, inc_scale/2 and inc_rotation/2 add to it. Each press of the button below turns the shape another 30 degrees:

button = Kino.Control.button("Turn 30°")
frame = Kino.Frame.new()

# Keeps the latest curve between presses.
{:ok, spinning} = Agent.start_link(fn -> shape end)

render = fn curve ->
  curve
  |> Curves.Svg.render(width: 360, height: 360, view: {{-5, -5}, {5, 5}})
  |> Kino.Image.new(:svg)
end

Kino.Frame.render(frame, render.(shape))

Kino.listen(button, fn _event ->
  Agent.update(spinning, &Transform.inc_rotation(&1, 30))
  Kino.Frame.render(frame, render.(Agent.get(spinning, & &1)))
end)

Kino.Layout.grid([button, frame], columns: 1)

Stretch, flip and turn around a point

Scale the two directions separately (a negative scale flips that way), and choose what the curve turns around: its own {0, 0} (here, its first point), or its middle with around: :center. The purple dot is the point it turns around, which never moves.

form =
  Kino.Control.form(
    [
      around: Kino.Input.select("Around", own_origin: "its own {0, 0}", center: ":center"),
      scale_x: Kino.Input.range("Scale x", min: -3, max: 3, step: 0.1, default: 1),
      scale_y: Kino.Input.range("Scale y", min: -3, max: 3, step: 0.1, default: 1),
      rotation: Kino.Input.range("Rotation (degrees)", min: -180, max: 180, step: 5, default: 0)
    ],
    report_changes: true
  )

frame = Kino.Frame.new()

render = fn %{around: around, scale_x: scale_x, scale_y: scale_y, rotation: rotation} ->
  {opts, pivot} =
    case around do
      :own_origin -> {[], {0, 0}}
      :center -> {[around: :center], {2, 1.5}}
    end

  shape
  |> Transform.set_scale({scale_x, scale_y}, opts)
  |> Transform.set_rotation(rotation, opts)
  |> Curves.Svg.render(width: 480, height: 480, view: {{-10, -10}, {10, 10}}, markers: [pivot])
  |> Kino.Image.new(:svg)
end

Kino.Frame.render(frame, render.(%{around: :own_origin, scale_x: 1, scale_y: 1, rotation: 0}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)

Turn the rotation with Around set to each option, and watch the difference: around its own {0, 0} the shape swings around its first point; around :center it spins in place. flip_x/2 and flip_y/2 are shortcuts for a scale of -1 one way.

Going backwards

Transform.reverse/1 gives you the same curve travelled the other way: t = 0.0 becomes its old end. Drag t and switch the direction:

form =
  Kino.Control.form(
    [
      direction: Kino.Input.select("Direction", forwards: "Forwards", backwards: "Backwards (reverse/1)"),
      t: Kino.Input.range("t", min: 0, max: 1, step: 0.01, default: 0.2)
    ],
    report_changes: true
  )

frame = Kino.Frame.new()

render = fn %{direction: direction, t: t} ->
  curve =
    case direction do
      :forwards -> shape
      :backwards -> Transform.reverse(shape)
    end

  curve
  |> Curves.Svg.render(width: 480, height: 360, markers: [Curves.solve!(curve, t)])
  |> Kino.Image.new(:svg)
end

Kino.Frame.render(frame, render.(%{direction: :forwards, t: 0.2}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)

Results between 0 and 1: :force_percent

Sometimes you only care where along the shape a point is, not its real coordinates; for example to drive an animation. force_percent: true rescales every result so that the shape's lowest point is 0.0 and its highest is 1.0, on both axes. Make the shape bigger, then switch it on: the numbers stop changing!

form =
  Kino.Control.form(
    [
      scale: Kino.Input.range("Scale", min: 0.5, max: 10, step: 0.5, default: 3),
      percent: Kino.Input.checkbox("force_percent", default: false)
    ],
    report_changes: true
  )

frame = Kino.Frame.new()

render = fn %{scale: scale, percent: percent} ->
  curve = Transform.set_scale(shape, scale)
  opts = [force_percent: percent]

  rows =
    for t <- [0.0, 0.25, 0.5, 0.75, 1.0] do
      {x, y} = Curves.solve!(curve, t, opts)
      "| #{t} | #{Float.round(x * 1.0, 3)} | #{Float.round(y * 1.0, 3)} |"
    end

  Kino.Layout.grid(
    [
      Kino.Markdown.new("| t | x | y |\n| --- | --- | --- |\n" <> Enum.join(rows, "\n")),
      curve
      |> Curves.Svg.render(width: 360, height: 300, solve_opts: opts, show_points: not percent, show_polygon: not percent)
      |> Kino.Image.new(:svg)
    ],
    columns: 2
  )
end

Kino.Frame.render(frame, render.(%{scale: 3, percent: false}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 1)

The "lowest and highest points" are those of the curve's own points (its knots and control points). This shape bends less far than its control points, so its y tops out at 0.75, not 1.0.

Draw it your way

Curves.Svg has lots of options. Play with a few of them here. Turn samples right down to see how the line is made of straight pieces between solved points.

colours = [{"#1c7ed6", "Blue"}, {"#e64980", "Pink"}, {"#2f9e44", "Green"}, {"#f08c00", "Orange"}]

form =
  Kino.Control.form(
    [
      samples: Kino.Input.range("samples", min: 1, max: 100, step: 1, default: 100),
      curve_width: Kino.Input.range("curve_width", min: 1, max: 12, step: 1, default: 3),
      curve_color: Kino.Input.select("curve_color", colours),
      show_points: Kino.Input.checkbox("show_points", default: true),
      show_polygon: Kino.Input.checkbox("show_polygon", default: true),
      show_axes: Kino.Input.checkbox("show_axes", default: true)
    ],
    report_changes: true
  )

frame = Kino.Frame.new()

render = fn options ->
  options = Map.update!(options, :samples, &round/1)

  Kino.Layout.grid(
    [
      Kino.Markdown.new("`Curves.Svg.render(curve, #{inspect(Map.to_list(options))})`"),
      [{0, 0}, {2, 3}, {4, 1}, {6, 4}, {8, 2}]
      |> Curves.define_catmull_rom()
      |> Curves.Svg.render([width: 480, height: 360] ++ Map.to_list(options))
      |> Kino.Image.new(:svg)
    ],
    columns: 1
  )
end

Kino.Frame.render(
  frame,
  render.(%{samples: 100, curve_width: 3, curve_color: "#1c7ed6", show_points: true, show_polygon: true, show_axes: true})
)

Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)

To keep a picture, Curves.Svg.save!/3 writes it to a file:

path = Path.join(System.tmp_dir!(), "my_curve.svg")
Curves.Svg.save!(shape, path, title: "My curve")

Read an SVG, change it, draw it again

Curves.from_svg/2 reads an SVG into a Curves.Drawing: its shapes become curves, and it keeps everything else (groups, colours, text) so Curves.Svg can draw it again as it was. The Curves.Transform functions work on the whole drawing, or on any part of it you find by its id with Curves.Drawing.update/3. Text moves along with the shapes, and a group moves everything in it together: the roof is a group of the triangle and its round window.

house = """
<svg width="300" height="300" viewBox="0 0 100 100">
  <rect x="0" y="0" width="100" height="100" fill="#e7f5ff"/>
  <g id="house">
    <g id="roof">
      <polygon points="10,50 50,15 90,50" fill="#e03131"/>
      <circle cx="50" cy="35" r="6" fill="#fff" stroke="#862e9c" stroke-width="2"/>
    </g>
    <rect x="18" y="50" width="64" height="40" fill="#ffe8cc" stroke="#862e9c"/>
    <rect id="door" x="42" y="65" width="16" height="25" rx="3" fill="#862e9c"/>
    <text x="50" y="98" font-size="6" text-anchor="middle">Home</text>
  </g>
</svg>
"""

form =
  Kino.Control.form(
    [
      mode: Kino.Input.select("Draw it", [{:design, "as designed"}, {:inspect, "as curves (mode: :inspect)"}]),
      turn: Kino.Input.range("Turn the house", min: -45, max: 45, step: 5, default: 0),
      size: Kino.Input.range("House size", min: 0.3, max: 1.2, step: 0.1, default: 1),
      lift: Kino.Input.range("Lift the roof", min: 0, max: 15, step: 1, default: 0),
      door: Kino.Input.range("Open the door", min: 0, max: 1, step: 0.1, default: 0)
    ],
    report_changes: true
  )

frame = Kino.Frame.new()

render = fn data ->
  drawing =
    house
    |> Curves.from_svg()
    |> Curves.Drawing.update("house", fn house ->
      house
      |> Curves.Transform.set_rotation(data.turn, around: :center)
      |> Curves.Transform.set_scale(data.size, around: :center)
    end)
    |> Curves.Drawing.update("roof", &Curves.Transform.inc_y(&1, -data.lift))
    # An opening door gets narrower, hinged on its left edge.
    |> Curves.Drawing.update("door", &Curves.Transform.set_scale(&1, {1 - data.door * 0.9, 1}, around: {42, 77}))

  # Curves draws curves with y going up, so in :inspect mode turn the drawing the right way up.
  drawing = if data.mode == :inspect, do: Curves.Transform.flip_y(drawing), else: drawing

  drawing
  |> Curves.Svg.render(mode: data.mode, width: 360, height: 360)
  |> Kino.Image.new(:svg)
end

Kino.Frame.render(frame, render.(%{mode: :design, turn: 0, size: 1, lift: 0, door: 0}))
Kino.listen(form, fn %{data: data} -> Kino.Frame.render(frame, render.(data)) end)
Kino.Layout.grid([form, frame], columns: 2)

Going the other way, Curves.Svg.path_data/1 writes any curve as exact SVG path data, for your own <path> elements:

Curves.define_catmull_rom([{0, 0}, {2, 3}, {4, 1}])
|> Curves.Svg.path_data()

That's the tour! 🎉

You now know the essentials: curves and t, control points, splines and u, transforms, drawing, and reading SVGs. The API docs have everything else. Have fun making things move!