The Pathable Protocol¶
The Pathable protocol is the foundation of parametric positioning in PyFreeform. Any object that implements point_at(t) can be used with the along/t system to position entities along arbitrary paths.
This guide covers the protocol, its optional methods, how the rendering pipeline converts pathables to smooth SVG, and a complete walkthrough of building a custom Lissajous curve pathable.
The Protocol Definition¶
from typing import Protocol, runtime_checkable
from pyfreeform.core.coord import Coord
@runtime_checkable
class Pathable(Protocol):
def point_at(self, t: float) -> Coord:
"""Get a point at parameter t (0.0 to 1.0)."""
...
That is the entire required interface -- a single method. The protocol is @runtime_checkable, meaning isinstance(obj, Pathable) works at runtime.
How t is interpreted¶
t value |
Meaning |
|---|---|
0.0 |
Start of the path |
0.5 |
Midpoint of the path |
1.0 |
End of the path |
For closed paths (like ellipses), t=0.0 and t=1.0 return the same point. For open paths (like lines), they return opposite endpoints.
Built-in pathable types¶
These entity types implement point_at(t) out of the box:
| Type | Path shape | Closed? |
|---|---|---|
Line |
Straight segment | No |
Curve |
Quadratic Bezier | No |
Ellipse |
Elliptical arc | Yes |
Path |
Any Pathable rendered as Bezier | Configurable |
Connection |
Straight segment between entities | No |
Point entities as connection endpoints
Point is an invisible entity with no visual output — ideal as a connection endpoint when you don't want a visible marker. Connections between Points still function as pathables, so you can position other entities along them with along=conn, t=0.5.
Built-in path shapes¶
Four ready-to-use pathable classes are available as nested classes on Path:
| Shape | Description |
|---|---|
Path.Wave |
Sinusoidal wave between two points |
Path.Spiral |
Archimedean spiral expanding from center |
Path.Lissajous |
Parametric Lissajous curve |
Path.Zigzag |
Triangle wave between two points |
All four implement point_at(t), angle_at(t), arc_length(), and to_svg_path_d(). When called with no arguments, they default to normalized coordinate space — ideal for connection shapes.
Optional Methods¶
Beyond point_at(t), pathables can implement additional methods that unlock more features:
arc_length() -> float¶
Returns the approximate total length of the path in pixels. Used by add_text() for auto-sizing font in textPath mode.
def arc_length(self) -> float:
"""Approximate total arc length in pixels."""
total = 0.0
prev = self.point_at(0.0)
for i in range(1, 101):
curr = self.point_at(i / 100)
total += prev.distance_to(curr)
prev = curr
return total
angle_at(t) -> float¶
Returns the tangent angle in degrees at parameter t. Used by the align=True feature to rotate entities to follow the path direction. If not implemented, the system falls back to numeric differentiation via tangent.get_angle_at().
Numeric fallback
When angle_at() is not implemented, PyFreeform uses get_angle_at() from core/tangent.py, which computes the angle via finite differences on point_at(). This works well for smooth paths. Only implement angle_at() when you have an exact analytical formula or when the numeric fallback is insufficiently accurate.
to_svg_path_d() -> str¶
Returns the SVG d attribute string for the path. Required for textPath warping (text that follows a curve). Without this method, add_text(along=path) will raise a TypeError.
def to_svg_path_d(self) -> str:
"""SVG path d attribute string."""
return f"M {self.start.x} {self.start.y} L {self.end.x} {self.end.y}"
How the along/t System Works¶
When you write cell.add_dot(along=curve, t=0.5), here is what happens internally:
cell.add_dot(along=curve, t=0.5)
|
|-- Surface._resolve_along(along, t, align=False, user_rotation=0)
| |-- position = along.point_at(0.5) # Get position from pathable
| |-- return (position, 0) # No rotation (align=False)
|
|-- Dot(position.x, position.y, ...) # Create entity at that position
|-- self._register_entity(dot) # Register with surface
With align=True, the rotation step becomes:
Surface._resolve_along(along, t=0.5, align=True, user_rotation=0)
|
|-- position = along.point_at(0.5)
|-- tangent_angle = get_angle_at(along, 0.5) # From tangent.py
| |-- If along has angle_at(): use it directly
| |-- Otherwise: numeric diff via point_at(0.5 - eps) and point_at(0.5 + eps)
|-- return (position, tangent_angle + user_rotation)
This works identically for add_line(), add_curve(), add_ellipse(), add_polygon(), add_rect(), and add_text() -- every builder method supports along/t/align.
Walkthrough: Creating a Lissajous Curve Pathable¶
This already exists as a built-in
Path.Lissajous provides a ready-to-use implementation of this exact curve. This walkthrough recreates it from scratch to teach the Pathable protocol step by step.
A Lissajous curve traces a parametric path defined by:
where a and b control frequency, delta is the phase shift, and A/B control amplitude.
Step 1: Implement point_at(t)¶
import math
from pyfreeform.core.coord import Coord
class Lissajous:
"""A Lissajous curve pathable."""
def __init__(
self,
center: Coord | tuple[float, float] = (0, 0),
a: int = 3,
b: int = 2,
delta: float = math.pi / 2,
size: float = 50,
) -> None:
if isinstance(center, tuple):
center = Coord(*center)
self.center = center
self.a = a # Horizontal frequency
self.b = b # Vertical frequency
self.delta = delta # Phase shift
self.size = size # Amplitude (pixels)
def point_at(self, t: float) -> Coord: # (1)!
"""Get point at parameter t (0.0 to 1.0)."""
angle = t * 2 * math.pi
x = self.center.x + self.size * math.sin(self.a * angle + self.delta)
y = self.center.y + self.size * math.sin(self.b * angle)
return Coord(x, y)
- This single method is all that is required. The class now works with
add_dot(along=lissajous, t=0.5).
This is already a fully functional pathable. You can use it immediately:
scene = Scene.with_grid(cols=20, rows=20, cell_size=15)
liss = Lissajous(center=scene.grid[10][10].center, a=3, b=2, size=80)
for cell in scene.grid:
cell.add_dot(along=liss, t=cell.brightness, color="coral", radius=0.15)
Step 2: Implement angle_at() for alignment¶
For entities to rotate and follow the curve direction, implement angle_at():
def angle_at(self, t: float) -> float:
"""Tangent angle in degrees at parameter t."""
angle = t * 2 * math.pi
# Derivatives of the parametric equations
dx = self.size * self.a * math.cos(self.a * angle + self.delta) * 2 * math.pi
dy = self.size * self.b * math.cos(self.b * angle) * 2 * math.pi
if dx == 0 and dy == 0:
return 0.0
return math.degrees(math.atan2(dy, dx))
Now align=True uses the exact tangent rather than numeric approximation:
curve_path = cell.add_curve()
# Small rectangles aligned to the Lissajous tangent
cell.add_rect(width=0.15, height=0.05, along=liss, t=0.25, align=True)
Step 3: arc_length() for text sizing¶
This function is provided by the PathShape parent class
def arc_length(self, samples: int = 200) -> float:
"""Approximate arc length via polyline sampling."""
total = 0.0
prev = self.point_at(0.0)
for i in range(1, samples + 1):
curr = self.point_at(i / samples)
dx = curr.x - prev.x
dy = curr.y - prev.y
total += math.sqrt(dx * dx + dy * dy)
prev = curr
return total
Step 4: to_svg_path_d() for textPath support¶
For text to warp along the Lissajous curve, it needs an SVG path definition. The simplest approach is to sample points and emit line segments.
This function is provided by the PathShape parent class
def to_svg_path_d(self, samples: int = 200) -> str:
"""SVG path d attribute as a polyline approximation."""
p0 = self.point_at(0.0)
parts = [f"M {p0.x} {p0.y}"]
for i in range(1, samples + 1):
p = self.point_at(i / samples)
parts.append(f" L {p.x} {p.y}")
return "".join(parts)
Polyline vs Bezier
A polyline to_svg_path_d() works but produces large SVG. For a smoother and smaller result, use add_path() instead, which fits cubic Beziers via Hermite interpolation. The to_svg_path_d() method is mainly needed for textPath support.
How add_path() Converts Pathables to Smooth SVG¶
When you call cell.add_path(lissajous), the Path entity converts your pathable into smooth cubic Bezier curves. Here is the algorithm:
Hermite-to-Bezier interpolation¶
For each segment i from 0 to N-1:
1. Sample the pathable at t_i and t_{i+1}
2. Compute tangent vectors at both points (via numeric differentiation)
3. Convert Hermite form to Bezier control points:
cp1 = p0 + tangent_0 * dt/3
cp2 = p3 - tangent_1 * dt/3
4. Clamp control points to prevent blowup (max 75% of chord length)
The result is C1-continuous -- tangent directions match at every joint, giving a visually smooth curve with no kinks.
For closed paths¶
Closed paths wrap the last segment back to the first point. The tangent computation uses modular arithmetic (t % 1.0) so the seam is invisible:
# From path.py
if closed and start_t == 0.0 and end_t == 1.0:
t_values = [i / n for i in range(n)] # N points, no duplicate endpoint
# Last segment: points[N-1] -> points[0], with wrapping tangents
for i in range(n):
j = (i + 1) % n # Wraps back to 0
Sub-paths (arcs)¶
Use start_t and end_t to render only a portion of any pathable:
# Render just the first quarter of a Lissajous curve
cell.add_path(liss, start_t=0.0, end_t=0.25, color="red", width=2)
# Render an arc of an ellipse
ellipse = cell.add_ellipse(rx=0.4, ry=0.3)
cell.add_path(ellipse, start_t=0.0, end_t=0.5, color="blue")
Complete Lissajous Pathable¶
"""Lissajous - A Lissajous curve pathable."""
import math
from ..core.coord import Coord, CoordLike
from .base import PathShape
from pyfreeform.core.coord import Coord
class Lissajous(PathShape):
"""
A Lissajous curve: x = A*sin(a*t + delta), y = B*sin(b*t).
This is a closed curve when a/b is rational. Common ratios:
a=3, b=2 -> figure-eight variant
a=1, b=2 -> parabola-like
a=5, b=4 -> complex knot
"""
def __init__(
self,
center: CoordLike = (0, 0),
a: int = 3,
b: int = 2,
delta: float = math.pi / 2,
size: float = 50,
) -> None:
self.center = Coord.coerce(center)
self.a = a
self.b = b
self.delta = delta
self.size = size
def point_at(self, t: float) -> Coord:
"""Get point at parameter *t* (0.0 to 1.0)."""
angle = t * 2 * math.pi
x = self.center.x + self.size * math.sin(self.a * angle + self.delta)
y = self.center.y + self.size * math.sin(self.b * angle)
return Coord(x, y)
def angle_at(self, t: float) -> float:
"""Tangent angle in degrees at parameter *t*."""
angle = t * 2 * math.pi
dx = self.size * self.a * math.cos(self.a * angle + self.delta) * 2 * math.pi
dy = self.size * self.b * math.cos(self.b * angle) * 2 * math.pi
if dx == 0 and dy == 0:
return 0.0
return math.degrees(math.atan2(dy, dx))
def __repr__(self) -> str:
return (
f"Lissajous(center={self.center}, a={self.a}, b={self.b}, "
f"delta={self.delta:.4f}, size={self.size})"
)
Using It in Practice¶
Position dots along a Lissajous curve¶
from pyfreeform import Scene, Coord
scene = Scene(400, 400, background="#1a1a2e")
liss = Lissajous(center=Coord(200, 200), a=3, b=2, size=150)
# Render the curve itself
scene.add_path(liss, closed=True, color="#334155", width=1)
# Place 50 dots along it
for i in range(50):
t = i / 50
scene.add_dot(along=liss, t=t, color="coral", radius=0.01)
scene.save("lissajous.svg")
Render as a filled closed path¶
Warp text along a Lissajous curve¶
Protocol Summary¶
| Method | Required? | Enables |
|---|---|---|
point_at(t) -> Coord |
Yes | along=/t= positioning for all builder methods |
angle_at(t) -> float |
No | Exact tangent for align=True (fallback: numeric diff) |
arc_length() -> float |
No | Auto font sizing for textPath mode |
to_svg_path_d() -> str |
No | textPath warping (add_text(along=...) without t) |
The minimum viable pathable is a class with a single point_at(t) method. Everything else is optional and adds capabilities incrementally.