Skip to content

Shapes

Rect

Rect draws a filled rectangle. Set its size with .size(width, height), fill color with .fill(), and position with .xy(x, y). By default the rect has zero size and is placed according to the parent's layout (centered for the default layout).

with Scene():
    Rect().size(160, 90).fill("steelblue")

Output image

fairyflow frame 0

Stroke

Add an outline with .stroke(color, width). Setting a fill color to None gives a hollow shape.

with Scene():
    r = Rect().size(160, 90)
    r.fill("lightyellow").stroke("navy", 4)

Output image

fairyflow frame 0

Passing None as the stroke color disables it again; omitting color entirely (e.g. .stroke(width=5)) leaves whatever stroke color is already set untouched:

with Scene():
    r = Rect().size(160, 90).fill("lightyellow").stroke("navy", 4)
    r.stroke(None)  # outline removed, fill unaffected

Output image

fairyflow frame 0

Gradients

.fill() also accepts gradient(*stops, angle=0) for a linear gradient — stops are either plain colors (evenly spaced) or explicit (offset, color) pairs. angle follows the CSS linear-gradient() convention: 0 points from bottom to top, increasing clockwise, so angle=90 goes left to right.

with Scene(width=200, height=100):
    Rect().xy(20, 20).size(160, 60).fill(gradient("tomato", "gold", angle=90))

Output image

fairyflow frame 0

Explicit offsets let you control where each color sits along the gradient, including sharp transitions (two stops at the same offset):

with Scene(width=200, height=100):
    Rect().xy(20, 20).size(160, 60).fill(
        gradient((0.0, "black"), (0.3, "black"), (1.0, "white"))
    )

Output image

fairyflow frame 0

Gradients are only supported for .fill().stroke() and Scene().background() raise a TypeError if given one. Note: Text().fill(gradient(...)) is not implemented in this version.

Rounded corners

.radius(r) rounds a rect's corners. It's clamped to min(width, height) / 2 at render time, so an oversized radius degrades to a fully-rounded "pill" shape instead of self-intersecting geometry.

with Scene(width=200, height=100):
    Rect().xy(20, 20).size(60, 60).radius(12).fill("steelblue")
    Rect().xy(110, 20).size(60, 60).radius(30).fill("mediumseagreen")  # pill

Output image

fairyflow frame 0

Dashed strokes

Pass dash=(on, off) to .stroke() for a dashed outline (pixel lengths); offset= shifts the pattern along the stroke — animate it for a "marching ants" effect. Dash is available on Rect, Ellipse, and Path.

with Scene(width=100, height=60):
    Rect().xy(20, 10).size(60, 40).stroke("black", 3, dash=(8, 4))

Output image

fairyflow frame 0

Relative sizing

rel(f) is a value marker for size() (and xy(), see Positioning): f times the parent's corresponding dimension. 1.0 equals the full parent extent on that axis. The bare "fill the whole parent" case has its own verb, .expand(). "Parent" here is the node's nearest Group(), or the Scene itself when there is no intermediate group — no wrapping Group() is required just to use rel().

with Scene(width=300, height=180):
    with Group().size(300, 180):
        Rect().expand().fill("whitesmoke")   # full background
        Rect().size(rel(0.5), rel(0.5)).fill("steelblue")  # top-left quadrant

Output image

fairyflow frame 0
with Scene(width=300, height=180):
    with Group().size(300, 180):
        Rect().expand().fill("whitesmoke")
        Rect().size(w=rel(1), h=rel(0.25)).fill("coral")   # full-width banner

Output image

fairyflow frame 0
with Scene(width=300, height=180):
    Rect().size(rel(0.5), rel(0.5)).fill("steelblue")   # relative to the Scene directly

Output image

fairyflow frame 0

Positioning

See Positioning for .xy(), .align(), and .move(). Rect and Ellipse also support .rotate(), .scale(), and .pivot() like any other drawable node — see Rotation and scale.


Ellipse

Ellipse draws an ellipse. When width == height it becomes a circle. Its API is identical to Rect.

with Scene():
    Ellipse().size(160, 110).fill("coral")

Output image

fairyflow frame 0
with Scene():
    Ellipse().size(80, 80).fill("orchid").xy(30, 60)
    Ellipse().size(80, 40).fill("gold").xy(130, 80)
    Ellipse().size(40, 80).fill("steelblue").xy(220, 60)

Output image

fairyflow frame 0

Path

Path draws an arbitrary vector shape from a sequence of commands. Use .stroke(color, width) to set the line color and thickness. Like Rect, it can also be filled with .fill().

Line segments

with Scene():
    p = Path()
    p.stroke("darkslateblue", 3).fill("lavender")
    p.move_to(30, 100)
    p.line_to(150, 40)
    p.line_to(270, 100)
    p.line_to(150, 160)
    p.close()

Output image

fairyflow frame 0

move_to/line_to also accept a live Position instead of (x, y)p.line_to(other.at("right")) tracks other's right edge as it moves.

Cubic Bézier curves

cubic_to(x, y, c1=, c2=) appends a cubic Bézier segment in one call — c1/c2 are (dx, dy) offsets for the two control points, relative to the segment's start and end respectively. The returned handle's .c1(dx, dy, dur=)/.c2(dx, dy, dur=) re-animate them later.

with Scene():
    p = Path()
    p.stroke("darkorange", 3)
    p.move_to(40, 150)
    p.cubic_to(260, 150, c1=(60, -130), c2=(-60, -130))

Output image

fairyflow frame 0

Arrows

.arrow() adds an arrowhead at the end of a path. Pass "start" to place it at the beginning instead. The arrowhead is automatically sized to match the stroke width.

with Scene():
    p = Path()
    p.stroke("steelblue", 3)
    p.move_to(40, 100)
    p.line_to(260, 100)
    p.arrow("end")
    p.arrow("start").fill("tomato")

Output image

fairyflow frame 0

The arrowhead inherits the path's stroke color by default. Call .fill() on the returned arrow object to override it independently — as shown above with the red start arrow.

The arrowhead scales automatically with stroke width. Pass length and width to arrow() to override the size explicitly — length is the tip-to-base distance, width is the base width (both default to 3 × stroke_width):

with Scene():
    # top: automatic size (stroke_width=8 → arrow 24×24)
    p = Path()
    p.stroke("steelblue", 8)
    p.move_to(40, 60)
    p.line_to(250, 60)
    p.arrow("end")

    # bottom: same stroke but arrow manually set to length=40, width=20
    q = Path()
    q.stroke("steelblue", 8)
    q.move_to(40, 140)
    q.line_to(250, 140)
    q.arrow("end", length=40, width=20)

Output image

fairyflow frame 0

Arrowhead styles

Pass style= to choose a different head shape. "open" and "bar" are stroked only (no fill); "dot" returns an Ellipse instead of a Path.

style shape
"triangle" filled triangle (default)
"open" two stroked lines forming a V
"stealth" concave filled head (TikZ-like)
"bar" perpendicular stroke — measurement / UML ends
"dot" filled circle at the endpoint
with Scene(width=200, height=200):
    for i, style in enumerate(["triangle", "open", "stealth", "bar", "dot"]):
        y = 20 + i * 35
        p = Path()
        p.stroke("steelblue", 3)
        p.move_to(40, y)
        p.line_to(140, y)
        p.arrow("end", style=style, width=20)

Output image

fairyflow frame 0

Connectors: Line and Arrow

For the common two-point case, Line(start, end) and Arrow(start, end, gap=0, head="end", style="triangle") build the same path in one call — both are plain Path subclasses, so .stroke()/.fill()/.alpha() and every other Path method still apply. Endpoints accept a plain (x, y) pair or a live Position (e.g. other.at("right")), tracked the same way move_to()/ line_to() are.

with Scene(width=200, height=60):
    Line((20, 30), (180, 30)).stroke("steelblue", 3)

Output image

fairyflow frame 0
with Scene(width=200, height=60):
    Arrow((20, 30), (180, 30), style="open").stroke("tomato", 3)

Output image

fairyflow frame 0

gap shifts the end(s) that get an arrowhead inward by that many pixels, so the head doesn't touch whatever it points at; head="start"/"both" puts the head at the other end(s) instead. .start/.end expose the underlying move_to/line_to handles, so a connector re-targets like any other path:

arrow = Arrow(a.at("right"), b.at("left"), gap=6).stroke("steelblue", 3)
arrow.end.pos(c.at("left"), dur=0.5)  # retarget the arrowhead end

Circular arrows

CircularArrow(center, radius, angle_start, angle_end, head="end", style="triangle", arrow_length=None, arrow_width=None) draws an arc — built internally as one or more cubic_to segments — with an arrowhead attached, for showing rotation or a curved connection. Like Polygon/RegularPolygon/Star below, it's computed geometry (no live Position tracking), but it's still a plain Path subclass underneath, so .stroke()/.fill()/.alpha() and the rest of Path's API apply.

Angles are in degrees, with pointing straight up and increasing clockwise — the same convention as RegularPolygon's rotation and .rotate(). Sweep from a smaller angle to a larger one to go clockwise; swap them to go counterclockwise.

with Scene(width=200, height=200):
    CircularArrow((100, 100), radius=70, angle_start=20, angle_end=290).stroke("steelblue", 4)

Output image

fairyflow frame 0

head, style, and arrow_length/arrow_width mirror Arrow's head/style and Path.arrow()'s length/width respectively:

with Scene(width=200, height=200):
    CircularArrow(
        (100, 100), radius=70, angle_start=30, angle_end=210, head="both", style="stealth"
    ).stroke("darkorange", 4)

Output image

fairyflow frame 0

Path cropping

.crop(start=, end=) trims the path from either end. Values are in [0.0, 1.0] where 0.0/1.0 is the full extent; either side may be omitted to leave it untouched. This is mainly used to animate paths drawing themselves in.

with Scene():
    p = Path()
    p.stroke("mediumseagreen", 4)
    p.move_to(30, 100)
    p.cubic_to(150, 40, c1=(50, -60), c2=(-50, -60))
    p.cubic_to(270, 100, c1=(50, 60), c2=(-50, 60))
    p.crop(end=0.5)

Output image

fairyflow frame 0

.draw(dur=, ease=) is sugar for the common "draw itself in" animation: it snaps crop(end=0), then animates it to 1 over dur.

with Scene():
    p = Path()
    p.stroke("darkorange", 3)
    p.move_to(30, 100)
    p.line_to(270, 100)
    p.draw(dur=1)

Output video


Polygon, RegularPolygon, and Star

All three build a closed Path from computed vertices, so .fill()/.stroke() work exactly as on any other Path — including the arrow/crop/draw features above, since a polygon is still just a Path underneath.

Polygon(points) takes an explicit list of (x, y) vertices:

with Scene(width=120, height=100):
    Polygon([(60, 10), (110, 90), (10, 90)]).fill("steelblue")

Output image

fairyflow frame 0

RegularPolygon(n, radius, *, center=(0, 0), rotation=0) places n vertices evenly around center, first vertex pointing up by default:

with Scene(width=120, height=120):
    RegularPolygon(6, 50, center=(60, 60)).fill("mediumseagreen")

Output image

fairyflow frame 0

Star(points, outer, inner, *, center=(0, 0), rotation=0) alternates between an outer and inner radius for a points-pointed star:

with Scene(width=120, height=120):
    Star(5, outer=50, inner=20, center=(60, 60)).fill("gold")

Output image

fairyflow frame 0

rotation (degrees) rotates the starting vertex on both RegularPolygon and Star. center places the shape within its own vertex coordinates — like Path, none of these three have their own .xy()/.rotate()/.scale(); nest one inside a Group and transform that if you need to move or spin the whole shape as a unit.