Custom shapes

Showcase panel 06: polygon winding rules, holes, union, open and closed paths.

Showcase panel 07: Catmull-Rom curves and beziers, curve resolution.

Showcase panel 06: polygon winding rules, holes, union, open and closed paths.
Showcase panel 07: Catmull-Rom curves and beziers, curve resolution.
Try it live: run uv run python examples/showcase.py, then hover panels 06 and 07, or see the showcase.

Circles and rectangles only go so far. With a with kiln.shape(): block and a few vertex(x, y) calls inside it, you can draw any outline you can describe: polygons, stars, letters with holes, smooth curves and living blobs. Like every other shape, they follow fill() and stroke() and the transform, and their edges are just as smooth.

Polygons

List the corners in order, and Kiln fills the inside. Concave shapes work too. With fill(None) and a stroke() you get the outline instead: shape(close=True) closes it back to the first point, and shape() leaves it open.

import math
import kiln

def draw():
    kiln.background(20)
    kiln.stroke(None)
    kiln.fill(255, 107, 82)
    with kiln.shape():
        for i in range(6):
            a = i * math.tau / 6
            kiln.vertex(100 + 70 * math.cos(a), 150 + 70 * math.sin(a))

    kiln.fill(80, 180, 255)
    with kiln.shape():
        for x, y in [(200, 120), (280, 120), (280, 80), (360, 150), (280, 220), (280, 180), (200, 180)]:
            kiln.vertex(x, y)

    kiln.fill(None)
    kiln.stroke(255, 220, 120, width=4)
    with kiln.shape():
        kiln.vertices([(30, 270), (90, 240), (150, 270), (210, 240), (270, 270), (330, 240)])

show(draw)

vertices(points) adds a whole list (or a NumPy array of shape (n, 2)) at once. Outline corners are mitred: sharp and pointy, until a corner is so sharp that the point would shoot off, and then it is cut flat (bevelled).

Stars and winding rules

A star alternates between an outer and an inner radius. A pentagram, drawn by jumping to every second point, crosses itself. Which parts of a self-crossing shape are “inside” is decided by the winding rule: with the default Winding.ODD the middle, which the outline goes around twice, stays empty; with NONZERO it is filled.

import math
import kiln

def star(cx, cy, points, r_out, r_in):
    with kiln.shape():
        for i in range(2 * points):
            r = r_out if i % 2 == 0 else r_in
            a = -math.pi / 2 + i * math.pi / points
            kiln.vertex(cx + r * math.cos(a), cy + r * math.sin(a))

def pentagram(cx, cy, r):
    with kiln.shape():
        for i in range(5):
            a = -math.pi / 2 + i * 2 * math.tau / 5
            kiln.vertex(cx + r * math.cos(a), cy + r * math.sin(a))

def draw():
    kiln.background(20)
    kiln.winding(kiln.Winding.ODD)
    kiln.fill(255, 220, 120)
    star(70, 150, 5, 60, 25)
    kiln.fill(255, 107, 82)
    pentagram(200, 155, 62)
    kiln.winding(kiln.Winding.NONZERO)
    kiln.fill(80, 180, 255)
    pentagram(330, 155, 62)

show(draw)

The rule shapes outlines too. Under fill(None) with a rule other than ODD, Kiln outlines the boundary of what that rule would fill: NONZERO traces the union of overlapping contours and ABS_GEQ_TWO only the part where they overlap. With ODD every contour is outlined as drawn.

import math
import kiln

def petals(cx, cy):
    with kiln.shape(close=True):
        for k in range(3):
            a0 = k * math.tau / 3
            if k:
                kiln.next_contour(True)
            for i in range(40):
                a = i * math.tau / 40
                kiln.vertex(cx + 26 * math.cos(a0) + 34 * math.cos(a), cy + 26 * math.sin(a0) + 34 * math.sin(a))

def draw():
    kiln.background(20)
    kiln.fill(None)
    kiln.stroke(width=3)
    modes = [kiln.Winding.ODD, kiln.Winding.NONZERO, kiln.Winding.ABS_GEQ_TWO]
    colours = [(255, 220, 120), (80, 180, 255), (255, 107, 82)]
    for i, (mode, colour) in enumerate(zip(modes, colours)):
        kiln.winding(mode)
        kiln.stroke(*colour)
        petals(70 + i * 130, 150)

show(draw)

Holes

next_contour() starts a new outline inside the same shape. Under the default rule, an outline inside another one cuts a hole, whichever way round you draw it.

import math
import kiln

def ring(cx, cy, r, n=48):
    return [(cx + r * math.cos(i * math.tau / n), cy + r * math.sin(i * math.tau / n)) for i in range(n)]

def draw():
    kiln.background(20)
    kiln.fill(255, 220, 120)
    kiln.circle(290, 150, 30)  # drawn first: it shows through the ring's hole
    kiln.circle(290, 60, 20)  # and the ring covers most of this one

    kiln.fill(80, 180, 255)
    with kiln.shape():
        kiln.vertices([(30, 60), (170, 60), (170, 240), (30, 240)])
        kiln.next_contour()
        kiln.vertices([(60, 90), (140, 90), (140, 140), (60, 140)])
        kiln.next_contour()
        kiln.vertices([(60, 170), (140, 170), (140, 210), (60, 210)])

    kiln.fill(255, 107, 82)
    with kiln.shape():
        kiln.vertices(ring(290, 150, 90))
        kiln.next_contour()
        kiln.vertices(ring(290, 150, 50))

show(draw)

Curves and beziers

There are two kinds of smooth path, and the small circles below show the points you give each one.

  • curve_vertex(x, y) draws a Catmull-Rom curve that passes through your points. The first and the last point only steer the ends, so they are not on the curve (hollow circles).
  • bezier_vertex(cx1, cy1, cx2, cy2, x, y) runs from the previous point to (x, y), pulled towards two control points (hollow) that it does not touch.

curve_resolution(n) sets how many points make each stretch (20 by default). The faint copy at the bottom uses 4.

import kiln

def dot(x, y, filled):
    # filled in the curve's stroke colour, or hollow
    ink = kiln.current_style().stroke
    with kiln.style(fill=ink if filled else None, stroke=None if filled else ink, stroke_width=1.5):
        kiln.circle(x, y, 4)

def draw():
    kiln.background(20)
    kiln.fill(None)
    kiln.stroke(width=3)
    kiln.curve_resolution(20)

    pts = [(20, 40), (40, 120), (110, 50), (160, 140), (190, 60)]
    kiln.stroke(255, 220, 120)
    with kiln.shape():
        for x, y in pts:
            kiln.curve_vertex(x, y)
    for i, (x, y) in enumerate(pts):
        dot(x, y, 0 < i < len(pts) - 1)

    kiln.stroke(80, 180, 255)
    start, c1, c2, end = (220, 140), (240, 20), (380, 20), (380, 140)
    with kiln.shape():
        kiln.vertex(*start)
        kiln.bezier_vertex(*c1, *c2, *end)
    kiln.stroke(80, 180, 255, 120, width=1)
    kiln.line(*start, *c1)
    kiln.line(*end, *c2)
    kiln.stroke(80, 180, 255)
    for p, on_curve in [(start, True), (c1, False), (c2, False), (end, True)]:
        dot(*p, on_curve)

    kiln.stroke(width=2)
    kiln.curve_resolution(4)
    kiln.stroke(255, 220, 120, 110)
    kiln.translate(0, 140)
    with kiln.shape():
        for x, y in pts:
            kiln.curve_vertex(x, y)
    kiln.stroke(80, 180, 255, 110)
    kiln.bezier(*start, *c1, *c2, *end)

show(draw)

curve and bezier draw one stretch in a single call. Like the other shapes they follow the fill and the stroke, so use fill(None) and a stroke() when you want a line.

A living blob

Put a vertex every few degrees around a circle and push each one in or out with noise. Moving through the noise over time makes the outline breathe.

import math
import kiln

class Blob(kiln.App):
    def setup(self):
        self.t = 0.0

    def update(self):
        self.t += 0.02

    def draw(self):
        kiln.background(14, 16, 28)
        kiln.fill(255, 107, 82)
        with kiln.shape():
            for i in range(120):
                a = i * math.tau / 120
                r = 70 + 60 * kiln.noise(math.cos(a) + 1, math.sin(a) + 1, self.t)
                kiln.vertex(200 + r * math.cos(a), 150 + r * math.sin(a))

show_video(Blob(), frames=90, fps=30)

Tips

  • Self-crossing fills cost more. A simple shape, even with a couple of hundred points, takes about a millisecond or less. Where contours cross or touch, Kiln splits the edges at every contact to apply the winding rule exactly, and that costs O(edges²) or more: a scribble of 20 points that crosses itself everywhere fills in about 5 ms, 80 points in 60 to 100 ms. Random-walk polygons and noise blobs that fold over themselves are fine a few at a time; for hundreds per frame, keep them small or make them not cross.
  • Outlines under ODD are cheap, whatever the shape: they are stroked as drawn. Outlines under the other rules pay a tenth or so of the fill cost.

Try this: a tide pool

Layered translucent blobs, each with its own slice of noise, outlined and scattered with curling fronds. Change the seed, the colours or the number of layers.

import math
import kiln

def blob(cx, cy, r, wobble, z, points=90):
    with kiln.shape(close=True):
        for i in range(points):
            a = i * math.tau / points
            rr = r + wobble * kiln.signed_noise(math.cos(a) * 1.3 + z, math.sin(a) * 1.3, z)
            kiln.vertex(cx + rr * math.cos(a), cy + rr * math.sin(a))

def frond(x, y, length, bend, z):
    with kiln.shape():
        kiln.curve_vertex(x, y + 10)
        for k in range(6):
            t = k / 5
            sway = bend * math.sin(t * 2.2) + 12 * kiln.signed_noise(z, t * 2)
            kiln.curve_vertex(x + sway, y - t * length)
        kiln.curve_vertex(x + bend * 1.2, y - length - 10)

def draw():
    kiln.background(10, 24, 36)
    kiln.random_seed(4)
    palette = [(32, 120, 140), (64, 170, 160), (250, 170, 90), (240, 110, 90), (250, 225, 160)]
    for layer in range(14):
        x, y = kiln.random(40, 360), kiln.random(40, 260)
        r = kiln.random(25, 70)
        kiln.stroke(None)
        kiln.fill(*palette[layer % len(palette)], 150)
        blob(x, y, r, r * 0.5, layer * 3.1)
        kiln.fill(None)
        kiln.stroke(255, 255, 255, 90, width=1.5)
        blob(x, y, r * 0.75, r * 0.35, layer * 3.1 + 0.4)
    kiln.fill(None)
    kiln.stroke(250, 225, 160, 200, width=2.5)
    for k in range(9):
        frond(30 + k * 42, 300, kiln.random(60, 140), kiln.random(-30, 30), k * 1.7)

show(draw)