Skip to content

Paths & Parametric Positioning

This is PyFreeform's "killer feature" — position any entity at any point along any path using the along / t system.

The Core Idea

Every Line, Curve, and Ellipse is a Pathable — it has a point_at(t) method where t goes from 0.0 (start) to 1.0 (end). You can place entities along these paths:

line = cell.add_diagonal()
cell.add_dot(along=line, t=cell.brightness)  # (1)!
  1. The dot's position slides along the diagonal based on brightness.

Dots along diagonals

Each dot slides along its cell's diagonal — bright areas push dots toward the top-right.

Along Curves

Curves make the positioning non-linear and organic:

curve = cell.add_curve(start="bottom_left", end="top_right", curvature=0.6, ...)
for t_val in [0.25, 0.5, 0.75]:
    cell.add_dot(along=curve, t=t_val, radius=0.10, color=colors.primary)

Dots along curves

Three dots per cell positioned along curves — the Bezier shape creates smooth distribution.

Along Ellipses

Ellipses are closed paths — t=0 is the rightmost point, going counterclockwise:

t value Position
0.0 Right
0.25 Top
0.5 Left
0.75 Bottom
ellipse = cell.add_ellipse(at="center", rx=0.4, ry=0.25, fill="none", stroke=colors.line)
cell.add_dot(along=ellipse, t=ny, radius=0.10, color=colors.accent)

Dots orbiting ellipses

Each dot orbits its cell's ellipse at a position driven by the row.

Understanding t Values

On a single path, t selects the position:

t values visualized

Five dots at t=0.00, 0.25, 0.50, 0.75, and 1.00 along a curve.

Built-in Path Shapes

PyFreeform includes four ready-to-use path shapes, accessible as Path.Wave, Path.Spiral, Path.Lissajous, and Path.Zigzag:

from pyfreeform import Path

wave = Path.Wave(start=(cx - 10, cy), end=(cx + 10, cy), amplitude=8, frequency=3)
cell.add_path(wave, segments=32, width=1.5, color=colors.primary)

All four work with add_path(), along=/t= positioning, and as connection shapes. See Connections & Paths for full parameter details.

Filled Closed Paths

Use closed=True and fill= to create filled shapes from any path. Layering semi-transparent fills produces striking geometric art:

liss = Path.Lissajous(center=(200, 200), a=3, b=2, size=150)
path = Path(liss, closed=True, fill="#4a90d9", color="#6ab0ff",
            width=1.2, fill_opacity=0.25, stroke_opacity=0.7, segments=128)
scene.place(path)

Filled Lissajous curves

Three Lissajous curves with different frequency ratios, layered with semi-transparent fills.

Relative Coordinates

Path shapes use pixel coordinates by default. When working inside a cell you usually don't want to multiply by cell.width and cell.height. Pass relative=True to add_path() and write the pathable's coordinates as surface-relative fractions (0.0–1.0) — exactly like every other add_* method:

# Without relative=True — pixel math required
w, h = cell.width, cell.height
cell.add_path(
    Path.Wave(start=(w * 0.05, h * 0.5), end=(w * 0.95, h * 0.5), amplitude=h * 0.15),
    width=2, color="coral",
)

# With relative=True — fraction-first, no pixel math
cell.add_path(
    Path.Wave(start=(0.05, 0.5), end=(0.95, 0.5), amplitude=0.15),
    relative=True, width=2, color="coral",
)

# Works for all four built-in shapes
cell.add_path(
    Path.Spiral(center=(0.5, 0.5), end_radius=0.38, turns=4),
    relative=True, width=1, color="teal",
)

The four built-in shapes already use normalized defaults (Wave() defaults to start=(0,0), end=(1,0)) — relative=True is the bridge that maps those fractions to the cell's actual pixel dimensions at render time.

Use relative=True with path shapes, not with entities

relative=True is designed for Wave, Spiral, Lissajous, and Zigzag. Don't use it with an Ellipse entity as a pathable — the entity already lives in pixel space and would be double-scaled.


Custom Pathables

You can also create your own — any object with point_at(t) -> Coord works as a path:

from pyfreeform import Coord

class MyPath:
    def point_at(self, t):
        x = t * 100
        y = 50 + 20 * math.sin(t * math.pi * 4)
        return Coord(x, y)

Custom wave paths

Wave paths with increasing frequency across the grid.

Sub-Paths and Arcs

Use start_t and end_t to render only a portion of a path:

cell.add_path(
    ellipse,
    start_t=0.1,      # Start at 10% around
    end_t=0.6,         # End at 60% around
    segments=24,
    width=2,
    color=colors.primary,
)

Arcs from ellipses

Partial arcs of ellipses — the start and length vary by position.

Text Along Paths

Pass a path to add_text(along=) without t to warp text along the full path:

curve = cell.add_curve(start=(0.05, 0.7), end=(0.95, 0.3), curvature=0.5, ...)
cell.add_text("Text flows along any path", along=curve, font_size=0.05, color=colors.accent)

Text along curve

Text automatically warped along a Bezier curve using SVG <textPath>.

Text along ellipse


Alignment

Set align=True to rotate entities to follow the path's tangent direction:

cell.add_polygon(
    Polygon.triangle(size=0.06),
    along=curve, t=0.5, align=True,  # (1)!
    fill=colors.primary,
)
  1. The triangle points in the direction the curve is heading at t=0.5.

Aligned triangles along curve

Triangles aligned to the curve's tangent — they point the way the path flows.

Offset

Use along_offset to shift an entity perpendicular to the path — useful for labels beside lines:

conn = rect_a.connect(rect_b, style=arrow_style)
scene.add_text("label", along=conn, t=0.5, along_offset=-0.02, font_size=0.015, color="#aaa")

Negative values shift above the line, positive values shift below. This is direction-independent — the same sign always means the same visual side, regardless of which way the path travels.


See also

For the full path and connection API, see Connections & Paths.

What's Next?

Explore the shape system and learn to compose reusable groups:

← Colors, Styles & Palettes Shapes & Polygons →